Implement staleness validation and skip-with-banner

Every crossing validates its expectations before writing: each step
carries per-item HistoryExpectations — folder, effective ancestor-walked
liveness, and exactly the fields the gesture set — and a mismatch pops
the step, posts the signpost ('Undo skipped — Fix login changed outside
Lanework'), and falls through to the next. Validation reads disk, not
the in-memory snapshot: the snapshot is by construction one reload
behind every app write, so a rapid second undo would false-skip against
the pre-state — disk is what current can honestly mean at press time.
Stale and failed part ways: a stale step is one the board moved past,
so dropping it loses nothing; a failed one is refused by a usually
momentary condition, so it stays put and the crossing stops with only
performWrite's own error row — which forced the provider off
NSUndoManager onto two plain arrays, since a popped group cannot be put
back. The read-only lock disables Undo/Redo through the adapter while
the stack survives to resume on clear. Delete and restore validate
presence alone — a machine timestamp is not a decision — and a
malformed field matches nothing, since it is a shape the app never
writes.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-28 14:54:42 -04:00
parent 2148ebb379
commit 50669489cb
10 changed files with 1333 additions and 149 deletions
+67 -15
View File
@@ -1,15 +1,43 @@
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 second case is 13-native-undo.md Rules **staleness validation**, in the vocabulary the
/// provider needs it in: "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 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.
/// 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.
@@ -19,6 +47,25 @@ public enum HistoryStepOutcome: Equatable, Sendable {
/// 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
@@ -47,11 +94,15 @@ public enum HistoryStepOutcome: Equatable, Sendable {
/// 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`
/// ### Both closures are `@MainActor`, and both are handed the direction
///
/// Everything they touch the store, the snapshot, the banners is, 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.
/// 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 {
/// The menu phrase, unprefixed see the type's note.
@@ -59,16 +110,16 @@ public struct HistoryStep {
/// 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 () -> HistoryStepOutcome
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 () -> HistoryStepOutcome
public let redo: @MainActor (HistoryDirection) -> HistoryStepOutcome
public init(
name: String,
undo: @escaping @MainActor () -> HistoryStepOutcome,
redo: @escaping @MainActor () -> HistoryStepOutcome
undo: @escaping @MainActor (HistoryDirection) -> HistoryStepOutcome,
redo: @escaping @MainActor (HistoryDirection) -> HistoryStepOutcome
) {
self.name = name
self.undo = undo
@@ -131,7 +182,8 @@ public protocol HistoryProviding: AnyObject {
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); an empty stack does
/// 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()