Files
lanework/DESIGN/02-architecture.md
T
rzen adfe87fdec Batch of eleven Minor design-review fixes across the corpus
- 02: welcome counts are live items only; failed first opens still record;
  duplicate-identity registry records merge silently (newest wins);
  BoardStore's FolderWatcher pointer reads "below"
- 11: lane-header new-card button inventoried (click names its target lane,
  overriding 04's ⌘N rule — cross-referenced there); attachments quiet-add
  affordance listed as a twin; Undo/Redo and Pull/Push rows gain the
  abnormal-state pause, read-only lock, and unresolvable-remote conditions;
  welcome scope added to Reveal in Finder
- 03/11: one lane context menu (header or empty space) with the full
  inventory, replacing the split rows
- 09: instantiated boards are never in git mode — actual mode follows
  detection at the destination (repo-nested possible)
- 08: the app never writes an existing CLAUDE.user.md; one-time rescue
  creation stated as the exception
- README: pathfinder path corrected to ../../Kanban

Claude-Session: https://claude.ai/code/session_01HJ7PhFNmQ19bvy9RMD6GSb
2026-07-26 21:09:23 -04:00

34 KiB

App Architecture

Platform

  • macOS 26+, Swift 6 (strict concurrency), SwiftUI.
  • Codename Kanban (target, scheme, bundle id dev.rzen.indie.Kanban); display name Lanework.
  • XcodeGen project (project.ymlxcodegen generate).
  • Sandboxed; security-scoped bookmarks for reopening boards across launches.

Layering

filesystem (source of truth)
    │  FSEvents
    ▼
Loader  ──validates──▶  BoardModel (value types)
    ▼
BoardStore (one per open board, @Observable, MainActor)
    ▼
SwiftUI views (board window + card windows share the store)

One-way flow: files → watcher → loader → store → views. User actions go through a Writer that mutates files; the change comes back around through the watcher like any external edit. The app trusts its own writes no more than anyone else's — for rendering (settled scope): the snapshot is only ever built from disk, never from memory of what the app meant to write — this is what makes external editors and agents first-class. Provenance is a separate, downstream concern: the EchoLedger (Components below) remembers what the app wrote so commit attribution (06-history-undo.md) and VoiceOver announcements (10-accessibility.md) can tell the app's own echo from a foreign change — without the render path ever trusting memory over disk.

The one named exception is transient UI state rendering things that don't exist on disk — concretely the new-card placeholder (04-interactions.md): the inline editor for a card being created renders as a pseudo-card overlaid on the snapshot, with no disk presence and no UUID until the title commits. Commit creates the folder through the Writer and round-trips through the watcher like any write — the placeholder stays visible until the real card arrives, then hands off. Abandoning (Escape, empty commit, click-away) discards it; disk was never touched. Watcher reloads swap the snapshot underneath the overlay (like selection surviving a reload); if the placeholder's lane vanished in the reload, it is discarded — consistent with card windows dismissing when their card is deleted. Everything durable still round-trips through files.

Components

  • Frontmatter — YAML value model: parse, serialize, atomic write, unknown-key preservation with key order. Owns the byte-identical round-trip guarantee. Pure, heavily unit-tested.
  • BoardLoader — walks the folder tree, applies the fail-fast/skip rules, produces an immutable BoardModel snapshot. Pure function of the tree.
  • BoardWriter — every mutation (create, move, reorder, tombstone, style) as an explicit filesystem operation. No hidden state; a write is done when the file is on disk. (The EchoLedger's receipts are not this bullet's "hidden state": a receipt describes a completed write, and the ledger lives beside the Writer, not in it — no write is ever pending in memory.)
  • EchoLedger — the write-provenance ledger (the "Writer/echo machinery" that 06-history-undo.md ▸ Commit attribution and 10-accessibility.md ▸ Live board announcements consume; settled). Every BoardWriter operation drops a receipt of its expected on-disk outcome before returning: path → content hash for writes (attachment imports hash during the copy — the bytes stream through the app anyway), an absence marker for deletes, an old→new pair for folder moves; a newer app write to the same path supersedes the receipt. Classification runs per observed changed file in a debounce window: current on-disk content matches the receipt → app-mediated, receipt consumed; no receipt, or mismatch → foreign. Final content deciding is what settles the races: an agent writing byte-identical bytes over a fresh app write matches and classifies app-mediated — with identical bytes the misattribution is unobservable in the tree, accepted; a foreign edit landing on an app-written path inside the same window misses the hash and the file classifies foreign — last writer wins the file, the app's subsumed intermediate never separately recorded (the diff compares snapshots, not a journal — 06-history-undo.md). Consumers: the auto-committer's author field and two-commit split (06), and the announcement filter (10) — on no-git boards the ledger runs identically with the announcer as its only consumer. In-memory, per-store, dies with the session — losing it costs attribution and nothing else, so the launch catch-up commit (06) classifies everything foreign: the app never vouches for changes it didn't witness. Bracketed operations don't consult it (they commit themselves and announce once at completion), and the reload-granularity origin tag (Live-reload resilience below) is orthogonal: it classifies reloads, the ledger classifies files. Feeds attribution and announcements only — never the render path (Layering above).
  • BoardStore — per-board @Observable object holding the current snapshot plus transient UI state that must be shared across that board's windows (TransientBoardState — see Changes from Kanban). Coalesces watcher reloads — at most one tree walk in flight, signals landing mid-walk fold into one follow-up; the debounce itself lives in FolderWatcher (below).
  • BoardStoreRegistry — refcounted registry so a board window and its card windows share one live store and one watcher. The board window owns the board (settled): card windows never outlive it — closing the board window closes its card windows too, so the last-window teardown and board-window close coincide. (The refcount still earns its keep ordering teardown while multiple windows close.)
  • FolderWatcher — FSEvents (debounced: 200 ms trailing, the timer restarting per event so a burst yields one reload after quiet, over 50 ms FSEvents latency — settled numbers), attached best-effort to whatever path the board lives at. Events under any .git path component are filtered out (settled): the board's own root-level repo (a worktree-link .git file included) is the app's auto-commit churn, and a repo nested deeper — a card folder containing a clone, a submodule — is a stray (01-storage-format.md) whose internals never render; neither can alter the rendered tree, so neither drives reloads. (A nested repo's working files still fire events like any stray's — those reloads are value-equal and quiet.) There is only this one watching path: no NSMetadataQuery for iCloud Drive, no polling fallback for network volumes — on those warned-against locations (07-sync-collab.md) FSEvents delivery is unreliable and live reload silently degrades, accepted per 07's no-accommodations stance.
  • Ranks — gapped fractional ordering math + compaction. Pure.
  • DropSlot — drop-geometry math: hit zones and insertion-position targeting for drags (lane/position within the masonry, cross-board, Finder file drops). Pure, like Ranks.
  • AgentGuide — writes/upgrades the board-root CLAUDE.md (see 08-agent-integration.md).
  • HistoryStore — git plumbing for undo/redo (see 06-history-undo.md).

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.
  • 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 liveness flip is a vanish for this purpose: re-resolution matches UUID and liveness side, so a foreign edit that tombstones a selected live card — or restores a selected tombstoned one — ejects it from the selection (and from the pending cut, which 04-interactions.md ▸ Clipboard already states), keeping 04's homogeneous-by-liveness invariant true across reloads. Liveness here is effective — ancestor-walked (settled): tombstoning a card's lane ejects the card too, its own flag notwithstanding — the card renders nowhere (03-board-ui.md collapses a tombstoned lane to a single restorable trash entry), and nothing invisible may stay selected, drag-included, or pending-cut. 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 (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 Put Back, 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 — the one mutating path with no menu item, 05-card-window.md's in-content rule) — and the board refuses drops, including drags arriving from another board's window. 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, and that lock starts at open, so no Edit session exists and the flush precondition is vacuous). 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).

