Files
lanework/Kanban/History/BoardStoreHistory.swift
rzen 274ccd9ff5 Realign code with the 2026-07-31 findings-resolution rulings
The full bullet list from Implementation card bf080d9a — both ruling
batches, including the three appended mid-session by 16ef377:

- Restore subjects compose the inverse, never nest: crossing "Undo: S"
  emits "Redo: S" and vice versa; parity, not stack depth, reads a
  legacy double prefix (GitHistoryProvider.restoreSubject).
- Git-operation failures join the one-shot failure banner tier:
  BannerCenter.GitFailureBanner (undo/redo/branchSwitch/addGit), error
  tone at failure rank merged with write one-shots by recency; the
  postLoss compromise is retired at both AppModel wirings.
- order/schema optional below the board root: append-at-end reading
  (ordered siblings first, folder-name tie-break among the order-less),
  schema reads 1, both coerce-tier logged; the root keeps its
  requirements. Ranks.resolvedOrders materializes finite ranks so
  models and placement math stay untouched; first Writer rewrite
  stamps a real rank on touch, placement against an order-less sibling
  stamps that sibling inline in the same bracket. Agent guide v10
  teaches optional keys and zero-read filing. Hostile-YAML order
  shapes become coercion tests; Fixtures/Valid/optional-keys.kanban
  replaces the four retired Malformed boards.
- .gitignore is the relocation-heal noise gate: GitignoreRules pure
  matcher (standard semantics, board-root file only), loader consults
  it once per walk so matched loose files keep the stray posture;
  seeded (.DS_Store + .*.lanework-*) at board creation and template
  instantiation, healed in when missing at open — repo-nested
  included; empty file honored, existing files never edited; the
  committer's obedience via libgit2 status is pinned by test.
- Comments crash-residue sweep gates on step ownership: HistoryStep
  derives backing from its own undo expectations, backedContent unions
  both stacks, the sweep purges per-entry only what no live step owns.
- Skip-purge decoupled (16ef377): a stale-skipped coarse step strands
  whole in NativeHistoryProvider.strandedSteps — still backing, retired
  only at session end; clean exits purge as before.
- Coarse close step named "Changes to '<card>'"; the fine body-edit
  wording never leaks onto the board menu.
- Branch-switch settle clears every open card window's fine stack on
  Save All and Discard alike; the empty fold registers no coarse step.
- Close flush awaits its covering snapshot (quiesce + one generation
  bump, 1s bound), and an explicit flush now queues behind an
  in-flight one instead of skipping — the audit-caught interleaving
  could lose a close flush permanently when the debounce fired inside
  the close sequence; regression tests force both races.
- Commit comment bullets sort chronologically by created, not UUID.
- The production-unwired CardBodyEditSession.editSessionDidChange seam
  is deleted with its seam-only tests.
- Composition-root pins: beginSession composes the committer with the
  store's own EchoLedger and binds the announcer (the miswire class).
