Files
lanework/DESIGN/06-history-undo.md
T

92 lines
52 KiB
Markdown

# History & Undo
**Tier scope: Lanework Pro** (12-editions.md). This doc is the git HistoryProvider; the free tier ships mode:none only, with macOS-native undo (13-native-undo.md) and the inert-`.git` posture (12). The Undo routing section below is tier-independent — both substrates dispatch through it.
Git is the undo substrate — on boards that have git. **Git is opt-in per board (a pivot from the pathfinder, which auto-initialized every board): a board may be created without git, and git can be added later** (via the board settings sheet — 03-board-ui.md; see 07-sync-collab.md's mode progression). A board without app-managed git binds the **native undo stack in every tier** — repo-nested included (re-ruled 2026-07-31, twice — the provider follows the board, 13-native-undo.md; formerly no-undo under Pro, which made upgrading remove undo from mode-none boards, and the repo-nested no-undo residue retired the same day: the native stack touches no git, so leave-strictly-alone is untouched and no board lacks ⌘Z). (Text editors keep their standard typing undo everywhere; see Undo routing below.) Deletes — card or lane — are recoverable on every board via the materialized trash (03-board-ui.md). Add-git swaps native → git mid-session, discarding the in-session native stack and seeding the git trail — the branch-switch discard-and-reseed precedent. On git-enabled boards, every settled change auto-commits; those mechanics are carried over from the pathfinder with their hard rules intact.
## Rules
- **Opt-in init**: adding git to a board initializes a local repo at the board root. Bundled libgit2 — no git install required. No silent auto-init, ever. **The initial branch is `main`** (blessed 2026-07-31): the host's `init.defaultBranch` lives in config layers the sandbox can't read, so add-git sets it deterministically — git's modern default, the pathfinder's choice.
- **Adoption**: a board whose root already contains `.git` opens **in git mode, silently** — adoption is not init. The no-silent-auto-init rule forbids *creating* a repository the user didn't ask for; recognizing one that exists is the opposite of that: the repo's presence *is* the opt-in (someone ran `git init` or `git clone`), and this is the primary way a second machine joins a shared board — clone in a terminal, open in the app (07-sync-collab.md's second entry arrow). All git-mode behavior applies from the first open: auto-commit, undo reseeded from the existing HEAD's first-parent ancestry, remote tracking if a remote is configured.
- **Detection is nearest-`.git`-wins**, checked at every board open: `.git` at the board root → git mode (adoption above); no `.git` at the root but one at any ancestor → repo-nested (below); neither → mode none. **Denial is not absence** (ruled 2026-07-31): the ancestor walk crosses paths above the board's sandbox grant, and a check the sandbox *refuses* (EACCES/EPERM) must never read as "no repo there" — detection distinguishes **clean none** (every ancestor answered not-found) from **unverifiable** (a check was denied); add-git is offered only on clean none, and unverifiable takes the repo-nested posture (conservative — the popover explains rather than offers). As hardening, add-git's create re-runs full detection and refuses unless it reads clean none, so the forbidden nested init is impossible even on a raced or stale read. Whether the shipped sandbox actually denies ancestor stats is an open empirical question (manual-verification list: a board deep inside an ungranted repo, and an ordinary board under an ungranted parent) — if it denies everywhere, every board would read unverifiable and this posture needs a data-informed revisit. A board can therefore change mode between opens (e.g. the user ran `git init` in a terminal) — the app just reflects what it finds. **Open-time only, deliberately — for *discovery***: a `git init` under an open mode-none board takes effect at the next open — the running session keeps its mode, and the watcher does not scan for `.git` appearing (no mid-session mode flips from watching; stated here so it isn't rediscovered as a bug). The one deliberate mid-session transition is the app's own **add-git** (Opt-in init above): clicking it flips the open board into git mode immediately — the settings sheet flows straight into the git sections, the first auto-commit follows — the rule forbids *discovered* flips, never commanded ones.
- **A `.git` that isn't a valid repository still reads as git mode — and fails loudly** (ruled 2026-07-31): detection is presence-shaped (any root `.git` entry, directory or worktree/submodule pointer file), so a corrupt or unopenable repo never falls to mode none — Add Git is never offered against an existing `.git`, whatever its condition (init into a repairable repo is exactly the never-mutate hazard). The board itself loads and edits normally — files are the board — but the failure is **loud**: a standing breakage-class banner at detection ("This board's git repository can't be read — history is paused; Lanework leaves the repository untouched"), announced per 10-accessibility.md, with the whole git surface paused (the abnormal-states posture below) and the popover's git section naming the state; the banner clears when a later open or reload finds the repo readable. Never a silent placeholder discovered only in the popover.
- **Abnormal repo states** (settled; adoption never assumes a tidy clone): an **unborn HEAD** (`git init`, no commits yet) is normal git mode — the first auto-commit creates the root commit on the branch HEAD names, and the undo trail simply starts empty. **The root commit has its own subject** (settled): whenever the app creates a repo's first commit — immediately on the app's own add-git (init doesn't wait for the debounce; the board is protected from the moment git exists), or at the first settled change on an adopted unborn repo — it commits the whole tree as **"Initial board state"**, never a folded diff-from-empty: there is no last-committed snapshot to diff against, and forty Adds would bury the event. **The root commit is never split and is user-authored** (blessed 2026-07-31): it is a baseline, not a change-set — the event it records is the user's act of putting the board under git, adopted unborn repos' unwitnessed files included. A **detached HEAD**, or an **in-progress merge/rebase/cherry-pick** left by outside-the-app git (`MERGE_HEAD`, `rebase-merge`/`rebase-apply`, `CHERRY_PICK_HEAD` — pause states that load fine on a clean tree and are otherwise invisible), instead **pauses the git surface honestly — the *whole* surface, remote half included** (settled): auto-commit holds (the auth-pause posture, 07-sync-collab.md — pause, badge, explain, never hammer), Undo/Redo and the branch controls disable, **and Pull, Push, and push-on-commit hold with them** — with auto-commit held, 07's clean-tree-by-pull-time invariant is false, and a pull's rebase (or a rejected push's fetch→rebase→push) would run against a dirty tree carrying uncommitted edits, precisely what flush-before-overwrite exists to prevent; the ahead/behind badge keeps counting (a fetch is a read), and the popover's git section names the state plainly ("HEAD is detached — commits would belong to no branch"; "a merge is in progress") and says resolving it belongs to the tool that created it. Edits keep landing on disk — files are the board — and commit as one settled batch when the state clears. The app **never mutates repo state it didn't create** (no auto branch-at-HEAD, no `merge --abort`); the check runs at open and again before every flush — and, because a terminal's cleanup moves only files under `.git`, which the watcher never delivers, **a standing pause re-reads the repository state every 15 s** (blessed 2026-07-31; injectable cadence, only while paused, a handful of stats — not a retry, nothing is attempted) — so finishing the operation in a terminal resumes the pipeline without ceremony. **The one exemption is the app's own leftovers** (settled): every bracketed operation stamps its intent app-side (per-board registry) before touching the repo, so an interrupted app-run rebase or checkout is recognizable as Lanework's — finding a pause state with a matching stamp, the app **aborts its own unfinished operation** to restore the pre-operation state and says so via banner ("a branch switch was interrupted — the previous state is restored"), then clears the stamp — **on success only; a failed abort keeps the stamp** (blessed 2026-07-31): the stamp is the sole evidence the leftover is the app's, and clearing it on failure would demote the leftover to somebody-else's forever — the pause would then send the user to "the tool that created it," which was this app. Kept, the next open or paused-state re-read recognizes it and retries; recovery converges instead of orphaning. Abort discards nothing: fetched commits stay fetched, local commits are restored — the rebase's own no-loss accounting. Without a matching stamp the leftover is outside git's, and the pause-and-defer stance above holds unchanged.
- **Boards nested inside an existing repository are left strictly alone** — git cannot be added to them (no nested repo, no commits into the user's repo), so they get **no app-managed history — while the native session stack still serves ⌘Z there in every tier** (re-ruled 2026-07-31, retiring the old no-undo residue: 13's stack is memory-only and journal-free, so self-containment holds; what repo-nested denies is git, never undo). The board popover's git section must say so honestly: not a hidden "add git" but a short explanation ("this board lives inside a repository; Lanework leaves it to that repository") — the option is absent because it *can't* apply, and the UI should teach that rather than look broken.
- **Auto-commit**: every settled change (debounced past drag/typing churn) commits with a descriptive message ("Move card 'Fix login' to Doing"). Board history sees **card-window sessions, not gestures** (re-ruled 2026-07-31, widening the Edit-sessions-not-save-ticks rule — 13-native-undo.md's session-coarsening model, applied to the commit substrate): while a card's window is open, everything happening inside it — the body editor's ~700 ms crash-safe disk saves, comment posts and deletes, draft-save cadence, sidebar changes — stays **uncommitted**, and the committer **stages around the whole open card folder** (the former Edit-session stage-around, widened; comments included); **window close flushes the session as one commit** ("Update card 'Fix login'"-shaped, the composer folding the card-scoped diff, body bullets carrying the events) — granular window activity never litters history. **The flush awaits the snapshot that covers it** (ruled 2026-07-31): the composer diffs `store.snapshot` against HEAD, so the close flush awaits a snapshot generation covering its changed paths before the committer runs — the commit's subject can never be outrun by its own reload; the cadence margin (2 s debounce vs 200 ms watcher) is the practical cushion, never the guarantee (the awaiting-bracket machinery is the shape). **A commit's comment bullets sort chronologically** (blessed 2026-07-31): by the comments' own `created`, folder name on ties — event order reads as the conversation did, never UUID-arbitrary. The EchoLedger's two-commit split still applies at close when the held window mixes foreign changes to that card with the app's own. Settled-tree events that cannot wait — a pull's flush-before-overwrite — commit the session's on-disk state as-is (a mechanical exception; 07-sync-collab.md's same-card signpost covers the visible half); branch switch instead gates on explicit save-or-discard (Branch switching below). The cadence constraint below demands batching at least this coarse. **Board-window close and app quit flush the pipeline** — any pending editor save (05-card-window.md), then open card-window sessions, then the pending auto-commit — before teardown; nothing settled is ever left unsaved or uncommitted by closing.
- **Flush-before-overwrite**: before an app write overwrites on-disk state that differs from the last-loaded snapshot (an uncommitted external change — e.g. an agent's body rewrite racing the card editor's debounced save, 05-card-window.md), the pending auto-commit is flushed so the external version enters history first. "Both versions exist as commits" is thereby a guarantee, not a likelihood — **as strong as the watcher's knowledge** (bounded, blessed 2026-07-31): the gate fires on *known* foreign changes, learned from landed reloads, so a foreign write still inside the watcher debounce can be overwritten unflushed; the window is the debounce (~200 ms), the disk-level outcome inside it is 05's last-writer-wins — the same ruling at history granularity — and `index.md` writes compose over fresh disk bytes anyway, so wholesale loss needs a body-save or raw-Apply race. A **failed reload counts as a foreign change** (can't know, so protect). A pre-write disk compare was weighed and declined — a read per write to buy back the debounce corner. **The flush commits synchronously on the main actor** (blessed 2026-07-31): the write path it must precede is synchronous, so the ordering guarantee can only be kept by a synchronous commit — 02's hang-avoidance doctrine and this guarantee genuinely conflict here, and the guarantee wins, being rare (the known-foreign window only), bounded (one stage-and-commit plus the composer's HEAD-tree materialization — a few board-sized walks, accepted so exactly the commit that preserves somebody else's version keeps its full semantic subject), and load-bearing (the alternative loses a version no commit protects). One attempt, no lock backoff on this path — a held `index.lock` falls through to the next quiet debounce. The same flush settles the tree before a pull runs (07-sync-collab.md).
- **Cadence constraint** (agreed): the auto-commit cadence must not make the history of a remote-shared board unbearable — one commit per drag is fine for a local undo trail but noisy as a shared log. The debounce/batching design here must serve both consumers; the push/pull side is settled in 07-sync-collab.md (optional push-on-commit, automatic fetch-rebase-push on rejected pushes).
- **Undo never rewrites history.** Undo (⌘Z) and redo (⇧⌘Z) restore earlier states as **new forward commits** — never reset, never force. The whole trail stays inspectable in any git client. The one deliberate rewrite anywhere in the app is pull's rebase of **unpushed local** commits (07-sync-collab.md); published history is never touched.
- **Undo restore vs open Edit sessions** (settled): a restore materializes only the diff between the current tree and the target state, so a card whose open Edit session the diff doesn't touch is simply unaffected — its uncommitted ~700 ms saves and the stage-around rule continue undisturbed, and most undos never meet an editor at all. When the diff *does* touch a session card, the restore **gates on the branch-switch save-or-discard step** (Branch switching below — Save All / Discard / Cancel, same machinery, same rationale): silently flushing would commit a tree the user deliberately hasn't saved, a checkout over uncommitted on-disk saves would destroy text no commit protects (the one place "both versions exist as commits" could otherwise fail), and a surviving dirty buffer's next debounced save would write pre-undo text over the restored card — a ⌘Z that visibly doesn't happen. With sessions settled the restore runs on a settled tree. Redo is symmetric. Open raw-source buffers get the branch-switch settle treatment too (Branch switching below).
- **The stack is HEAD's first-parent ancestry, live** (settled): foreign commits — watcher-auto-committed agent work and agents' *self*-commits alike — push onto the in-session undo stack as ordinary steps as they land. The stack re-syncs its top to HEAD before every undo/redo (self-commits move HEAD outside the app's committer; the pre-flight sync is how the stack learns), so ⌘Z always steps back exactly **one** commit — it can never silently revert twenty minutes of agent work landed since the user's last operation. Any commit arriving from anywhere clears the redo stack (classic behavior; redo also starts empty on the relaunch reseed below) — **except a heal-only window** (blessed 2026-07-31): heal transparency (below) makes heal commits invisible to the stack, and a heal-only clear would half-defeat it — the trap would dissolve for undo while redo still died on every landed repair; a window mixing a heal with real changes clears via the real changes. In-session and post-relaunch behavior are thereby one rule — the reseed is the same ancestry walk from scratch. **The root commit is the stack's floor, not a step** (settled 2026-07-31): it is a baseline, not a change-set (Abnormal repo states above), and it has no parent — crossing it could only mean restoring the empty tree, every file on the board deleted by one ⌘Z. The stack stops above it: undo bottoms out at the initial board state, and the reseed walk stops in the same place.
- **Undo survives relaunch**: the undo stack reseeds from HEAD's first-parent ancestry on load; redo starts empty. In-session it behaves as classic dual stacks; after relaunch, past restore commits reappear as ordinary undoable steps. Deliberate: no sidecar state, nothing ever lost. Interaction with pull (07-sync-collab.md): a pull rebases unpushed local commits, so the in-session stack must remap onto the rewritten commits — the pre-rebase hashes are orphaned. A pleasant consequence of the reseed rule: the fetched remote commits sit in HEAD's first-parent ancestry, so after the next relaunch remote work becomes ordinary undoable steps too.
- **Heal commits are transparent to undo, in-session** (ruled 2026-07-29): heal-class commits — their paths known by the Writer's heal-marked receipts (Commit messages below) — never become undo steps: the stack pointer passes over them, and a restore materializing an older target **excludes paths whose divergence is heal work**, so a ⌘Z run never reverts a repair and never summons the scheduler (reverting one would re-arm the memo on the recreated defect signature, land a fresh heal commit, and — redo cleared — trap the run on an ever-renewing top; transparency dissolves the trap instead of suppressing the healer). The in-session qualifier is honest: receipts live in memory and the reseed is deliberately sidecar-free, so after relaunch old heal commits reappear as ordinary steps — undoing one recreates its defect and the scheduler re-heals within a reload, restore commit plus fresh heal commit, notice included. That **residual bounce is accepted family-wide** — the agent-guide quirk (Agent collaboration below) generalized to the relocation, the legacy migration, the displacement, and the remint — and it is **self-limiting to one bounce**: the fresh heal commit is in-session, transparent, and the undo run continues past it. Redo is symmetric.
- **Undo is board-local.** A cross-board move-out undone at the source resurrects the card even though it lives on in the destination — per-board histories cannot and must not mutate other boards. The resulting same-UUID fork across boards is legitimate (boards are independent identity namespaces); if the two ever meet through a move-in, the import boundary remints the arrival (01-storage-format.md's identity lifecycle).
- The git surface splits across the **board popover** and the **board settings sheet** (03-board-ui.md; the 2026-07-31 popover/sheet split): the popover carries the daily face — branch/source display, the switch picker, posture lines — alongside board rename and styling; the sheet carries setup — add-git, branch creation, the commit-identity name/email fields (see Interaction with external writers below), remote and credentials (07).
## Undo routing
**Routing is by focus** — the platform's first-responder rule, its own section because two undo systems coexist and four docs cite the rule. While a text-editing surface is focused (card title field, body Edit mode, raw source, board inline rename), ⌘Z/⇧⌘Z are that editor's own **text undo** — standard, transient, session-scoped: leaving the editor (mode flip, focus loss, close) ends the session, and from then on that content's undo story is the git trail. Text undo works on **every** board — and since the 2026-07-31 repo-nested re-ruling, so does board-level undo: every board binds a provider (native or git), so "no undo" is no longer a state any board is in. **Control-class text fields route the same way** (settled): the search field (04-interactions.md ▸ Search), the popover's rename field, and the settings sheet's text fields (commit identity, credentials, remote URL) own ⌘Z/⇧⌘Z as field-local text undo while focused — "board menu commands stay enabled" never hands Edit ▸ Undo to git while a text-bearing control has focus; a reflexive undo over a typo must never become a tree checkout. With focus outside every text-bearing surface — editor or control — Edit ▸ Undo/Redo are, **in a card window, that window's own session stack** (13-native-undo.md's two-level model, re-ruled 2026-07-31 — fine-grained window gestures, both tiers; the coarse close unit is the tier-split: one native board step, or one commit), and on board surfaces board history — the board's bound provider, git or native (every board binds one since 2026-07-31; disabling is locks and empty stacks). **No fall-through**: exhausting a focused editor's — or the window's — stack beeps; it never reaches board history.
## Commit messages
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 / **Replace** for attachment files ("Move card 'Fix login' to Doing", "Rename lane 'Todo' → 'Doing'"; Replace added 2026-07-31 — a changed file under a card's `attachments/` with an unchanged listing is a content replacement, named from the path alone: "Replace attachment 'photo.png' — card 'X'", never the anonymous path generic), 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'" — the trash's Delete, Empty Trash, and any foreign hard removal, which the shape rule catches and describes accurately for free); the inverse oddity — a card *arriving from outside the tree directly in `.trash/`* — composes "Add card 'X'" with a to-the-trash detail (blessed 2026-07-31: the arrival shape is an Add, the detail names the destination). 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 **say so in the subject** (re-ruled 2026-07-31, retiring the bare "Update board" fallback): **"Mixed update — N changes"**, always with a bulleted body naming every event, so the oneline log stays scannable and never dresses a grab-bag as one thing; when every event in the window shares one item — the card-window session flush's usual shape — the subject keeps the name: **"Mixed update — N changes to card '⟨title⟩'"**.
- **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". **One level further down, a card's own event swallows its thread entirely** (blessed 2026-07-31): a card moved, deleted, restored, purged, or reminted carries its `comments/` paths silently — the card's event already explains every file under it, and comment bullets trailing "Delete card 'X'" would be the burying this rule exists to prevent.
- **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; a three-way window commits **foreign → heal → user** (blessed 2026-07-31 — causal: what was found, the response to it, the newest overwrite); split staging starts by **resetting the shared index to HEAD** (blessed 2026-07-31 — another writer's staged-but-uncommitted picks are discarded, their *content* still committing on the app's classified terms; the exposed race is only the gap between a foreign `add` and its `commit`, since a held `index.lock` already backs the app off, and a private in-memory index beneath the wrapper is the named upgrade if cohabitation friction ever shows) (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. **Heal commits are authored `Lanework Integrity <[email protected]>`** (ruled 2026-07-31 — the third pinned synthetic, joining Lanework External and the agent-slug family; strings are API): a heal is a third origin — not the user's gesture, not a foreign writer — and the separation exists for audit, so the trail filters by author like every origin; the committer stays the user (the recorded-by convention above). 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.
- **Titles truncate in subjects only** (~40 chars, keeping `git log --oneline` sane); body lines carry full titles. An untitled item reads "(untitled)" — never a bare `""` (a pathfinder edge fixed, not carried). Undo/redo restores commit as "Undo: ⟨subject⟩" / "Redo: ⟨subject⟩"; the undo-menu labels are the *crossed* commit's subject, so labels never nest. **Subjects don't nest either — crossing a restore composes the inverse** (settled 2026-07-31): when the crossed commit's subject already carries a restore prefix — the post-relaunch case, where the reseed has made old restore commits ordinary steps — the composer emits the *inverse* label instead of stacking: crossing "Undo: S" yields "Redo: S", crossing "Redo: S" yields "Undo: S". This is the truer label, not a euphemism — undoing the restore that undid a move *re-applies* the move — and it caps prefixes at one across any number of relaunches. The sniff is on the subject string, so a foreign commit that happens to open with a prefix gets the inverse label too; that's cosmetic — the restore itself is unaffected. **Restore commits are user-authored, whatever they cross** (blessed 2026-07-31): the commit records the user's decision to put things back, not the crossed change — a foreign or agent-authored commit undone by ⌘Z yields a restore under the user's identity, keeping `--author` filtering truthful (an agent never appears to revert itself). **Menu enablement reads a cached stack picture that refreshes asynchronously after a landed commit** (blessed 2026-07-31): a crossing always awaits settlement — ⌘Z acts on true history, never the cache — so the lag can only mislabel or mis-grey a menu row for one beat, matching AppKit's own validation cadence; a synchronous walk on the commit-report path was declined as cost without an observable win.
**The external gap, closed** (the pathfinder weakness this section exists to fix): the composer is origin-agnostic, but external writers routinely touch what the pathfinder's diff never modeled — `labels`, `assignees`, `due`, custom frontmatter keys — so their commits degraded to a generic fallback even though the attribution machinery knew plenty. The rewrite:
- **The diff models the full schema-1 surface — plus the reserved metadata trio, deliberately**: label, assignee, and due changes compose ("Relabel card 'X'", "Assign card 'X'", "Set due date on card 'X'") even though 01-storage-format.md reserves those keys out of this version's UI — external writers (pathfinder-era boards, agents) are exactly who touches them, and naming three known keys in a pure diff function costs nothing. A change to any other unmodeled or custom key **says what it is** (re-ruled 2026-07-31 — first lines self-describe; generics are a last resort, kept very rare): **"Change custom key on card 'X'"** (board and lane likewise — "Change custom key on board '⟨title⟩'"; several keys fold plural), the body naming each key with its old → new values. The bare named generic ("Update card 'X'") survives only for a change in a known file that is neither a vocabulary event nor a key change — a shape that should almost never occur; never a board-level shrug when the touched item is identifiable.
- **Foreign commits speak the same vocabulary.** Origin lives in the author field (structural attribution below), not in message prose — a foreign move reads "Move card …" exactly like an app-mediated one, and any git client filters by author.
- **The launch catch-up commit composes too**: changes found pending at board open diff HEAD's tree against the working tree through the same composer, instead of committing blind.
## Branch switching
Switching a branch from the popover's picker (or creating-and-switching from the settings sheet — the 2026-07-31 split) — **create-and-switch runs the identical settle → flush → stamp → switch sequence, no at-HEAD fast path** (blessed 2026-07-31): "creating at HEAD can't change the tree" fails as a proof under concurrent writers (a foreign commit can land between the check and the create), and flushing pending work onto the branch it was made on is the honest cadence regardless — the occasional save-or-discard beat on a create buys one sequence with no special case:
- **Settle the editors first — explicitly, never silently.** Branch switch neither silently commits nor silently abandons an open card-body Edit session: if any open card window has one (unsaved keystrokes, or on-disk ~700 ms saves the session hasn't committed — the mid-session state Auto-commit above deliberately leaves uncommitted), the switch presents a **save-or-discard step**: **Save All** ends every session with its normal commit (each card's Edit→Preview flip), **Discard** reverts buffers and uncommitted saves to HEAD, **Cancel** keeps the current branch and the sessions. **Open raw-source buffers are settled by the same step** (settled — an unsettled raw buffer is the worse hazard: its Apply later writes the *entire* pre-switch `index.md` byte-for-byte onto the new branch's card): Save All *applies* each raw buffer — and since Apply validates, a buffer that fails validation cancels the whole switch with focus on the offending window, nothing half-switched; Discard exits raw source without writing; Cancel keeps everything. External checkouts the app can't gate are the accepted last-writer-wins case, same as the Edit buffer (05-card-window.md's dirty-buffer rule; on git boards the overwritten version is a commit, one revert away). Silently flushing the commit alone would be wrong twice over: the tree can be clean precisely because a save hasn't landed, and a later debounced save would write old-branch text onto the new branch's card. With sessions settled, the pending auto-commit flushes (flush-before-overwrite above) and checkout runs on a truly settled tree: it cannot fail dirty, and no in-flight work is lost or dragged across branches. **The settle also clears each open card window's fine undo stack** (ruled 2026-07-31): pre-switch steps describe the branch being left — Save All and Discard alike end with every window's stack empty, the board-stack discard-and-reseed precedent one level down; the windows stay open, following their cards onto the new branch with fresh stacks. **The flush is Save-All-shaped** (blessed 2026-07-31): after Discard, the session's reverted bytes are *reconciled, never flushed* — the pending window for that folder drops, since disk again agrees with HEAD and committing the discarded saves would betray the button; pending changes elsewhere on the board still flush normally. (Inline title editors need no step of their own: reaching the popover's picker or the sheet's create control commits them — click-away commits, and board-scoped commands disable while one is focused — 04-interactions.md ▸ Grammar.)
- **The undo/redo stack does not survive a switch.** It is discarded and reseeded from the new HEAD's first-parent ancestry — the relaunch rule applied at switch time; redo starts empty. (Replaying a restore commit from the previous branch onto the new one would be wrong.)
- **Everything remote-facing tracks the current branch**: ahead/behind, Pull/Push, and push-on-commit all operate against the current branch's upstream. On a branch with no upstream yet, the first push — manual or push-on-commit — **creates it on the remote quietly** (`push -u` semantics): creating a remote branch is non-destructive, and quiet is consistent with push-failures-never-nag (07-sync-collab.md). Genuine failures queue with the badge as usual.
- The switch itself is bracketed (02-architecture.md): watcher suspended, one full reload at the end. If that final reload fails, the board locks read-only until a successful reload — see 02's live-reload resilience; the on-screen snapshot is from the previous branch and must not be edited over the new one.
## Interaction with external writers
Agent and hand edits arrive through the watcher like any change and get auto-committed on the same debounce — so agent work is undoable, attributed, and *described* in the same trail: foreign changes compose through the same message engine as app-mediated ones (Commit messages above — the pathfinder's generic "External edit: 2 cards changed" fallback is gone), with origin carried by the author field. One attribution exception: the app's own agent-guide writes (08-agent-integration.md) are the app's own Writer operations — app-mediated by the echo machinery, carrying the guide's version-marker first line — and committed as "Update agent guide (vN)", not "External edit". The guide-commit undo quirk is the heal-transparency rule's oldest case (Rules ▸ Heal commits are transparent): in-session the upgrade commit never enters the stack; only a relaunch-old guide commit bounces — once, restore commit plus fresh upgrade commit, the fresh one transparent. Harmless; the guide is app-owned and self-healing by design.
**Commit attribution is structural, not just a message convention.** The Writer/echo machinery (the **EchoLedger** — 02-architecture.md ▸ Components, where its matching rule and race cases are settled) lets the auto-committer classify every observed change, per file, as **app-mediated** (the user acting through the app) or **foreign** (anything else). User-driven commits carry the user's git identity; foreign changes are committed under the pinned synthetic author **`Lanework External <[email protected]>`** — so any git client can filter, log, and blame by origin. **The committer field is always the user's identity** (blessed 2026-07-31 — git's own `am`/cherry-pick convention: author = whose change, committer = who recorded it): every commit the app makes, foreign-authored included, records the user's app as its committer. The strings are API (users script against them; the `.invalid` TLD honestly marks a non-routable synthetic identity) — they change with the deliberateness of a schema change.
**Where the user's git identity comes from** (no git install is assumed, and the sandbox doesn't read `~/.gitconfig` — honest limits, not bugs): **repo-local `.git/config` wins when present** — standard git semantics, readable in-sandbox because it lives under the board root, and the natural state of adopted/cloned boards. The board settings sheet's identity section (03-board-ui.md — the 2026-07-31 split moved the fields out of the popover) exposes name/email fields that **write that repo-local config** — the setting *is* the file, portable to any git client, per-board by nature (work and personal boards can differ). **The fields re-read the config at 2 s while the sheet is visible** (blessed 2026-07-31): the watcher never delivers `.git`, so no board event can carry a terminal-side config edit — the unfocused-resync courtesy needs its own signal, and a visibility-scoped poll is the 15 s paused-state re-read's shape at sheet cadence (a focused field keeps its keystrokes; dismissing the sheet stops the poll). **Writes append, reads take the last** (blessed 2026-07-31): the writer appends a plain `[user]` section and never edits existing sections or `[user "…"]` subsections (their semantics are tool-specific); the reader — like git itself — takes the last plain-section value, which is exactly what an append produces. The asymmetry lets the write always win without the writer ever reformatting what it didn't create — the frontmatter engine's never-reformat instinct applied to git config; the worst case is a slightly redundant file git reads correctly. Absent repo config, the **derived default** applies: the macOS account's full name plus `shortname@hostname` — git's own no-config fallback shape, zero ceremony. Commits pushed to a forge under the derived email won't link to a forge account; the sheet's identity fields are the fix when that matters. **The derived default is passed as an explicit per-commit signature, never written into repo config** (ruled 2026-07-31 — the signature-capable commit path gates the pro-m1 ship): repo config is the record of the user's popover edits and of adopted repos' own state, and an app-written identity there would outrank the user's global `~/.gitconfig` for their *own terminal commits* in that board. The build-time interim that materializes identity into a fresh repo's `.git/config` (SwiftGitX 0.4.0's signatureless commit + the sandbox's unreadable global config) is tolerated in-tree during pro-m1 construction and must die before release — the attribution rules above (per-commit author variation) require explicit signatures anyway. A debounce window containing both kinds is **split into two commits**, never mixed (flush-before-overwrite already orders them: foreign first, then the user's overwrite). Honest limit: the app distinguishes app-mediated from foreign, not human from agent — a hand edit in a text editor and an agent write look identical *unless the writer says otherwise via `modified-by` (below)*. Agents wanting precise attribution are encouraged (via the agent guide, 08-agent-integration.md) to commit their own changes; the app follows along.
**`modified-by` refines foreign attribution** (the self-reported provenance key — 01-storage-format.md): when every file changed in a foreign debounce window carries the same `modified-by: X`, that commit is authored as **X** with the synthetic email `<slug>@agents.lanework.invalid` (display name verbatim, email local part slugified; the domain marks self-reported identity, distinct from both the user and the generic external author). Any disagreement between stamps, any unstamped changed file, or any true deletion in the window falls back to `Lanework External` — a deletion leaves no file to stamp. **A folder move is not a deletion**: items match by id across the whole board (Commit messages above — the same matching that reads a move as a move, not delete+add), so a moved card attributes by its stamp like any changed file. But a bare `mv` rewrites nothing — the moved `index.md` still carries whatever the app last wrote (no stamp) and demotes the window under the unstamped-file rule — so the agent guide teaches re-stamping on move (08-agent-integration.md). Same trust level as self-committing — it's what the writer claims, accepted as such; the stale-stamp hand-edit case (01) is the known misattribution edge. Self-committing remains the precise path; the stamp is the lightweight middle.
**Two writers, one repository — the designed situation, not an edge case.** Self-committing agents mean the auto-committer shares the repo with concurrent `git` processes, and it must be graceful about it:
- **`index.lock` contention is never an error.** If the auto-committer finds the index locked (an agent's commit in flight), it backs off briefly and retries; if the lock persists, it simply re-debounces — the pending changes are still pending, and the next quiet moment commits them. No banner, no log-worthy failure: a held lock is another writer doing its job. (Genuine commit failures — disk full, repo corruption — are different: files stay safe on disk but history stops advancing; surfaced per 02-architecture.md ▸ Write-failure surfacing, retried on the next debounce.) **The same posture covers every app-initiated operation** (settled): pull, push, branch switch, and undo restore meeting a held lock wait and retry briefly, silently; contention outlasting the brief retry surfaces as a *waiting* state in the operation's in-progress banner row ("waiting for another writer's git lock"), retrying on its cadence — never an error dialog, never a hammer — and a wait that persists implausibly long names the lock path (a crashed writer's leftover is the user's to clear; the never-mutate rule's one exemption is the app's own leftovers, Abnormal repo states above). **The wait is bounded — 30 s, then a clean failure** (blessed 2026-07-31; injectable): a genuine writer finishes in seconds, so only a stale lock ever reaches the bound, and an eternal spinner holding the wholesale bracket — and with it the board — is worse than a failure that names the path to delete. The bound expiring is the clean-failure case below, tree untouched. An operation that fails *cleanly* — disk error, refused checkout; network and auth are 07-sync-collab.md's pause-and-badge story — surfaces as a one-shot banner failure naming the operation and the error, the tree left as it was; failure after the tree changed wholesale is instead 02-architecture.md's failed-final-reload lock. **Form-anchored operations answer at the form first** (ruled 2026-07-31; container updated by the popover/sheet split — these forms live in the settings sheet): add-git — and later sheet-asked operations like verify-remote — fail into an inline caption in the sheet's relevant section while the sheet is up (the user asked from a form still under their eye; dismissing the sheet dismisses the stale error, retry is right there, VoiceOver reads it from the focused surface); if the sheet has been dismissed before the answer arrives, the failure falls back to the one-shot banner above — inline is the primary surface, never a silence trap. The banner enumeration stays the posture for board-wholesale brackets that outlive any one surface — branch switch and undo restore included (blessed 2026-07-31: a switch's bounded lock wait can expire long after the popover dismissed). Which window's strip carries the row is 02-architecture.md's hosted-by-the-window-of-origin rule — the card window hosts its own strip, and a ⌘Z pressed there surfaces its failure there (re-homing to the board window if the card window closes first, per 02).
- **A clean tree is the happy path, not a malfunction.** When the debounce fires and the tree has nothing to commit — the agent already committed its own work — the auto-committer no-ops silently. The agent's commit, under the agent's own authorship, *is* the record; that is precisely what the self-commit recommendation is for.
- **An agent's `git add -A` can sweep up the user's not-yet-committed app-mediated changes** under the agent's authorship, muddying structural attribution for that window. Accepted limit — the app cannot police another process's staging; the agent guide (08-agent-integration.md) tells agents to commit only their own paths, which keeps well-behaved agents honest.
## Repository hygiene
- **`.gitignore` seeded on every board, never touched after** (re-ruled 2026-07-31 — the file outgrew git: it is the one noise definition the loose-file relocation heal obeys, 01-storage-format.md ▸ Rules, so every board carries it, git or not). Board creation writes the minimal seed — `.DS_Store` plus the writer's temp pattern (`.*.lanework-*`) — and a board missing the file gains it by scheduled heal at open (the guide-refresh cadence; deletion is answered by re-seeding, and the escape hatch for wanting no exclusions is an *empty* file, which the app honors and never rewrites). The app never edits an existing `.gitignore` — it's the user's from the seed on, and fine-tuning what counts as noise over time means fine-tuning the seed. Repo-nested boards are seeded too (re-ruling the old no-app-`.gitignore` posture): the file serves the heal there, not any app-managed git; the visible untracked file in the user's repository is accepted on the agent-guide precedent, with the honest side effect that the enclosing repo's git reads it for paths under the board.
- **Repo growth is accepted.** Unbounded history is the price of never-rewrite, and every attachment version lives in the repo forever. The app may run safe libgit2 housekeeping (repacking loose objects) periodically — it rewrites nothing. **"Periodically" has numbers** (blessed 2026-07-31, all injectable): the pack runs when the loose-object count crosses **6,700** — git's own `gc.auto` default, borrowed for the identical reason — **8 s after session activation** (outlasting the committer's launch catch-up), at background priority, skipped under a pause, a lock, or an in-flight commit, and **attempted at most once per session** — hygiene must never become a hot loop; a skipped or failed pack waits for the next open. **Packs accumulate — the pass never consolidates, rewrites, or deletes existing packs** (blessed 2026-07-31): it deletes only loose files it proved redundant against the pack it just wrote, so a long-lived board accrues roughly one pack per threshold's worth of objects, forever — the never-rewrite posture's accepted cost (git tolerates many packs gracefully; a terminal `git gc` consolidates at the user's discretion). **SHA-256 repositories are unsupported, safely** (blessed 2026-07-31): an adopted SHA-256 repo the engine cannot open takes the corrupt-repo loud-failure path — never a silent fall to mode-none; should one ever open, hygiene abstains by construction — its loose-object filter matches the SHA-1 filename shape only, so its work list is empty and the repo is left untouched (recognizing nothing and touching nothing is the fail-safe direction; a looser match that deleted half-recognized files would be the dangerous one). Content-removing compaction is explicitly out (it would rewrite history); size tooling joins the wishlist if growth ever bites in practice.
- **Deleting never forgets.** On a git board, deleting a card removes it from the board but never from history — every version of its content and attachments stays reachable in any git client, and even a trash purge (Empty Trash, or the future age-based auto-purge — 01-storage-format.md) only cleans the working tree. This is part of the design; users should learn it here, not from a repo browser.
## Changes from Kanban
- **Commit granularity resettled to Edit sessions**: body commits land at the Edit→Preview flip (the effective Save button), not at typing-settle; the committer stages around open sessions (Rules ▸ Auto-commit).
- **Branch switch gates on explicit save-or-discard** for open Edit sessions instead of silently flushing (Branch switching).
- **Commit messages upgraded for external writers**: full schema-1 diff surface, foreign commits in the same vocabulary (no "External edit" prose), semantic launch catch-up, untitled-item rendering (Commit messages).
- The old repo's AI-ANALYSIS-git-operations.md conclusions (forward-restore model, C3/C8) are extracted into [14-git-operations.md](14-git-operations.md) rather than re-derived — cite it, not the pathfinder file.
## Out of scope
- **Board-wide history browser / timeline view** (the old C8 direction): decided — this is a tracker-integration feature, belonging to the out-of-scope integration story (07-sync-collab.md). The commit trail remains its natural substrate whenever that feature happens; nothing here needs to prepare for it beyond the existing undo-never-rewrites rule. **One deliberate carve-in**: the card window's read-only per-card History section (05-card-window.md) — a scoped log view, not a browser; per-row restore and lane history stay on the wishlist.
## Open questions
None currently — the remote-cadence concern is promoted to a design constraint (see Rules above); the push/pull mechanics are settled in 07-sync-collab.md.