Write-failure surfacing

The read-side rules above have a write-side mirror — one banner vocabulary for both directions:

  • The one-way flow makes write failures honest by construction. Views render only what is on disk, so a failed Writer operation (disk full, permissions, volume error) never shows phantom state — the action visibly doesn't happen. The failure surfaces in the same non-modal banner as read-side breakage, naming the operation and the cause ("Couldn't move 'Fix login' — disk full"). The operation is a closed enum, not a string (settled): Writer errors identify the failed operation as an enum case (create, move, reorder, tombstone, restore, style, …) carrying the affected item's title where known; the banner owns all user-facing phrasing and localization from that vocabulary, and a new Writer operation without a banner rendering is a compile-time hole, not a silent default. Free-form English survives only inside the diagnostic reason, never as the banner's verb. One-shot actions (move, tombstone, style, create) fail once and wait for the user to act again; nothing is queued behind their back.
  • 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.
  • 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/.

The banner surface (settled)

The non-modal banner named throughout the read- and write-side rules above is one UI component, specified here:

  • Hosted by the window of origin. Every window hosts a banner strip; a condition surfaces in the window whose action produced it — debounced body save, attachment drop, and raw-source Apply failures in their card window; reload breakage, one-shot write failures, commit failures, and lock states in the board window. A card window that closes while its condition persists re-homes the banner to the board window (the condition is still true; it must stay visible somewhere).
  • One-shots dismiss, conditions heal. One-shot failures ("Couldn't move 'Fix login' — disk full") carry an explicit dismiss control and no timeout — an error never evaporates unread. Persistent conditions (reload breakage, suspended auto-commit, read-only locks) have no dismiss: they describe ongoing state, standing until the next success clears them, per the rules above.
  • Concurrent conditions stack. The strip presents independent rows, precedence-ordered: in-progress rows (pinned) > read-only lock > reload breakage > one-shot write failures > commit and attachment failures > passive info rows (the remote-change signpost); newest first within a class. Each row heals or dismisses independently; beyond three rows the remainder collapse behind a "+N more" disclosure. In-progress rows are exempt from the collapse and don't count toward its budget (settled): they are the strip's only explanation for a bracket's write lock and for a close/quit deferring teardown, and the copy rows carry the reachable Cancel — a spinner may never hide behind "+N more". They're safe to pin: few at once, self-clearing. Passive info rows rank last and may collapse — calm by design, nothing gated on seeing them instantly.
  • Tones, not components. The banner has kinds — error, warning, info — sharing layout and the accessibility announcement path (10-accessibility.md). The card window's remote-change signpost (07-sync-collab.md) is this same component in the info tone: visually calm, no error color.
  • In-progress operations are info rows (settled): bracketed git operations ("Pulling…", "Switching to 'main'…") and long non-git work (big-board Duplicate, template instantiation, large attachment imports) each show an info-tone row with a spinner — determinate where progress is knowable. Completion clears the row (the VoiceOver completion announcement of 10-accessibility.md rides the same event); failure swaps it for the error row. Sighted and VoiceOver users learn one vocabulary.
  • Cancel appears on safe copies only (settled): copy-shaped work — attachment imports, Duplicate, template instantiation — carries Cancel, meaning "remove the partial copy, nothing lost". Git brackets get no Cancel: seconds long, and aborting a rebase mid-flight is a repair job, not a cancel.

