# Storage format
Every issue is a file like `docs/issues/alice/2026-09-11-A1B2C3D4-my-title.md`:
```markdown
---
number: 1
id: A1B2C3D4
title: My issue
status: todo
priority: high
labels: bug, docs
assignee: @alice
files: [openspec/specs/tui-keybindings/spec.md]
created: 2026-09-11
updated: 2026-09-11
---
The body is plain markdown — screenshots and diagrams work as usual.
```
Human-readable, human-edit-able, git-friendly. No data is locked away.
## Issue id vs. number
`number` is the **human** handle: a small integer, `max(number) + 1` when the
issue is created, stable for the life of a store. Because it is derived from the
files on one checkout, two people who create issues on two branches (or in two
clones) can both get the same number, and that is accepted — a `#7` collision is
a cosmetic nuisance for the human, not a data-model problem.
`id` is the **identity**: eight Crockford base32 characters (`A1B2C3D4`, no `I`,
`L`, `O` or `U`), drawn at random when the issue is created. Two branches cannot
collide on it, so it is what two clones use to agree on "the same issue". It is
case-insensitive, is never all digits (so it is never mistaken for a `number`),
and `tix show <id>` resolves it directly. In the filename it follows the date
(`2026-09-11-A1B2C3D4-my-title.md`), so a directory listing is creation order.
An issue written before the id schema has no `id:` line; it keeps parsing and
keeps its date-leading filename. Only issues created after the change carry an
id; nothing renames or backfills the older files.
## `tix` re-renders the frontmatter on write
A hand-edited file is *not* preserved byte for byte: list items are re-indented
flush left, and strings may come back with different quoting.
```console
- Alpha # you wrote it indented
$ tix view 1 > copy.md && diff issue.md copy.md
9,10c9,10
< - Alpha
< - Beta
---
> - Alpha
> - Beta
```
Nothing is lost — every field survives and re-parses to the same issue — but
`tix view <n> > copy.md` will show a diff on a hand-edited file. This is
pre-existing and general (`cycles`, `milestones`, and `relationships` all
normalise); it is noted here because "human-editable" implies your formatting
survives, and for the frontmatter it does not. The `## History` section is
preserved verbatim instead — see `kitty-specs/…/data-model.md` §8.
## Custom frontmatter keys
A top-level key `tix` does not recognise is **preserved**, byte for byte, and
`serde_yaml`'s re-quoting never touches it. It is spliced back into the
frontmatter on every write, after the known keys, in sorted order.
```console
$ cat issue.md
---
number: 1
title: My issue
build_id: ci-4471 # not a tix field
---
$ tix edit 1 --priority high && grep build_id issue.md
build_id: ci-4471 # still there
```
`tix lint` reports an unrecognised key as a **warning** — printed, counted, and
**exit 0**. A file carrying a custom key is a valid file, so failing a build over
it would report a non-problem as a broken one:
```console
$ tix lint; echo "exit: $?"
warning[W-tix-lint]: unknown top-level frontmatter key `build_id`: it is preserved
on write but nothing reads it. If that is deliberate, declare it in
`[custom_attributes]` in tix.toml; if not, it is probably a typo.
Lint OK with 1 warning: 1 issues checked.
exit: 0
```
The warning exists because an unrecognised key is usually a **typo**. `titel:`
round-trips silently and is read by nothing, which is a far worse failure than a
key that was dropped. Declare the ones you mean:
```toml
[custom_attributes]
keys = ["build_id", "reviewed_by"]
```
Names only, top-level only — there is no dotted-path syntax, because the thing
being declared is a top-level key. Declared keys do not warn.
A new project gets the shape from `tix init`, which writes a **commented**
example you only need to uncomment when you actually have a custom key:
```toml
# [custom_attributes]
# keys = ["build_id", "reviewed_by"]
```
`tix.toml` is re-read before an editor return is linted, so declaring a key while
a TUI session is open takes effect on the very next edit rather than after a
restart. A `tix.toml` that does not load is reported and the previous
configuration is kept, so a half-saved file cannot break the session.
The TUI offers the same fix where the problem appears: saving a file the
frontmatter made unreadable — or one `tix lint` rejects, or one whose title was
lost — raises the **edit repair** modal, which lists every problem with its
diagnostic and a proposed fix. **`a`** applies the first proposed fix: for an
undeclared key that is the declaration, appended to `[custom_attributes]` in
`tix.toml`, and `d` declares it explicitly. (`y` applies too, because it is the
accept key everywhere else.) A key already declared is not appended twice, and a
problem with nothing to declare says so.
Nothing is discarded automatically. The editor's output is kept as a retained
copy, named in the modal: `r` reopens the editor on it, `i`/`Enter`/`Esc`
acknowledges and keeps it, and only a confirmed **`x`** removes it. A fix
operates on that copy and re-parses it, so a fix that does not make the file
valid leaves everything untouched and re-reports. Before any write replaces the
issue, the previous content is saved beside it as `<name>.tixbak` and named in
the status line. The CLI's `tix edit` behaves the same way: it reports the real
parse error and keeps the copy it names rather than deleting it. See
[key bindings](../key-bindings.md) for the modal's keys.
The retired `links:` key is the exception that proves the rule: it is
**recognised but never preserved**, reported by its own `retired_links_key`
finding and then dropped, so the typed-relationship migration is not silently
undone. See [relationships](relationships.md) for the breaking-change note.
`files` records repository-relative governing specifications or supporting
documents; `relationships` records the typed edges. `tix create`, `tix edit`,
`tix link`, and `tix unlink` maintain these fields, and the TUI detail view
displays them.
## Related issues
`related_issues` is a free-form list of references to other issues, including
issues in other git repositories. Each entry is stored verbatim:
- a local issue number or selector — `42`, `#42`, or a title/filename fragment;
- a relative or absolute path to another issue file — `../other-repo/docs/issues/x.md`
(relative paths resolve against the referencing issue's own directory);
- a repository URL, optionally with an issue reference — `gitlab.com/foo/bar`,
`gitlab.com/foo/bar#42`, `https://gitlab.com/foo/bar/-/issues/42`.
The TUI renders a prominent `related:` block in the detail view, marks a card
with `[↔ N]` on the board, and lets you follow an entry (`o`) and come back
(`backspace`); `r` opens a picker. `tix create --related-issues` and
`tix edit --related-issues` set/replace/clear the list; `tix lint` warns about an
unresolved local entry and about a path that does not exist, and notes a remote
URL as `not linted (remote)`.
With `lint_foreign_repositories = true` (the default), `tix lint` also lints the
filesystem-reachable repositories referenced from `related_issues` and merges
their findings, prefixed with the repo (`[other-repo] …`). `tix lint --no-foreign`
restricts a run to the local store.
## Layout
An issue's on-disk path is configurable:
```toml
[layout]
directory = "{assignee}/{type}/{status}" # default
filename = "{date}-{id}-{slug}.md" # default
move_on_change = true
```
Directory tokens: `{assignee}` `{type}` `{status}` `{priority}` `{cycle}`
`{milestone}`; filename tokens: `{id}` `{date}` `{slug}` `{number}` `{title}`.
Missing values fall back to `unassigned` / `default` / `_`. The reader discovers
issues at any depth, so existing flat or `<assignee>/` stores keep loading.
`move_on_change` relocates a file's **directory** when a templated value changes;
the basename is preserved, so an existing file's name is never rewritten to a new
template. `tix layout show` prints the effective template, and
`tix layout migrate [--apply]` relocates existing files (dry run by default).