Researched against the Frontmatter implementation: the duplicate-key rescue's retry-loop pattern (compose a placeholder-patched copy, derive spans from original lines) extends directly. Ratified: a top-level key line whose plain unquoted remainder trips "mapping values are not allowed" reads as everything after the first ": "; quoted/flow remainders and nested shapes stay fail-fast; bytes preserved, fully editable, heals on first app write of the key; silent with a log line. Test obligation named (no hostile-YAML case covers the shape today). Claude-Session: https://claude.ai/code/session_01HJ7PhFNmQ19bvy9RMD6GSb
141 lines
25 KiB
Markdown
141 lines
25 KiB
Markdown
# Storage Format (Schema 1)
|
||
|
||
The on-disk contract between the filesystem (source of truth) and every consumer — the app, hand editors, and agents. This is the successor to the pathfinder's `SCHEMA.md`; once stable it graduates into a normative `SCHEMA.md` in the new repo. The schema number **stays `1`**: the pathfinder never shipped, so this design *redefines* schema 1 rather than bumping it — there is no migration story and no compatibility obligation to pathfinder boards.
|
||
|
||
The schema splits in two. The **core schema** — everything specified in this document: boards, lanes, cards, attachments — is what this version implements. The **enhanced schema** — comments, tracker integration fields — is out of scope for this version; core only *reserves* its names (see Enhanced schema below) so nothing collides later.
|
||
|
||
## Fractal layout
|
||
|
||
Every level is a **folder containing an `index.md`**. Identity is the folder name; everything else lives in the file.
|
||
|
||
```
|
||
MyBoard.kanban/ ← board = the document
|
||
├── index.md ← board settings + description
|
||
├── 3f2a9c41-8b7e-4d2a-9c1f-5e6a7b8c9d0e/ ← lane folder
|
||
│ ├── index.md ← lane metadata + description
|
||
│ ├── 27ca60f1-ef07-44d5-b688-73bf1f77e066/ ← card folder
|
||
│ │ ├── index.md ← card frontmatter + body
|
||
│ │ └── attachments/ ← card attachments
|
||
│ │ └── sketch.png
|
||
│ └── 8d3b1f22-…/
|
||
│ └── index.md
|
||
└── b1e4d7a0-…/
|
||
└── index.md
|
||
```
|
||
|
||
### Rules (carried over from the pathfinder, proven)
|
||
|
||
- **Level is position.** Root = board, depth 1 = lane, depth 2 = card. No `type` field.
|
||
- **Folder names are lowercase UUIDv4, immutable, never renamed.** The folder name is the primary key; titles live in frontmatter only. Collisions are impossible by construction. **The identity predicate is shape-only — lowercase v4 is the emission rule, not the gate** (settled): a folder counts as an identity iff its name is 8-4-4-4-12 hexadecimal (36 chars, hyphens in place) in **any case and any UUID version** — effectively "does `UUID(uuidString:)` parse it". The lenient gate exists for the headline use case: macOS's `uuidgen` and Swift's `UUID().uuidString` both print *uppercase*, and a strict gate would make an agent's standard-tool card a silently skipped stray — the worst failure for a files-first app; the version nibble protects no invariant (an agent's v7 is as unique as a v4). **Identity comparison is UUID-value equality, never string equality** — two case-spellings of one UUID are one identity everywhere (the import-boundary collision check included), matching default-APFS case-insensitivity, where they are one folder anyway; on a case-sensitive volume two case-spelled siblings are the duplicate-UUID edge below, not two identities. The *app* still writes only lowercase v4 (`UUID().uuidString` explicitly lowercased), never renames an existing folder to canonicalize, and the agent guide keeps teaching lowercase — accept liberally, emit conservatively.
|
||
- **Identity lifecycle: moves keep the UUID, copies mint fresh ones.** Every move (between lanes, between boards, cut+paste) is a physical folder move — identity travels with it. Every copy (⌘C/⌘V, ⌥-drag duplicate, the cross-board drag default — 04-interactions.md's locality model — and template instantiation) mints fresh UUIDs for every folder it materializes; a copy keeps `created` (it's a fork) but is a new identity. **Whole-board copies are the carve-out**: Save as Template (09-templates.md) and File ▸ Duplicate (03-board-ui.md) copy the board folder wholesale, **GUIDs kept** — each flow states its own exclusions (Save as Template drops `.git` and tombstoned items, 09; Duplicate carries both and strips only the remote configuration, 03) — the remint rule governs *item-level* copies landing inside an existing board, where identities could collide; a whole-board copy is a new namespace, and Duplicate's fork-keeps-history guarantee requires it (copied `.git` history must keep naming the paths it describes). Template GUIDs are inert anyway — instantiation remints at its own boundary. **Exception: template instantiation stamps `created`/`modified` fresh** — a board created from a template is born today, not forked from it (09-templates.md); Save as Template, by contrast, is an ordinary fork and keeps timestamps (inert anyway — instantiation restamps them). The app never produces a duplicate UUID: **within-board uniqueness is enforced at the import boundary** — a move-in (a cross-board ⌘-drag move or a cut+paste — 04-interactions.md) whose UUID already exists anywhere in the destination board is degraded to a copy (fresh UUID minted, content intact; the source is removed as usual for a move). **Degradation is per-folder, at the finest grain**: in a compound arrival (a lane with its cards, a multi-card drag or paste), only the colliding folders are reminted — everything else keeps its identity and remains a true move, and source removal is uniform regardless (the whole selection leaves the source, as for any move). A lane drag with one colliding nested card thus stays a lane *move*; just that card arrives reminted. One gesture silently yielding moved originals plus reminted copies is the intended behavior, not an accident — the collision is an identity repair, invisible in content terms. Boards are **independent identity namespaces**: the same UUID may legitimately exist in two boards (e.g. after a cross-board move-out undone at the source — 06-history-undo.md); such forks are tolerated and only ever meet at an import boundary, where the collision is neutralized. A user hand-copying a card folder in Finder can still produce sibling duplicates — the app doesn't currently detect that, and behavior with duplicate sibling-level UUIDs in one board is unspecified.
|
||
- `index.md` = YAML frontmatter between `---` delimiters, then Markdown body.
|
||
- **Reserved child names** at card level: `attachments/` (specified below) and `comments/` (enhanced schema — see below). Never levels themselves, never identity (identity-bearing siblings are UUIDs).
|
||
- **Name shape gates level detection** (settled): only folders passing the identity predicate (the shape-only rule above — 8-4-4-4-12 hex, any case, any version) are lane/card candidates. Anything else — even with an `index.md` inside — is a stray: skipped with a log warning, preserved verbatim, never rendered. A hand-made `notes/` folder or a board nested inside another can never brick a load; the cost — a hand-authored lane named `todo/` doesn't render — is what the warning and the agent guide exist for.
|
||
- A UUID-named folder below the root **lacking `index.md` is skipped with a log warning**, not an error — an interrupted two-step create must not brick the board. **Stray tolerance is uniform at every level**: non-reserved files and folders at board, lane, or card level are ignored by the loader, preserved verbatim, never rendered (inside a card, only `index.md` and the reserved child names mean anything — anything else is not the app's business). **Three board-root names are app-claimed, not strays** — the one scope on the verbatim promise: `CLAUDE.md` is app-owned and rewritten on guide version bumps, with a markerless user-authored `CLAUDE.md` relocated to `CLAUDE.user.md` (08-agent-integration.md ▸ The agent guide — that name is claimed too), and adding git seeds `.gitignore` (06-history-undo.md ▸ Repository hygiene; seeded once, then the user's to edit). Agents and hand-editors should treat these names as the app's at board root; everything else is untouched.
|
||
- **Atomic writes**: temp file, rename over `index.md`.
|
||
- **Unknown frontmatter keys and their order are preserved verbatim** on every rewrite. This is load-bearing for agent overlays (`project:`, `sphere:`, custom tags) — see 08-agent-integration.md.
|
||
- **The app never reformats a body it didn't change** (byte-identical round-trip; plain string comparison, no re-serialization).
|
||
- **Encoding and line endings** (settled — the contract underneath "byte-identical"): `index.md` is **UTF-8, no BOM**. Reads are strict — a file that doesn't decode as UTF-8 is the loud malformed-input rejection, offending path named; a BOM'd file fails the frontmatter delimiter and is rejected the same way, deliberately (external writers must produce plain UTF-8, which the agent guide teaches). App writes are BOM-less UTF-8. **Line endings are preserved per line, never normalized**: LF and CRLF both parse; a rewritten frontmatter line keeps the ending it had (a CRLF file stays CRLF); untouched bytes — the body and every unedited line — round-trip byte-identically; and the file's final newline, present or absent, survives rewrites. One card edit can never become a whole-file line-ending diff poisoning the semantic-commit story (06-history-undo.md). A line the app *appends* (stamping a key into a file that never carried it) has no prior ending to preserve — it adopts the file's prevailing ending, read off the opening `---` delimiter line: a CRLF file stays uniformly CRLF, an LF file stays LF, and one stamp never produces a mixed-ending file. Files the app *creates* are LF.
|
||
|
||
## Frontmatter
|
||
|
||
### Common to all levels
|
||
|
||
| Field | Type | Required | Notes |
|
||
|---|---|---|---|
|
||
| `schema` | integer | yes | `1` for this design |
|
||
| `title` | string | no | Optional at every level — the display name everywhere. Lanes/cards: a missing title shows the untitled placeholder. Boards: a missing title falls back to the folder name (see Board below) |
|
||
| `created` | ISO-8601 | no | Set at creation, with timezone |
|
||
| `modified` | ISO-8601 | no | Updated on every app write **that rewrites this `index.md`** — see below |
|
||
| `modified-by` | string | no | Self-reported writer identity, set by external writers only; the app clears it on every write — see below |
|
||
| `deleted` | ISO-8601 | no | Tombstone — see Deletion |
|
||
| `background` | string | no | Palette name or `#RRGGBB[AA]` — see 03-board-ui.md |
|
||
| `icon` | string | no | SF Symbol name, per-level defaults |
|
||
| `iconColor` | string | no | Palette name or hex |
|
||
|
||
**`modified`'s scope — content provenance, not file activity (settled).** The stamp updates on every app write that rewrites the item's `index.md`, and only those — it describes the item's *content* (title, body, frontmatter), never its files. Two designed app writes therefore don't bump it, deliberately: **raw-source Apply** writes the validated buffer byte-for-byte (05-card-window.md — the verbatim contract outranks stamping, the same reason it doesn't clear `modified-by` below), and **attachment operations** never touch `index.md` at all — load-bearing, not an oversight: Add Attachment stays enabled during an open raw-source edit precisely because it can't fight the buffer (03-board-ui.md ▸ toolbar). Accepted consequence: the card window's "Modified ⟨date⟩" line (05-card-window.md) can read older than the card's latest attachment activity — attachment history lives where files do (the sidebar shows them; on git boards the Attach/Remove commits are the activity trail — 06-history-undo.md).
|
||
|
||
**`modified-by` — self-reported provenance (settled).** An external writer (agent, script) may stamp a file it writes with a short free-form identity (`modified-by: claude`). The app never sets it and **clears it on every app-mediated write** — absence means "the board's user, via the app"; the file is being rewritten anyway, so clearing costs no extra write. Consequences compose from existing rules: copies arrive cleared (paste and duplicate are app writes), template instantiation strips it alongside its timestamp restamp (09-templates.md), Save as Template keeps it inertly. The key is schema-owned, not an unknown key — unknown-key preservation doesn't apply to it. Downstream surfaces: the card window's modified line renders it (05-card-window.md), and it refines foreign commit attribution (06-history-undo.md). Honest limit: it is self-reported — a hand edit that leaves an old stamp in place inherits its attribution until the next app write clears it. One carve-out: **raw-source Apply** (05-card-window.md) writes byte-for-byte and does *not* clear a stamp the user typed or kept — the outlet exists to write anything, and the validated-then-verbatim contract outranks the clearing rule.
|
||
|
||
### Board (`<root>/index.md`)
|
||
|
||
| Field | Type | Required | Notes |
|
||
|---|---|---|---|
|
||
| `template` | mapping | no | `{order: N}` — picker position when the board lives in a template store (09-templates.md); future subkeys possible. **Carried onto instantiated boards**, where it is ignored and preserved like any unknown key. |
|
||
|
||
Body: board description (free Markdown).
|
||
|
||
**Board naming**: the schema is uniform at every level — folder name is storage identity, `title` is display. A board's `title` is what the window title and welcome recents show; when absent, the UI falls back to the folder name (sans extension). The in-app Board rename (03-board-ui.md) edits `title` only, never the folder — so the app's display name and the Finder document name can diverge (accepted; renaming the folder is a Finder operation). Template instantiation seeds the new board's `title` with the user-chosen document name, so the two start out matching (09-templates.md).
|
||
|
||
### Lane (`<root>/<guid>/index.md`)
|
||
|
||
| Field | Type | Required | Notes |
|
||
|---|---|---|---|
|
||
| `order` | number | yes | Rank among lanes, ascending = left-to-right |
|
||
| `width` | integer | no | Width multiplier ≥ 1 (default 1); no cap |
|
||
|
||
Body: lane description / WIP policy / notes.
|
||
|
||
### Card (`<root>/<guid>/<guid>/index.md`)
|
||
|
||
| Field | Type | Required | Notes |
|
||
|---|---|---|---|
|
||
| `order` | number | yes | Rank within its lane, ascending = top-to-bottom |
|
||
|
||
Body: the card's content — the whole point.
|
||
|
||
Schema-owned display fields are Lanework's to interpret — **coerce where a sensible reading exists, fall back to the field's default where none does** (settled). A scalar of the wrong YAML type reads as its source text (`title: 2048` displays as "2048", `width: "2"` reads as 2); where no sensible reading exists — a sequence or mapping where a scalar belongs, a non-integer width, an unparseable timestamp — the field falls back to its default: untitled placeholder, width 1, no color, no icon. Coercion is read-side only; the bytes on disk are **preserved verbatim, never rewritten**. One tombstone nuance: any *present* `deleted:` key tombstones the item — an unusable timestamp still deletes, its date merely unknown (the user's intent to delete outranks the broken date). **Duplicate keys: last one wins** (settled — the coercing read; strict YAML would reject the file, so this is a deliberate divergence in the editor's favor): a *top-level* key appearing twice reads as its last occurrence, earlier occurrences preserved verbatim on disk and invisible. A duplicate inside a nested mapping value remains a YAML error — the rescue applies where the editor's slip actually happens, the top level. Last-wins resolves *before* strictness: a duplicated `order` reads as its final occurrence and only that value faces the malformed-order check — duplication itself is never a fail-fast. An app write of a duplicated key rewrites the winning (last) occurrence and removes the earlier ones — the app owns the keys it writes, and leaving a stale twin would resurrect it if the winner were later removed; removing a key removes all its occurrences. **App rewrites preserve comments** (settled): comments on their own lines always survive a rewrite; an inline comment on a rewritten value line is re-spliced after the new value — best-effort, guaranteed for plain scalar lines (the realistic case), dropped only in pathological shapes. **Unquoted colons in a value read as the string they meant** (ratified — the third member of the rescue family, same posture as last-wins and coercion): the classic slip `title: Fix: the thing` — invalid YAML ("mapping values are not allowed in this context") — recovers when, and only when, the scanner error's mark lands on a line that *starts a top-level key* and whose remainder is a **plain unquoted scalar**: everything after the first `: ` reads as the value. A remainder opening with a quote or flow character (`"`, `'`, `[`, `{`) is a different breakage and stays fail-fast; nested/indented shapes never match the guard and stay errors. Mechanism mirrors the duplicate-key rescue: compose retries on a modified copy (the offending value swapped for a same-line placeholder), so one recovered line leaves every other line's diagnostics intact — a second, genuinely broken line still fails loudly with its own error. Read-side only, bytes preserved verbatim; the span editor keys by the line's key prefix, so the file stays fully editable, and the first app write of that key emits the value properly quoted — the slip heals on first touch. Recovery is **silent with a log line** (the family's posture: the recovered value is exactly what the writer meant, so there is nothing actionable to surface). The agent guide keeps teaching quoting — the rescue is a net, not a license. Test obligation: the hostile-YAML suite gains the `key: value: more` shape (none covers it today). Fail-fast remains reserved for structure (`schema`, `order`, YAML validity) — and it covers *malformed*, not just missing: an `order` that is present but non-numeric is the same loud malformed-input rejection as a missing one. **Readable-but-uneditable shapes load; writes to them refuse** (settled): frontmatter the surgical editor can't key by spans — a whole-frontmatter flow mapping, non-scalar keys, or frontmatter whose YAML resolves to an *empty* mapping while still carrying text (`{}`, `null`, `~` between the delimiters: zero keys load fine, but appending a key after that text would produce unparseable YAML; truly blank or comment-only frontmatter stays editable) — reads and renders normally, and any app write to that file fails loudly through the per-file write-failure banner (02-architecture.md ▸ Write-failure surfacing) naming the shape, never a silent corruption and never a load rejection.
|
||
|
||
## Enhanced schema (reserved, out of scope)
|
||
|
||
Future integrations extend the core schema; this version only reserves their names so nothing else squats on them. The app treats reserved keys as ordinary unknown keys (preserved verbatim, invisible in the UI) and reserved folders as ignored non-level content:
|
||
|
||
- **`comments/` card child** — a future comment thread: fractal like everything else (folder-per-comment holding `index.md` + `attachments/`, `author`/`created` frontmatter, chronological). Specified fully when the enhanced schema is designed; no comments functionality in this version.
|
||
- **Card metadata keys `labels`, `assignees`, `due`** — issue-style metadata belonging to the tracker-integration story (the pathfinder shipped them natively; the rewrite reserves them instead). One deliberate carve-out: the commit-message composer names changes to these three keys ("Relabel card 'X'" — 06-history-undo.md), invisible in the UI though they are.
|
||
- **`remote` key** (board, card) and **`remote-state` key** (lane) — future tracker connectors (Gitea/GitHub/GitLab/Jira), per 07-sync-collab.md's out-of-scope note.
|
||
|
||
## Ordering
|
||
|
||
Carried over unchanged — gapped fractional ranks:
|
||
|
||
- New items append at `max + 1024`; insert at head = `min − 1024`; insert between two = midpoint.
|
||
- A reorder rewrites **only the moved item's** `index.md`.
|
||
- Midpoint precision exhausted → renumber that one lane's cards (or the board's lanes) to whole multiples of 1024. Rare, local — and **the one exception to the only-the-moved-item rule**: a renumber rewrites every sibling's `index.md` in that lane. On a remote-shared board the burst is conflict-prone in principle but benign in practice: each rewritten file changes only its `order`, the local-wins rebase (07-sync-collab.md) resolves per file, orders stay valid floats, and deterministic tie-breaking keeps rendering stable even if two machines' renumbers interleave. The batch is atomic per file, not per lane — an interrupted renumber (crash, volume error mid-lane) leaves some siblings rescaled and some not, which is accepted the same way: every order still a valid float, display order still deterministic via the tie-break, and the next renumber completes the job. It lands inside the triggering operation's single commit (06-history-undo.md): the renumber batches with the insert or move that exhausted the midpoint, and its sequence-preserving `order` rewrites are bookkeeping to the message composer — the commit reads as the triggering event ("Move card 'Fix login' to Doing"), never as N separate edits or a standalone renumber.
|
||
- Ties broken by folder name (lexicographic) for deterministic rendering.
|
||
|
||
## Attachments
|
||
|
||
- `attachments/` under a card folder holds the card's files. The app's attachment surfaces (the card window's sidebar section and import — 05-card-window.md; the face carousel — 03-board-ui.md ▸ Card face) are **flat**: top-level files only.
|
||
- **Import collisions auto-rename, Finder-style** (`shot.png` → `shot 2.png`): an import never overwrites an existing file and never refuses the drop. Never-refuses is policy, not an I/O guarantee — a failed copy (source unreadable, destination full) surfaces per 02-architecture.md ▸ Write-failure surfacing, with any partial file removed.
|
||
- **Subfolders are tolerated, preserved verbatim, never created by the app, and not surfaced** in those surfaces. Their contents stay reachable via Reveal in Finder, and body-embedded relative paths into them (``) still render — preview resolves against the card folder.
|
||
|
||
## Deletion — tombstones
|
||
|
||
- Deleting writes `deleted: <ISO-8601>` into frontmatter; tombstoned items (and everything beneath) are hidden from the board. **Hiding beneath is the renderer's walk, not a stored flag** (pinned by the fixture suite): the snapshot flags only the item carrying `deleted:` — a live card inside a tombstoned lane stays `isDeleted == false`, and every consumer of the model (board view, trash, search, counts) must treat ancestor tombstones as hiding the subtree rather than filtering on the item flag alone and surface in the **trash quasi-lane** (03-board-ui.md) — a pure view. **Tombstoned items stay exactly where they are on disk**, which is what makes restore (Put Back = removing the key) position-preserving: the item reappears at its recorded `order` among its current siblings. One acknowledged edge: a renumber rescales *visible* siblings only (tombstones are inert to ordering), so a post-renumber Put Back can land among different neighbors than the ones it was deleted between — same lane, deterministic placement, just not always the original gap.
|
||
- Tombstones are **inert to ordering**: appends and renumbering operate on visible siblings only; a restored item re-enters at its recorded `order`, ties broken deterministically.
|
||
- A folder that disappears without a tombstone (Finder deletion) is also a delete; the app reflects it (such items are gone entirely — they never enter the trash).
|
||
- A `deleted:` key at **board level** is legal per the common frontmatter table but meaningless — a board can't tombstone itself out of its own window. The loader ignores it and logs (the lenient-values philosophy: preserved verbatim, invisible in the UI).
|
||
- Physical removal is the trash's **Delete Immediately / Empty Trash** (03-board-ui.md). Time-based auto-purge of old tombstones: deliberate follow-up, not in the initial release.
|
||
|
||
## Malformed input — fail fast
|
||
|
||
Loud, specific error (path + what's wrong) for: unparseable YAML, missing required fields (`schema`; `order` where required), `schema` newer than the app, board root without `index.md`. No partial loads. The only tolerated absence is a missing `index.md` below the root (skip + warn, per Rules above). An explicitly null value (`order:` with nothing after it, `order: null`) reads as **missing** (settled): on a required field that is the missing-required-field rejection — it describes what the hand-editor actually did, started the key and never gave it a value; on an optional field, null is simply absent and the default applies.
|
||
|
||
Fail-fast is the **initial-load** contract. Once a board is open, a failed live reload does not blank the board: the window keeps the last good snapshot and surfaces the same loud specifics in a non-modal banner — see 02-architecture.md's live-reload resilience.
|
||
|
||
## Document packaging
|
||
|
||
- Boards are created as `.kanban` packages (UTI conforming to `com.apple.package`) so Finder treats a board as one document.
|
||
- Extension-less board folders still open.
|
||
|
||
## Changes from the pathfinder schema
|
||
|
||
No migration is implied by these — pathfinder boards have no compatibility claim on the rewrite (a hand-rename of `media/` → `attachments/` etc. covers any board worth carrying over).
|
||
|
||
- `media/` → **`attachments/`** (clearer, one unambiguous home for card files).
|
||
- The schema is split into **core** (this version) and **enhanced** (comments, tracker fields — reserved only, out of scope).
|
||
|
||
## Open questions
|
||
|
||
None currently — trash-bin behavior is settled: in-place tombstones with the trash as a pure view (Deletion above; UI in 03-board-ui.md).
|