Windows

  • Welcome window — Xcode-style: branding + actions left, recents right (board icon, name, location, lane/card counts, sorted by last opened).
  • Board windows — one per board root; multiple boards open at once; per-board frame memory (repositioned onto a live screen if the saved one is gone).
  • Card windowsWindowGroup(for: CardWindowRef.self); at most one per card (reopen focuses); follows its card across lanes; dismisses itself if the card is deleted.

Launch and window lifecycle (settled)

  • Restoration is a preference — "Restore open boards at launch" in Settings (⌘, — 11-command-nexus.md), default on. On: boards open at last quit reopen (bookmark-resolved), with their per-board frames and 05's card-window restoration. Off: every launch starts at welcome.

  • The restoration set is a live open marker, never an at-quit write (settled): each registry record (Per-board app state below) carries an open-now flag, set when the board's window opens and cleared on user-initiated close — quit's teardown closes deliberately leave it standing (the boards were open at quit by definition; teardown distinguishes user-close from quit-close, and that distinction is the whole mechanism). Restoration reads the flagged records, reopening by lastOpened order. Crash recovery falls out for free: after a crash the flags describe what was open at crash time, so relaunch restores it — no separate recovery logic, no once-at-quit stamp to race teardown or miss on a crash. The preference gates only whether the flagged set is consulted; the flags are maintained regardless.

  • Welcome appears only when nothing restores — restoration off, nothing was open, or every restoration failed. Always reachable via Window ▸ Welcome to Lanework. Opening a board from welcome closes welcome.

  • Closing the last board window leaves the app windowless (menu bar alive) — the close is respected. Reactivation (Dock click) with no windows shows welcome.

  • A restored board that fails surfaces on welcome, row-level: its window doesn't open; welcome appears alongside whatever did restore, the failed board's recents row carrying fail-fast's specifics (load error) or the unavailable state per Graceful orphaning (offline volume, dead bookmark). Other restorations proceed unaffected — never a launch-time modal chain, never a silent drop.

  • Close flushes: closing a board window (and app quit) first closes the board's card windows — each open Edit session ends with its normal session commit (06-history-undo.md's granularity) — then flushes pending debounced work, editor saves before the pending auto-commit, before the store tears down. Nothing about this is conditional: a card window cannot exist without its board window (the ownership rule above), so the close flush is always the whole story.

  • Close waits for in-flight operations (settled): a close or quit landing while an in-progress banner row is live — a bracketed git operation or copy-shaped work (The banner surface above) — defers teardown until that operation completes: the window stays open with its row spinning, and completion (or failure) resumes the close-flush sequence unchanged. Nothing is interrupted and nothing initiated is silently discarded — a copy row's Cancel stays available throughout for a user who'd rather expedite the quit ("remove the partial copy, nothing lost"). 06-history-undo.md's own-leftovers stamp recovery is thereby a crash net only; no deliberate quit or close ever leans on it.

Per-board app state

