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
+56 -26
View File
@@ -141,22 +141,29 @@ extension BoardStore {
return nil
}
let folder = card.folder
let title = card.title
let draftFolder = CommentThread.draftFolder(inCard: folder)
let postedFolder = CommentThread.commentFolder(posted.id, inCard: folder)
// **Anchored to the card, not to its folder** (13 Rules, ruled 2026-07-31): both halves of
// the move name the card's identity plus the thread position, and the card's folder is
// resolved at apply time so a lane move under an open window leaves this step, and the
// coarse step folding it, exactly as sound as they were.
let cardAnchor = HistoryAnchor.card(id)
let operation = WriteOperation.postComment(title: title)
registerStep(
HistoryPhrase.comment,
subject: title,
on: window,
undoExpects: [.present(postedFolder), .absent(draftFolder)],
redoExpects: [.present(draftFolder), .absent(postedFolder)]
) { _ in
try BoardWriter.unpostComment(posted.id, inCard: folder, cardTitle: title)
} redo: { _ in
undoExpects: [.present(.comment(posted.id, inCard: id)), .absent(.commentDraft(inCard: id))],
redoExpects: [.present(.commentDraft(inCard: id)), .absent(.comment(posted.id, inCard: id))]
) { store in
try BoardWriter.unpostComment(
posted.id,
inCard: try store.requiredFolder(for: cardAnchor, operation),
cardTitle: title
)
} redo: { store in
try BoardWriter.repostComment(
as: posted.id,
inCard: folder,
inCard: try store.requiredFolder(for: cardAnchor, operation),
stamping: posted.posted,
cardTitle: title
)
@@ -205,19 +212,28 @@ extension BoardStore {
on window: CardWindowUndo? = nil
) {
guard priorBody != newBody, let card = commentSubject(id) else { return }
let folder = CommentThread.commentFolder(commentID, inCard: card.folder)
let title = card.title
let comment = HistoryAnchor.comment(commentID, inCard: id)
let operation = WriteOperation.editComment(title: title)
registerStep(
HistoryPhrase.name(.edit, kind: .comment),
subject: title,
on: window,
undoExpects: [.present(folder, .body(newBody))],
redoExpects: [.present(folder, .body(priorBody))]
) { _ in
_ = try BoardWriter.editComment(at: folder, body: priorBody, cardTitle: title)
} redo: { _ in
_ = try BoardWriter.editComment(at: folder, body: newBody, cardTitle: title)
undoExpects: [.present(comment, .body(newBody))],
redoExpects: [.present(comment, .body(priorBody))]
) { store in
_ = try BoardWriter.editComment(
at: try store.requiredFolder(for: comment, operation),
body: priorBody,
cardTitle: title
)
} redo: { store in
_ = try BoardWriter.editComment(
at: try store.requiredFolder(for: comment, operation),
body: newBody,
cardTitle: title
)
}
}
@@ -228,9 +244,10 @@ extension BoardStore {
///
/// The step is the move read backwards, `moveToTrash`'s registration one level down and without
/// its rank half: a comment's trash has no order, so there is no `.order` after-value to compare
/// and existence is the whole predicate. The container rides in the path exactly as it does at
/// board level the undo expects the comment in `comments/.trash/`, the redo expects it back in
/// the thread so a foreign restore or a foreign re-delete skips the right half by itself.
/// and existence is the whole predicate. The container rides in the *anchor* exactly as it rides
/// in the path at board level the undo expects the comment in `comments/.trash/`, the redo
/// expects it back in the thread so a foreign restore or a foreign re-delete skips the right
/// half by itself, while the card's own lane is nowhere in either claim.
///
/// **Both sides of the move are declared**, `postComment`'s shape (added 2026-07-31 with the
/// window stack): the undo needs the trashed folder there *and the live path free*, because the
@@ -256,17 +273,30 @@ extension BoardStore {
}
guard landed != nil else { return false }
let trashed = CommentThread.trashedCommentFolder(commentID, inCard: folder)
// `postComment`'s anchoring, for its reason: the pair of anchors is the move, and the card's
// folder is resolved at apply time rather than baked into the step (13 Rules, ruled
// 2026-07-31).
let cardAnchor = HistoryAnchor.card(id)
let comment = HistoryAnchor.comment(commentID, inCard: id)
let trashed = HistoryAnchor.trashedComment(commentID, inCard: id)
let operation = WriteOperation.deleteComment(title: title)
registerStep(
HistoryPhrase.name(.delete, kind: .comment),
subject: title,
on: window,
undoExpects: [.present(trashed), .absent(live)],
redoExpects: [.present(live), .absent(trashed)]
) { _ in
try BoardWriter.restoreComment(commentID, inCard: folder, cardTitle: title)
} redo: { _ in
_ = try BoardWriter.deleteComment(at: live, cardTitle: title)
undoExpects: [.present(trashed), .absent(comment)],
redoExpects: [.present(comment), .absent(trashed)]
) { store in
try BoardWriter.restoreComment(
commentID,
inCard: try store.requiredFolder(for: cardAnchor, operation),
cardTitle: title
)
} redo: { store in
_ = try BoardWriter.deleteComment(
at: try store.requiredFolder(for: comment, operation),
cardTitle: title
)
}
return true
}