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
+64 -15
View File
@@ -32,10 +32,19 @@ import os
/// Beside the two closures, each registration below hands the funnel two `[HistoryExpectation]`
/// lists: what the board must look like for the undo to be safe (the state the *forward* write left),
/// and what it must look like for the redo to be safe (the state the *undo* leaves). One entry per
/// item the gesture touched, naming that item's folder, whether it should be there and live, and the
/// fields the write actually set "extend the registration to carry whatever the predicate needs, no
/// more". A field this gesture never wrote is never listed, which is what makes a foreign edit
/// elsewhere another card, another field of the same card leave the step alone.
/// item the gesture touched, naming that item, whether it should be there and live, and the fields
/// the write actually set "extend the registration to carry whatever the predicate needs, no more".
/// A field this gesture never wrote is never listed, which is what makes a foreign edit elsewhere
/// another card, another field of the same card leave the step alone.
///
/// ### Board gestures name a folder; session steps name a card
///
/// The one split in that sentence (`HistoryAnchor`, ruled 2026-07-31): a board gesture's expectation
/// carries the path it wrote to, because where the item sits is what the gesture is *about*; a card
/// window session's carries the card's **identity**, and the folder is resolved at apply time by the
/// same snapshot walk the window resolves its own card with. So a lane move stales a move step (it
/// should) and no longer stales the body edit that happens to have been typed into the same card.
/// `folder(for:)` below is the one resolution both halves of a crossing go through.
@MainActor
extension BoardStore {
@@ -88,7 +97,9 @@ extension BoardStore {
// call site has, and a store-wide "current window" would be a second answer able to be wrong
// for exactly one gesture (the board styling a card whose window happens to be open).
guard let sink: any HistoryProviding = window?.stack ?? history else {
// No substrate at all a repo-nested board (06 Rules), or a store with no session.
// No substrate at all a store with no session, or a test's substrate-less board. No
// board the app composes lands here any more (`AppModel.makeHistoryProvider`, re-ruled
// 2026-07-31: repo-nested boards bind the native stack like every other gitless board).
// Nothing records the step, so nothing can ever retire it: the work is owed now.
retirement?.run()
return
@@ -126,13 +137,18 @@ extension BoardStore {
/// Everything about *what* the step does is `CardWindowUndo.netEffect()`'s; everything about
/// whether there is a board to register it on is this method's:
///
/// - **A vanished card registers nothing.** 05-card-window.md Deletion & lifecycle dismisses the
/// window when its card leaves the board into the trash, with its lane, to another board and
/// the card's own departure is already a board step of its own (`deleteCard`). A session step
/// naming folders that have moved could only be a step that skips, so the honest answer is not
/// to register one: the window's fine stack dies with the window, as 13's session-only rule has
/// it. (A trashed card keeps its `comments/.trash/` too "a trashed card carries its
/// `comments/`", 01-storage-format.md and the residue sweeps at the next open of that card.)
/// - **A card that resolves nowhere registers nothing** purged, or moved out of the board.
/// There is no folder for the step's components to be about, so a step registered here could
/// only be a step that skips, and the honest answer is not to register one: the window's fine
/// stack dies with the window, as 13's session-only rule has it.
/// - **A card in the trash still registers**, and that is the ruling of 2026-07-31 read at the
/// coarse step: the resolution below is `cardBodyTarget`'s, spanning both containers exactly as
/// `writeCardBody`'s does, so "a trash move" is one of the tracked relocations that "never
/// stales the step". 05-card-window.md Deletion & lifecycle dismisses the window when its card
/// is deleted, and the session it was in the middle of is still the user's to walk back into
/// the trash folder the card now sits in, whose subtree the delete moved intact. (A trashed card
/// carries its `comments/` 01-storage-format.md which is what makes that true of the deleted
/// comments too.)
/// - **A session with no net change registers nothing**, which is `netEffect()`'s `nil`.
///
/// - Parameter retiring: the deferred `comments/.trash/` purge (13 Interaction with the trash).
@@ -146,11 +162,11 @@ extension BoardStore {
inCard cardID: ItemID,
retiring: @escaping @MainActor () -> Void
) -> Bool {
guard let item = Self.boardItem(cardID, in: snapshot), item.cardID != nil else { return false }
guard let target = Self.cardBodyTarget(cardID, in: snapshot) else { return false }
guard let net = window.netEffect() else { return false }
registerStep(
HistoryPhrase.cardSession,
subject: item.title,
subject: Self.cardTitle(at: target, in: snapshot),
retiring: retiring,
undoExpects: net.undoExpects,
redoExpects: net.redoExpects,
@@ -178,7 +194,7 @@ extension BoardStore {
// gone is not one to pop: the stack is about to be cleared with the session anyway.
guard let store else { return .failed }
guard HistoryStaleness.isCurrent(expectations) else {
guard HistoryStaleness.isCurrent(expectations, resolvedBy: store.folder(for:)) else {
store.banners.postSkippedStep(direction, subject: subject)
return .skipped
}
@@ -206,6 +222,39 @@ extension BoardStore {
private static let historyLogger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "history")
// MARK: Resolving an anchor at apply time
/// **Where a step's anchor points on this board right now** the one resolver, read by the
/// staleness predicate and by every card-anchored inverse below (13 Rules, ruled 2026-07-31:
/// "apply-time validation resolves the card's *current* folder exactly the way the window itself
/// always resolves its card").
///
/// One method rather than two so validation and the write it guards can never disagree about
/// where a step is aimed: `cross` checks with this and the inverse writes with it, in that order,
/// against the same snapshot and the same `rootURL` which a mid-session folder rename may have
/// moved (`HistoryAnchor.folder(under:in:)`).
func folder(for anchor: HistoryAnchor) -> URL? {
anchor.folder(under: rootURL, in: snapshot)
}
/// The same, as a write's precondition rather than a question.
///
/// Unreachable in the ordinary crossing `cross` has already validated every anchor, and an
/// unresolvable one skipped the whole step before any of this ran so the throw exists to keep
/// the impossible case honest rather than to be caught: an inverse that could not find its card
/// must not silently write nothing and report success. It reads as an ordinary write failure,
/// because that is what it would be.
func requiredFolder(for anchor: HistoryAnchor, _ operation: WriteOperation) throws(BoardWriteError) -> URL {
guard let folder = folder(for: anchor) else {
throw BoardWriteError(
operation: operation,
path: rootURL.path,
reason: .staleTarget(message: "the card this step was registered against is no longer on the board")
)
}
return folder
}
// MARK: Creates
/// One item a gesture brought into being: where it landed, the bytes it landed with, and any
+11 -8
View File
@@ -61,14 +61,17 @@ public final class BoardUndoManager: UndoManager {
///
/// ### `nil` is a board with **no undo provider**, and it is a real state
///
/// A **repo-nested** board under Pro gets no provider at all, and since the re-ruling of
/// 2026-07-31 it is the only board that does: "the pair disabled only on repo-nested boards,
/// under locks, and on empty stacks the provider follows the board, so gitless boards bind
/// 13-native-undo.md's native stack in **every** tier" (03-board-ui.md Toolbar Catalog;
/// 06-history-undo.md Rules). Every question below answers the empty way, so the Edit menu's
/// rows, the toolbar pair, and Z itself go quiet together, through the same validation path a
/// lock uses. Modelling it as an absent substrate rather than as a substrate that always says no
/// is the honest shape: there is nothing there, and nothing can accidentally accumulate in it.
/// **No board the app composes is one any more** (re-ruled 2026-07-31, twice): "the pair disabled
/// only under locks and on empty stacks the provider follows the board, so boards without
/// app-managed git repo-nested included bind 13-native-undo.md's native stack in **every**
/// tier" (03-board-ui.md Toolbar Catalog). The repo-nested board was the last holder of this
/// state and no longer is: that rule was about *git*, and this stack never touches git.
///
/// The state stays modelled because the seam still admits it a test binds a substrate-less
/// session through `AppModel.makeHistoryProvider` and because an absent substrate is the honest
/// shape for one: every question below answers the empty way, so the Edit menu's rows, the
/// toolbar pair, and Z itself go quiet together through the same validation path a lock uses,
/// and nothing can accidentally accumulate in a stack that is not there.
///
/// ### Settable, for exactly one event
///
+29 -8
View File
@@ -36,6 +36,14 @@ import Foundation
/// prior style fields) and the coarse step is their composition. That also makes the coarse step's
/// *after*-values the values the app itself wrote, so a foreign edit landing between a gesture and
/// the close makes the step stale (13's field-level predicate) instead of being quietly reverted.
///
/// ### The values are anchored to the card, not to its folder
///
/// "Session steps anchor by card identity, never by path" (13 Rules, ruled 2026-07-31): every write
/// folded here names the card's UUID and the thread position inside it (`HistoryAnchor`), never the
/// lane the card happened to be in when the gesture ran. The fold is therefore over identities the
/// whole way down, and one board-side lane move which used to stale every component of the coarse
/// step at once changes nothing about it.
@MainActor
public final class CardWindowUndo {
@@ -154,6 +162,11 @@ public final class CardWindowUndo {
/// card fold to one target carrying both, while two that set the same dimension fold to one value.
/// Presence is whole-target and takes the later answer, which is what makes a post-then-delete of
/// one comment fold to "in the trash" rather than to two contradictory claims.
///
/// **A target is an anchor, not a path** (13 Rules, ruled 2026-07-31): every write folded here
/// is a card window's, so every anchor is a card identity, and the fold is over identities the
/// whole way down which is what carries "session steps anchor by card identity" into the coarse
/// step without the fold knowing anything about it.
static func fold(_ lists: [[HistoryExpectation]]) -> Fold {
var fold = Fold()
for list in lists {
@@ -170,7 +183,7 @@ public final class CardWindowUndo {
var fields: [ExpectedField.Kind: ExpectedField]
}
private var targets: [URL: Target] = [:]
private var targets: [HistoryAnchor: Target] = [:]
/// First-seen order, so the folded list a step carries is stable rather than hash-ordered
/// a skip banner and a test both read better when the card comes before its comments.
///
@@ -178,17 +191,17 @@ public final class CardWindowUndo {
/// built by walking the same writes in opposite directions, so their orders differ by
/// construction while the question being asked did anything actually change is about the
/// values alone.
private var order: [URL] = []
private var order: [HistoryAnchor] = []
static func == (lhs: Fold, rhs: Fold) -> Bool { lhs.targets == rhs.targets }
/// **A later `.absent` clears what earlier writes said about the path**, which is how a move
/// **A later `.absent` clears what earlier writes said about the target**, which is how a move
/// inside one session folds correctly: a comment edited and then deleted leaves nothing at its
/// live path, and carrying the edit's body expectation there would make the session's own step
/// stale the moment it was registered. Every move-shaped step declares both of its paths
/// precisely so this is expressible (`BoardStore.deleteComment`, `postComment`).
/// live location, and carrying the edit's body expectation there would make the session's own
/// step stale the moment it was registered. Every move-shaped step declares both of its
/// anchors precisely so this is expressible (`BoardStore.deleteComment`, `postComment`).
mutating func merge(_ expectation: HistoryExpectation) {
let key = expectation.folder.standardizedFileURL
let key = Self.key(expectation.anchor)
if targets[key] == nil {
order.append(key)
targets[key] = Target(presence: expectation.presence, fields: [:])
@@ -209,10 +222,18 @@ public final class CardWindowUndo {
order.compactMap { key in
guard let target = targets[key] else { return nil }
let fields = Self.fieldOrder.compactMap { target.fields[$0] }
return HistoryExpectation(folder: key, presence: target.presence, fields: fields)
return HistoryExpectation(anchor: key, presence: target.presence, fields: fields)
}
}
/// Two anchors are one target when they name the same thing. Identity anchors already do that
/// by construction a UUID is its own normal form so the only normalization left is the
/// path anchor's, which a board gesture may spell with a trailing slash or a `.` component.
private static func key(_ anchor: HistoryAnchor) -> HistoryAnchor {
guard case let .path(url) = anchor else { return anchor }
return .path(url.standardizedFileURL)
}
private static let fieldOrder: [ExpectedField.Kind] = [.title, .order, .width, .background, .icon, .body]
}
}
+4 -4
View File
@@ -203,10 +203,10 @@ public struct HistoryStep {
///
/// `AppModel.BoardSession` is where the board half's ownership lives, and the composition root binds
/// which implementation it gets **following the board, not the tier alone** (re-ruled 2026-07-31):
/// a gitless board binds `NativeHistoryProvider` (two step stacks over the inverses registered at
/// the Writer boundary) in every tier, a Pro git board binds the git provider (undo as forward
/// restore commits over HEAD's first-parent ancestry 06-history-undo.md), a repo-nested board
/// binds none at all, and Teams inherits Pro's.
/// a board without app-managed git repo-nested included (re-ruled 2026-07-31) binds
/// `NativeHistoryProvider` (two step stacks over the inverses registered at the Writer boundary) in
/// every tier, a Pro git board binds the git provider (undo as forward restore commits over HEAD's
/// first-parent ancestry 06-history-undo.md), and Teams inherits Pro's.
///
/// ### What this protocol deliberately does not say
///
+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) }
}