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
+43
View File
@@ -320,6 +320,25 @@ public final class BannerCenter {
signposts.insert(InfoSignpost(message: message), at: 0)
}
/// **The skipped undo step** (13-native-undo.md Rules staleness validation): an inverse found
/// its target holding somebody else's newer value, so it was popped rather than applied and Z
/// fell through to the next step. This is the row that says so.
///
/// **A signpost, and no new class** the vocabulary's answer rather than a compromise. 13 asks
/// for an "info-tone banner", and 02-architecture.md § The banner surface gives the info tone
/// exactly two halves: the pinned in-progress row with its spinner, and the passive signpost.
/// Nothing is in flight here, so the passive half is the whole of the choice. It also reads
/// right: unlike a `loss` row (warning tone, "content that didn't arrive though nothing failed"),
/// **nothing was lost and nothing failed** the file holds exactly what its most recent writer
/// meant it to, the stack moved on to a step that did apply, and the user's Z did something. A
/// row that ranks last and may collapse behind "+N more" is the honest weight for that: calm by
/// design, nothing gated on seeing it instantly. And it is emphatically not a `oneShot`, which
/// carries a `BoardWriteError` a *failed* inverse posts one of those instead, and the two rows
/// must stay distinguishable (`HistoryStepOutcome`).
public func postSkippedStep(_ direction: HistoryDirection, subject: String) {
postSignpost(Self.skippedStepMessage(direction, subject: subject))
}
/// One item a degraded paste could not bring its attachments with what
/// `degradedPasteMessage(for:)` names.
///
@@ -796,6 +815,30 @@ public final class BannerCenter {
return "Moved '\(name)' into attachments — \(subject)"
}
/// The skipped-step line 13-native-undo.md Rules' own example sentence, "Undo skipped 'Fix
/// login' changed outside Lanework", with Z's mirror ("Redo skipped ").
///
/// **The verb is the command the user pressed**, not the half of the step that declined: a step
/// already undone sits on the redo stack reversed, so the closure Z crosses is the one
/// registered as `redo`, and a sentence naming the half would tell the user they pressed the
/// other key (`HistoryDirection`).
///
/// **`subject` is quoted whatever it names** the item's title for a step with one target
/// ("'Fix login'"), the step's own 06 phrase for a batch or an untitled item ("'Move 3 Cards'").
/// One sentence shape for both readings, chosen where the step is registered
/// (`BoardStore.registerStep`) because that is the only place that knows how many items it named.
///
/// **"changed outside Lanework"** is the design's own wording and stays literal: it is the whole
/// explanation the row owes the app did not decline out of caution, somebody else wrote to that
/// item, and the reason the step is gone is that applying it would have thrown their edit away.
public nonisolated static func skippedStepMessage(_ direction: HistoryDirection, subject: String) -> String {
let verb = switch direction {
case .undo: "Undo"
case .redo: "Redo"
}
return "\(verb) skipped — '\(subject)' changed outside Lanework"
}
/// The suspended-history line. It names the *consequence* the user cares about undo and the
/// flush-before-overwrite guarantee are degraded rather than the git mechanics, and carries
/// the diagnosis as its tail.