User-proposed and ruled 2026-07-29, retiring the design's sole
destructive delete: lane delete becomes a move into .trash/, exactly as
cards move. Full rationale on the Redesign board's Resolved card;
implementation is post-m12 ("Lanes delete into the trash" on the
Backlog) — the docs deliberately lead the code until then.
- 01: kind: lane always present on lane frontmatter (created with the
lane, healed on touch, never stripped) — discriminates lane from card
in the flat .trash/, where an empty lane is shape-identical to a
card; legacy deleted: on lanes is now simply ignored (no migration,
no notice — backward compatibility deliberately not funded).
- 03: trashed lane = opaque dimmed row with card count, restores whole
or purges whole; confirms count lane freight; fixed 03's own copy of
the "native undo restores a permanent delete" contradiction.
- 04: delete staged by place for lanes too (board→trash no dialog,
trash→permanent confirmed); lane drop on shown trash deletes;
kind-homogeneous trash selections, ranges skip by kind; ⌘X/⌘V restore
rides the existing lane-paste rule.
- 10: trashed lane is one flattened element, never a container.
- 13: lane delete inverse = ordinary move back; recreate-from-capture
retires — no destructive delete remains outside the trash.
- 08: agent guide v6 — lanes trash by move, kind: lane taught.
Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
32 KiB
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
typefield. - 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'suuidgenand Swift'sUUID().uuidStringboth 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; 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().uuidStringexplicitly 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.gitand.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.githistory must keep naming the paths it describes). Template GUIDs are inert anyway — instantiation remints at its own boundary. Exception: template instantiation stampscreated/modifiedfresh — 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 (laneorder, then cardorder, then the folder-name tie-break) the last resort when dates tie or are unreadable. 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 surfaced, not silent (settled): the board window raises a warning-tone condition banner naming the duplicate ("Duplicate id: 'Fix login' exists twice"; several fold into one row with a count) and offering Repair — copy semantics applied at detection: the hand copy was a copy, so Repair 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 under user intent and commits normally ("Repair duplicate of 'Fix login'" — 06-history-undo.md's vocabulary gains the verb) — and the banner follows the condition lifecycle (02-architecture.md ▸ The banner surface): it heals when the duplicates are gone, via Repair or externally (another device repaired first; the copy was hand-deleted), and Repair racing a vanished duplicate is a no-op heal, never an error. 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) andcomments/(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.mdinside — is a stray: skipped with a log warning, preserved verbatim, never rendered. A hand-madenotes/folder or a board nested inside another can never brick a load; the cost — a hand-authored lane namedtodo/doesn't render — is what the warning and the agent guide exist for. - A UUID-named folder below the root lacking
index.mdis 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, onlyindex.mdand 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'sindex.md(notattachments/, not a reserved name) belongs inattachments/, 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, anotes/folder) are legitimate residents. The agent guide teaches the rule (08-agent-integration.md): card files belong inattachments/. Three board-root names are app-claimed, not strays — the one scope on the verbatim promise:CLAUDE.mdis app-owned and rewritten on guide version bumps, with a markerless user-authoredCLAUDE.mdrelocated toCLAUDE.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. - 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.mdis 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 |
modified's scope — content provenance, not file activity (settled). The stamp updates on every app write that rewrites the item's index.md, and only those — it describes the item's content (title, body, frontmatter), never its files. Two designed app writes therefore don't bump it, deliberately: raw-source Apply writes the validated buffer byte-for-byte (05-card-window.md — the verbatim contract outranks stamping, the same reason it doesn't clear modified-by below), and attachment operations never touch index.md at all — load-bearing, not an oversight: Add Attachment stays enabled during an open raw-source edit precisely because it can't fight the buffer (03-board-ui.md ▸ toolbar). Accepted consequence: the card window's "Modified ⟨date⟩" line (05-card-window.md) can read older than the card's latest attachment activity — attachment history lives where files do (the sidebar shows them; on git boards the Attach/Remove commits are the activity trail — 06-history-undo.md).
modified-by — self-reported provenance (settled). An external writer (agent, script) may stamp a file it writes with a short free-form identity (modified-by: claude). The app never sets it and clears it on every app-mediated write — absence means "the board's user, via the app"; the file is being rewritten anyway, so clearing costs no extra write. Consequences compose from existing rules: copies arrive cleared (paste and duplicate are app writes), template instantiation strips it alongside its timestamp restamp (09-templates.md), Save as Template keeps it inertly. The key is schema-owned, not an unknown key — unknown-key preservation doesn't apply to it. Downstream surfaces: the card window's modified line renders it (05-card-window.md), and it refines foreign commit attribution (06-history-undo.md). Honest limit: it is self-reported — a hand edit that leaves an old stamp in place inherits its attribution until the next app write clears it. One carve-out: raw-source Apply (05-card-window.md) writes byte-for-byte and does not clear a stamp the user typed or kept — the outlet exists to write anything, and the validated-then-verbatim contract outranks the clearing rule.
Board (<root>/index.md)
| Field | Type | Required | Notes |
|---|---|---|---|
template |
mapping | no | {order: N} — picker position when the board lives in a template store (09-templates.md); future subkeys possible. Carried onto instantiated boards, where it is ignored and preserved like any unknown key. |
Body: board description (free Markdown).
Board naming: the schema is uniform at every level — folder name is storage identity, title is display. A board's title is what the window title and welcome recents show; when absent, the UI falls back to the folder name (sans extension). The in-app Board rename (03-board-ui.md) edits title only, never the folder — so the app's display name and the Finder document name can diverge (accepted; renaming the folder is a Finder operation). Template instantiation seeds the new board's title with the user-chosen document name, so the two start out matching (09-templates.md).
Lane (<root>/<guid>/index.md)
| Field | Type | Required | Notes |
|---|---|---|---|
order |
number | yes | Rank among lanes, ascending = left-to-right |
width |
integer | no | Width multiplier ≥ 1 (default 1); no cap |
kind |
string | no | Always lane (ruled 2026-07-29) — written at lane creation, healed on touch when absent, never stripped. Redundant with depth on the live board; load-bearing inside .trash/, where it discriminates a trashed lane from a card (Deletion below). Cards never carry it. |
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. 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.
Enhanced schema (reserved, out of scope)
Future integrations extend the core schema; this version only reserves their names so nothing else squats on them. The app treats reserved keys as ordinary unknown keys (preserved verbatim, invisible in the UI) and reserved folders as ignored non-level content:
comments/card child — a future comment thread: fractal like everything else (folder-per-comment holdingindex.md+attachments/,author/createdfrontmatter, chronological). Specified fully when the enhanced schema is designed; no comments functionality in this version.- Card metadata keys
labels,assignees,due— issue-style metadata belonging to the tracker-integration story (the pathfinder shipped them natively; the rewrite reserves them instead). One deliberate carve-out: the commit-message composer names changes to these three keys ("Relabel card 'X'" — 06-history-undo.md), invisible in the UI though they are. remotekey (board, card) andremote-statekey (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.mdin that lane. On a remote-shared board the burst is conflict-prone in principle but benign in practice: each rewritten file changes only itsorder, 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-preservingorderrewrites 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 (
) 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 (joiningCLAUDE.mdand the seeded.gitignoreon the claimed list; agents and hand-editors should treat it as the app's)..trash/holds card and lane folders interleaved directly, noindex.mdof its own. Every arrival lands at the top regardless of kind: the move mints anorderrank above the current topmost, so the trash sorts newest-first by ordinary ranks — no timestamp sort, no tie-break rules. The move stampsmodified(deletion is an edit to the item's story — the deliberate exception to moves-don't-stamp), 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.mdboth). Laneindex.mdfrontmatter therefore carrieskind: laneat all times — written at lane creation, healed on touch when absent (minimum: the trash move stamps it; the fuller healing posture is the validation-service design, tracked on the Redesign board), never stripped (restore stays a pure move; the key is inert on the live board where depth wins). Cards never carry the key — absent means card. The trash reader's rule:kind: lane→ lane; no key but UUID-shaped children with their ownindex.md→ lane (self-heal the key); neither → 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 carryingdeleted: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 carryingdeleted: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 writesdeleted:. Adeleted: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.
Document packaging
- Boards are created as
.kanbanpackages (UTI conforming tocom.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).