Files
lanework/DESIGN/01-storage-format.md
T
rzen 68fa503250 Comments storage schema specified — fractal folders, chronological, window-scoped
Designed with the user 2026-07-29 (Redesign card 82e2de6c, storage half; UI half remains open). A comment is a UUID folder under comments/ holding index.md + optional attachments/ — a card's anatomy one level down, every fractal rule applying verbatim. Field table: kind: comment, lenient self-reported author (content, not overlay — survives app writes), created/modified; no title, no order. Threads sort chronologically by created (ranks rejected: independent clocks across machines and trackers), flat this iteration with nesting deferred. Comment defects never refuse the board — stray posture at worst. Delete skips the trash, undoable without confirm. Copies carry the thread, reminted, stripping remote/remote-state so two local comments never claim one remote object. Comments are window-scoped, outside the board snapshot — the walk stays O(cards); the committer already protects them via whole-root staging, and composer/announcer describe foreign comment changes by path shape. Count chip on faces filed as WISHLIST #9. Schema edition-blind.

Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
2026-07-29 18:17:51 -04:00

163 lines
49 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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. **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); 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. **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 moves-don't-stamp rule** (ruled 2026-07-29; the rule Deletion below and 03 ▸ Trash already cited). `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, a cross-lane move, 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). The trash move is the one deliberate exception (Deletion below): deletion is an edit to the item's story, so it stamps and clears both. 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 moves-don't-stamp rule above) — position changes touch no content, so a standing stamp survives a reorder or move, while the trash move, 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. **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. 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; no comments functionality ships in this version — the card-window UI is its own pending design pass, tracked on the findings board). 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. `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's no-timestamp-sort rationale inverts here); 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. **Delete skips the trash**`.trash/` holds cards and lanes; a comment deletes immediately and undoably (base captures and recreates, on git boards it's a commit), no confirm, undo is the net. **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` (two local comments must never both claim to be the same remote object — content preserved, mapping severed); 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: board-level search never sees comment bodies (the card window's own find does), 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…" (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. The schema is **edition-blind** (one format, no edition-specific keys); which edition renders and authors comments is the UI pass's question — 12-editions.md pins the enhanced schema to Teams today.
- **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 (moves-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. 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. Every arrival lands at the **top** regardless of kind: the move mints an `order` rank above the current topmost, so the trash sorts newest-first by ordinary ranks — no timestamp sort, no tie-break rules. The move **stamps `modified` and clears `modified-by`** (deletion is an edit to the item's story — the deliberate exception to the moves-don't-stamp rule, `modified`'s scope above), which 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 rank mint 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 / Delete Immediately / 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); 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).