diff --git a/DESIGN/01-storage-format.md b/DESIGN/01-storage-format.md index 9176df0..d66f3b4 100644 --- a/DESIGN/01-storage-format.md +++ b/DESIGN/01-storage-format.md @@ -26,7 +26,7 @@ MyBoard.kanban/ ← board = the document ### Rules (carried over from the pathfinder, proven) - **Level is position.** Root = board, depth 1 = lane, depth 2 = card. No `type` field. -- **Folder names are lowercase UUIDv4, immutable, never renamed.** The folder name is the primary key; titles live in frontmatter only. Collisions are impossible by construction. **The identity predicate is shape-only — lowercase v4 is the emission rule, not the gate** (settled): a folder counts as an identity iff its name is 8-4-4-4-12 hexadecimal (36 chars, hyphens in place) in **any case and any UUID version** — effectively "does `UUID(uuidString:)` parse it". The lenient gate exists for the headline use case: macOS's `uuidgen` and Swift's `UUID().uuidString` both print *uppercase*, and a strict gate would make an agent's standard-tool card a silently skipped stray — the worst failure for a files-first app; the version nibble protects no invariant (an agent's v7 is as unique as a v4). **Identity comparison is UUID-value equality, never string equality** — two case-spellings of one UUID are one identity everywhere (the import-boundary collision check included), matching default-APFS case-insensitivity, where they are one folder anyway; on a case-sensitive volume two case-spelled siblings are the duplicate-UUID edge below, not two identities. The *app* still writes only lowercase v4 (`UUID().uuidString` explicitly lowercased), never renames an existing folder to canonicalize, and the agent guide keeps teaching lowercase — accept liberally, emit conservatively. +- **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; the loser takes the stray posture — skipped with a pointed log line, preserved verbatim, never rendered. (Content-distinct duplicate sibling UUIDs — a hand-copied folder given a same-UUID name — remain the unspecified edge 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 tombstoned items, 09; Duplicate carries both and strips only the remote configuration, 03) — the remint rule governs *item-level* copies landing inside an existing board, where identities could collide; a whole-board copy is a new namespace, and Duplicate's fork-keeps-history guarantee requires it (copied `.git` history must keep naming the paths it describes). Template GUIDs are inert anyway — instantiation remints at its own boundary. **Exception: template instantiation stamps `created`/`modified` fresh** — a board created from a template is born today, not forked from it (09-templates.md); Save as Template, by contrast, is an ordinary fork and keeps timestamps (inert anyway — instantiation restamps them). The app never produces a duplicate UUID: **within-board uniqueness is enforced at the import boundary** — a move-in (a cross-board ⌘-drag move or a cut+paste — 04-interactions.md) whose UUID already exists anywhere in the destination board is degraded to a copy (fresh UUID minted, content intact; the source is removed as usual for a move). **Degradation is per-folder, at the finest grain**: in a compound arrival (a lane with its cards, a multi-card drag or paste), only the colliding folders are reminted — everything else keeps its identity and remains a true move, and source removal is uniform regardless (the whole selection leaves the source, as for any move). A lane drag with one colliding nested card thus stays a lane *move*; just that card arrives reminted. One gesture silently yielding moved originals plus reminted copies is the intended behavior, not an accident — the collision is an identity repair, invisible in content terms. Boards are **independent identity namespaces**: the same UUID may legitimately exist in two boards (e.g. after a cross-board move-out undone at the source — 06-history-undo.md); such forks are tolerated and only ever meet at an import boundary, where the collision is neutralized. A user hand-copying a card folder in Finder can still produce sibling duplicates — the app doesn't currently detect that, and behavior with duplicate sibling-level UUIDs in one board is unspecified. - `index.md` = YAML frontmatter between `---` delimiters, then Markdown body. - **Reserved child names** at card level: `attachments/` (specified below) and `comments/` (enhanced schema — see below). Never levels themselves, never identity (identity-bearing siblings are UUIDs). diff --git a/DESIGN/02-architecture.md b/DESIGN/02-architecture.md index 962baca..77d04ea 100644 --- a/DESIGN/02-architecture.md +++ b/DESIGN/02-architecture.md @@ -54,7 +54,7 @@ The read-side rules above have a write-side mirror — one banner vocabulary for - **The debounced body save retries on its own cadence** — keystrokes stay in the dirty buffer, so nothing is lost while the window stays open; the banner stands until a save lands. The **one modal moment on the write-failure path**: closing a window (or the board, or quitting) with a dirty buffer that cannot be written — the only state that exists nowhere but memory — raises an alert (retry / save a copy elsewhere / discard) instead of failing silently. Everything else on this path stays non-modal. (Deliberate confirmations elsewhere are their own stories: Empty Trash… and Delete Immediately on boards without git history — 03-board-ui.md, the branch-switch save-or-discard step — 06-history-undo.md, machine-key regeneration — 07-sync-collab.md, the raw-source Apply validation alert — 05-card-window.md, the once-per-board iCloud/network-volume warning on open/create — 07, and the SSH trust-on-first-use fingerprint confirmation with its mismatch hard-block — 07.) - **A renamed or moved board root follows its file identity** (settled): the board the app has open is the *file*, not the path string — the registry's security-scoped bookmark is the identity, mid-session as much as across opens (01-storage-format.md calls Finder renames ordinary, and mid-session must honor that). On any root-gone signal — the watcher's path stops delivering, a write lands on a stale path — the app first **re-resolves the bookmark**: if it resolves to a new location, the rename/move is absorbed transparently — the watcher re-attaches there, Writer URLs and card-window keys re-derive from the new root, one full reload runs, and the window title follows the folder-name fallback where it applies — no banner, no lock, nothing was ever wrong. Only when the bookmark does not resolve is the root truly vanished (below). Either way, a root-gone signal **cancels any armed debounced tree-event delivery** (settled): both outcomes end in a full reload — at the re-resolved root, or on the root's return from the vanished-root lock — so delivering a stale tree event for a path that just stopped being the root would only be noise. - **A vanished board root locks the board read-only** — the bracketed-reload vocabulary applied to a root that is gone (volume unmounted, folder Finder-deleted while open — and the rename re-resolution above found nothing): every write would land nowhere, so the last-good snapshot stays on screen, read-only, banner up. The watcher keeps watching; if the root returns (remount, Finder undo), the next successful reload clears the lock and pending dirty buffers save normally. **A root change landing mid-bracket is owned by the root-change path, not the bracket** (settled): re-resolution runs immediately even inside a bracket — a rename is absorbed transparently and the bracket's final reload simply runs at the re-resolved root; a true vanish raises this lock at once and the bracket's eventual final reload becomes a no-op rather than a redundant failure. Nothing is lost by skipping it: the root's return runs the reconciling reload, and an operation the vanish killed mid-flight is 06-history-undo.md's own-leftovers case, recognized at the next open or flush. (The composition matters for a pull-rebase mid-flight when a volume unmounts — 07-sync-collab.md.) -- **An unwritable board location enters the read-only lock at open** (settled): opening probes the root's writability — a read-only volume (DMG, snapshot, read-only share) or a permission-denied folder opens straight into the read-only lock, banner naming the cause ("this board's volume is read-only"), rather than letting every gesture fail one at a time — fail loudly, specifically, *once*. The open-time agent-guide write (08-agent-integration.md) is skipped-with-log, the `CLAUDE.user.md`-taken precedent. Writability re-probes on every reconciling reload (wake, activation — above), so a fixed permission or rewritable remount clears the lock without ceremony. The lock's read affordances stay live as always — inspecting an archived board on a DMG is a legitimate errand, and viewing-first is the point. +- **An unwritable board location enters the read-only lock at open** (settled): opening probes the root's writability — a read-only volume (DMG, snapshot, read-only share) or a permission-denied folder opens straight into the read-only lock, banner naming the cause ("this board's volume is read-only"), rather than letting every gesture fail one at a time — fail loudly, specifically, *once*. The open-time agent-guide write (08-agent-integration.md) is skipped-with-log, the `CLAUDE.user.md`-taken precedent. Writability re-probes on every reconciling reload (wake, activation — above), **and the probe is symmetric** (settled): a rewritable remount or fixed permission clears the lock without ceremony, and a volume gone read-only mid-session *raises* it at the next probe — banner up front, not every gesture failing one at a time (the lock's own founding rationale). Between probes, a write that hits the newly read-only volume fails as an ordinary one-shot; the next reconciliation converts the condition into the standing lock. The lock's read affordances stay live as always — inspecting an archived board on a DMG is a legitimate errand, and viewing-first is the point. - **Auto-commit failures beyond `index.lock` contention** (06-history-undo.md covers the lock) — disk full mid-commit, repo corruption: the files are safely on disk but history stops advancing, which quietly suspends the undo trail and the flush-before-overwrite guarantee. That degradation is surfaced, not hidden: the banner states that changes aren't being recorded to history; the committer retries on the next debounce and the banner clears on the first successful commit. - **Attachment import copy failures** (source unreadable, destination full): the drop was accepted — "never refuses the drop" (01-storage-format.md ▸ Attachments) is policy, not an I/O guarantee — so a failed copy surfaces in the banner with the filename, and any partial file is removed; no half-copied attachment is ever left in `attachments/`.