Realign undo with the evening rulings — repo-nested and identity anchors

Repo-nested boards bind native undo in every tier (25d2513): the
no-undo case is gone, makeHistoryProvider answers git or native, and
the native path provably never touches the enclosing repository's
.git. Session undo steps anchor by card identity, never by path
(9119aa1): HistoryAnchor carries the card UUID (plus comment/draft
vocabulary) and apply-time validation resolves the current folder via
the same both-container walk writeCardBody uses — a board-side lane or
trash move no longer stales the coarse close step, while a genuine
field collision still skips it whole.

2448 tests in 423 suites green.

Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
This commit is contained in:
2026-07-31 21:19:42 -04:00
parent 54951e92ef
commit d076427ee0
15 changed files with 600 additions and 151 deletions
+152 -18
View File
@@ -63,6 +63,99 @@ public enum ExpectedField: Sendable, Equatable {
}
}
// MARK: - HistoryAnchor
/// **What an expectation is an expectation *about*** a folder fixed when the step was registered,
/// or a card identity resolved afresh every time the step is crossed.
///
/// ### Board gestures anchor by path
///
/// A move, a reorder, a delete, a create, a board-issued restyle: the gesture *is* about where an
/// item sits, its inverse is the move back, and the path it names is the path it wrote to. The
/// container check rides in that path (`HistoryExpectation`), and for these steps that is exactly the
/// reading wanted a foreign restore out of the trash *should* stale a delete step's undo.
///
/// ### Session steps anchor by card identity
///
/// "**Session steps anchor by card identity, never by path**" (13-native-undo.md Rules, ruled
/// 2026-07-31): "the coarse step and the window's fine steps it folds stores the card's UUID plus
/// expected values, and apply-time validation resolves the card's *current* folder exactly the way
/// the window itself always resolves its card (the per-snapshot UUID walk; `writeCardBody` already
/// resolves trash locations on purpose). A tracked relocation a lane move mid-session or after
/// close, a trash move therefore never stales the step; only genuine content changes do, which is
/// what the validation exists to catch."
///
/// The defect that ruled it: every component of a card window's session carried a **lane-bearing**
/// folder path, so one board-side lane move a drag on the board while the window sat open, or any
/// move after it closed staled all of them at once and the whole session step skipped, though
/// nothing about the card's *content* had changed.
///
/// ### The card-relative cases are a closed vocabulary
///
/// Four, and they are exactly the folders a card window's gestures write to: the card itself, one
/// posted comment, one deleted comment, the composer's draft. Cases rather than a card id plus a
/// relative path list, so the path grammar stays in one place resolution calls `CommentThread`'s own
/// folder helpers, and a step can never disagree with the thread reader about where a comment lives.
public enum HistoryAnchor: Sendable, Hashable {
/// A folder, as the gesture resolved it at registration time.
case path(URL)
/// A card's own folder, wherever the card is now.
case card(ItemID)
/// `<card>/comments/<id>/` one posted comment.
case comment(ItemID, inCard: ItemID)
/// `<card>/comments/.trash/<id>/` one deleted comment, undo's backing store.
case trashedComment(ItemID, inCard: ItemID)
/// `<card>/comments/.draft/` the composer's backing file.
case commentDraft(inCard: ItemID)
}
extension HistoryAnchor {
/// Where this anchor points **now**, or `nil` when it points nowhere.
///
/// **The card walk is `writeCardBody`'s, deliberately** (`BoardStore.cardBodyTarget`): the one
/// resolution in the app that spans both containers, because 05-card-window.md Deletion &
/// lifecycle already needs a card window's own flush to reach a card that was moved into the trash
/// out from under it. 13 names that walk by hand as the one a session step resolves through, so a
/// trash move is a tracked relocation here rather than a vanishing.
///
/// `nil` is "the card resolves nowhere purged, or moved out of the board", which 13 calls "the
/// honest skip". The snapshot is the store's own, one reload behind the app's own writes exactly as
/// the card window's is: the window resolves its card this way on every gesture, so a step that
/// resolved any *fresher* would be answering a question the window itself never asks.
public func folder(under root: URL, in snapshot: BoardModel) -> URL? {
switch self {
case let .path(url):
url
case let .card(id):
Self.cardFolder(id, under: root, in: snapshot)
case let .comment(id, card):
Self.cardFolder(card, under: root, in: snapshot).map { CommentThread.commentFolder(id, inCard: $0) }
case let .trashedComment(id, card):
Self.cardFolder(card, under: root, in: snapshot).map { CommentThread.trashedCommentFolder(id, inCard: $0) }
case let .commentDraft(card):
Self.cardFolder(card, under: root, in: snapshot).map { CommentThread.draftFolder(inCard: $0) }
}
}
private static func cardFolder(_ id: ItemID, under root: URL, in snapshot: BoardModel) -> URL? {
BoardStore.cardBodyTarget(id, in: snapshot)?.folder(under: root)
}
/// The folder a `.path` anchor names, and `nil` for every identity anchor the resolver for a
/// caller with no board to resolve against, which in the app is nobody and in a test is the
/// shortest way to check a path-anchored expectation.
public var literalPath: URL? {
guard case let .path(url) = self else { return nil }
return url
}
}
// MARK: - HistoryExpectation
/// What one folder must currently hold for a step to be safe to cross the state that step's write
@@ -84,11 +177,19 @@ public enum ExpectedField: Sendable, Equatable {
/// leaves nothing. That is why `Presence` is a two-case answer rather than the tombstone era's
/// three-way live/tombstoned/absent reading of a `deleted:` key there is no key to read, and no
/// ancestor to walk to find one.
///
/// **A card-anchored expectation makes the same check about a path it resolves rather than
/// remembers**, which is the whole of the difference: a comment's anchor still names
/// `comments/.trash/<id>` versus `comments/<id>`, so the container reading above is untouched, while
/// the card's own lane the part of the path no session gesture is about stops being asserted.
/// Which of the two anchorings a step uses is `HistoryAnchor`'s subject and the one thing this type
/// stayed neutral about: everything below reads the folder the anchor resolves to, identically either
/// way.
public struct HistoryExpectation: Sendable, Equatable {
/// Where the item this step wrote to should be the destination for a move, the item's own
/// folder for everything else, and the board root for the board's own rename and styling.
public let folder: URL
/// What the item this step wrote to is addressed by a path for a board gesture, a card identity
/// for a session step (`HistoryAnchor`).
public let anchor: HistoryAnchor
/// Whether the item should be there.
public let presence: Presence
@@ -107,26 +208,40 @@ public struct HistoryExpectation: Sendable, Equatable {
case absent
}
public init(folder: URL, presence: Presence, fields: [ExpectedField]) {
self.folder = folder
public init(anchor: HistoryAnchor, presence: Presence, fields: [ExpectedField]) {
self.anchor = anchor
self.presence = presence
self.fields = fields
}
/// The item is at this path and its fields say what the step set them to.
public static func present(_ folder: URL, _ fields: ExpectedField...) -> HistoryExpectation {
HistoryExpectation(folder: folder, presence: .present, fields: fields)
/// The item is where this anchor points and its fields say what the step set them to.
public static func present(_ anchor: HistoryAnchor, _ fields: ExpectedField...) -> HistoryExpectation {
HistoryExpectation(anchor: anchor, presence: .present, fields: fields)
}
/// The same, for a caller whose field list is computed the styling gesture's, which varies per
/// dimension. A label rather than a second variadic, so `.present(folder)` stays unambiguous.
/// dimension. A label rather than a second variadic, so `.present(anchor)` stays unambiguous.
public static func present(_ anchor: HistoryAnchor, fields: [ExpectedField]) -> HistoryExpectation {
HistoryExpectation(anchor: anchor, presence: .present, fields: fields)
}
/// Nothing is where this anchor points.
public static func absent(_ anchor: HistoryAnchor) -> HistoryExpectation {
HistoryExpectation(anchor: anchor, presence: .absent, fields: [])
}
/// The path-anchored trio, spelled with the folder a board gesture already holds the shape every
/// call site outside a card window uses.
public static func present(_ folder: URL, _ fields: ExpectedField...) -> HistoryExpectation {
HistoryExpectation(anchor: .path(folder), presence: .present, fields: fields)
}
public static func present(_ folder: URL, fields: [ExpectedField]) -> HistoryExpectation {
HistoryExpectation(folder: folder, presence: .present, fields: fields)
HistoryExpectation(anchor: .path(folder), presence: .present, fields: fields)
}
/// Nothing is at this path.
public static func absent(_ folder: URL) -> HistoryExpectation {
HistoryExpectation(folder: folder, presence: .absent, fields: [])
HistoryExpectation(anchor: .path(folder), presence: .absent, fields: [])
}
}
@@ -157,23 +272,42 @@ public struct HistoryExpectation: Sendable, Equatable {
/// The tombstone era's liveness half walked a folder's ancestors looking for a `deleted:` key, and
/// needed the root to know where to stop. Materializing the trash removed the walk: an item's
/// container is its path, and a path is checked by asking the filesystem whether anything is there.
///
/// ### It needs a *resolver*, though one, injected
///
/// A session step's expectations name a card rather than a folder (`HistoryAnchor`, ruled
/// 2026-07-31), so somebody has to turn the anchor into the path this reads. That somebody is the
/// board `BoardStore.folder(for:)`, the store's own snapshot walk handed in as a closure rather
/// than reached for, which keeps this type what it has always been: a predicate over disk with no
/// board, no root and no state of its own.
public enum HistoryStaleness {
/// Whether every target a step named still holds what that step left there.
public static func isCurrent(_ expectations: [HistoryExpectation]) -> Bool {
expectations.allSatisfy(isCurrent)
///
/// - Parameter resolve: where each anchor points now. **An anchor that resolves nowhere fails**,
/// whatever its presence half says: "a card that resolves nowhere (purged, or moved out of the
/// board) is the honest skip" (13 Rules), and reading an unresolvable card's `.absent`
/// expectations as satisfied would let half a step through on a card that has left.
public static func isCurrent(
_ expectations: [HistoryExpectation],
resolvedBy resolve: (HistoryAnchor) -> URL?
) -> Bool {
expectations.allSatisfy { expectation in
guard let folder = resolve(expectation.anchor) else { return false }
return isCurrent(expectation, at: folder)
}
}
/// One target's answer.
/// One target's answer, at the folder its anchor resolved to.
///
/// A file that cannot be read or parsed fails a `.present` expectation: an `index.md` somebody
/// has just broken is not one holding this step's after-value, and the honest reading of "the
/// field no longer holds it" covers a field that can no longer be read at all.
public static func isCurrent(_ expectation: HistoryExpectation) -> Bool {
public static func isCurrent(_ expectation: HistoryExpectation, at folder: URL) -> Bool {
guard expectation.presence != .absent else {
return !FileManager.default.fileExists(atPath: expectation.folder.path)
return !FileManager.default.fileExists(atPath: folder.path)
}
guard let document = index(at: expectation.folder) else { return false }
guard let document = index(at: folder) else { return false }
return expectation.fields.allSatisfy { matches($0, in: document) }
}