State that belongs to the app, not the user's files — the recents list, per-board window frames, the open-now restoration flag (Launch and window lifecycle above), the push-on-commit setting and the once-per-board iCloud warning flag (07-sync-collab.md), and whatever accumulates later — lives in a board registry in Application Support: one record per known board, anchored by the security-scoped bookmark the sandboxed app keeps anyway for reopening boards.

  • Keyed by file identity, never by path. Bookmarks track renames and moves on the same volume; an opened URL is matched to its record by bookmark resolution / file identity, so a moved board keeps its settings. The recents list is this registry sorted by last-opened.
  • Recents counts are registry-cached. The lane/card counts in the welcome window come from the record, stamped at last close — no directory scan at welcome time (which would be slow or hang on big/unavailable boards). Staleness until the next open is accepted. The counts are live items only (settled): tombstoned lanes and cards — and cards hidden beneath a tombstoned lane — don't count; the row advertises the board's working size, and the trash is an errand, not inventory. Records that can't be counted show without counts: unavailable boards per Graceful orphaning below; a board that fails to load just fails on open, fail-fast — the welcome row doesn't pre-detect it. A first open that fails fail-fast still records (settled): the registry record is created before loading, so the failed board lands in recents carrying fail-fast's specifics — retry after fixing the file is one click, uniform with the restored-board failure row, and Forget is the eraser for a genuinely mistaken open. Records that collapse onto one file identity merge silently (settled — a restored registry file, or an orphan whose bookmark re-resolves onto a recreated board): on detection, the record with the newest lastOpened wins wholesale and the others retire — recents never shows one board twice, and per-board settings are conveniences that don't earn a merge UI.
  • The row's title and icon are registry-cached too — with live write-through (settled): the record carries the board's title, icon, and iconColor beside the counts, and the welcome row reads only the record — it never opens any board's index.md (the same hang-avoidance that motivated the counts rule). Unlike the counts' at-close stamp, these three refresh whenever an open board's reload changes them: the store already holds the new snapshot, so an in-app Board rename (03-board-ui.md) lands in the record instantly — never a welcome row showing a name the user just changed away from — and a foreign rename of an open board rides the same path for free. The honest residual: renaming a board that isn't open (an agent editing its root index.md) stays stale until the next open — accepted, the counts' staleness class. The title falls back to the folder name per 01-storage-format.md, cached at the same moments.
  • Files-first stays absolute: nothing app-private is ever written into the board folder — no frontmatter keys, no sidecar files, no xattrs. Two machines sharing a board via a remote each keep their own record (push-on-commit and window frames are genuinely per-machine choices).
  • Graceful orphaning: a record whose bookmark no longer resolves (board deleted, or moved across volumes where bookmarks can't follow) is orphaned — recents surface it as unavailable with Forget; its settings are conveniences and die with it (accepted).
  • App-wide state has the same home. Not everything app-side is board-scoped: quick-style recents (03-board-ui.md), the SSH host-key assignment table and TOFU fingerprint store (07-sync-collab.md — host-scoped), the last-used card-window size (05-card-window.md), and their peers live beside the registry in Application Support (or UserDefaults where a scalar fits) — no per-board record involved. Secrets are the named exception: Keychain only, never here (07).

The old app loaded boards fast enough that the planned SwiftData cache was never built. Position for the rewrite: same discipline — the loader reads files directly; any cache introduced later must be rebuildable from files at any time and populated only by watcher events (never written by the UI path). Cross-board search is the feature that would force the cache into existence; until it ships, no cache.

Testing

  • Unit: Frontmatter round-trip (including hostile YAML), Ranks, DropSlot zone math, Loader fixtures (valid, malformed, interrupted-create).
  • UI: XCUITests over fixture boards via a debug-only --open-board launch hook (inline rename, new-card focus, drag cleanup — the flows that regress).

Changes from Kanban

  • The store's transient-state grab-bag gets an explicit home (settled, m3): TransientBoardState, one per store, holding state by how a reload treats it. Item-referencing sets — selection, drag membership, the pending cut — share one shape (a UUID set plus the liveness side it lives on) and one constraint rule, members must exist in the current universe, applied in two directions by one primitive: a reload re-resolves each set independently against the new snapshot (present on the same effective-liveness side, ancestor-walked), and the search filter constrains the selection to its visible set — the hidden-cards-leave-the-selection rule and the reload-survival rule are one rule, expressed once. Derived state is stored as its inputs only: the search query is kept, its result set never is — the predicate re-runs against each snapshot (04-interactions.md's live filter). The overlay — the new-card placeholder — is anchored to its lane, not to items: no UUID until the title commits, discarded when a reload drops or effectively tombstones its lane, and handed off by discarding itself the moment the created card's UUID appears in a snapshot. Trash visibility rides along as a plain per-open value: hidden on every open, never persisted — visiting the trash is an errand, not a layout choice.

Open questions

None currently — the iCloud watching question dissolved with the decision not to support iCloud Drive boards (07-sync-collab.md).