# 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 one identity with two folders, and **the loader dedupes them deterministically** (settled — a snapshot must never carry two items with equal ids): the canonical all-lowercase spelling wins where present, else the lexicographically first spelling — unless the twins straddle the trash boundary, where the live occurrence renders (the container-first tie-break, Identity lifecycle below); the loser takes the stray posture — skipped with a pointed log line, preserved verbatim, never rendered. (Content-distinct duplicates — a hand-copied folder keeping its UUID — are deduped by the earlier-occurrence-wins rule below.) 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. **Item-level copies sever tracker identity** (ruled 2026-07-29 — generalizing the comments rule below): every folder an item-level copy materializes drops the reserved `remote`/`remote-state` keys, at every level — card, lane, comment — because two local objects must never both claim to be the same remote object (content preserved, mapping severed; the keys are schema-reserved, so stripping is the app's right — the `modified-by`-clearing precedent). Nothing consumes the keys until Teams, but copies made today are the boards Teams will meet — the sever costs nothing now and spares a stale double-claim later. Whole-board forks carry them verbatim, as always. **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 `.trash/`, 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 duplicate ids — in the same lane or another. **Duplicate ids within a board are never tolerated** (settled): the loader keeps exactly one occurrence per id, board-wide — the case-twin dedupe generalized to content-distinct duplicates. **The occurrence that entered the board earlier wins**: on git boards, the path history already tracks outranks the newcomer (both tracked: the path that entered history first — read best-effort: the walk caps at 512 first-parent commits and does not follow renames, blessed 2026-07-31, both degradations falling tie-ward into the rungs below, never toward a wrong winner; revisit if the git wrapper grows rename detection); without history, the older folder (filesystem birth date) wins, with deterministic traversal order (lane `order`, then card `order`, then the folder-name tie-break) the last resort when dates tie or are unreadable. **On `.git` boards the permanent remint waits for the history rung** (ruled 2026-07-31): the board's first load runs before the session composes its git state, so the opening walk resolves collisions with the degraded ladder *for rendering only* — the snapshot still carries exactly one occurrence per id — while the remint heal for the duplicate class is skipped at acquire-time when the board has a `.git`; session composition kicks the heal scheduler once history state is bound, so a Pro board heals with the history rung consulted (a clone's first open is exactly where birth dates are all checkout-time noise), and a free-tier `.git` board heals one beat later with the same ladder it would have had. Boards without `.git` heal at open as always. **The dedupe universe is the whole board, `.trash/` included — and the container boundary is the first tie-break** (stated 2026-07-29): when occurrences straddle live and trashed, the **live occurrence keeps the identity** regardless of age. The realistic straddle is a restore done as a copy (an ⌥-drag out of the trash in Finder, an agent that copies instead of moves), where earlier-entrant-wins would withhold the very card the user just restored while its ghost sits in the trash — the visible card never loses to its own ghost; the heal remints the trashed occurrence. The same preference governs a trashed lane sharing a live lane's UUID. Every later occurrence is withheld from rendering — preserved verbatim, pointed log line — so the snapshot invariant (one item per id) holds by construction and nothing is lost on disk; but unlike a true stray it is **healed, not merely tolerated** (re-ruled 2026-07-29 — silent heal, superseding the former user-gated Repair banner: Lanework owns the board and re-mints object UUIDs at will, the claimed-names precedent, and this repair is reliably healable — unambiguous, content-lossless): copy semantics applied at detection — the hand copy was a copy, so the heal gives each withheld occurrence the fresh identity the import boundary would have minted, reminting its folder to a fresh lowercase v4 through the Writer (a rename — content, frontmatter, children untouched), after which it renders as an ordinary item. The loader itself still never writes — detection is read-only; the remint runs as a **scheduled heal** (Validation and healing below): memo-guarded, disk re-verified at write time (a remint racing a vanished duplicate — another device healed first, a hand-deleted copy — is a no-op, never an error), committing separately on git boards as its own heal commit ("Repair duplicate of 'Fix login'" — 06-history-undo.md keeps the verb as the heal commit's name). Announced, not invisible: the relocation-style warning-tone notice names the repair ("Repaired duplicate id — 'Fix login'"; several fold with a count) — identity changed, so a line says so, but nothing waits on consent. Case-spelled twins of one identity (above) stay silent strays — they are spelling artifacts of the same item, not copies, and reminting one would *create* duplicate content the user never made.
- `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). **One carve-out — loose *files* in a card folder are relocated, not tolerated** (settled 2026-07-28, Lanework-owns-the-board): a regular file sitting beside a card's `index.md` (not `attachments/`, not a reserved name) belongs in `attachments/`, and the app moves it there — Finder-style rename on collision — surfacing a graceful warning-tone notice naming the card and files ("Moved 'notes.txt' into attachments — 'Fix login'"; plurals fold). Detection stays read-only in the loader (the Repair precedent); the relocation runs through the Writer as an app-mediated write, commits normally on git boards, and waits out any read-only lock (strays stay tolerated until it clears). The carve-out is exactly that narrow: stray *folders* in a card (a nested clone, a hand-made subfolder), symlinks (never touched, above), and everything at board or lane level keep the verbatim posture — relocating a directory into the flat attachment model would be wrong, and board/lane-level strays (`CLAUDE.user.md`, a `notes/` folder) are legitimate residents. The agent guide teaches the rule (08-agent-integration.md): card files belong in `attachments/`. **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. **A claimed name held by the wrong kind of node is Lanework's to heal — by displacement** (ruled 2026-07-29 — Lanework owns the board, so an invalid artifact on a claimed name is a defect, not a resident): a regular file or symlink squatting `.trash` (a directory name), or a directory or symlink squatting `CLAUDE.md` (a file name), is moved aside by a scheduled heal (Validation and healing below) via the Finder-style rename ladder (`.trash` → `.trash 2`), **preserved verbatim, never destroyed**, with the relocation-style warning-tone notice naming old and new; the freed name then serves the app (the next delete mints the real `.trash/`; the guide write proceeds). **The rule is level-uniform** (extended 2026-07-29): a card's reserved child names are claimed the same way — a regular file or symlink squatting `attachments` (a directory name) displaces by the same ladder (`attachments` → `attachments 2`), so imports, Finder drops, and the sidebar listing never fail one gesture at a time against a squatted name; the displaced file, now an ordinary loose file, rides the next relocation into the real `attachments/` — the heals compose. The reserved-but-unconsumed `comments` is the timing principle's own illustration: nothing reads that name until the tracker era, so a wrong-kind holder is a tolerated stray today and joins the scheduled class the day the name becomes load-bearing. This upgrades the agent guide's former untouchable-skip to a displacement — the invariant that survives is displacement-never-destruction. One standing exception: the settled `CLAUDE.user.md`-taken skip stands — that displacement has a designated *destination*, and freeing a destination by a second displacement would cascade renames; the guide write skips with a log as settled (08-agent-integration.md).
- **Symlinks are never traversed** (settled): the loader and the watcher treat any symbolic link in the board tree — identity-shaped name or not — as a stray: skipped with a log warning, preserved verbatim, never rendered, never followed. A link passing the identity predicate would otherwise render bytes living outside the board that FSEvents never reports — the silently-stale board 02-architecture.md structurally excludes — and a link cycle would make the tree walk non-terminating. Copy flows copy the link itself, never its target: Duplicate, Save as Template, instantiation, cross-board copies, and clipboard staging preserve the link verbatim as they do any stray.
- **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 |
| `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 |
| `kind` | string | no | The object's kind — `board`, `lane`, `card` (`comment` — its storage schema now specified, Enhanced schema below). **Written at creation of every object** (re-ruled 2026-07-29 — consistency across the schema, even where position already answers). The value — never the key's mere presence — names the kind, and consumers that consult it trust the value outright: no stripping, no corroboration machinery. Only consequential inside `.trash/` today, where position can't answer (Deletion below); everywhere else it is redundant with position (level is position) and carried for uniformity. Missing on an older object, it **backfills on touch** — the integrity service's on-touch heal (Validation and healing below), never a scheduled sweep. |

