The parse memo's blind spot blessed on file — reachable, pinned, and still outside the app's care

Minor ruled into 02 ▸ the memoize ruling: a stamp-preserving rewrite is
a decision on file rather than a surprise in the field — git lives with
the same blind spot, the behavior is test-pinned, and the tightenings
(hashing, generation stamps) wait for a real-world defeat.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
2026-08-06 21:02:47 -04:00
parent 13388776e1
commit b1aa0c4483
+1 -1
View File
@@ -44,7 +44,7 @@ The **one named exception** is transient UI state rendering things that don't ex
- **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 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 — and, blessed 2026-07-31, the store **skips the assignment entirely** when the fresh snapshot equals the current one: assigning an equal tree into an `@Observable` property still costs a render pass, so "costs nothing visible" becomes *costs nothing*), 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. **The skip's structural consequence — the two-counter split (blessed 2026-08-06):** once value-equal landings stop bumping the applied-snapshot generation, anything whose subject is the *walk* rather than the applied snapshot can no longer key on it. The store therefore carries two counters — snapshots **applied** (the committed-overlay hold's event, meaning unchanged) and walks **landed** with a snapshot in hand, bumped on every successful reload, equal or not; a failed reload bumps neither. The rule for choosing: **anything outside the snapshot, or about the walk itself, watches landed walks, not applied snapshots** — the card window's comment thread (comments are outside the snapshot, so a foreign comment arriving leaves the model value-equal), the comment search index (same shape), and the auto-committer's covering gate (what covers a flush is a completed walk, whether or not it found anything to show); on the applied counter each would sleep through exactly the value-equal landing it exists to notice. **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). **The walk memoizes its parse, never its result** (blessed 2026-07-31 — a performance posture, not a semantic change): the loader may reuse the previous snapshot's parsed item for any `index.md` whose path, mtime, and size are unchanged — the previous snapshot *is* the memo — while directory enumeration (folder discovery, attachment listings, trash entries) stays fresh every walk, because attachment changes never touch `index.md`. The loader's contract is result-purity with cost unspecified: same tree in, same snapshot out, and the memo can only change how fast. The mtime+size trust is the git-index heuristic; a writer that defeats it — content changed, mtime and size both preserved — is outside the app's care.
- **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 — and, blessed 2026-07-31, the store **skips the assignment entirely** when the fresh snapshot equals the current one: assigning an equal tree into an `@Observable` property still costs a render pass, so "costs nothing visible" becomes *costs nothing*), 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. **The skip's structural consequence — the two-counter split (blessed 2026-08-06):** once value-equal landings stop bumping the applied-snapshot generation, anything whose subject is the *walk* rather than the applied snapshot can no longer key on it. The store therefore carries two counters — snapshots **applied** (the committed-overlay hold's event, meaning unchanged) and walks **landed** with a snapshot in hand, bumped on every successful reload, equal or not; a failed reload bumps neither. The rule for choosing: **anything outside the snapshot, or about the walk itself, watches landed walks, not applied snapshots** — the card window's comment thread (comments are outside the snapshot, so a foreign comment arriving leaves the model value-equal), the comment search index (same shape), and the auto-committer's covering gate (what covers a flush is a completed walk, whether or not it found anything to show); on the applied counter each would sleep through exactly the value-equal landing it exists to notice. **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). **The walk memoizes its parse, never its result** (blessed 2026-07-31 — a performance posture, not a semantic change): the loader may reuse the previous snapshot's parsed item for any `index.md` whose path, mtime, and size are unchanged — the previous snapshot *is* the memo — while directory enumeration (folder discovery, attachment listings, trash entries) stays fresh every walk, because attachment changes never touch `index.md`. The loader's contract is result-purity with cost unspecified: same tree in, same snapshot out, and the memo can only change how fast. The mtime+size trust is the git-index heuristic; a writer that defeats it — content changed, mtime and size both preserved — is outside the app's care (blessed 2026-08-06 as a decision on file, the boundary being reachable in practice — a byte-length-preserving edit plus deliberate utimes, a restore tool replaying old attributes — and pinned by test: git itself lives with the same blind spot, and the named tightenings — content hashing, which is the read the memo exists to avoid, or fileSystemFileNumber/generation stamps — wait for a real-world defeat, not a hypothetical one).
- **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).
- **A failed reload after a bracketed operation locks the board read-only** — the exception to "editing is not locked out" above. Ordinary watcher breakage is per-file: the snapshot still describes the tree, so editing around the broken file is safe. But a bracketed git operation changed the tree *wholesale*: if its final reload fails, the last-good snapshot on screen describes the pre-operation state (after a branch switch, a different branch entirely — 06-history-undo.md), and writes derived from it would land nonsense on the new tree. The banner carries the same fail-fast specifics plus the read-only state; the next successful reload (typically after the offending file is fixed) clears both. **The rule arms on every bracket exit, thrown operations included** (settled): an operation that fails or aborts mid-flight is precisely when the tree's state is least known, so the mandatory final reload runs regardless — succeeding, it renders whatever the operation left (often value-equal after a clean failure, whose tree is left as it was — 06-history-undo.md); failing, it locks exactly as above. **The lock's scope** (shared with the vanished-root case below) spans every window sharing the store — card windows included: every mutating command disables via menu validation — creation, delete and restore, paste, Move/Style/rename, trash operations, the popover's git controls, and the card window's write paths: the flip into Edit mode, raw-source entry and Apply, Add Attachment and the whole-window file drop, the sidebar's mutating actions, and task-list checkbox toggles (these disable in place as controls — 05-card-window.md's in-content rule) — and the board refuses drops, including drags arriving from another board's window. **The lock is a predicate every mutating entry point consults, not a menu-validation sweep** (settled): mutating paths without a menu item exist beyond the checkboxes — the attachment row's ⌫/Remove (grammar key + context menu only, 11-command-nexus.md ▸ Context menus), plain-⌫ delete, Return-creation — and each disables with its surface or follows its command twin's validation; menu validation is the lock's most visible face, never its whole mechanism. **File ▸ Duplicate and File ▸ Save as Template join the disabled set** (settled): both copy the on-disk tree, which the lock marks as gone (vanished root), unknown (this failed-reload state), or unwritable — and both are specified to run the close flush first (03-board-ui.md, 09-templates.md), which the lock's suspended saves make impossible to honor. **One carve-out: under the unwritable-location lock alone (below), Save as Template stays live** — it reads the board and writes into Application Support, the copy-out-is-a-read principle applied (archiving the read-only DMG board being inspected is a legitimate errand). **The carve-out gates on the hazard itself, open sessions, not on lock provenance** (settled): the item disables while any open Edit or raw-source session holds unsaved content — content the lock's suspended saves cannot flush, which the template would silently miss (09-templates.md's never-misses-keystrokes guarantee outranks availability) — and re-enables when those sessions settle or the lock clears. A lock standing since open never meets this state: it disables the flip into Edit mode, so no session can start beneath it and the gate is vacuously open; unsaved sessions under this lock exist only when the symmetric probe (▸ Write-failure surfacing) raised it mid-session. Duplicate stays disabled even there — its destination is the same unwritable parent (a writable-parent/unwritable-board permission split was weighed and set aside as too rare to earn the inconsistency). Drags *out* of a locked board offer the copy variant only — copy-out is a read; a ⌘-drag move's source-side delete is a write, so the modifier doesn't take. **An Edit buffer already open when the lock lands keeps its content and stays typable** — memory is not disk — but its debounced save suspends for the lock's duration; the held text's fate follows the lock's cause: a branch switch or undo restore can't leave a session open behind the lock at all (both settle editors first — 06-history-undo.md), a post-pull buffer saves on clear and wins per the sync model (05-card-window.md, 07-sync-collab.md), and a returned root saves normally (below). Selection, navigation, search, ⌘C copy-out, and Reveal in Finder stay live (reading the last-good snapshot is the point of keeping it).