Files
lanework/DESIGN/02-architecture.md
T
rzen 515708a11b Name the EchoLedger in 02 — settle write provenance for attribution and announcements
06 and 10 consumed a per-file app-vs-foreign classifier that 02's component
inventory never named and whose layering prose seemed to deny. Settled: a
new EchoLedger component records each BoardWriter operation's expected
outcome (content hash / absence / move pair); final-content matching
classifies each observed file, deciding both races (byte-identical foreign
write → app-mediated, accepted; same-window foreign overwrite → foreign,
last writer wins). Ledger is in-memory and per-store; launch catch-up is
all-foreign; bracketed operations bypass it. "Trusts its own writes no more
than anyone else's" is scoped to rendering; BoardWriter's no-hidden-state
bullet stands.

Claude-Session: https://claude.ai/code/session_01HJ7PhFNmQ19bvy9RMD6GSb
2026-07-26 20:05:54 -04:00

29 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 (selection, drag state, search query, pending cut, the new-card placeholder, trash visibility). 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 (above).
  • 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 — and the board refuses drops, including drags arriving from another board's window. 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: read-only lock > reload breakage > one-shot write failures > commit and attachment failures; newest first within a class. Each row heals or dismisses independently; beyond three rows the remainder collapse behind a "+N more" disclosure.
  • 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.

  • 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 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. 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.
  • 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 (selection, drag, search) gets an explicit home rather than accreting — exact shape TBD during implementation planning.

Open questions

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