**`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). The third is a class, not a write: **order-only rewrites don't stamp — the reorders-don't-stamp rule** (ruled 2026-07-29 as moves-don't-stamp; refined 2026-07-30 — container changes stamp; Deletion below and 03 ▸ Trash cite the rule). `order` is logically the *container's* property — a relationship among the lane's members that the format happens to store as an attribute inside each member's file — so a reorder within the item's container (a card among its lane's siblings, a lane among the board's lanes) and a renumber's whole-lane rescale (Ordering below) rewrite `index.md` without touching content: no stamp, and no `modified-by` clear (the pairing below — attribution can't change when content didn't). **A move that changes the item's container stamps both** (the 2026-07-30 refinement): a cross-lane move, a cross-board arrival, and the trash move (Deletion below) change the item's story — which lane a card lives in is state; where it stands in line is presentation — so they stamp `modified` and clear `modified-by` like any content write. The trash move thus stops being an exception and becomes the container rule's plainest instance. Considered and declined: stamping the container's own `modified` on reorder (the lane's ordering did change) is a fair reading, declined pragmatically — it fans one member's write out to the container's file and is hard to enforce without adding risk, for a signal nothing reads. 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. Two carve-outs: **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; and **order-only rewrites** (the reorders-don't-stamp rule above) — position changes touch no content, so a standing stamp survives a within-container reorder, while a container-changing move (cross-lane, cross-board, into or out of the trash), stamping `modified`, clears here too.

### 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. **A no-sensible-reading fallback logs** (ruled 2026-07-29 — the family posture: every silent recovery leaves a trace): field, path, and raw text, carried as coerce-tier entries in the integrity service's Defect stream (Validation and healing below) — the one place where an observed-in-the-wild shape can later be promoted to a heuristic heal or a notice; no banner, no behavior change. **The unified log is the stream's consumer** (ruled 2026-07-30): the structured log line — subsystem-filterable in Console.app — is the whole surface this version, and promotion is deliberately manual, investigation-driven: a reported oddity leads to the log, the log names the shape, the shape informs the next heuristic. A no-telemetry app has no other observer to build for; an in-app diagnostics surface listing what a load coerced or tolerated is WISHLIST #11, and the typed stream is the plumbing it would read. **Ranges are part of the sensible reading** (settled): an integer `width` below 1 reads as 1 — the table's ≥ 1 is a validity bound, and 03-board-ui.md's width division must never see a zero or negative unit — while a non-finite `order` (`.nan`, `.inf`) is the same loud malformed-input rejection as a non-numeric one: fail-fast's `order` guard covers unusable values, not merely non-numeric ones (NaN has no place in the total order the tie-break and midpoint math assume). The hostile-YAML suite gains both shapes (`width: 0`, `order: .nan`). Coercion is read-side only; the bytes on disk are **preserved verbatim, never rewritten**. One legacy nuance: a *present* `deleted:` key is migration input, not live state — the item relocates per the Deletion section's legacy rule, whatever the timestamp's condition. **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. **Compound operations are transactions against this rule** (ruled 2026-07-29): every copy flow that rewrites descendants' `index.md` (paste, ⌥-drag duplicate, the cross-board copy, template instantiation — the stamp and the `modified-by` clear are part of the copy contract, not optional bookkeeping) **preflights the entire subtree** and refuses whole, loudly, naming the offending item — never a partial copy, never a silently unstamped descendant; the finest-grain precedent covers identity collisions, not skipped contract work. **The preflight reaches comment depth** (ruled 2026-07-31): a comment whose frontmatter cannot take the stamp refuses the copy exactly like a card or lane — never-refuse (Enhanced schema below) is a *load* posture, and a user-initiated copy is a transaction, not a load; the refusal names the comment's card so the fix — repair or delete the annotation — is one gesture away, and no copy ever carries a live `remote` claim or stale `modified-by` the sever rule exists to prevent. A mid-flight failure after a clean preflight (disk error) removes the partial result wholesale — construct-then-clean (09-templates.md's atomicity). Whole-board forks (Duplicate, Save as Template) stamp nothing and are exempt.

## Enhanced schema (reserved; comments storage specified 2026-07-29, feature out of scope)

Future integrations extend the core schema; this version reserves their names — and, for comments, specifies the storage schema ahead of the feature — 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 — the comment thread** (storage specified 2026-07-29, the card-window UI designed the same day — 05-card-window.md ▸ The comments column; no comments functionality ships in this version). A comment is a **UUID-named folder** under `comments/`, holding `index.md` (frontmatter + Markdown body, the card-body subset) and optionally `attachments/` — a card's anatomy one level down, so the fractal rules apply verbatim: two-step-create tolerance, stray tolerance, loose-file relocation, displacement of a squatted `attachments`, shape-only identity. **The heals run window-scoped at this depth** (ruled 2026-07-31): comments are outside the board walk, so the thread read is the detector — it reports loose files and claimed-name squatters as window-scoped defects, and the card window relocates/displaces them memo-less on the read-cadence guard (02-architecture.md ▸ HealScheduler's window-scoped pattern); a loose file beside a comment's `index.md` moves into that comment's `attachments/` with the relocation family's notice ("Moved 'notes.txt' into attachments — comment on 'Fix login'"), and the heals compose as at board level. `comments/` itself stays a plain reserved child — never a level, never identity. **Field table for `kind: comment`** (exercising IntegrityRules' per-kind hook for the first time): `schema` required; `kind: comment` written at creation (the common rule); **`author`** lenient — self-reported *content*, not overlay: unlike `modified-by` it survives app writes; the app writes the macOS account's full name (the identity 06-history-undo.md's derived default already uses), agents write their own (the guide teaches it), tracker sync writes the remote author verbatim, and missing renders unattributed; `created`/`modified` as everywhere — `created` is load-bearing (ordering below), and the edited indicator is simply `modified` differing, no extra field; `remote`/`remote-state` ride as reserved tracker keys. **No `title`, no `order`.** The rules, all ruled 2026-07-29: **Ordering is chronology, not ranks** — the thread sorts by `created` ascending; chronology *is* a conversation's semantics, and cross-machine or tracker-synced comments carry independent clocks where minted ranks would interleave arbitrarily (the trash joined the timestamp family 2026-07-31 — it sorts by `modified` descending, Deletion below); ties and missing/malformed `created` (coerce-tier fallback, logged) sort after dated siblings, folder-name order. **Flat this iteration** — no reply nesting, deliberately deferred with the door open (a future reply relation would be frontmatter, and the window-scoped read below holds either way). **Comment defects never refuse the board** — worst case is the stray posture (tolerated, logged, unrendered): a broken leaf annotation must not brick a load; deliberate, proportionate divergence from card fail-fast. The posture is **load-scoped** (ruled 2026-07-31): a user-initiated copy is a transaction whose stamping preflight refuses whole at comment depth too (Identity lifecycle above) — the board always loads; the gesture may refuse. **Delete skips the board trash, not the pattern** (re-ruled 2026-07-29 — supersedes the earlier capture parenthetical): the root `.trash/` holds cards and lanes; a deleted comment **moves into `comments/.trash/`** — the materialized-trash pattern one level down, joining `.draft` in the claimed names (a squatter displaces by the ladder). The move is immediate, no confirm, and undo is the net: the inverse is the ordinary move back — **no byte capture in any tier** (13-native-undo.md's no-capture rule holds; on git boards the move is a commit like any other). `comments/.trash/` is undo's backing store, never a UI surface: excluded from the thread, **purged when undo no longer needs it** (re-ruled 2026-07-31 — the session-coarsening model, 13-native-undo.md: on free-tier boards the close step's undo restores deleted comments, so the purge waits until that coarse step leaves the board stack or the board session ends; on Pro the close commit nets delete-plus-purge and revert restores from history, so the purge rides the close flush; crash residue sweeps at the next card-window open, armed-then-cleared like every heal memo), and **stripped at every copy boundary** (clipboard staging, Duplicate, Save as Template — a copy must not carry ghosts no window session will ever purge; sibling of the `remote`-severing rule). The container-change stamping rule applies — the move stamps `modified` (`modified`'s scope above). **Copies carry the thread** — a copy is a fork, and dropping a subtree would be the one place a copy loses content; comment folders remint like every copied folder, and the copy's comments drop `remote`/`remote-state` (an instance of the item-level sever rule — Identity lifecycle above); whole-board forks carry verbatim as always. **Comments are window-scoped, outside the board snapshot** — the walk stays O(cards): the card window reads its own thread and live-reloads it from the same FSEvents stream, and the board snapshot never loads comment content. This is the stated exception to snapshot completeness, costs named: the snapshot never carries comment content — board search reaches comment bodies through its own transient search-time sweep instead (04-interactions.md ▸ Search, re-ruled 2026-07-29), the card window's find covers the focused thread (05) — and foreign comment changes are described by **path shape** — the "Update agent guide (vN)" mechanism: a changed path under `…/comments/<uuid>/` composes "Comment on '⟨card title⟩'" / "Edit comment on…" / "Delete comment on…" — and a comment leaving the tree from `comments/.trash/` composes **"Permanently delete comment on…"** (blessed 2026-07-31 — the board trash-pair's leaving-the-tree rule one level down, so purge-only windows never fall to a shrug and foreign purges read honestly); plurals fold noun-first where the post subject forces it (blessed 2026-07-31): "3 comments on 'Fix login'" / "Comment on 3 cards" / "Edit 3 comments on 'Fix login'" / "Draft comment on 3 cards" — (06 gains the verb family when the feature ships), and the announcer speaks arrivals the same way. Git protection needs nothing new — the committer already stages the whole root and its condition is the tree. Trash interplay is free: a trashed card carries its `comments/`, purge kills it, restore returns it. Comments never count in the welcome counts. A comment-count chip on card faces is WISHLIST #9. **`comments/.draft/` is the card's single comment draft** (ruled 2026-07-29, the UI pass — user content with a lifecycle name, the `.trash` pattern one level down): a reserved dot-named folder holding ordinary comment schema, `attachments/` included; excluded from the thread; **posting renames it to a fresh lowercase UUID and restamps `created`/`modified` in the same bracket** — chronology is post time, not drafting time — one commit. `.draft` joins the claimed names (a wrong-kind node squatting it displaces by the ladder); copies and the trash carry it like any comment folder (fork-lossless); a draft emptied of text with no attachments is deleted by the app, never litter. The schema is **tier-blind** (one format, no tier-specific keys), and so is the feature: **comments render and author in every tier** (ruled 2026-07-29 — 05-card-window.md ▸ The comments column); only tracker sync is tier-gated (Teams — 12-editions.md).
- **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 — order-only rewrites, so no `modified` stamp and no `modified-by` clear (reorders-don't-stamp, `modified`'s scope above). 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, **comparing the canonical lowercase spelling** — settled: ordering follows the value-based identity model, never an uppercase folder's ASCII accident) for deterministic rendering. The same canonicalization applies to every folder-name tie-break in the corpus (the trash sort included — 03-board-ui.md).

## Attachments

- `attachments/` under a card folder holds the card's files. The app's attachment surface (the card window's sidebar section and import — 05-card-window.md; the board face shows only the attachment chip — 03-board-ui.md ▸ Card face, the no-carousel resettlement) is **flat**: top-level files only. Its listing order is **Finder order** (settled — `localizedStandardCompare`): one shared enumeration between loader and Writer, so every consumer of the listing agrees.
- **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 (`![](attachments/sub/x.png)`) still render — preview resolves against the card folder.

## Deletion — the materialized trash (resettled 2026-07-28)

The tombstone model is **retired** — no `deleted:` key, no in-place flags, no ancestor-walked hiding. Deletion is a **move**:

- **Deleting a card or a lane moves its folder into `<board-root>/.trash/`** (lanes joined 2026-07-29 — retiring the design's sole destructive delete) — a reserved, app-claimed name at board root (joining `CLAUDE.md` and the seeded `.gitignore` on the claimed list; agents and hand-editors should treat it as the app's). `.trash/` holds card and lane folders interleaved directly, no `index.md` of its own. (The name recurs one level down — `comments/.trash/`, Enhanced schema below — so a folder's placement is decided by its real ancestry, never by its own name and its parent's alone: a `<uuid>` under a `.trash` is a board-trash entry only when that `.trash` sits at board root.) A non-directory node squatting the name — a file, a symlink — is an invalid artifact healed by displacement (the claimed-names rule above): until the heal lands, the loader reads the board as having an empty trash and a delete's directory mint fails as an ordinary one-shot — a window measured in one reload, not a standing state. **The trash sorts by `modified` descending** (re-ruled 2026-07-31, retiring the arrival rank mint): the move **stamps `modified` and clears `modified-by`** (a container-changing move — the stamping half of reorders-don't-stamp, `modified`'s scope above; deletion is an edit to the item's story), and that stamp *is* the position — newest-first falls out with no `order` rewrite at all; a delete is a pure folder move plus the stamp, and the item's `order` key rides along untouched (which is also what undo's move-back restores for free). Ties break by title (case-insensitive), then folder name — the deterministic tail. A foreign mover that skips the restamp sorts by its old stamp, mid-list — acceptable; the agent guide teaches restamp-on-move. The same stamp is what a future age-based auto-purge will read (deliberate follow-up, not in the initial release). A trashed lane keeps its subtree untouched — its cards ride along and come back with it.
- **`kind:` discriminates inside `.trash/`** (ruled 2026-07-29): depth defines meaning *on the live board*, but the trash is flat, and an empty lane folder is shape-identical to a card folder (`<uuid>/index.md` both). Every object carries `kind` from creation (re-ruled 2026-07-29 — the common-schema row, Frontmatter above); inside `.trash/` it is where the key earns its keep. A lane's `kind: lane` is **backfilled on touch when absent** (the integrity service's on-touch heal, Validation and healing below: any Writer rewrite of that lane's `index.md`, the trash move's `modified` stamp included, stamps the missing key — **on-touch only, never a scheduled backfill sweep**) and never stripped (restore stays a pure move; the key is inert on the live board where position wins). The trash reader's rule **trusts the value** (re-ruled 2026-07-29 — the value-names-the-kind posture): `kind: lane` → lane; `kind: card` → card; an unrecognized value or no key falls through to shape — UUID-shaped children with their own `index.md` → lane (the key backfills on the next touch), else card. Trusting the value means an external writer's `kind: lane` on what looks card-shaped is honored, never policed. Honest limit of on-touch-only backfill, accepted: a kind-less trashed lane emptied of its children before any touch becomes indistinguishable from a card.
- **Restore is a move out**: any move of a card folder from `.trash/` into a lane, or of a lane folder to board level — drag, or cut+paste — is an ordinary move; there is no restore-specific machinery and no Put Back. Position (and, for cards, lane) are chosen at restore time.
- A folder that disappears entirely (Finder deletion) is also a delete; the app reflects it — such items are gone, they never enter the trash.
- Physical removal in-app is the trash's **Delete / Empty Trash** (03-board-ui.md).
- **Legacy `deleted:` keys: cards migrate, lanes ignore** (lane clause re-ruled 2026-07-29): a card carrying `deleted:` is relocated into `.trash/` (key removed — the loose-file relocation posture: detection read-only in the loader, the fix through the Writer, a graceful warning-tone notice, deferred under any read-only lock; the migration move stamps `modified` **from the legacy `deleted:` timestamp** where parseable — the deletion time is when the card entered the trash, so real deletion order survives into the `modified`-descending sort — and from migration time otherwise); a lane carrying `deleted:` simply **loads live with the key ignored** — no migration machinery, no key-strip write, no notice (backward compatibility deliberately not funded: old tombstoned lanes reappearing is accepted; the key is inert, preserved verbatim like any unhandled key, logged). The app never writes `deleted:`. A `deleted:` key at board level remains meaningless — ignored and logged, preserved verbatim.

## 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.

## Validation and healing — the integrity taxonomy (settled 2026-07-29)

Every rule in this document that refuses, tolerates, recovers, or repairs is an instance of **one five-verdict taxonomy**, owned by one vocabulary — the **integrity service** (`IntegrityRules` + `HealScheduler`, 02-architecture.md ▸ Components). A detectable defect classifies into exactly one verdict, and the verdict fixes everything downstream — surface, write behavior, race posture — so no mechanism ever re-reasons its posture individually. The doctrine behind every lenient verdict (stated 2026-07-29): **leniency is recovering recoverable issues through reliable heuristics owned by this service — never accepting loss that could surprise the user.** Where a reliable recovery exists, it applies (silently where nothing is lost); where it doesn't, the defect surfaces — refusal and alerting are the honest ends of the same scale, and "proceed partially, lose a little" is never a verdict:

- **Refuse** — fail-fast (Malformed input above): the defect defeats rendering or ordering. Initial-load refusal; once open, the reload-breakage condition (02-architecture.md). **Refusal is the healer's last resort, and no longer a dead end** (re-ruled 2026-07-29 — fail-fast is *gated by the integrity service*): a defect with a reliable heal heals and moves on (a non-blocking notice at most); only an unhealable defect blocks, and blocking means a **decision surface** — the load pauses, the user chooses the fix, the fix applies, the load proceeds. Never a modal chain: the loader collects every fail-fast defect in the walk rather than stopping at the first, and one aggregated surface presents them all. The surface's design — per-defect-class decision vocabulary, aggregation shape, reconciliation with the welcome failure row, record-before-load, and the pre-snapshot loading state — is its own design pass (tracked on the findings board).
- **Refuse writes** — readable-but-uneditable shapes (Frontmatter above): the file renders fine, and every app write to it fails loudly per file.
- **Tolerate** — outside the schema's claim: strays, symlinks, case-twins, lane- and board-level `deleted:`. Preserved verbatim, logged, never rendered (or rendered with the key inert).
- **Coerce** — a sensible reading exists (the coercion rulebook, last-wins, null-as-missing, the rescue family): silent, read-side only, bytes preserved — the recovered value is exactly what the writer meant.
- **Heal** — an app-owned invariant is violated *and* a lossless canonical repair exists: the only verdict that writes.

Heals classify on two axes. **Consent**: *app-initiated* covers every **reliably healable** repair — semantically unambiguous and content-lossless (loose-file relocation, legacy card migration, `kind` backfill, the agent guide, and — re-ruled 2026-07-29 — the duplicate-id remint: Lanework owns the board and re-mints identity at will, Fractal layout above); *user-gated* is reserved for fixes that would genuinely choose interpretation — a class with **no current members** since the remint went silent, kept as the boundary line. The silent posture is safe because heals are auditable: on Pro boards every heal commits separately (below) — the durable trail; on base it simply happens quietly — and a heal that changes user content announces itself with a warning-tone notice (the relocation's precedent), never a consent gate. **Timing**, three modes: *inline* when the triggering gesture cannot proceed without it (the midpoint-exhaustion renumber, the import-boundary remint — each stays inside its gesture's write bracket and commit); *on-touch* when the defect is latent — the fix folds into a write already rewriting that file (`kind` backfill, duplicate-key twin removal, the colon rescue's quote-on-first-write); *scheduled* when the defect degrades behavior while it stands (loose files invisible to the attachment surface, `deleted:` cards rendering by a retired model, a stale agent guide actively misleading agents, a wrong-typed node squatting a claimed name at any level — deletion, or a card's attachment surface, broken while it stands) — run at the reload tail and at board open, uniformly. The timing principle: **proactive when the defect is load-bearing now, on-touch when it's latent.**

**Write discipline needs no rule of its own** (settled — the existing stamp rule decides every case): a heal that rewrites an `index.md` is an ordinary app write — stamps `modified`, clears `modified-by`; an on-touch heal rides its host write's stamp — while a heal that only renames or relocates folders and files never opens `index.md` and stamps nothing (the relocation, the remint — identity repairs, not edits). Atomicity is per file (temp + rename); interrupted batches are accepted per the renumber precedent — every intermediate state is valid. **Every scheduled heal re-verifies its defect against disk at write time** and no-ops when it is gone: losing the race to a foreign fix — an agent mid-edit, another repair — is success, never an error (the remint-races-a-vanished-duplicate precedent, generalized). Scheduled heals are memo-guarded against hot loops — armed before the attempt, cleared explicitly on success, re-armed only by a changed defect signature — and **defer, never abandon**, under any read-only lock.

**Healing mutations commit separately** (ruled 2026-07-29 — largely redundant by construction, made a guarantee): on git boards, a scheduled heal's batch is never folded into a commit carrying anyone else's work — a debounce window holding both splits the heal's paths into their own commit, the two-commit split gaining a heal class (06-history-undo.md ▸ Commit messages: the Writer's heal operations drop heal-marked receipts in the EchoLedger — attribution machinery like the author split, never message tagging; the composer stays a pure diff reader and names the heal commit from its own diff shape, the way "Update agent guide (vN)" already commits alone today). In practice each scheduled healer already runs its own write bracket at the reload tail — normally its own window, its own commit; the split converts that timing accident into a rule. The carve-outs are structural, not exceptions to intent: an **on-touch** heal rides its host write's single atomic rewrite — inseparable from the host's commit, the backfilled `kind` composing no event (bookkeeping, the renumber-rescale class — 06); **inline** heals batch with the gesture that triggered them (Ordering above — never a standalone renumber, never a separately-named remint at the import boundary); the duplicate-id remint — formerly the user-gated carve-out — is an ordinary scheduled heal since 2026-07-29 and commits separately by the main rule.

The vocabulary is per-kind and extensible: adding an object kind (the enhanced schema's comment) adds its field table and shape rules in one place, never a parallel mechanism.

## 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 behavior is settled: the materialized `.trash/` (Deletion above; UI in 03-board-ui.md).
