diff --git a/DESIGN/01-storage-format.md b/DESIGN/01-storage-format.md index 264db69..a470b39 100644 --- a/DESIGN/01-storage-format.md +++ b/DESIGN/01-storage-format.md @@ -87,11 +87,11 @@ 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 unusable exactly like a non-numeric one and takes the same reading — append-at-end (re-ruled 2026-07-31, Ordering below; NaN has no place in the total order the tie-break and midpoint math assume, so an unusable value never enters it — it sorts by the default instead). The hostile-YAML suite keeps both shapes (`width: 0`, `order: .nan`), now as coercion cases. 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 usability reading — duplication itself never degrades anything. 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 with no sensible reading: YAML validity, a `schema` newer than the app, and the board root's requirements (re-ruled 2026-07-31 — below the root, `order` and `schema` are optional and default instead; a present-but-unusable `order` takes the same append-at-end reading as a missing one, Ordering below). **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) +## Enhanced schema (comments shipped; tracker keys reserved) -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: +Future integrations extend the core schema. Comments — specified here 2026-07-29 ahead of the feature — have since **shipped**: the `comments/` schema below is live, rendered and authored in the app on both platforms. The remaining names stay reserved 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 cleanly or the board session ends (a stale-skipped step's backing waits for session end — 13's skip-purge decoupling, ruled 2026-07-31); 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 — residue defined as content no live coarse step owns, ruled 2026-07-31: 13-native-undo.md's sweep gate), 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//` 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). +- **`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; shipped: thread column, composer, `.draft`, and `comments/.trash/` are all live). 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 cleanly or the board session ends (a stale-skipped step's backing waits for session end — 13's skip-purge decoupling, ruled 2026-07-31); 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 — residue defined as content no live coarse step owns, ruled 2026-07-31: 13-native-undo.md's sweep gate), 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//` 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.