Files
lanework/Kanban/History/HistoryProviding.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

371 lines
21 KiB
Swift

import Foundation
// MARK: - HistoryDirection
/// Which command the user pressed — ⌘Z or ⇧⌘Z.
///
/// **Not which half of a step is running.** A step that has already been undone goes onto the redo
/// stack *reversed* (`HistoryStep.reversed`), so the closure ⇧⌘Z crosses is the one registered as
/// `redo` and the closure a second ⌘Z crosses is the one registered as `undo` — the halves swap, and
/// a step cannot tell which it is by looking at itself. What it has to be told is the **command**,
/// because the one sentence a step ever says out loud names it: "Undo skipped — 'Fix login' changed
/// outside Lanework" (13-native-undo.md ▸ Rules), with "Redo skipped" as its mirror.
public enum HistoryDirection: Sendable, Equatable {
case undo
case redo
/// The stack a step lands on once it has been crossed this way.
public var opposite: HistoryDirection {
self == .undo ? .redo : .undo
}
}
// MARK: - HistoryStepOutcome
/// What happened when a step was asked to walk its write back (or forward again).
///
/// The three cases are three different fates for the *stack*, which is the only thing the provider
/// asks about:
///
/// - `.applied` — the write landed; the step's mirror image joins the opposite stack.
/// - `.skipped` — **stale** (13-native-undo.md ▸ Rules ▸ staleness validation): "target folder gone,
/// or the field no longer holding the step's after-value → the step is **skipped, not applied**:
/// popped from the stack ... and ⌘Z falls through to the next step". The step is dropped and the
/// crossing continues.
/// - `.failed` — the inverse was attempted and could not be written. The step **stays**, and the
/// crossing stops (see the case's own note).
///
/// The provider reads this answer and nothing else — the *predicate* (field-level, settled) and the
/// info-tone banner that explains a skip both belong to the step, which is the only side that knows
/// what it wrote and which board to say it on.
public enum HistoryStepOutcome: Equatable, Sendable {
/// The step ran. Its mirror image joins the opposite stack.
case applied
/// The step declined: the board no longer holds the value this step's write left, so applying
/// its inverse would clobber somebody else's newer edit. Nothing ran, the step is dropped, and
/// the crossing continues with the next one down.
case skipped
/// The step tried and could not: the Writer refused the inverse (a disk error, a permission
/// problem, a board that has gone read-only under the stack). Nothing landed.
///
/// **The step stays put and the crossing stops** — the one thing that distinguishes this from
/// `.skipped`, and the reason the case exists. 13 is silent here, so the posture is the honest
/// reading of its two rules: a *stale* step is one the board has moved past, so dropping it
/// loses nothing; a *failed* one is a step the user still means to cross, refused by a condition
/// that is usually momentary (a full disk, an unplugged volume), so popping it would spend their
/// only route back on a transient error. The failure has already banners itself as an ordinary
/// write failure (`BoardStore.performWrite` posts before it rethrows), which is exactly the
/// vocabulary 02-architecture.md § Write-failure surfacing gives it — and which is why a failure
/// must *not* also raise the info-tone skip row: two rows for one event would say the step is
/// both gone and retryable.
///
/// Stopping rather than falling through follows from the same reading: fall-through exists to
/// walk past steps the board no longer has a use for, and a disk that just refused one write is
/// not a reason to attempt N more.
case failed
}
// MARK: - HistoryStep
/// One undoable step: a name, and the pair of actions that walk the board backwards and forwards
/// across it.
///
/// ### An operation pair, not an `NSUndoManager` registration
///
/// 13-native-undo.md ▸ Rules puts every undoable change at the Writer boundary — "each Writer call
/// site registers the inverse operation, computed from the pre-write snapshot the store already
/// holds: move → move back ...; rename → restore title" — and both halves of that write are already
/// in the caller's hands: the before-value *is* the inverse, and the after-value is what the write
/// set (which is also what the staleness predicate compares). A step is therefore that pair, in the
/// design's own terms, and deliberately says nothing about how a stack stores it: a gitless board's
/// stack is `NSUndoManager`-backed and a Pro git board's is git (12-editions.md ▸ The provider
/// seam), and neither substrate appears here.
///
/// ### `name` is the 06 vocabulary, unprefixed
///
/// "The 06 vocabulary supplies menu titles ('Undo Move 3 Cards'), via NSUndoManager's dynamic
/// retitling — the same naming machinery both tiers use" (13). What the step carries is the bare
/// phrase — `"Move Card"`, `"Move 3 Cards"`, `"Rename Lane"` — in the vocabulary of
/// 06-history-undo.md ▸ Commit messages, plural-folded by the same rule ("one gesture, one undo
/// step — a multi-card move is one step with a plural title"). The **"Undo "/"Redo " prefix is
/// never part of it**: the platform composes and localizes that (`BoardUndoManager`), and a step
/// that spelled it would read "Undo Undo Move Card" in the Edit menu.
///
/// ### Both closures are `@MainActor`, and both are handed the direction
///
/// Everything they touch — the store, the snapshot, the banners — is `@MainActor`, and a step exists
/// to be run from a menu command. Marking them says so at the seam instead of leaving each provider
/// to rediscover it.
///
/// The `HistoryDirection` argument is **which command the user pressed**, not which half is running:
/// `reversed` swaps the two closures, so the half a ⌘Z crosses is the `redo` one as often as not,
/// and the skip sentence has to name the keystroke rather than the half (see `HistoryDirection`).
public struct HistoryStep {
/// **What a step owes the world once it has left history for good** — run exactly once, by
/// whichever provider drops it.
///
/// It exists for one consumer, and the design names it precisely: the window-close coarse step
/// defers a card's `comments/.trash/` purge, because "the coarse close step's undo restores
/// deleted comments, so their backing lives as long as the step does — the purge runs when the
/// coarse step leaves the board stack **cleanly** — undone-and-superseded, or dropped off the end
/// — or when the board session ends" (13-native-undo.md ▸ Interaction with the trash, re-ruled
/// 2026-07-31). A step is the only object that knows those moments, and it knows none of them
/// itself — so the *provider* reports them, through this.
///
/// **A stale skip is deliberately not one of them** (the skip-purge decoupling, ruled
/// 2026-07-31): "a stale-skipped step's backing instead survives to board-session end ... the
/// skip banner says nothing was applied, and an irreversible purge riding that gesture would be
/// surprise loss". Leaving the stack and leaving history for good stopped being the same event
/// there, and this latch marks the second — which is why a substrate that keeps a skipped step's
/// hold alive holds the *step* (`NativeHistoryProvider.strandedSteps`) rather than running this
/// early and re-arming the sweep from somewhere else.
///
/// **A reference type inside a value type, deliberately.** `reversed` copies the step every time
/// it crosses, and the two copies must not each run the work: sharing one latch is what makes
/// "exactly once" a property of the object rather than of the bookkeeping around it.
///
/// A step with no retirement — every board gesture — carries `nil` and costs nothing.
@MainActor
public final class Retirement {
private var work: (@MainActor () -> Void)?
public init(_ work: @escaping @MainActor () -> Void) {
self.work = work
}
/// Runs the work, once. Later calls do nothing, which is what lets every provider report a
/// drop without first checking whether another one already has.
public func run() {
let work = self.work
self.work = nil
work?()
}
/// Whether the work is still owed — the assertion a test makes instead of watching disk.
public var isOwed: Bool { work != nil }
}
/// This step's identity, stable across `reversed` — which is what makes it usable as a key into
/// state held *beside* a stack (`CardWindowUndo`, whose fold has to find the raw write behind a
/// step that may have crossed any number of times).
public let id: UUID
/// The menu phrase, unprefixed — see the type's note.
public let name: String
/// **What this step holds as undo backing** — deleted content that exists on disk only because
/// *this step's undo would move it back out*, and which is therefore not residue for as long as
/// the step is crossable (13-native-undo.md ▸ Interaction with the trash, ruled 2026-07-31).
///
/// > "`comments/.trash/` content referenced by a live coarse step on the board stack is a step's
/// > **backing, not residue** — the open-time sweep consults the stack and skips owned content,
/// > re-arming when the owning step leaves the stack (which is exactly when the deferred purge
/// > wanted to run; one condition, two consumers). Reopening a window can therefore never destroy
/// > its prior session's undo backing."
///
/// The pair with `retirement` is the whole mechanism: this says *what* is being held, that says
/// *when the hold ends*, and the two are read by the two consumers the ruling names — the
/// crash-residue sweep (`BoardStore.sweepCommentTrashResidue(inCard:)`, through
/// `HistoryProviding.backedContent`) and the deferred purge itself.
///
/// **Declared by the step's own expectations rather than by hand** — see `backing(declaredBy:)`.
/// Empty for every board gesture, which is all but a comment delete and the coarse step folding
/// one.
public let backing: Set<HistoryAnchor>
/// What this step owes when it leaves history — see `Retirement`. `nil` for every step that owes
/// nothing, which is all but the card-window close step.
public let retirement: Retirement?
/// Walks the board back across this step. Registered at the Writer boundary as the *inverse* of
/// the write that just landed.
public let undo: @MainActor (HistoryDirection) -> HistoryStepOutcome
/// Walks it forward again — the original write, replayed. Reached only after `undo` applied,
/// because that is the only way a step reaches the redo stack.
public let redo: @MainActor (HistoryDirection) -> HistoryStepOutcome
public init(
id: UUID = UUID(),
name: String,
backing: Set<HistoryAnchor> = [],
retirement: Retirement? = nil,
undo: @escaping @MainActor (HistoryDirection) -> HistoryStepOutcome,
redo: @escaping @MainActor (HistoryDirection) -> HistoryStepOutcome
) {
self.id = id
self.name = name
self.backing = backing
self.retirement = retirement
self.undo = undo
self.redo = redo
}
/// **The backing a step's own registration already names**: every target its **undo** expects to
/// find *in a trash*.
///
/// Derived rather than passed, so no call site can forget it and no call site can say something
/// its expectations contradict. The reading is exact: a step whose undo requires a folder to be
/// present under `comments/.trash/` is a step whose undo *is* the move back out of it — which is
/// the definition of backing, spelled in the currency the step already carries.
///
/// **`.trashedComment` is the whole vocabulary**, deliberately. The board's own `.trash/` holds
/// the same relationship (a delete step's undo restores from it) and is deliberately absent: the
/// board trash is a UI surface the user empties on purpose, with a confirm, and nothing sweeps it
/// behind their back — so there is no consumer for the answer. `comments/.trash/` is the one
/// trash the app purges on its own schedule (01-storage-format.md § Enhanced schema), which is
/// exactly why it is the one that needs asking.
public static func backing(declaredBy undoExpects: [HistoryExpectation]) -> Set<HistoryAnchor> {
var backing: Set<HistoryAnchor> = []
for expectation in undoExpects where expectation.presence == .present {
guard case .trashedComment = expectation.anchor else { continue }
backing.insert(expectation.anchor)
}
return backing
}
/// The same step read backwards — what a provider puts on the opposite stack once this one has
/// applied. The name does not change, which is the whole of "Undo Move Card" becoming "Redo Move
/// Card": the phrase names the *gesture*, not the direction.
///
/// **The identity, the backing and the retirement travel with it**, all three for one reason: a
/// step that has crossed is the same step, so the fold that keyed state on it must still find that
/// state, the content it holds must still be held, and the purge it defers must still be owed
/// exactly once.
///
/// The backing claim is constant across the crossing rather than swapped with the closures, and
/// that is the honest reading: an undone coarse step has *already* moved its comments back out of
/// `comments/.trash/`, so there is nothing there to sweep — and its redo will put them back,
/// after which its undo needs them again. A claim that lapsed while the step sat on the redo stack
/// would be a claim that lapsed exactly when the step was still crossable.
public var reversed: HistoryStep {
HistoryStep(id: id, name: name, backing: backing, retirement: retirement, undo: redo, redo: undo)
}
}
// MARK: - HistoryProviding
/// The undo/redo substrate, behind one protocol boundary (12-editions.md ▸ The provider seam).
///
/// ### One per board session — and one per open card window
///
/// "Two levels: one stack per board, one per open card window" (13-native-undo.md ▸ Rules, re-ruled
/// 2026-07-31). The **board** stack is the session's, shared by board surfaces, and its
/// implementation is what this protocol is a seam for. A **card window** owns a second stack for its
/// own gestures — always a `NativeHistoryProvider`, in either tier, because a window's fine-grained
/// inverses are values-based whatever the board's substrate is (`CardWindowUndo`); what reaches this
/// seam from a window is the one coarse step its close registers.
///
/// `AppModel.BoardSession` is where the board half's ownership lives, and the composition root binds
/// which implementation it gets — **following the board, not the tier alone** (re-ruled 2026-07-31):
/// a board without app-managed git — repo-nested included (re-ruled 2026-07-31) — binds
/// `NativeHistoryProvider` (two step stacks over the inverses registered at the Writer boundary) in
/// every tier, a Pro git board binds the git provider (undo as forward restore commits over HEAD's
/// first-parent ancestry — 06-history-undo.md), and Teams inherits Pro's.
///
/// ### What this protocol deliberately does not say
///
/// - **No `NSUndoManager`, anywhere in the signature.** It is the native provider's implementation
/// detail, and a seam that vended one would be a seam only one provider could ever satisfy — the
/// opposite of the reason the seam exists at all ("the free tier's native undo is the first proof
/// the seam is real", 12). AppKit still needs an `UndoManager` to hand the responder chain; that
/// adapter is `BoardUndoManager`, which sits *over* this protocol rather than inside it.
/// - **No persistence promise.** The native stack dies with the session (13); Pro's survives
/// relaunch because git does (06). Both are honest implementations of these seven members.
/// - **No routing.** Which surface ⌘Z reaches is focus's answer, not the substrate's
/// (06 ▸ Undo routing, tier-independent) — `BoardUndoRouting`.
///
/// ### One obligation every implementation shares: retire what you let go of
///
/// A step may owe work for as long as the substrate holds it and no longer (`HistoryStep.Retirement`
/// — the deferred `comments/.trash/` purge). Only the substrate knows when it has stopped holding
/// one, so **every implementation must call `retirement?.run()` on every step it lets go**: the redo
/// stack it clears on a `register`, everything in `clear()`, and — for a substrate that keeps no
/// steps at all — the step handed to `register` itself. Nothing else in the app can observe that
/// moment, and a step dropped in silence would defer its work forever.
///
/// **Held is not the same as crossable**, since the skip-purge decoupling (13 ▸ Interaction with the
/// trash, ruled 2026-07-31): a step a stale skip popped is crossable by nothing, and its hold
/// nonetheless stands to the session's end, because "the skip banner says nothing was applied, and an
/// irreversible purge riding that gesture would be surprise loss". A substrate that pops stale steps
/// therefore has a place to put them (`NativeHistoryProvider.strandedSteps`); one that never keeps a
/// step is untouched by the distinction, which is why the git provider needed no change.
///
/// ### And its mirror: say what you are still holding
///
/// The same fact, asked the other way round — `backedContent`. Retirement is the *moment* a hold
/// ends; that is the *inventory* of holds standing right now, which is what a sweep needs before it
/// removes anything (13 ▸ Interaction with the trash, ruled 2026-07-31: "one condition, two
/// consumers"). The two must be answered off the same steps or the pair stops being one condition —
/// which is the whole reason a stranded step is kept whole rather than filleted into a retirement
/// here and a set of anchors there. It defaults to nothing, so a substrate that keeps no steps needs
/// no line of code.
@MainActor
public protocol HistoryProviding: AnyObject {
/// Records one undoable step, on top of the undo stack, clearing the redo stack — the classic
/// rule, and the one every substrate shares.
///
/// Called once per *gesture*, never once per write: "coalescing follows commit granularity ...
/// a multi-card move is one step with a plural title; an Edit session is one step, registered at
/// the Edit→Preview flip" (13 ▸ Rules).
func register(_ step: HistoryStep)
/// Whether there is a step to cross. What the Edit menu's Undo row and the toolbar's Undo item
/// enable on, through the same responder-chain answer.
var canUndo: Bool { get }
var canRedo: Bool { get }
/// The name of the step ⌘Z would cross, or `nil` when there is none — the phrase the menu title
/// is composed from ("Move 3 Cards" → "Undo Move 3 Cards").
var undoActionName: String? { get }
var redoActionName: String? { get }
/// Crosses one step backwards. A stale step is skipped rather than applied, and the crossing
/// falls through to the next one (13 ▸ Rules ▸ staleness validation); a step whose write *failed*
/// stays where it is and stops the crossing (`HistoryStepOutcome.failed`); an empty stack does
/// nothing.
func undo()
func redo()
/// Drops every step in both directions — session-only persistence (13 ▸ Rules), run at the board
/// session's teardown. Also what a substrate that must re-seed (a branch switch, 06) calls first.
func clear()
/// **The undo backing every step this substrate still holds** — the union of `HistoryStep.backing`
/// over everything it has not yet let go of.
///
/// Read by the crash-residue sweep before it removes anything
/// (`BoardStore.sweepCommentTrashResidue(inCard:)`): "content referenced by a live coarse step on
/// the board stack is a step's backing, not residue" (13 ▸ Interaction with the trash, ruled
/// 2026-07-31). Both directions count, because both are live: a step sitting on the redo stack has
/// not retired, and 13's own release condition — "undone-and-superseded" — is the moment it does.
/// So does a step a stale skip stranded, which is crossable in *neither* direction and holding all
/// the same: the skip-purge decoupling put its backing's release at the session's end, and a sweep
/// that could not see it would take the release back (`NativeHistoryProvider.strandedSteps`).
///
/// A *point-in-time* answer, computed on demand rather than cached: the stacks are the truth, and
/// a second copy of this could only ever be a stale one.
var backedContent: Set<HistoryAnchor> { get }
}
extension HistoryProviding {
/// **A substrate that keeps no steps holds no backing** — the git provider's honest answer
/// (`GitHistoryProvider.register` retires every step on arrival, which is what makes "purge rides
/// the close flush" true on Pro), and a test fake's.
///
/// A default on the requirement rather than a free function, so the dispatch is the substrate's:
/// an implementation that *does* keep steps overrides it and every caller through `any
/// HistoryProviding` sees the override.
public var backedContent: Set<HistoryAnchor> { [] }
}