From f153e7915626b0827d02d6e90ae3d28c554228ae Mon Sep 17 00:00:00 2001 From: rzen Date: Wed, 29 Jul 2026 18:00:58 -0400 Subject: [PATCH] =?UTF-8?q?The=20dedupe=20universe=20includes=20.trash/=20?= =?UTF-8?q?=E2=80=94=20and=20the=20container=20boundary=20is=20the=20first?= =?UTF-8?q?=20tie-break?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stated 2026-07-29: when duplicate occurrences straddle live and trashed, the live one keeps the identity regardless of age — a restore done as a copy must never see the restored card withheld in favor of its trash ghost; the silent heal remints the trashed occurrence. Uniform across content duplicates, case-twins, and trashed lanes sharing a live lane's UUID. Board: Minor card 94086f7b → Resolved. Minor lane now holds only tombstoned sweep cards. Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97 --- DESIGN/01-storage-format.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/DESIGN/01-storage-format.md b/DESIGN/01-storage-format.md index 0473029..ff909bc 100644 --- a/DESIGN/01-storage-format.md +++ b/DESIGN/01-storage-format.md @@ -26,8 +26,8 @@ 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 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().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. 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. +- **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.