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
+171 -20
View File
@@ -879,11 +879,11 @@ public final class BoardStore {
/// hand-written `width: 1` is legal and preserved until the app itself next edits width the
/// unchanged-units guard below skips it, so only a real change reaches the remove.
private func writeLaneWidths(_ changes: [(id: ItemID, units: Int)]) {
let writes: [(folder: URL, units: Int, prior: FieldValue<Int>)] = changes.compactMap { change in
let writes: [(folder: URL, units: Int, prior: FieldValue<Int>, title: String?)] = changes.compactMap { change in
guard let lane = snapshot.lanes.first(where: { $0.id == change.id }),
LaneLayoutMath.displayUnits(of: lane) != change.units
else { return nil }
return (rootURL.appendingPathComponent(change.id.rawValue), change.units, lane.width)
return (rootURL.appendingPathComponent(change.id.rawValue), change.units, lane.width, lane.title.value)
}
guard !writes.isEmpty else { return }
@@ -900,7 +900,18 @@ public final class BoardStore {
// resize prior width (13-native-undo.md Rules). One step whatever the batch's size the
// menu items step every selected lane in one gesture, and one gesture is one step.
registerStep(HistoryPhrase.name(.resize, kind: .lane, count: writes.count)) { _ in
//
// The validated field is `width`, read as the app reads it: a lane landing on one unit has
// **no key at all** (the remove-at-default rule above), which is a real after-value and the
// one `nil` here means. The redo's expectation is the prior as the inverse restores it
// `prior.value`, which is `nil` for a missing *and* for a malformed prior, exactly matching
// `restoreWidth`'s own reading.
registerStep(
HistoryPhrase.name(.resize, kind: .lane, count: writes.count),
subject: writes.count == 1 ? writes[0].title : nil,
undoExpects: writes.map { .live($0.folder, .width($0.units == 1 ? nil : $0.units)) },
redoExpects: writes.map { .live($0.folder, .width($0.prior.value)) }
) { _ in
for write in writes {
try BoardWriter.updateIndex(inItemFolder: write.folder, operation: .resize(title: nil)) { document in
Self.restoreWidth(write.prior, in: &document)
@@ -1029,6 +1040,7 @@ public final class BoardStore {
/// gesture" and the reload shows the true state, which is the honest one.
public func applyStyle(to target: StyleTarget, background: StyleChange = .keep, icon: StyleChange = .keep) {
let edits: [(
id: ItemID?,
folder: URL,
background: StyleChange,
icon: StyleChange,
@@ -1040,6 +1052,7 @@ public final class BoardStore {
let icon = Self.effective(icon, against: subject.icon)
guard background != .keep || icon != .keep else { return nil }
return (
id: subject.id,
folder: subject.folder,
background: background,
icon: icon,
@@ -1069,7 +1082,28 @@ public final class BoardStore {
case .lane: .lane
case .card: .card
}
registerStep(HistoryPhrase.name(.restyle, kind: kind, count: edits.count)) { _ in
// **Per dimension, not per item**: a gesture that set only `background` validates only
// `background`, so a foreign `icon:` edit on the very same card leaves the step alone. That is
// the field-level predicate read at its narrowest, and it is free `effective(_:against:)`
// has already narrowed each dimension to what this write actually changed.
let subject = edits.count == 1
? edits[0].id.flatMap { Self.liveItem($0, in: snapshot)?.title }
: nil
registerStep(
HistoryPhrase.name(.restyle, kind: kind, count: edits.count),
subject: subject,
undoExpects: edits.map {
.live($0.folder, fields: Self.styledFields(background: $0.background, icon: $0.icon))
},
redoExpects: edits.map {
.live($0.folder, fields: Self.restoredStyleFields(
background: $0.background,
priorBackground: $0.priorBackground,
icon: $0.icon,
priorIcon: $0.priorIcon
))
}
) { _ in
for edit in edits {
try BoardWriter.updateIndex(inItemFolder: edit.folder, operation: .style(title: nil)) { document in
Self.restore(edit.priorBackground, to: FrontmatterKeys.background, in: &document)
@@ -1216,7 +1250,7 @@ public final class BoardStore {
// may have written is inside the captured bytes, so a redo puts the card back where the
// gesture put it, not merely at the bottom of the lane.
if let item = createdItem(at: laneFolder.appendingPathComponent(created.rawValue, isDirectory: true), kind: .card) {
registerCreation([item], kind: .card)
registerCreation([item], kind: .card, subject: title)
}
return created
}
@@ -1284,8 +1318,17 @@ public final class BoardStore {
// rename restore title (13-native-undo.md Rules). The prior title is the *typed* value,
// `nil` for an untitled item so undoing a rename that gave an untitled card a name takes
// the `title` key away again rather than writing `title: ""`.
//
// The validated field is `title` and nothing else: an agent that restyles this very card
// between the rename and the Z has not touched what this step wrote, so the undo applies
// "a foreign change to an unrelated item must not skip anything", read one level finer.
let priorTitle = target.title
registerStep(HistoryPhrase.name(.rename, kind: target.cardID == nil ? .lane : .card)) { _ in
registerStep(
HistoryPhrase.name(.rename, kind: target.cardID == nil ? .lane : .card),
subject: newTitle ?? priorTitle,
undoExpects: [.live(folder, .title(newTitle))],
redoExpects: [.live(folder, .title(priorTitle))]
) { _ in
try Self.setTitle(priorTitle, at: folder)
} redo: { _ in
try Self.setTitle(newTitle, at: folder)
@@ -1437,13 +1480,31 @@ public final class BoardStore {
/// Liveness is `writeCardBody`'s deliberately blind walk (`cardBodyTarget`), so a session that
/// ended because its card was tombstoned still registers the keystrokes survived into the
/// tombstoned folder, and their undo has to be able to reach the same place.
///
/// ### Its staleness predicate is the bytes, and the side of the trash the card was on
///
/// "Body steps compare bytes" (13 Rules), so the expectation is the whole body span as this
/// session left it a foreign editor that changed one character of it skips the step rather than
/// throwing that character away. The liveness half is captured rather than assumed, for the same
/// reason this method resolves its folder blind: a session that ended *because* the card was
/// tombstoned belongs to a tombstoned card, and demanding a live one would make its own undo
/// stale the instant it was registered.
public func registerBodyEdit(inCard cardID: ItemID, priorBody: String, newBody: String) {
guard priorBody != newBody, let target = Self.cardBodyTarget(cardID, in: snapshot) else { return }
let folder = rootURL
.appendingPathComponent(target.laneID.rawValue, isDirectory: true)
.appendingPathComponent(target.cardID.rawValue, isDirectory: true)
registerStep(HistoryPhrase.name(.edit, kind: .card)) { _ in
let lane = snapshot.lanes.first { $0.id == target.laneID }
let card = lane?.cards.first { $0.id == target.cardID }
let tombstoned = lane?.isDeleted == true || card?.isDeleted == true
registerStep(
HistoryPhrase.name(.edit, kind: .card),
subject: card?.title.value,
undoExpects: [.item(folder, tombstoned: tombstoned, .body(newBody))],
redoExpects: [.item(folder, tombstoned: tombstoned, .body(priorBody))]
) { _ in
_ = try BoardWriter.writeBody(inItemFolder: folder, body: priorBody)
} redo: { _ in
_ = try BoardWriter.writeBody(inItemFolder: folder, body: newBody)
@@ -1599,8 +1660,15 @@ public final class BoardStore {
}
guard landed != nil else { return }
// rename restore title, at the one level with no item to aim at.
registerStep(HistoryPhrase.name(.rename, kind: .board)) { _ in
// rename restore title, at the one level with no item to aim at. The board root is never
// tombstoned however its frontmatter reads (a board-level `deleted:` is a tolerated load
// warning), so `.live` here means exactly "the root is still readable".
registerStep(
HistoryPhrase.name(.rename, kind: .board),
subject: newTitle ?? priorTitle,
undoExpects: [.live(folder, .title(newTitle))],
redoExpects: [.live(folder, .title(priorTitle))]
) { _ in
try Self.setTitle(priorTitle, at: folder)
} redo: { _ in
try Self.setTitle(newTitle, at: folder)
@@ -1669,7 +1737,12 @@ public final class BoardStore {
// parent the board root is the only one there is so 06's vocabulary word for it is
// Reorder, not Move.
let restored = priorOrder
registerStep(HistoryPhrase.name(.reorder, kind: .lane)) { _ in
registerStep(
HistoryPhrase.name(.reorder, kind: .lane),
subject: lanes[from].title.value,
undoExpects: [.live(folder, .order(newOrder))],
redoExpects: [.live(folder, .order(restored))]
) { _ in
try Self.setOrder(restored, at: folder)
} redo: { _ in
try Self.setOrder(newOrder, at: folder)
@@ -1745,7 +1818,12 @@ public final class BoardStore {
// per gesture whatever the set's size" is the same sentence as one gesture, one undo step.
let inverse = Array(zip(rewrites.map(\.folder), priorOrders))
let forward = rewrites
registerStep(HistoryPhrase.name(.reorder, kind: .lane, count: forward.count)) { _ in
registerStep(
HistoryPhrase.name(.reorder, kind: .lane, count: forward.count),
subject: members.count == 1 ? members[0].title.value : nil,
undoExpects: forward.map { .live($0.folder, .order($0.order)) },
redoExpects: inverse.map { .live($0.0, .order($0.1)) }
) { _ in
for (folder, order) in inverse {
try Self.setOrder(order, at: folder)
}
@@ -1788,6 +1866,9 @@ public final class BoardStore {
let id: ItemID
let laneID: ItemID
let order: Double
/// What it is called, for the skip banner a stale step would raise read here because the
/// snapshot this resolves against is the pre-write one, which is where a title still is.
let title: String?
}
/// `ids` narrowed to live cards under live lanes and sorted into **flatten order** "lane
@@ -1798,15 +1879,17 @@ public final class BoardStore {
/// membership is a UUID set that vanished items leave silently (02-architecture.md), and "partial
/// vanishing drops the survivors" is the design's own wording.
private func draggedCards(_ ids: Set<ItemID>) -> [DraggedCard] {
var homes: [ItemID: (lane: ItemID, order: Double)] = [:]
var homes: [ItemID: (lane: ItemID, order: Double, title: String?)] = [:]
for lane in snapshot.lanes where !lane.isDeleted {
for card in lane.cards where !card.isDeleted {
homes[card.id] = (lane.id, card.order)
homes[card.id] = (lane.id, card.order, card.title.value)
}
}
return SelectionGrammar.liveCards(in: snapshot)
.filter { ids.contains($0) }
.compactMap { id in homes[id].map { DraggedCard(id: id, laneID: $0.lane, order: $0.order) } }
.compactMap { id in
homes[id].map { DraggedCard(id: id, laneID: $0.lane, order: $0.order, title: $0.title) }
}
}
/// The within-board card drop: `ids` land contiguously at logical position `index` among
@@ -1899,7 +1982,17 @@ public final class BoardStore {
)
}
let crossedLanes = members.contains { $0.laneID != laneID }
registerStep(HistoryPhrase.name(crossedLanes ? .move : .reorder, kind: .card, count: arrivals.count)) { _ in
// The two lists are index-aligned mirror images `inverse[i].from` is where the card is now
// and `forward[i].from` is where it was so the expectations read as one swap: **the undo
// wants the card at its destination holding the rank the drop gave it; the redo wants it back
// at its origin holding the rank it left.** The destination *path* is the lane check: a card
// a foreign writer moved elsewhere leaves nothing there to validate.
registerStep(
HistoryPhrase.name(crossedLanes ? .move : .reorder, kind: .card, count: arrivals.count),
subject: members.count == 1 ? members[0].title : nil,
undoExpects: zip(inverse, forward).map { .live($0.from, .order($1.order)) },
redoExpects: zip(inverse, forward).map { .live($1.from, .order($0.order)) }
) { _ in
for step in inverse {
_ = try BoardWriter.moveItem(
at: step.from,
@@ -2561,7 +2654,11 @@ public final class BoardStore {
// from what actually landed rather than from `urls`, so a batch that failed halfway still
// hands Z exactly the cards it left behind.
let items = created.compactMap { createdItem(at: $0.folder, kind: .card, attachments: [$0.source]) }
registerCreation(items, kind: .card)
registerCreation(
items,
kind: .card,
subject: created.count == 1 ? Self.cardTitle(forFile: created[0].source) : nil
)
}
/// The title a dropped file's card takes: **the filename without its extension**
@@ -2662,8 +2759,18 @@ public final class BoardStore {
// reorder restore original `order` (13-native-undo.md Rules). The step is named for the
// *gesture's* subject the cards the user was moving not for every sibling the permutation
// displaced, which is the same rule 06 applies to a commit subject.
//
// Every rewritten rank is validated, the displaced siblings' included: they are what this
// permutation wrote, so they are what it must find unchanged the step is *named* for the
// gesture's subject and *validated* over its whole write.
let steps = rewrites
registerStep(HistoryPhrase.name(.reorder, kind: .card, count: selection.ids.count)) { _ in
let moved = selection.ids
registerStep(
HistoryPhrase.name(.reorder, kind: .card, count: moved.count),
subject: moved.count == 1 ? moved.first.flatMap { Self.liveItem($0, in: snapshot)?.title } : nil,
undoExpects: steps.map { .live($0.folder, .order($0.to)) },
redoExpects: steps.map { .live($0.folder, .order($0.from)) }
) { _ in
for step in steps {
try Self.setOrder(step.from, at: step.folder)
}
@@ -2826,8 +2933,19 @@ public final class BoardStore {
// tombstone restore. The undo is Put Back's own write, which is what makes 13's "the stack
// and the trash are two doors to the same tombstone state" true on disk rather than by
// agreement undoing a delete is *identical* in effect to Put Back.
//
// **Existence and liveness, no fields** (13: "existence/liveness for ... delete ... steps"):
// what a tombstone writes is the item's side of the trash, so that is the whole after-value.
// The `deleted` timestamp is deliberately *not* compared it is a machine stamp rather than
// a decision, and an item somebody put back and re-deleted is still on the side this step
// left it on.
let kind: HistoryPhrase.Kind = paths.contains(where: \.isLane) ? .lane : .card
registerStep(HistoryPhrase.name(.delete, kind: kind, count: folders.count)) { _ in
registerStep(
HistoryPhrase.name(.delete, kind: kind, count: folders.count),
subject: paths.count == 1 ? title(at: paths[0]) : nil,
undoExpects: folders.map { .tombstoned($0) },
redoExpects: folders.map { .live($0) }
) { _ in
for folder in folders {
try BoardWriter.restoreItem(at: folder)
}
@@ -2875,7 +2993,12 @@ public final class BoardStore {
// restore (Put Back) tombstone (13-native-undo.md Rules) the trash pair read the other
// way round from `tombstone(_:)`'s step.
let kind: HistoryPhrase.Kind = paths.contains(where: \.isLane) ? .lane : .card
registerStep(HistoryPhrase.name(.restore, kind: kind, count: restored.count)) { _ in
registerStep(
HistoryPhrase.name(.restore, kind: kind, count: restored.count),
subject: paths.count == 1 ? title(at: paths[0]) : nil,
undoExpects: restored.map { .live($0.folder) },
redoExpects: restored.map { .tombstoned($0.folder) }
) { _ in
for item in restored {
try BoardWriter.updateIndex(inItemFolder: item.folder, operation: .delete(title: nil)) { document in
Self.restoreTombstone(item.deleted, in: &document)
@@ -2891,6 +3014,16 @@ public final class BoardStore {
/// The `deleted` value a trash row currently carries the one field a Put Back's inverse has to
/// carry forward, and one the `ItemPath` vocabulary deliberately does not (a path is a location,
/// not a reading of the file there).
/// What a trash-pair step's one item is called the skip banner's quoted subject, `nil` for an
/// untitled item (which falls back to the step's own phrase) and for an id the snapshot has
/// already lost. A path is a location, not a reading of the file there, so this is the same
/// deliberate lookup `deletedField(at:)` is.
private func title(at path: TrashModel.ItemPath) -> String? {
guard let lane = snapshot.lanes.first(where: { $0.id == path.laneID }) else { return nil }
guard let cardID = path.cardID else { return lane.title.value }
return lane.cards.first(where: { $0.id == cardID })?.title.value
}
private func deletedField(at path: TrashModel.ItemPath) -> FieldValue<Date> {
guard let lane = snapshot.lanes.first(where: { $0.id == path.laneID }) else { return .missing }
guard let cardID = path.cardID else { return lane.deleted }
@@ -3069,8 +3202,26 @@ public final class BoardStore {
// restore tombstone (13-native-undo.md Rules), with the position half of the gesture
// walked back too: the row returns to the lane it was trashed in, at the rank it was trashed
// holding, under the timestamp it was trashed at which is exactly where its trash row was.
//
// Both halves of the gesture are validated, because both were written: the card must be live
// in the destination lane at the rank the drop gave it, and the other way round back in
// the lane it was trashed in, tombstoned, at the rank it was trashed holding. The same-lane
// case collapses to one folder and is still exactly this: the write that set no `order` set
// it to the value it already had.
let steps = moves
registerStep(HistoryPhrase.name(.restore, kind: .card, count: steps.count)) { _ in
registerStep(
HistoryPhrase.name(.restore, kind: .card, count: steps.count),
subject: rows.count == 1 ? rows[0].card.title.value : nil,
undoExpects: steps.map {
.live(laneFolder.appendingPathComponent($0.cardID.rawValue, isDirectory: true), .order($0.order))
},
redoExpects: steps.map {
.tombstoned(
TrashModel.ItemPath(laneID: $0.laneID, cardID: $0.cardID).folder(under: root),
.order($0.priorOrder)
)
}
) { _ in
for step in steps {
let priorFolder = TrashModel.ItemPath(laneID: step.laneID, cardID: step.cardID).folder(under: root)
if step.laneID != laneID {