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
+156 -5
View File
@@ -320,12 +320,13 @@ struct CardSessionCloseTests {
#expect(try fixture.entryNames("\(card)/comments/.trash").isEmpty)
}
@Test("A window whose card left the board registers nothing")
func aVanishedCardRegistersNoSessionStep() async throws {
@Test("A window whose card was trashed still registers — the trash is a relocation, not a vanishing")
func aTrashedCardStillRegistersItsSession() async throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
_ = try makeCommentBoard(fixture)
let window = try makeWindow(fixture)
let original = try body(fixture, cardPath)
editBody(window, to: "Edited.\n")
// The card window's own Actions Delete a board gesture, on the board's stack, which
@@ -336,7 +337,34 @@ struct CardSessionCloseTests {
#expect(window.board.undoActionName == "Delete Card")
await window.session.endSession()
#expect(window.board.undoActionName == "Delete Card", "no session step for a card that has gone")
// "A tracked relocation a lane move mid-session or after close, **a trash move** never
// stales the step" (13 Rules, ruled 2026-07-31): the session resolves through the walk that
// spans both containers, so the step registers over the delete rather than being dropped.
#expect(window.board.undoActionName == "Edit Card")
window.board.undo()
#expect(window.store.banners.signposts.isEmpty, "nothing about the card's content changed")
#expect(try body(fixture, ".trash/\(Ident.card1)") == original,
"walked back where the card is now, its subtree intact")
#expect(window.board.undoActionName == "Delete Card", "and the delete is the next step down")
}
@Test("A card that resolves nowhere registers nothing")
func aCardThatResolvesNowhereRegistersNoSessionStep() async throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
_ = try makeCommentBoard(fixture)
let window = try makeWindow(fixture)
editBody(window, to: "Edited.\n")
// Purged, or moved out of the board neither container holds the card, so there is no folder
// for the step's components to be about and "the honest skip" is not to register one at all.
try FileManager.default.removeItem(at: fixture.url(cardPath))
window.store.handleWatcherEvent(.treeChanged(.foreign))
await window.store.awaitQuiescence()
await window.session.endSession()
#expect(!window.board.canUndo, "no session step for a card that is nowhere")
}
@Test("A style change made in the window's sidebar joins the session, not the board")
@@ -421,6 +449,127 @@ struct CardSessionStalenessTests {
}
}
// MARK: - Identity anchoring
/// **Session steps anchor by card identity, never by path** (13-native-undo.md Rules, ruled
/// 2026-07-31) the defect these tests exist for, stated as the gesture that produced it: a card
/// window's steps carried lane-bearing folder paths, so one board-side lane move staled *every*
/// component of the session step at once and the whole session skipped, though nothing about the
/// card's content had changed.
///
/// The claim is not "moves are ignored". It is that a **relocation** and a **content change** are
/// different questions and only the second is what validation exists to catch so the collision half
/// is pinned here beside the round trip, at the card's *new* home.
@MainActor
@Suite("Card session undo ▸ identity anchoring")
struct CardSessionAnchorTests {
/// The one-lane comment board with somewhere to move the card to.
@MainActor
private func twoLaneBoard(_ fixture: WriterFixture) throws -> String {
let card = try makeCommentBoard(fixture)
try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing"))
return card
}
/// The card's folder after a move into the second lane.
private let movedPath = "\(Ident.lane2)/\(Ident.card1)"
@Test("A board-side lane move never stales the session — all three gestures still apply")
func aLaneMoveDoesNotStaleTheSessionStep() async throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try twoLaneBoard(fixture)
let deleted = commentPath(CommentIdent.one, inCard: card)
try fixture.item(deleted, commentText(body: "kept somewhere\n"))
let window = try makeWindow(fixture)
window.comments.reload()
let original = try body(fixture, cardPath)
editBody(window, to: "Edited in the window.\n")
let posted = try #require(postComment(window, body: "A remark.\n"))
window.comments.delete(ItemID(rawValue: CommentIdent.one))
// The board moves the card to another lane while its window is open a drag on the board,
// which is a board gesture on the board's own stack. Every folder the session wrote to has
// just changed path.
window.store.moveCards([cardID], toLane: ItemID(rawValue: Ident.lane2), at: 0)
window.store.handleWatcherEvent(.treeChanged(.appMediated))
await window.store.awaitQuiescence()
#expect(fixture.exists(movedPath))
#expect(window.board.undoActionName == "Move Card")
await window.session.endSession()
#expect(window.board.undoActionName == "Edit Card", "the session registered over the move")
window.board.undo()
#expect(window.store.banners.signposts.isEmpty, "a relocation is not a collision")
#expect(try body(fixture, movedPath) == original, "the body is back — at the card's new home")
#expect(!fixture.exists("\(movedPath)/comments/\(posted.rawValue)"), "the post is unposted")
#expect(fixture.exists("\(movedPath)/comments/.draft"))
#expect(fixture.exists("\(movedPath)/comments/\(CommentIdent.one)"),
"and the deleted comment is back, out of the trash that travelled with the card")
#expect(window.board.undoActionName == "Move Card", "the move is the next step down")
}
@Test("A genuine field collision at the card's new home still skips the whole step")
func aFieldCollisionAfterAMoveStillSkipsWhole() async throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try twoLaneBoard(fixture)
let deleted = commentPath(CommentIdent.one, inCard: card)
try fixture.item(deleted, commentText(body: "kept somewhere\n"))
let window = try makeWindow(fixture)
window.comments.reload()
editBody(window, to: "Edited in the window.\n")
window.comments.delete(ItemID(rawValue: CommentIdent.one))
window.store.moveCards([cardID], toLane: ItemID(rawValue: Ident.lane2), at: 0)
window.store.handleWatcherEvent(.treeChanged(.appMediated))
await window.store.awaitQuiescence()
await window.session.endSession()
// Somebody else rewrites the body at the folder the card now sits in, which is exactly where
// the anchor resolves. One component, and the whole step goes (13: "any stale component skips
// the whole step never a partial session revert").
_ = try BoardWriter.writeBody(inItemFolder: fixture.url(movedPath), body: "Somebody else.\n")
window.board.undo()
#expect(window.store.banners.signposts.map(\.message)
== ["Undo skipped — 'Fix login' changed outside Lanework"])
// The skip pops the step and **Z falls through to the next one down** (13 Rules), which
// here is the board's own move so the card, the foreign body and the still-deleted comment
// all travel back to the lane the move took them from.
#expect(fixture.exists(cardPath))
#expect(try body(fixture, cardPath) == "Somebody else.\n", "never applied over a newer write")
#expect(!fixture.exists("\(cardPath)/comments/\(CommentIdent.one)"),
"and the comment half did not half-happen either")
#expect(try fixture.entryNames("\(cardPath)/comments/.trash").isEmpty,
"the skipped step retired, so the backing it was holding was purged with it")
#expect(!window.board.canUndo, "both steps are gone — one skipped, one applied")
}
@Test("The window's own ⌘Z survives a lane move too — the fine steps carry the same anchors")
func theWindowStackSurvivesALaneMove() async throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
_ = try twoLaneBoard(fixture)
let window = try makeWindow(fixture)
let original = try body(fixture, cardPath)
editBody(window, to: "Edited in the window.\n")
window.store.moveCards([cardID], toLane: ItemID(rawValue: Ident.lane2), at: 0)
window.store.handleWatcherEvent(.treeChanged(.appMediated))
await window.store.awaitQuiescence()
// "The coarse step **and the window's fine steps it folds**" (13 Rules): the fold is only
// as sound as its components, so the window's own stack is anchored the same way.
window.window.stack.undo()
#expect(try body(fixture, movedPath) == original)
#expect(window.store.banners.signposts.isEmpty)
}
}
// MARK: - The deferred purge
@MainActor
@@ -543,8 +692,10 @@ struct CardSessionPurgeTests {
@Test("A board with no substrate at all owes the purge immediately")
func noProviderRunsTheRetirementAtOnce() throws {
// Repo-nested boards bind no provider (06-history-undo.md Rules), so nothing could ever
// report the step's death the work is owed at registration or never.
// A store with no session behind it no board the app composes binds no provider any more
// (`AppModel.makeHistoryProvider`, re-ruled 2026-07-31: repo-nested boards bind the native
// stack too). Nothing could ever report the step's death, so the work is owed at registration
// or never.
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)