- Deliberate 06 conformance pass over every 2026-07-31-tagged
  sentence: fixed Change-custom-key subjects (the retired named
  generic was the only producer), the unbuilt Replace attachment
  vocabulary, heal commits now authored Lanework Integrity, the config
  reader scopes identity to plain [user] sections, add-git re-runs
  detection at create (a stale mode-none could initialize inside the
  user's repo), and add-git failures answer at the form or the banner.
  Structural residue filed on the Redesign board.

2554 tests / 439 suites green.

Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
2026-08-01 07:43:45 -04:00

418 lines
24 KiB
Swift

import Foundation
import os
// MARK: - Registration at the Writer boundary
/// The inverse-registration layer: how a `BoardStore` write turns into an undo step
/// (13-native-undo.md ▸ Rules).
///
/// ### Why it is an extension rather than lines inside each method
///
/// Every registration in `BoardStore` is the same four moves — name the gesture, hold the *values*
/// the write is about to overwrite, hold the values it is about to set, and hand the pair to the
/// session's stack — and only the middle two differ per operation. Spelling the frame once here
/// leaves each call site with the part that is actually about *that* operation: which fields it
/// touched and what they said before. It also keeps the one rule every step must obey in a single
/// place — **an inverse is performed as an ordinary app-mediated write**, through `performWrite`,
/// so an undo brackets the watcher, echoes back through the reload like any other change, refreshes
/// every window on the board, and (on git boards, pro-m1) commits. Undone changes are real writes,
/// never in-memory reverts.
///
/// ### Values, never live references
///
/// Nothing a step closes over is read from the snapshot at crossing time: every closure below
/// captures ids, folder URLs, orders, titles, style values and body bytes as **values**, computed
/// from the pre-write snapshot the store was holding when the gesture ran. That is what makes a step
/// meaningful minutes later, after any number of reloads, and it is what the **field-level staleness
/// predicate** compares against — each step carries both sides of its write (13 ▸ Rules ▸ staleness
/// validation).
///
/// ### Every step declares what its write left behind
///
/// Beside the two closures, each registration below hands the funnel two `[HistoryExpectation]`
/// lists: what the board must look like for the undo to be safe (the state the *forward* write left),
/// and what it must look like for the redo to be safe (the state the *undo* leaves). One entry per
/// item the gesture touched, naming that item, whether it should be there and live, and the fields
/// the write actually set — "extend the registration to carry whatever the predicate needs, no more".
/// A field this gesture never wrote is never listed, which is what makes a foreign edit elsewhere —
/// another card, another field of the same card — leave the step alone.
///
/// ### Board gestures name a folder; session steps name a card
///
/// The one split in that sentence (`HistoryAnchor`, ruled 2026-07-31): a board gesture's expectation
/// carries the path it wrote to, because where the item sits is what the gesture is *about*; a card
/// window session's carries the card's **identity**, and the folder is resolved at apply time by the
/// same snapshot walk the window resolves its own card with. So a lane move stales a move step (it
/// should) and no longer stales the body edit that happens to have been typed into the same card.
/// `folder(for:)` below is the one resolution both halves of a crossing go through.
@MainActor
extension BoardStore {
// MARK: The funnel
/// Registers one gesture's step on the board's stack — the one call every write site below makes,
/// and the only place `HistoryStep` is built.
///
/// `undo` walks the board back across the write; `redo` replays the write itself. Both are handed
/// the store rather than capturing it, so the only reference a step holds to the board is the
/// weak one this method installs: a stack outliving its session must not be the reason a board
/// stays in memory.
///
/// `subject` is what the **skip banner** quotes: the item's title where the step names exactly
/// one titled item, and otherwise `nil`, which falls back to the step's own 06 phrase. That is
/// two readings of one sentence rather than two sentences — 13's own example is an item ("Undo
/// skipped — 'Fix login' changed outside Lanework") and a batch has no single item to name, so it
/// says what it is instead ("Undo skipped — 'Move 3 Cards' changed outside Lanework"). An
/// untitled item takes the phrase too: "Untitled" is a rendering, never a value (03-board-ui.md
/// § Card face).
///
/// **A failed inverse answers `.failed`, a stale one `.skipped`** — the distinction the stack
/// depends on (`HistoryStepOutcome`): a stale step is dropped and ⌘Z falls through, a failed one
/// stays put with `performWrite`'s error banner already standing. The read-only lock reaches this
/// path only from a direct call — the adapter disables Undo and Redo under it
/// (`BoardUndoManager`, 13 ▸ Rules ▸ locks) — and is a refusal rather than a staleness, so it too
/// leaves the stack where it is: the lock clears, the stack resumes.
///
/// The seam is spelled with an **untyped** `throws`, deliberately and at a cost worth recording:
/// every inverse below is made of `BoardWriter` calls and throws nothing but `BoardWriteError`,
/// but a multi-statement closure literal cannot inherit a typed `throws` from its context —
/// Swift's inference stops at the brace — so a typed seam would make all twelve call sites spell
/// `{ (_: BoardStore) throws(BoardWriteError) -> Void in }` (the same wart `performWrite`'s doc
/// comment records, arriving from a third direction). `cross` narrows it back, and reports rather
/// than swallows the case that cannot happen.
func registerStep(
_ name: String,
subject: String? = nil,
on window: CardWindowUndo? = nil,
retiring: (@MainActor () -> Void)? = nil,
undoExpects: [HistoryExpectation],
redoExpects: [HistoryExpectation],
undo: @escaping @MainActor (BoardStore) throws -> Void,
redo: @escaping @MainActor (BoardStore) throws -> Void
) {
let retirement = retiring.map(HistoryStep.Retirement.init)
// **The routing decision, and the whole of it** (13 ▸ Rules ▸ two levels): a gesture issued
// in a card window lands on that window's stack, and everything else on the board's. It is a
// parameter rather than ambient state on purpose — the issuing surface is knowledge only the
// call site has, and a store-wide "current window" would be a second answer able to be wrong
// for exactly one gesture (the board styling a card whose window happens to be open).
guard let sink: any HistoryProviding = window?.stack ?? history else {
// No substrate at all — a store with no session, or a test's substrate-less board. No
// board the app composes lands here any more (`AppModel.makeHistoryProvider`, re-ruled
// 2026-07-31: repo-nested boards bind the native stack like every other gitless board).
// Nothing records the step, so nothing can ever retire it: the work is owed now.
retirement?.run()
return
}
let named = subject ?? name
let step = HistoryStep(
name: name,
// **What this step holds in a trash, read off what it already declares** — the sweep's
// gate (`HistoryStep.backing`, ruled 2026-07-31). Derived here rather than passed by each
// call site because the expectations *are* the claim: a step whose undo needs a folder
// present under `comments/.trash/` is a step whose undo is the move back out of it.
backing: HistoryStep.backing(declaredBy: undoExpects),
retirement: retirement,
undo: { [weak self] direction in
BoardStore.cross(self, direction, named, undoExpects, undo)
},
redo: { [weak self] direction in
BoardStore.cross(self, direction, named, redoExpects, redo)
}
)
// The raw halves, kept beside the window's stack for the close fold — before the register, so
// a fold taken from inside a registration's own side effects can never see a step it has no
// write for (`CardWindowUndo.netEffect`).
window?.record(step.id, CardWindowUndo.Write(
undoExpects: undoExpects,
redoExpects: redoExpects,
undo: undo,
redo: redo
))
sink.register(step)
}
// MARK: The window close's coarse step
/// **Registers one card-window session as one board step** — 13-native-undo.md ▸ Rules' window
/// close ("the session's net effect registers on the board stack as one coarse step named
/// 'Changes to ⟨card⟩', values-based, whose undo restores the card subtree to its session-start
/// state — deleted comments included — and whose redo reapplies the net effect").
///
/// Everything about *what* the step does is `CardWindowUndo.netEffect()`'s; everything about
/// whether there is a board to register it on is this method's:
///
/// - **A card that resolves nowhere registers nothing** — purged, or moved out of the board.
/// There is no folder for the step's components to be about, so a step registered here could
/// only be a step that skips, and the honest answer is not to register one: the window's fine
/// stack dies with the window, as 13's session-only rule has it.
/// - **A card in the trash still registers**, and that is the ruling of 2026-07-31 read at the
/// coarse step: the resolution below is `cardBodyTarget`'s, spanning both containers exactly as
/// `writeCardBody`'s does, so "a trash move" is one of the tracked relocations that "never
/// stales the step". 05-card-window.md ▸ Deletion & lifecycle dismisses the window when its card
/// is deleted, and the session it was in the middle of is still the user's to walk back — into
/// the trash folder the card now sits in, whose subtree the delete moved intact. (A trashed card
/// carries its `comments/` — 01-storage-format.md — which is what makes that true of the deleted
/// comments too.)
/// - **A session with no net change registers nothing**, which is `netEffect()`'s `nil`.
///
/// - Parameter retiring: the deferred `comments/.trash/` purge (13 ▸ Interaction with the trash).
/// - Returns: whether the purge now has an owner — a live step holding it until the substrate
/// lets the step go (which a stale skip deliberately does not do; 13's skip-purge decoupling,
/// ruled 2026-07-31), or a substrate that declined to keep the step and therefore ran it
/// already (`GitHistoryProvider.register`). `false` means nothing was registered and the caller
/// still owes the purge.
@discardableResult
func registerCardSession(
_ window: CardWindowUndo,
inCard cardID: ItemID,
retiring: @escaping @MainActor () -> Void
) -> Bool {
guard let target = Self.cardBodyTarget(cardID, in: snapshot) else { return false }
guard let net = window.netEffect() else { return false }
// **The title the step is registered with, not the one the session opened on** (the phrase is
// 13's "Changes to '⟨card⟩'", ruled 2026-07-31). Read once and spent twice, which is the
// reason: the menu row and the skip banner must quote *one* name for one step, and a step
// whose row said "Changes to 'Fix login'" while its banner said "'Fix log-in' changed outside
// Lanework" would be two names for one card. Registration time is also the honest instant —
// the row appears in the Edit menu the moment this returns, beside a board already showing
// this title, and a session that renamed the card is a session whose rename is *in* the net
// effect the row is offering to walk back.
let title = Self.cardTitle(at: target, in: snapshot)
registerStep(
HistoryPhrase.cardSession(title),
subject: title,
retiring: retiring,
undoExpects: net.undoExpects,
redoExpects: net.redoExpects,
undo: net.undo,
redo: net.redo
)
return true
}
/// Validates one side of a step against disk, then runs it as an ordinary bracketed write.
///
/// The order is the whole rule: **nothing is written until every target still holds what this
/// step left there** (13 ▸ Rules ▸ staleness validation, "Never apply a stale inverse on top of
/// someone else's newer write"). A stale target posts the info-tone row that explains the skip and
/// answers `.skipped`, which pops the step and falls the crossing through to the next one down.
private static func cross(
_ store: BoardStore?,
_ direction: HistoryDirection,
_ subject: String,
_ expectations: [HistoryExpectation],
_ write: @MainActor (BoardStore) throws -> Void
) -> HistoryStepOutcome {
// The board went away under its own stack — a session torn down between the registration and
// the ⌘Z. There is nothing to write to and nothing to say it on, and a step whose board is
// gone is not one to pop: the stack is about to be cleared with the session anyway.
guard let store else { return .failed }
guard HistoryStaleness.isCurrent(expectations, resolvedBy: store.folder(for:)) else {
store.banners.postSkippedStep(direction, subject: subject)
return .skipped
}
var foreign: (any Error)?
let landed: Void? = try? store.performWrite { () throws(BoardWriteError) -> Void in
do {
try write(store)
} catch let error as BoardWriteError {
// The Writer's own failure: `performWrite` posts it to the banner and rethrows, which
// is the whole of how an inverse that could not land explains itself.
throw error
} catch {
foreign = error
}
}
if let foreign {
historyLogger.error("an inverse threw something that is not a write error: \(String(describing: foreign), privacy: .public)")
return .failed
}
// `nil` is either the Writer's `BoardWriteError` (bannered on the way past) or the read-only
// lock's refusal (the lock row is already standing). Both are failures, never staleness.
return landed == nil ? .failed : .applied
}
private static let historyLogger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "history")
// MARK: Resolving an anchor at apply time
/// **Where a step's anchor points on this board right now** — the one resolver, read by the
/// staleness predicate and by every card-anchored inverse below (13 ▸ Rules, ruled 2026-07-31:
/// "apply-time validation resolves the card's *current* folder exactly the way the window itself
/// always resolves its card").
///
/// One method rather than two so validation and the write it guards can never disagree about
/// where a step is aimed: `cross` checks with this and the inverse writes with it, in that order,
/// against the same snapshot and the same `rootURL` — which a mid-session folder rename may have
/// moved (`HistoryAnchor.folder(under:in:)`).
func folder(for anchor: HistoryAnchor) -> URL? {
anchor.folder(under: rootURL, in: snapshot)
}
/// The same, as a write's precondition rather than a question.
///
/// Unreachable in the ordinary crossing — `cross` has already validated every anchor, and an
/// unresolvable one skipped the whole step before any of this ran — so the throw exists to keep
/// the impossible case honest rather than to be caught: an inverse that could not find its card
/// must not silently write nothing and report success. It reads as an ordinary write failure,
/// because that is what it would be.
func requiredFolder(for anchor: HistoryAnchor, _ operation: WriteOperation) throws(BoardWriteError) -> URL {
guard let folder = folder(for: anchor) else {
throw BoardWriteError(
operation: operation,
path: rootURL.path,
reason: .staleTarget(message: "the card this step was registered against is no longer on the board")
)
}
return folder
}
// MARK: Creates
/// One item a gesture brought into being: where it landed, the bytes it landed with, and any
/// files the same gesture imported into it.
///
/// The bytes are captured at create time because the *inverse of a create is a removal* — "create
/// → remove the created folder" (13 ▸ Rules) — and a removal leaves nothing for a redo to read.
/// Holding them is what lets ⇧⌘Z put the item back at its own path under its own UUID, which
/// every step registered above this one on the stack depends on.
struct CreatedItem {
let folder: URL
let indexText: String
/// The Finder files this create imported (`createCards(fromFiles:)`), replayed on redo from
/// the same source URLs the gesture used. Empty for every other create.
var attachments: [URL] = []
}
/// Registers a create's step: undo removes the folders, redo puts them back byte-for-byte.
///
/// **Removal, not a move into the trash**, exactly as 13 words it: an undone create leaves *no
/// trace*, because the item was born of the gesture being undone — filing it in the trash would
/// leave a card the user never really made for them to find. `purgeIsUnrecoverable` is untouched
/// by this: that flag is about the trash's own permanent delete, whose loss is the user's own
/// final gesture, while this loss is one ⇧⌘Z away.
///
/// The honest edge, recorded rather than papered over: anything that happened *inside* the
/// created folder through an operation that registers no step of its own — an attachment added by
/// hand, a foreign edit — goes with the folder and does not come back on redo. That follows from
/// 13's own two rulings (remove the folder; attachment operations register nothing in v1) rather
/// than from anything decided here.
///
/// **Its staleness predicate is existence, and deliberately nothing else** (13: "existence …
/// for create/delete/restore steps"). A create's after-value *is* the item's being there, so the
/// undo validates that the folders are still at their paths and the redo that they are still
/// gone. The same honest edge follows: a foreign *edit* inside a created card does not stop ⌘Z
/// from removing it, because the create never wrote that field — while a foreign *delete* does,
/// since the card the user is looking at in the trash is at a different path now.
func registerCreation(_ items: [CreatedItem], kind: HistoryPhrase.Kind, subject: String? = nil) {
guard !items.isEmpty else { return }
let operation: WriteOperation = kind == .lane ? .createLane : .createCard
registerStep(
HistoryPhrase.name(.add, kind: kind, count: items.count),
subject: subject,
undoExpects: items.map { .present($0.folder) },
redoExpects: items.map { .absent($0.folder) }
) { _ in
// Reversed, so a lane and a card created by one gesture unwind child-first — the same
// order they were made in, read backwards.
for item in items.reversed() {
try BoardWriter.purgeItem(at: item.folder)
}
} redo: { _ in
for item in items {
try BoardWriter.recreateItem(at: item.folder, indexText: item.indexText, operation: operation)
guard !item.attachments.isEmpty else { continue }
_ = try BoardWriter.importAttachments(item.attachments, intoCard: item.folder)
}
}
}
/// A just-created item, ready to register — or `nil` when its bytes could not be read back, in
/// which case the gesture simply registers nothing rather than arming an undo whose redo could
/// not restore anything.
func createdItem(at folder: URL, kind: HistoryPhrase.Kind, attachments: [URL] = []) -> CreatedItem? {
let operation: WriteOperation = kind == .lane ? .createLane : .createCard
guard let text = try? BoardWriter.readIndexText(ofItem: folder, operation: operation) else { return nil }
return CreatedItem(folder: folder, indexText: text, attachments: attachments)
}
// MARK: Restoring a field to what it said before
/// Puts a lenient string field (`title`, `background`, `icon`) back to the value it held before
/// the write — or removes the key, which is what "before" means for a field that was not there.
///
/// A **malformed** prior reads as a removal, and that is the one place an inverse is not
/// byte-exact: the app cannot re-emit `background: [a, b]` through a document edit that only
/// knows how to set scalars. It is also the case the forward write was designed to clear
/// ("choosing any well replaces it" — 03-board-ui.md § Styling ▸ Controls), so the undo lands the
/// item on the app's own reading of that field rather than resurrecting a value nothing could
/// read.
static func restore(_ prior: FieldValue<String>, to key: String, in document: inout FrontmatterDocument) {
if let value = prior.value {
document.set(key, to: .string(value))
} else {
document.remove(key)
}
}
/// The style fields a gesture actually set — **one entry per dimension it did not `.keep`**, so a
/// step that only chose a colour never validates the symbol beside it.
///
/// `.remove` is spelled as `nil`, which is the after-value the None well leaves: the key is gone,
/// and the item renders the level's default (03-board-ui.md § Styling ▸ Controls).
static func styledFields(background: StyleChange, icon: StyleChange) -> [ExpectedField] {
field(background, as: ExpectedField.background) + field(icon, as: ExpectedField.icon)
}
/// The same dimensions, holding the values the *inverse* restores — what the redo half validates
/// against once ⌘Z has run.
///
/// The prior is read as `FieldValue.value`, which is `nil` for a missing key **and** for a
/// malformed one, exactly matching `restore(_:to:in:)`: a malformed prior reads as a removal, so
/// the state the undo leaves is an absent key either way.
static func restoredStyleFields(
background: StyleChange,
priorBackground: FieldValue<String>,
icon: StyleChange,
priorIcon: FieldValue<String>
) -> [ExpectedField] {
var fields: [ExpectedField] = []
if background != .keep {
fields.append(.background(priorBackground.value))
}
if icon != .keep {
fields.append(.icon(priorIcon.value))
}
return fields
}
private static func field(
_ change: StyleChange,
as make: (String?) -> ExpectedField
) -> [ExpectedField] {
switch change {
case .keep: []
case let .set(value): [make(value)]
case .remove: [make(nil)]
}
}
/// Puts a lane's `width` back — the integer it held, or no key at all.
///
/// Note the asymmetry with the forward write, which removes the key at one unit (the
/// remove-at-default family): an inverse restores the *value*, so a hand-written `width: 1` comes
/// back as `width: 1` rather than as an absence that renders the same. Restoring what was there
/// outranks re-deriving what the app would have written.
static func restoreWidth(_ prior: FieldValue<Int>, in document: inout FrontmatterDocument) {
if let value = prior.value {
document.set(FrontmatterKeys.width, to: .int(value))
} else {
document.remove(FrontmatterKeys.width)
}
}
}