From 176c8520fc7266df575772d8a70bdd1fd2a474c4 Mon Sep 17 00:00:00 2001 From: rzen Date: Wed, 29 Jul 2026 17:10:29 -0400 Subject: [PATCH] =?UTF-8?q?Reliably=20healable=20repairs=20run=20silently?= =?UTF-8?q?=20=E2=80=94=20the=20user-gated=20Repair=20banner=20retires?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit General ruling (2026-07-29): app-initiated covers every reliably healable repair — unambiguous, content-lossless — because heals are auditable: Pro's separate heal commit is the durable trail, base happens quietly, and a heal that changes user content announces with a warning-tone notice, never a consent gate. The user-gated class stays defined but has no current members. Duplicate-id healing goes silent under Lanework-owns-the-board (free to re-mint UUIDs at will): no condition banner, no Repair button — a scheduled heal remints withheld occurrences (memo-guarded, disk re-verified, no-op on vanished duplicates) and posts "Repaired duplicate id — 'Fix login'". Winner rule unchanged: earlier commit on git boards, filesystem birth date then traversal order without history. 06 keeps the Repair verb as the heal commit's name; 13 moves the remint outside undo with all heals. Dissolves the Repair-has-no-Nexus-row contradiction — no command, no row owed. Board: Contradictions card ce5e7f60 → Resolved (lane now empty); Implementation card 50ed2b5d (Active) retitled "Dedupe duplicate ids and heal them silently" and realigned mid-flight. Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97 --- DESIGN/01-storage-format.md | 8 ++++---- DESIGN/02-architecture.md | 2 +- DESIGN/06-history-undo.md | 2 +- DESIGN/13-native-undo.md | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/DESIGN/01-storage-format.md b/DESIGN/01-storage-format.md index ba59371..5b88821 100644 --- a/DESIGN/01-storage-format.md +++ b/DESIGN/01-storage-format.md @@ -27,7 +27,7 @@ MyBoard.kanban/ ← board = the document - **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 **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. +- **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. - `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. @@ -137,11 +137,11 @@ Every rule in this document that refuses, tolerates, recovers, or repairs is an - **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* when the repair is semantically unambiguous and content-lossless (loose-file relocation, legacy card migration, `kind` backfill, the agent guide); *user-gated* when the fix chooses identity or interpretation (the duplicate-id **Repair** mints identity, so it waits behind its condition banner — Fractal layout above). **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 — deletion is 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.** +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 — deletion is 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 Repair-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. +**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); the user-gated **Repair** is its own gesture and already commits as its own subject. +**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. diff --git a/DESIGN/02-architecture.md b/DESIGN/02-architecture.md index ec41866..6a21c09 100644 --- a/DESIGN/02-architecture.md +++ b/DESIGN/02-architecture.md @@ -43,7 +43,7 @@ The **one named exception** is transient UI state rendering things that don't ex ### Live-reload resilience - **A failed reload never replaces a good snapshot.** Fail-fast (01-storage-format.md) is the *initial-load* contract, where there is nothing to fall back on. Once a board is open, a watcher-triggered reload that fails (unparseable YAML, missing required fields — typically a non-atomic external write caught mid-flight) keeps the last good snapshot on screen and raises a **non-modal banner** carrying fail-fast's specifics (offending path + what's wrong). The watcher keeps watching; the next successful reload clears the banner automatically — transient breakage self-heals without the user losing the board, persistent breakage stays loudly visible. Editing is not locked out: writes go through the Writer as usual (the breakage is per-file and localized), and the reload debounce already absorbs most momentary invalid states before they surface. -- **Duplicate-id detection surfaces as a repairable condition** (settled): the loader's board-wide dedupe (01-storage-format.md ▸ Fractal layout rules) withholds losing occurrences from every snapshot; a warning-tone condition banner names them and offers **Repair** — a Writer remint under user intent (the loader itself never writes). The condition heals when the duplicates are gone, however they go — Repair here, a repair elsewhere, a hand-deleted copy. +- **Duplicate-id detection heals silently** (re-ruled 2026-07-29, superseding the repairable-condition banner): the loader's board-wide dedupe (01-storage-format.md ▸ Fractal layout rules) withholds losing occurrences from every snapshot; a scheduled heal remints them through the Writer (the loader itself never writes) and a warning-tone notice reports the repair — no banner, no button, nothing waits on consent. The withheld window is one heal cycle, not a standing condition; a remint racing a vanished duplicate (repaired elsewhere, a hand-deleted copy) is a no-op, never an error. - **The watcher is self-reconciling, never trusted blindly** (settled): every reload is already a full tree walk producing a value-type snapshot, so recovery from any blind window is always the same act — reload. A **reconciling reload** runs on wake-from-sleep and on app re-activation (debounced; an identical tree swaps in value-equal, so quiet reconciliations cost nothing visible), on any FSEvents flag admitting missed events (`MustScanSubDirs`, queue overflow — degrade to the reload rather than trust the gap), and after any stream re-creation. **Streams die and are recreated, not merely kept**: a volume unmount kills the stream with its root; the vanished-root and rename re-resolution rules (below) attach a *fresh* stream at the current root when it returns, reconciling reload included. A silently stale board — the worst failure for a files-are-truth app — is structurally excluded: every known blind window ends in a reload. **A reconcile request arriving mid-bracket is banked** (settled): the mandatory post-bracket reload delivers as the *reconciling* kind rather than app-mediated — an explicit reconciliation is never silently lost. FSEvents missed-events flags arriving mid-bracket are, by contrast, simply swallowed: the post-bracket reload is a full walk either way, and only the origin tag differs (it feeds commit attribution and the VoiceOver announcement vocabulary — a deliberate asymmetry). - **App-initiated git churn is bracketed.** Operations the app runs itself (pull-rebase, branch switch, undo restore — 06-history-undo.md, 07-sync-collab.md) suspend watcher reloads for their duration and finish with one full reload — half-checked-out trees are never rendered. **The bracket also locks writes** (settled): for its duration the board is read-only with exactly the failed-reload lock's scope — mutating commands disable via menu validation, drops are refused, selection/navigation/search/copy-out stay live. 07's interaction-rest rule composes: the bracket starts only at gesture rest, so nothing in flight is interrupted; the lock ends with the final reload — seconds, honestly signaled by the operation's in-progress banner row (▸ The banner surface). External git activity (the user running git in a terminal) can't be bracketed: the debounce coalesces its churn, and a transiently inconsistent but parseable tree may render briefly and heals on the next event — accepted. - **Selection survives reloads by UUID.** Selection — and every transient state that references items (drag state, pending cut) — is a set of UUIDs over the snapshot, re-resolved when a reload swaps it: items still present stay selected; items that vanished leave the selection silently, no substitute invented — the search filter's hidden-cards-leave-the-selection rule (04-interactions.md) applied to external change. **A container crossing is a vanish for this purpose** (resettled 2026-07-28 — the materialized trash): re-resolution matches UUID *and* container side (board vs `.trash/`), so a foreign move that trashes a selected board card — or restores a selected trash card — ejects it from the selection (and from the pending cut, which 04-interactions.md ▸ Clipboard already states), keeping 04's container-boundary invariant true across reloads. The old effective-liveness ancestor walk is retired with the tombstone model — presence in the snapshot is the whole question. The search filter is deliberately absent from that list: the query string is transient state, but its result set is *derived* — the predicate re-runs against each new snapshot (04's live filter), so a card an agent files mid-search appears the moment the reload lands, and a card edited to no longer match animates out. Kin rules elsewhere: card windows dismiss when their card is deleted or moved to the trash (05-card-window.md), the placeholder is discarded when its lane vanishes (above), and VoiceOver announces a vanished focused card and recovers focus to its lane (10-accessibility.md). App-mediated deletion is deliberately different — an act, not a surprise: ⌫ selects the successor sibling (04-interactions.md ▸ The map). diff --git a/DESIGN/06-history-undo.md b/DESIGN/06-history-undo.md index 6c21e92..dca8a5e 100644 --- a/DESIGN/06-history-undo.md +++ b/DESIGN/06-history-undo.md @@ -30,7 +30,7 @@ Git is the undo substrate — on boards that have git. **Git is opt-in per board The pathfinder's message engine carries over as the model — it is what earns the "semantic" in semantic commit messages, and it stays a pure, testable function: - **Pure snapshot diff, no write-site tagging.** Messages compose at commit time from a structural diff of two board snapshots (last-committed vs. current) — never by intercepting operations. Items match by id across the *whole* board, so a lane change is distinguishable from delete+add and a cross-lane move reads as a move. Bookkeeping — `order` changes that preserve sibling sequence (a renumber's rescale — 01-storage-format.md), `modified`/`created`, and an on-touch heal's backfilled `kind` (01-storage-format.md ▸ Validation and healing) — produces no events: a diff touching only those composes nothing. Sequence is what the diff compares, not raw `order` values: an order change that *repositions* an item among its siblings composes Reorder, so a foreign writer's single-file reorder still reads as one. A midpoint-exhaustion renumber batches with the insert or move that triggered it, so its commit reads as that event. -- **Vocabulary**: Add / Delete / Move / Rename / Edit / Restyle / Resize / Reorder over cards, lanes, and the board, plus Attach / Remove for attachment files ("Move card 'Fix login' to Doing", "Rename lane 'Todo' → 'Doing'"), plus **Repair** for the duplicate-id remint ("Repair duplicate of 'Fix login'" — 01-storage-format.md's banner-gated fix; app-mediated, so the composer names it directly instead of reading the folder swap as Permanently delete + Add), plus the trash pair (settled) — **Restore** and **Permanently delete** — distinguished by diff shape alone, keeping the composer a pure snapshot diff: a move into `.trash/` is Delete, a move out of it is Restore ("Restore card 'X'" — drag-to-restore, cut+paste), and an item *leaving the tree entirely* is Permanently delete ("Permanently delete card 'X'" — Delete Immediately, Empty Trash, and any foreign hard removal, which the shape rule catches and describes accurately for free). The trail thereby tells moved-to-trash from gone-forever — the distinction Deleting never forgets (below) asks users to learn. Plural folding applies as usual: Empty Trash reads "Permanently delete 12 cards", cleanly distinct from a multi-select ⌫'s "Delete 12 cards". One commit per debounce window: a single event is the subject (with a detail body where one helps); several events of one kind fold into a plural subject, with shared destinations preserved ("Move 3 cards to Done"); genuinely mixed windows fall back to "Update board" — always with a bulleted body naming every event, so the oneline log stays scannable and the full message stays complete. +- **Vocabulary**: Add / Delete / Move / Rename / Edit / Restyle / Resize / Reorder over cards, lanes, and the board, plus Attach / Remove for attachment files ("Move card 'Fix login' to Doing", "Rename lane 'Todo' → 'Doing'"), plus **Repair** for the duplicate-id remint ("Repair duplicate of 'Fix login'" — 01-storage-format.md's silent scheduled heal, re-ruled 2026-07-29 from its former banner gate; app-mediated and heal-marked, so its separate heal commit names the remint directly instead of reading the folder swap as Permanently delete + Add), plus the trash pair (settled) — **Restore** and **Permanently delete** — distinguished by diff shape alone, keeping the composer a pure snapshot diff: a move into `.trash/` is Delete, a move out of it is Restore ("Restore card 'X'" — drag-to-restore, cut+paste), and an item *leaving the tree entirely* is Permanently delete ("Permanently delete card 'X'" — Delete Immediately, Empty Trash, and any foreign hard removal, which the shape rule catches and describes accurately for free). The trail thereby tells moved-to-trash from gone-forever — the distinction Deleting never forgets (below) asks users to learn. Plural folding applies as usual: Empty Trash reads "Permanently delete 12 cards", cleanly distinct from a multi-select ⌫'s "Delete 12 cards". One commit per debounce window: a single event is the subject (with a detail body where one helps); several events of one kind fold into a plural subject, with shared destinations preserved ("Move 3 cards to Done"); genuinely mixed windows fall back to "Update board" — always with a bulleted body naming every event, so the oneline log stays scannable and the full message stays complete. - **Implied events don't steal the subject**: deleting a lane with five cards reads "Delete lane 'X'" with the card deletions as body bullets — not "Update board". - **Non-snapshot files commit too** (settled — the repo tracks more than the model: the agent guide, `CLAUDE.user.md`, the seeded `.gitignore`, and strays at every level): the committer **stages the whole board root** — whatever `git status` shows, `.gitignore` respected, **open Edit sessions still staged around** (settled): the session card's folder stays excluded exactly as in Rules ▸ Auto-commit, whole-root staging widening *what* commits, never overriding the exclusion — and its commit condition is the *tree*, not the snapshot diff, so a stray-only window commits rather than leaving the tree dirty (firing mid-session, it commits the strays and leaves the session folder untouched) (a permanently dirty stray would break branch switch's cannot-fail-dirty guarantee and void flush-before-overwrite for every file the model can't see). The composer's input extends accordingly: beside the snapshot diff it receives the changed-path list, and non-snapshot paths compose **path-shaped events** — `CLAUDE.md` composes "Update agent guide (vN)", the version read from the guide's marker first line (a pure function of file content, *not* write-site tagging — the no-interception rule stands); any other non-snapshot path composes "Update '⟨path⟩'", folding plural ("Update 3 files"). Model events keep the subject when present; non-snapshot changes then ride as body bullets — recorded, never silently absorbed under an unrelated subject. Attribution needs no new rule: the EchoLedger (02-architecture.md) is path-keyed, so the app's guide write classifies app-mediated by its receipt (the mechanism behind the guide-attribution exception below) and a stray edit classifies foreign, the two-commit split applying per file as everywhere else. - **Healing mutations commit separately** (ruled 2026-07-29 — 01-storage-format.md ▸ Validation and healing): a debounce window holding a scheduled heal's changes alongside anyone else's splits the heal's paths into their own commit — the two-commit split's mechanism with a third class, keyed by the Writer's heal-marked receipts in the EchoLedger (attribution machinery, like the author split; the composer stays a pure diff reader and names the heal commit from its own diff shape — a loose-file relocation reads as its Attach-shaped event, a legacy migration's move into `.trash/` as Delete, the guide as "Update agent guide (vN)"; that the shape vocabulary doesn't say "healed" is accepted, the banner already told the user). Mostly redundant — each scheduled healer runs its own bracket at the reload tail, normally its own window — but the split makes separation a guarantee rather than a timing accident. On-touch and inline heals are structurally exempt: each lives inside a host write or its triggering gesture and rides that commit, the backfilled `kind` composing no event per the bookkeeping rule above. diff --git a/DESIGN/13-native-undo.md b/DESIGN/13-native-undo.md index 537c698..a2c6936 100644 --- a/DESIGN/13-native-undo.md +++ b/DESIGN/13-native-undo.md @@ -6,7 +6,7 @@ The undo/redo substrate for base Lanework (12-editions.md), filling the one gap - **One stack per board, owned by the board session.** Not per-window: every window over a board (board window, its card windows) shares the store and shares the stack. `window.undoManager` for board surfaces returns the session's manager; 06 ▸ Undo routing applies unchanged — text-editing surfaces get their session-scoped text undo, everywhere else ⌘Z/⇧⌘Z hit the board stack. Undo is board-local, exactly as git undo was. - **Registration at the Writer boundary.** Every app-mediated mutation already passes through the Writer as a `WriteOperation` (02-architecture.md) — that closed enum is the exact inventory of undoable operations. Each Writer call site registers the inverse operation, computed from the pre-write snapshot the store already holds: move → move back (original lane, original `order`); reorder → restore original `order`; rename → restore title; restyle → restore prior style; resize → restore prior width; Edit-session body save → restore prior body bytes; card or lane delete (⌫) → move back out of `.trash/` (lanes rejoined the trash 2026-07-29 — the recreate-from-capture inverse retires with the last destructive delete); restore-by-move → move back in; create → remove the created folder. -- **What is not undoable** (settled): **Permanently delete** (Delete Immediately, Empty Trash) — `purgeIsUnrecoverable` stays true in base, and the existing confirmation rule (03-board-ui.md) already fires on all base boards, since none have git history: the confirm *is* the safety. **Repair** (01-storage-format.md's duplicate-id remint) — undoing a remint would recreate the duplicate id the operation exists to remove. Both match their existing "destructive, confirmed, final" posture. +- **What is not undoable** (settled): **Permanently delete** (Delete Immediately, Empty Trash) — `purgeIsUnrecoverable` stays true in base, and the existing confirmation rule (03-board-ui.md) already fires on all base boards, since none have git history: the confirm *is* the safety. **The duplicate-id remint** (01-storage-format.md — a silent scheduled heal since 2026-07-29, formerly the user-gated Repair) — heals aren't user gestures, so nothing enters the stack, and undoing one would recreate the duplicate id it exists to remove. Permanently delete matches its existing "destructive, confirmed, final" posture; the remint sits outside undo as all heals do. - **Coalescing follows commit granularity** (settled): one gesture, one undo step — a multi-card move is one step with a plural title; an Edit session is one step, registered at the Edit→Preview flip (the effective Save — 05-card-window.md); a styling batch is one step (03's one-gesture-one-commit rule, substrate swapped). The 06 vocabulary supplies menu titles ("Undo Move 3 Cards"), via NSUndoManager's dynamic retitling — the same naming machinery both editions use. - **Session-only persistence** (settled): the stack lives with the board session and dies at close/quit — standard macOS behavior. Git undo's survive-relaunch property is a Pro difference, stated honestly (12's matrix). - **Foreign writes never join the stack** (settled): NSUndoManager can only undo what the app mediated. An agent's or hand edit is not a step — the honest capability gap vs Pro (12's matrix). Foreign changes also do not clear the stack wholesale; collisions are handled lazily, per step, by validation: