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
+31 -20
View File
@@ -291,7 +291,7 @@ public final class AppModel {
/// can bind a fake without a second `AppModel` initializer, `@ObservationIgnored` because /// can bind a fake without a second `AppModel` initializer, `@ObservationIgnored` because
/// nothing renders from it. /// nothing renders from it.
/// ///
/// ### The three answers, and the one `nil` among them /// ### The two answers, and the `nil` that is no longer one of them
/// ///
/// - **No `HistoryStore` at all** the free tier, where `HistoryStore.compose` returns `nil` /// - **No `HistoryStore` at all** the free tier, where `HistoryStore.compose` returns `nil`
/// without so much as a `stat`: the **native stack, on every board**. "The free tier binds it /// without so much as a `stat`: the **native stack, on every board**. "The free tier binds it
@@ -300,17 +300,26 @@ public final class AppModel {
/// native undo runs". The absent git state *is* the tier test; nothing here reads a flag. /// native undo runs". The absent git state *is* the tier test; nothing here reads a flag.
/// - **Mode `git`** (Pro only no other tier composes a git state) the git provider: undo as /// - **Mode `git`** (Pro only no other tier composes a git state) the git provider: undo as
/// forward restore commits over HEAD's first-parent ancestry (06). /// forward restore commits over HEAD's first-parent ancestry (06).
/// - **Mode `none`** the **native stack**, exactly as in the free tier. "Gitless boards bind /// - **Mode `none` and mode `repoNested` alike** the **native stack**, exactly as in the free
/// the native undo stack in every tier an upgrade never removes undo" (12). /// tier. "Boards without app-managed git repo-nested included bind 13-native-undo.md's
/// - **Mode `repoNested`** **no provider**, the one no-undo case: a board inside somebody /// native stack in **every** tier" (03-board-ui.md Toolbar Catalog, re-ruled 2026-07-31
/// else's repository is one the app "leaves strictly alone so they get **no undo**" (06 /// twice; 12 The provider seam; 13's header).
/// Rules), and an in-memory stack there would be the app-managed undo journal that rule
/// forbids. Edit Undo/Redo and the toolbar pair disable there and nowhere else but under a
/// lock and on an empty stack (03-board-ui.md Toolbar Catalog).
/// ///
/// The `HistoryStore` argument is what makes that decidable here, and it is why `beginSession` /// **The repo-nested no-undo case is gone** (re-ruled 2026-07-31): 06's leave-strictly-alone
/// composes the git state *before* the provider: which substrate a board gets is a question about /// stance "concerns *git*, and this stack never touches git memory-only, journal-free,
/// its repository, and a root that had to ask the disk itself would be a second detection. /// session-scoped so what repo-nested denies is app-managed history, never Z" (13's header).
/// It also made the Pro upgrade story exceptional, which was the other half of the same defect:
/// the free tier could not tell such a board from a plain one and bound the native stack anyway,
/// so subscribing *removed* undo from exactly the boards it left alone. Edit Undo/Redo and the
/// toolbar pair now disable only under a lock and on an empty stack.
///
/// This closure therefore never answers `nil`, and the seam stays optional for the seam's own
/// reason: a test binds a substrate-less board through it (`BoardUndoManager.history`).
///
/// The `HistoryStore` argument is what makes the git/gitless split decidable here, and it is why
/// `beginSession` composes the git state *before* the provider: which substrate a board gets is a
/// question about its repository, and a root that had to ask the disk itself would be a second
/// detection.
/// ///
/// ### Consumers /// ### Consumers
/// ///
@@ -322,8 +331,7 @@ public final class AppModel {
guard let git else { return NativeHistoryProvider() } guard let git else { return NativeHistoryProvider() }
switch git.mode { switch git.mode {
case .git: return GitHistoryProvider(boardRoot: store.rootURL) case .git: return GitHistoryProvider(boardRoot: store.rootURL)
case .none: return NativeHistoryProvider() case .none, .repoNested: return NativeHistoryProvider()
case .repoNested: return nil
} }
} }
@@ -349,11 +357,13 @@ public final class AppModel {
/// Which implementation it is, is the tier's answer and nobody else's /// Which implementation it is, is the tier's answer and nobody else's
/// (12-editions.md The provider seam) see `AppModel.makeHistoryProvider`. /// (12-editions.md The provider seam) see `AppModel.makeHistoryProvider`.
/// ///
/// **`nil` is a board with no undo at all** a repo-nested board under Pro, and nothing else /// **`nil` is a board with no undo at all, and no board the app composes is one any more**
/// (06-history-undo.md Rules: boards inside an existing repository "get **no undo**"). /// (re-ruled 2026-07-31 see `AppModel.makeHistoryProvider`): boards without app-managed git,
/// Gitless boards bind the native stack in every tier (re-ruled 2026-07-31 see /// repo-nested included, bind the native stack in every tier, and git boards bind the git
/// `AppModel.makeHistoryProvider`). The command surface disables through `undoManager`, which /// provider. What keeps the optionality is the seam rather than a board: a test binds a
/// answers the empty way over an absent substrate. /// substrate-less session through `makeHistoryProvider`, and a store with no session at all
/// registers nothing (`BoardStore.registerStep`). The command surface disables through
/// `undoManager`, which answers the empty way over an absent substrate.
/// ///
/// A `var`, unlike `tier` beside it, and for one event only: **add-git**, the design's single /// A `var`, unlike `tier` beside it, and for one event only: **add-git**, the design's single
/// sanctioned mid-session mode flip, *swaps* the substrate here on the board it flips /// sanctioned mid-session mode flip, *swaps* the substrate here on the board it flips
@@ -398,8 +408,9 @@ public final class AppModel {
/// **Its first consumer is the provider seam** `makeHistoryProvider` reads exactly this to /// **Its first consumer is the provider seam** `makeHistoryProvider` reads exactly this to
/// know whether the board has a repository to be an undo stack for, and it is the *mode* /// know whether the board has a repository to be an undo stack for, and it is the *mode*
/// rather than the tier that decides (re-ruled 2026-07-31): `git` binds the git provider, /// rather than the tier that decides (re-ruled 2026-07-31): `git` binds the git provider,
/// `none` the native stack, `repoNested` nothing. The popover's git section is the other /// `none` and `repoNested` alike the native stack. The popover's git section is the other
/// reader. /// reader and the one place the two gitless modes still differ, since add-git is offered on
/// one and explained away on the other.
/// ///
/// `@MainActor` because the state it reads is: a nested type does not inherit its enclosing /// `@MainActor` because the state it reads is: a nested type does not inherit its enclosing
/// type's isolation, and everything that asks a session what mode it is in is main-actor /// type's isolation, and everything that asks a session what mode it is in is main-actor
+4 -2
View File
@@ -14,8 +14,10 @@ import Foundation
/// ///
/// `repoNested` is not "git mode with the repository somewhere else". A board inside a user's /// `repoNested` is not "git mode with the repository somewhere else". A board inside a user's
/// existing repository gets **no app-managed git at all** "no nested repo, no commits into the /// existing repository gets **no app-managed git at all** "no nested repo, no commits into the
/// user's repo, no undo" (06 Rules) which makes it as distinct from `git` as `none` is, and the /// user's repo" (06 Rules) which makes it as distinct from `git` as `none` is, and the reason it
/// reason it is a case rather than a flag on `git`. /// is a case rather than a flag on `git`. What it no longer costs is Z: the native stack binds here
/// too (re-ruled 2026-07-31 13-native-undo.md's header; `AppModel.makeHistoryProvider`), because
/// that stack is memory-only and touches no repository, anybody's.
/// ///
/// ### The remote half is deliberately absent /// ### The remote half is deliberately absent
/// ///
+2 -2
View File
@@ -161,8 +161,8 @@ public final class GitAutoCommitter {
/// ///
/// `nil` wherever no `GitHistoryProvider` is listening which in practice is nowhere a committer /// `nil` wherever no `GitHistoryProvider` is listening which in practice is nowhere a committer
/// exists at all: a committer's existence is exactly mode `git`, and mode `git` is exactly where /// exists at all: a committer's existence is exactly mode `git`, and mode `git` is exactly where
/// the composition root binds the git provider (`AppModel.makeHistoryProvider`). Mode-none boards /// the composition root binds the git provider (`AppModel.makeHistoryProvider`). Mode-none and
/// have a native stack and no committer; repo-nested boards have neither. /// repo-nested boards alike have a native stack and no committer.
@ObservationIgnored @ObservationIgnored
public var reportLanded: (@MainActor (GitLandedWindow) -> Void)? public var reportLanded: (@MainActor (GitLandedWindow) -> Void)?
+6 -5
View File
@@ -20,11 +20,12 @@ import os
/// ///
/// This is the foundation card of pro-m1: mode, a repository, add-git, and the loader's path-history /// This is the foundation card of pro-m1: mode, a repository, add-git, and the loader's path-history
/// ranker. **The provider binding reads `mode` and nothing else about a tier** the composition /// ranker. **The provider binding reads `mode` and nothing else about a tier** the composition
/// root binds the git provider on mode `git`, the native stack on mode `none`, and nothing on a /// root binds the git provider on mode `git` and the native stack on modes `none` and `repoNested`
/// repo-nested board (`AppModel.makeHistoryProvider`, re-ruled 2026-07-31: the provider follows the /// alike (`AppModel.makeHistoryProvider`, re-ruled 2026-07-31: the provider follows the board, and
/// board). Auto-commit, commit messages, branch controls, the identity fields, the /// what a repo-nested board denies is app-managed history, never Z). Auto-commit, commit messages,
/// `.gitignore` seed and its periodic housekeeping each arrived as their own card and are composed /// branch controls, the identity fields, the `.gitignore` seed and its periodic housekeeping each
/// here now; remotes are pro-m2's and deliberately still absent. /// arrived as their own card and are composed here now; remotes are pro-m2's and deliberately still
/// absent.
@MainActor @MainActor
@Observable @Observable
public final class HistoryStore { public final class HistoryStore {
+64 -15
View File
@@ -32,10 +32,19 @@ import os
/// Beside the two closures, each registration below hands the funnel two `[HistoryExpectation]` /// 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), /// 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 /// 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 /// item the gesture touched, naming that item, whether it should be there and live, and the fields
/// fields the write actually set "extend the registration to carry whatever the predicate needs, no /// the write actually set "extend the registration to carry whatever the predicate needs, no more".
/// more". A field this gesture never wrote is never listed, which is what makes a foreign edit /// A field this gesture never wrote is never listed, which is what makes a foreign edit elsewhere
/// elsewhere another card, another field of the same card leave the step alone. /// 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 @MainActor
extension BoardStore { 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 // 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). // for exactly one gesture (the board styling a card whose window happens to be open).
guard let sink: any HistoryProviding = window?.stack ?? history else { 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. // Nothing records the step, so nothing can ever retire it: the work is owed now.
retirement?.run() retirement?.run()
return return
@@ -126,13 +137,18 @@ extension BoardStore {
/// Everything about *what* the step does is `CardWindowUndo.netEffect()`'s; everything about /// 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: /// 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 /// - **A card that resolves nowhere registers nothing** purged, or moved out of the board.
/// window when its card leaves the board into the trash, with its lane, to another board and /// There is no folder for the step's components to be about, so a step registered here could
/// the card's own departure is already a board step of its own (`deleteCard`). A session step /// only be a step that skips, and the honest answer is not to register one: the window's fine
/// naming folders that have moved could only be a step that skips, so the honest answer is not /// stack dies with the window, as 13's session-only rule has it.
/// to register one: the window's fine stack dies with the window, as 13's session-only rule has /// - **A card in the trash still registers**, and that is the ruling of 2026-07-31 read at the
/// it. (A trashed card keeps its `comments/.trash/` too "a trashed card carries its /// coarse step: the resolution below is `cardBodyTarget`'s, spanning both containers exactly as
/// `comments/`", 01-storage-format.md and the residue sweeps at the next open of that card.) /// `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`. /// - **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). /// - Parameter retiring: the deferred `comments/.trash/` purge (13 Interaction with the trash).
@@ -146,11 +162,11 @@ extension BoardStore {
inCard cardID: ItemID, inCard cardID: ItemID,
retiring: @escaping @MainActor () -> Void retiring: @escaping @MainActor () -> Void
) -> Bool { ) -> 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 } guard let net = window.netEffect() else { return false }
registerStep( registerStep(
HistoryPhrase.cardSession, HistoryPhrase.cardSession,
subject: item.title, subject: Self.cardTitle(at: target, in: snapshot),
retiring: retiring, retiring: retiring,
undoExpects: net.undoExpects, undoExpects: net.undoExpects,
redoExpects: net.redoExpects, 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. // gone is not one to pop: the stack is about to be cleared with the session anyway.
guard let store else { return .failed } 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) store.banners.postSkippedStep(direction, subject: subject)
return .skipped return .skipped
} }
@@ -206,6 +222,39 @@ extension BoardStore {
private static let historyLogger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "history") 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 // MARK: Creates
/// One item a gesture brought into being: where it landed, the bytes it landed with, and any /// 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 /// ### `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 /// **No board the app composes is one any more** (re-ruled 2026-07-31, twice): "the pair disabled
/// 2026-07-31 it is the only board that does: "the pair disabled only on repo-nested boards, /// only under locks and on empty stacks the provider follows the board, so boards without
/// under locks, and on empty stacks the provider follows the board, so gitless boards bind /// app-managed git repo-nested included bind 13-native-undo.md's native stack in **every**
/// 13-native-undo.md's native stack in **every** tier" (03-board-ui.md Toolbar Catalog; /// tier" (03-board-ui.md Toolbar Catalog). The repo-nested board was the last holder of this
/// 06-history-undo.md Rules). Every question below answers the empty way, so the Edit menu's /// state and no longer is: that rule was about *git*, and this stack never touches git.
/// 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 /// The state stays modelled because the seam still admits it a test binds a substrate-less
/// is the honest shape: there is nothing there, and nothing can accidentally accumulate in it. /// 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 /// ### 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 /// 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 /// *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 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 @MainActor
public final class CardWindowUndo { 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. /// 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 /// 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. /// 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 { static func fold(_ lists: [[HistoryExpectation]]) -> Fold {
var fold = Fold() var fold = Fold()
for list in lists { for list in lists {
@@ -170,7 +183,7 @@ public final class CardWindowUndo {
var fields: [ExpectedField.Kind: ExpectedField] 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 /// 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. /// 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 /// 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 /// construction while the question being asked did anything actually change is about the
/// values alone. /// values alone.
private var order: [URL] = [] private var order: [HistoryAnchor] = []
static func == (lhs: Fold, rhs: Fold) -> Bool { lhs.targets == rhs.targets } 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 /// 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 /// live location, and carrying the edit's body expectation there would make the session's own
/// stale the moment it was registered. Every move-shaped step declares both of its paths /// step stale the moment it was registered. Every move-shaped step declares both of its
/// precisely so this is expressible (`BoardStore.deleteComment`, `postComment`). /// anchors precisely so this is expressible (`BoardStore.deleteComment`, `postComment`).
mutating func merge(_ expectation: HistoryExpectation) { mutating func merge(_ expectation: HistoryExpectation) {
let key = expectation.folder.standardizedFileURL let key = Self.key(expectation.anchor)
if targets[key] == nil { if targets[key] == nil {
order.append(key) order.append(key)
targets[key] = Target(presence: expectation.presence, fields: [:]) targets[key] = Target(presence: expectation.presence, fields: [:])
@@ -209,10 +222,18 @@ public final class CardWindowUndo {
order.compactMap { key in order.compactMap { key in
guard let target = targets[key] else { return nil } guard let target = targets[key] else { return nil }
let fields = Self.fieldOrder.compactMap { target.fields[$0] } 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] 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 /// `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): /// 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 /// a board without app-managed git repo-nested included (re-ruled 2026-07-31) binds
/// the Writer boundary) in every tier, a Pro git board binds the git provider (undo as forward /// `NativeHistoryProvider` (two step stacks over the inverses registered at the Writer boundary) in
/// restore commits over HEAD's first-parent ancestry 06-history-undo.md), a repo-nested board /// every tier, a Pro git board binds the git provider (undo as forward restore commits over HEAD's
/// binds none at all, and Teams inherits Pro's. /// first-parent ancestry 06-history-undo.md), and Teams inherits Pro's.
/// ///
/// ### What this protocol deliberately does not say /// ### 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 // MARK: - HistoryExpectation
/// What one folder must currently hold for a step to be safe to cross the state that step's write /// 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 /// 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 /// three-way live/tombstoned/absent reading of a `deleted:` key there is no key to read, and no
/// ancestor to walk to find one. /// 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 { public struct HistoryExpectation: Sendable, Equatable {
/// Where the item this step wrote to should be the destination for a move, the item's own /// What the item this step wrote to is addressed by a path for a board gesture, a card identity
/// folder for everything else, and the board root for the board's own rename and styling. /// for a session step (`HistoryAnchor`).
public let folder: URL public let anchor: HistoryAnchor
/// Whether the item should be there. /// Whether the item should be there.
public let presence: Presence public let presence: Presence
@@ -107,26 +208,40 @@ public struct HistoryExpectation: Sendable, Equatable {
case absent case absent
} }
public init(folder: URL, presence: Presence, fields: [ExpectedField]) { public init(anchor: HistoryAnchor, presence: Presence, fields: [ExpectedField]) {
self.folder = folder self.anchor = anchor
self.presence = presence self.presence = presence
self.fields = fields self.fields = fields
} }
/// The item is at this path and its fields say what the step set them to. /// The item is where this anchor points and its fields say what the step set them to.
public static func present(_ folder: URL, _ fields: ExpectedField...) -> HistoryExpectation { public static func present(_ anchor: HistoryAnchor, _ fields: ExpectedField...) -> HistoryExpectation {
HistoryExpectation(folder: folder, presence: .present, fields: fields) HistoryExpectation(anchor: anchor, presence: .present, fields: fields)
} }
/// The same, for a caller whose field list is computed the styling gesture's, which varies per /// 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 { 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 { 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 /// 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 /// 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. /// 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 { public enum HistoryStaleness {
/// Whether every target a step named still holds what that step left there. /// 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 /// 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 /// 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. /// 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 { 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) } return expectation.fields.allSatisfy { matches($0, in: document) }
} }
+47 -18
View File
@@ -1502,15 +1502,28 @@ public final class BoardStore: HealHost {
/// close step. The board popover, the Style popover and the quick-style rows pass nothing, which /// close step. The board popover, the Style popover and the quick-style rows pass nothing, which
/// is the board's stack where a board-issued gesture belongs even when it names a card whose /// is the board's stack where a board-issued gesture belongs even when it names a card whose
/// window is open. /// window is open.
///
/// **And so is the step's *anchoring***, by exactly the same split (13 Rules, ruled
/// 2026-07-31). A window's restyle is a session gesture: it anchors to the card's identity, so the
/// lane move that would once have staled it and with it the whole coarse step it folds into
/// now resolves through. A board-issued restyle keeps its path anchors, because a board gesture's
/// subjects are a *selection*, spanning lanes and the board root, and "board-stack steps keep
/// their existing path-anchored expectations" is the scope the ruling drew.
public func applyStyle( public func applyStyle(
to target: StyleTarget, to target: StyleTarget,
background: StyleChange = .keep, background: StyleChange = .keep,
icon: StyleChange = .keep, icon: StyleChange = .keep,
on window: CardWindowUndo? = nil on window: CardWindowUndo? = nil
) { ) {
// A session gesture anchors to the card, everything else to the folder it resolved (above).
// The level is asked once, off the target, because a window's editor names exactly one card
// and a board gesture's selection may name lanes and the root neither of which a card walk
// could ever resolve.
let anchorsByIdentity = window != nil && styleLevel(of: target) == .card
let edits: [( let edits: [(
id: ItemID?, id: ItemID?,
folder: URL, folder: URL,
anchor: HistoryAnchor,
background: StyleChange, background: StyleChange,
icon: StyleChange, icon: StyleChange,
priorBackground: FieldValue<String>, priorBackground: FieldValue<String>,
@@ -1520,9 +1533,15 @@ public final class BoardStore: HealHost {
let background = Self.effective(background, against: subject.background) let background = Self.effective(background, against: subject.background)
let icon = Self.effective(icon, against: subject.icon) let icon = Self.effective(icon, against: subject.icon)
guard background != .keep || icon != .keep else { return nil } guard background != .keep || icon != .keep else { return nil }
let anchor: HistoryAnchor = if anchorsByIdentity, let id = subject.id {
.card(id)
} else {
.path(subject.folder)
}
return ( return (
id: subject.id, id: subject.id,
folder: subject.folder, folder: subject.folder,
anchor: anchor,
background: background, background: background,
icon: icon, icon: icon,
priorBackground: subject.background, priorBackground: subject.background,
@@ -1569,20 +1588,20 @@ public final class BoardStore: HealHost {
subject: subject, subject: subject,
on: window, on: window,
undoExpects: edits.map { undoExpects: edits.map {
.present($0.folder, fields: Self.styledFields(background: $0.background, icon: $0.icon)) .present($0.anchor, fields: Self.styledFields(background: $0.background, icon: $0.icon))
}, },
redoExpects: edits.map { redoExpects: edits.map {
.present($0.folder, fields: Self.restoredStyleFields( .present($0.anchor, fields: Self.restoredStyleFields(
background: $0.background, background: $0.background,
priorBackground: $0.priorBackground, priorBackground: $0.priorBackground,
icon: $0.icon, icon: $0.icon,
priorIcon: $0.priorIcon priorIcon: $0.priorIcon
)) ))
} }
) { _ in ) { store in
for edit in edits { for edit in edits {
try BoardWriter.updateIndex( try BoardWriter.updateIndex(
inItemFolder: edit.folder, inItemFolder: try store.requiredFolder(for: edit.anchor, .style(title: nil)),
kind: edit.id == nil ? .board : nil, kind: edit.id == nil ? .board : nil,
operation: .style(title: nil) operation: .style(title: nil)
) { document in ) { document in
@@ -1590,10 +1609,10 @@ public final class BoardStore: HealHost {
Self.restore(edit.priorIcon, to: FrontmatterKeys.icon, in: &document) Self.restore(edit.priorIcon, to: FrontmatterKeys.icon, in: &document)
} }
} }
} redo: { _ in } redo: { store in
for edit in edits { for edit in edits {
try BoardWriter.updateIndex( try BoardWriter.updateIndex(
inItemFolder: edit.folder, inItemFolder: try store.requiredFolder(for: edit.anchor, .style(title: nil)),
kind: edit.id == nil ? .board : nil, kind: edit.id == nil ? .board : nil,
operation: .style(title: nil) operation: .style(title: nil)
) { document in ) { document in
@@ -1971,14 +1990,17 @@ public final class BoardStore: HealHost {
/// the delimiter were never this write's to change. That is the one inverse in the app whose /// the delimiter were never this write's to change. That is the one inverse in the app whose
/// fidelity is byte-level rather than field-level. /// fidelity is byte-level rather than field-level.
/// ///
/// ### Its staleness predicate is the bytes, at the path the session wrote to /// ### Its staleness predicate is the bytes, at the card the session wrote to
/// ///
/// "Body steps compare bytes" (13 Rules), so the expectation is the whole body span as this /// "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 /// session left it a foreign editor that changed one character of it skips the step rather than
/// throwing that character away. The **container rides in the path** (`HistoryStaleness`): a /// throwing that character away.
/// session that ended because its card was moved to the trash registers against the trash folder ///
/// it actually flushed into, and a later restore moves the card out from under the step, which /// **The step is anchored to the card, never to its folder** (13 Rules, ruled 2026-07-31): it
/// the ordinary existence check then reads as the collision it is. /// carries the card's UUID and the bytes, and the folder is resolved at apply time by the walk
/// `writeCardBody` itself uses. A lane move, mid-session or long after, therefore leaves the step
/// alone, and so does the trash move a dismissing window flushes into the two relocations this
/// step used to be staled by, though neither is a change to the bytes it is about.
/// ///
/// ### Which stack it lands on is the caller's to say /// ### Which stack it lands on is the caller's to say
/// ///
@@ -1994,19 +2016,26 @@ public final class BoardStore: HealHost {
on window: CardWindowUndo? = nil on window: CardWindowUndo? = nil
) { ) {
guard priorBody != newBody, let target = Self.cardBodyTarget(cardID, in: snapshot) else { return } guard priorBody != newBody, let target = Self.cardBodyTarget(cardID, in: snapshot) else { return }
let folder = target.folder(under: rootURL)
let title = Self.cardTitle(at: target, in: snapshot) let title = Self.cardTitle(at: target, in: snapshot)
let card = HistoryAnchor.card(cardID)
let operation = WriteOperation.editBody(title: title)
registerStep( registerStep(
HistoryPhrase.name(.edit, kind: .card), HistoryPhrase.name(.edit, kind: .card),
subject: title, subject: title,
on: window, on: window,
undoExpects: [.present(folder, .body(newBody))], undoExpects: [.present(card, .body(newBody))],
redoExpects: [.present(folder, .body(priorBody))] redoExpects: [.present(card, .body(priorBody))]
) { _ in ) { store in
_ = try BoardWriter.writeBody(inItemFolder: folder, body: priorBody) _ = try BoardWriter.writeBody(
} redo: { _ in inItemFolder: try store.requiredFolder(for: card, operation),
_ = try BoardWriter.writeBody(inItemFolder: folder, body: newBody) body: priorBody
)
} redo: { store in
_ = try BoardWriter.writeBody(
inItemFolder: try store.requiredFolder(for: card, operation),
body: newBody
)
} }
} }
+56 -26
View File
@@ -141,22 +141,29 @@ extension BoardStore {
return nil return nil
} }
let folder = card.folder
let title = card.title let title = card.title
let draftFolder = CommentThread.draftFolder(inCard: folder) // **Anchored to the card, not to its folder** (13 Rules, ruled 2026-07-31): both halves of
let postedFolder = CommentThread.commentFolder(posted.id, inCard: folder) // 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( registerStep(
HistoryPhrase.comment, HistoryPhrase.comment,
subject: title, subject: title,
on: window, on: window,
undoExpects: [.present(postedFolder), .absent(draftFolder)], undoExpects: [.present(.comment(posted.id, inCard: id)), .absent(.commentDraft(inCard: id))],
redoExpects: [.present(draftFolder), .absent(postedFolder)] redoExpects: [.present(.commentDraft(inCard: id)), .absent(.comment(posted.id, inCard: id))]
) { _ in ) { store in
try BoardWriter.unpostComment(posted.id, inCard: folder, cardTitle: title) try BoardWriter.unpostComment(
} redo: { _ in posted.id,
inCard: try store.requiredFolder(for: cardAnchor, operation),
cardTitle: title
)
} redo: { store in
try BoardWriter.repostComment( try BoardWriter.repostComment(
as: posted.id, as: posted.id,
inCard: folder, inCard: try store.requiredFolder(for: cardAnchor, operation),
stamping: posted.posted, stamping: posted.posted,
cardTitle: title cardTitle: title
) )
@@ -205,19 +212,28 @@ extension BoardStore {
on window: CardWindowUndo? = nil on window: CardWindowUndo? = nil
) { ) {
guard priorBody != newBody, let card = commentSubject(id) else { return } guard priorBody != newBody, let card = commentSubject(id) else { return }
let folder = CommentThread.commentFolder(commentID, inCard: card.folder)
let title = card.title let title = card.title
let comment = HistoryAnchor.comment(commentID, inCard: id)
let operation = WriteOperation.editComment(title: title)
registerStep( registerStep(
HistoryPhrase.name(.edit, kind: .comment), HistoryPhrase.name(.edit, kind: .comment),
subject: title, subject: title,
on: window, on: window,
undoExpects: [.present(folder, .body(newBody))], undoExpects: [.present(comment, .body(newBody))],
redoExpects: [.present(folder, .body(priorBody))] redoExpects: [.present(comment, .body(priorBody))]
) { _ in ) { store in
_ = try BoardWriter.editComment(at: folder, body: priorBody, cardTitle: title) _ = try BoardWriter.editComment(
} redo: { _ in at: try store.requiredFolder(for: comment, operation),
_ = try BoardWriter.editComment(at: folder, body: newBody, cardTitle: title) 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 /// 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 /// 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 /// and existence is the whole predicate. The container rides in the *anchor* exactly as it rides
/// board level the undo expects the comment in `comments/.trash/`, the redo expects it back in /// in the path at board level the undo expects the comment in `comments/.trash/`, the redo
/// the thread so a foreign restore or a foreign re-delete skips the right half by itself. /// 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 /// **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 /// 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 } 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( registerStep(
HistoryPhrase.name(.delete, kind: .comment), HistoryPhrase.name(.delete, kind: .comment),
subject: title, subject: title,
on: window, on: window,
undoExpects: [.present(trashed), .absent(live)], undoExpects: [.present(trashed), .absent(comment)],
redoExpects: [.present(live), .absent(trashed)] redoExpects: [.present(comment), .absent(trashed)]
) { _ in ) { store in
try BoardWriter.restoreComment(commentID, inCard: folder, cardTitle: title) try BoardWriter.restoreComment(
} redo: { _ in commentID,
_ = try BoardWriter.deleteComment(at: live, cardTitle: title) 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 return true
} }
+2 -2
View File
@@ -26,8 +26,8 @@ public enum Tier: String, Sendable, Equatable, Codable, CaseIterable {
/// Lanework Pro an active auto-renewable subscription. It is what puts git on the table; which /// Lanework Pro an active auto-renewable subscription. It is what puts git on the table; which
/// substrate a given board then binds is the *board's* answer, not this case's (re-ruled /// substrate a given board then binds is the *board's* answer, not this case's (re-ruled
/// 2026-07-31 `AppModel.makeHistoryProvider`): the git provider on a git board /// 2026-07-31 `AppModel.makeHistoryProvider`): the git provider on a git board
/// (06-history-undo.md, 07-sync-collab.md), the same native stack the free tier uses on a gitless /// (06-history-undo.md, 07-sync-collab.md), and the same native stack the free tier uses on every
/// one, nothing on a repo-nested one. /// board without app-managed git, repo-nested ones included.
case pro case pro
} }
+156 -5
View File
@@ -320,12 +320,13 @@ struct CardSessionCloseTests {
#expect(try fixture.entryNames("\(card)/comments/.trash").isEmpty) #expect(try fixture.entryNames("\(card)/comments/.trash").isEmpty)
} }
@Test("A window whose card left the board registers nothing") @Test("A window whose card was trashed still registers — the trash is a relocation, not a vanishing")
func aVanishedCardRegistersNoSessionStep() async throws { func aTrashedCardStillRegistersItsSession() async throws {
let fixture = try WriterFixture() let fixture = try WriterFixture()
defer { fixture.tearDown() } defer { fixture.tearDown() }
_ = try makeCommentBoard(fixture) _ = try makeCommentBoard(fixture)
let window = try makeWindow(fixture) let window = try makeWindow(fixture)
let original = try body(fixture, cardPath)
editBody(window, to: "Edited.\n") editBody(window, to: "Edited.\n")
// The card window's own Actions Delete a board gesture, on the board's stack, which // 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") #expect(window.board.undoActionName == "Delete Card")
await window.session.endSession() 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") @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 // MARK: - The deferred purge
@MainActor @MainActor
@@ -543,8 +692,10 @@ struct CardSessionPurgeTests {
@Test("A board with no substrate at all owes the purge immediately") @Test("A board with no substrate at all owes the purge immediately")
func noProviderRunsTheRetirementAtOnce() throws { func noProviderRunsTheRetirementAtOnce() throws {
// Repo-nested boards bind no provider (06-history-undo.md Rules), so nothing could ever // A store with no session behind it no board the app composes binds no provider any more
// report the step's death the work is owed at registration or never. // (`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() let fixture = try WriterFixture()
defer { fixture.tearDown() } defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture) let card = try makeCommentBoard(fixture)
+5 -3
View File
@@ -554,10 +554,12 @@ struct CommentUndoTests {
let path = commentPath(CommentIdent.one, inCard: card) let path = commentPath(CommentIdent.one, inCard: card)
try fixture.item(path, commentText()) try fixture.item(path, commentText())
#expect(HistoryStaleness.isCurrent([.present(fixture.url(path))])) // Path anchors resolve to themselves the resolver a caller with no board hands in
#expect(HistoryStaleness.isCurrent([.absent(fixture.url("\(card)/comments/.draft"))])) // (`HistoryAnchor.literalPath`); the card-anchored halves are `CardSessionUndoTests`'.
#expect(HistoryStaleness.isCurrent([.present(fixture.url(path))], resolvedBy: \.literalPath))
#expect(HistoryStaleness.isCurrent([.absent(fixture.url("\(card)/comments/.draft"))], resolvedBy: \.literalPath))
try FileManager.default.removeItem(at: fixture.url(path)) try FileManager.default.removeItem(at: fixture.url(path))
#expect(!HistoryStaleness.isCurrent([.present(fixture.url(path))])) #expect(!HistoryStaleness.isCurrent([.present(fixture.url(path))], resolvedBy: \.literalPath))
} }
} }
+31 -15
View File
@@ -898,17 +898,21 @@ struct GitUndoSessionTests {
/// **The provider follows the board, not the tier alone** (re-ruled 2026-07-31 12-editions.md /// **The provider follows the board, not the tier alone** (re-ruled 2026-07-31 12-editions.md
/// The provider seam; 13-native-undo.md's header; 06-history-undo.md Rules), stated as the /// The provider seam; 13-native-undo.md's header; 06-history-undo.md Rules), stated as the
/// matrix it is: gitless boards bind the native stack in *every* tier ("an upgrade never removes /// matrix it is: **boards without app-managed git repo-nested included bind the native stack in
/// undo"), a Pro git board binds the git provider, and a repo-nested board binds nothing. /// *every* tier** ("an upgrade never removes undo"), and a Pro git board binds the git provider.
/// There is no third answer any more.
/// ///
/// ### The free tier's row is one cell wide, structurally /// ### The repo-nested row stopped being an exception
/// ///
/// `HistoryStore.compose` returns `nil` off Pro, so a free-tier session never detects a mode at all /// `HistoryStore.compose` returns `nil` off Pro, so a free-tier session never detects a mode at all
/// and cannot tell a repo-nested board from a plain one which is not an omission but 12 The free /// and cannot tell a repo-nested board from a plain one which is not an omission but 12 The free
/// tier and `.git` verbatim: "opening a board that has one (a formerly-subscribed user's board, a /// tier and `.git` verbatim: "opening a board that has one (a formerly-subscribed user's board, a
/// 1.x board, **a repo-nested board**) works normally files read and write as on any board, /// 1.x board, **a repo-nested board**) works normally files read and write as on any board,
/// **native undo runs**". 06's no-undo rule for repo-nested boards is a rule of a doc whose own first /// **native undo runs**". Pro used to answer differently on the same board, which made subscribing
/// line reads "Tier scope: Lanework Pro", and the tests below pin both halves. /// *remove* Z from it; the re-ruling of 2026-07-31 retired that case outright "leave strictly
/// alone concerns *git*, and this stack never touches git memory-only, journal-free,
/// session-scoped" (13's header) so the two tiers now agree on every board there is, and the tests
/// below pin both halves of that agreement.
@MainActor @MainActor
@Suite("Git undo ▸ which board gets a provider") @Suite("Git undo ▸ which board gets a provider")
struct GitUndoBindingTests { struct GitUndoBindingTests {
@@ -977,8 +981,8 @@ struct GitUndoBindingTests {
#expect(log.crossings == ["undo Move Card"]) #expect(log.crossings == ["undo Move Card"])
} }
@Test("Pro on a repo-nested board binds no provider — the one no-undo case") @Test("Pro on a repo-nested board binds the native stack — the no-undo case is retired")
func proOnARepoNestedBoardBindsNothing() throws { func proOnARepoNestedBoardBindsTheNativeStack() throws {
let outer = try WriterFixture() let outer = try WriterFixture()
defer { outer.tearDown() } defer { outer.tearDown() }
// A repository at the *parent*, with the board inside it the nested posture. // A repository at the *parent*, with the board inside it the nested posture.
@@ -995,16 +999,26 @@ struct GitUndoBindingTests {
let ref = try openBoard(model, at: boardRoot) let ref = try openBoard(model, at: boardRoot)
let session = try #require(model.session(for: ref)) let session = try #require(model.session(for: ref))
// 06 Rules: a board inside somebody else's repository is "left strictly alone so they get // The mode is detected Pro walks the ancestors and it no longer decides Z: "Pro binds it
// **no undo**" no app-managed undo journal, which an in-memory stack here would be. // on mode-none **and repo-nested** boards alike what repo-nested denies is app-managed
// history, never Z" (13's header, re-ruled 2026-07-31).
#expect(session.gitMode == .repoNested) #expect(session.gitMode == .repoNested)
#expect(session.history == nil, "the app leaves that repository strictly alone") #expect(session.history is NativeHistoryProvider)
#expect(session.undoManager.canUndo == false) #expect(session.undoManager.canUndo == false, "empty, not absent")
#expect(session.undoManager.canRedo == false)
#expect(session.undoManager.undoMenuItemTitle == "Undo", "a bare row, with nothing to name") // A working stack, exactly as the free tier's on this same board which is the whole point
// And a crossing that somehow started still writes nothing. // of the re-ruling: the two tiers answer alike here now.
let log = StepLog()
session.history?.register(log.step("Move Card"))
#expect(session.undoManager.canUndo)
#expect(session.undoManager.undoMenuItemTitle == "Undo Move Card")
session.undoManager.undo() session.undoManager.undo()
session.undoManager.redo() #expect(log.crossings == ["undo Move Card"])
// And the enclosing repository is still left strictly alone: no repository was created at the
// board, and nothing here reaches for the ancestor's `.git` (13: memory-only, journal-free).
#expect(!FileManager.default.fileExists(atPath: boardRoot.appendingPathComponent(".git").path))
#expect(session.git?.committer == nil, "no committer, and so nothing that could write there")
} }
@Test("The free tier's repo-nested board still binds the native stack — it never detects one") @Test("The free tier's repo-nested board still binds the native stack — it never detects one")
@@ -1031,6 +1045,8 @@ struct GitUndoBindingTests {
#expect(session.git == nil) #expect(session.git == nil)
#expect(session.gitMode == .none, "no detection ran; the session reports the tier's one mode") #expect(session.gitMode == .none, "no detection ran; the session reports the tier's one mode")
#expect(session.history is NativeHistoryProvider) #expect(session.history is NativeHistoryProvider)
// Pro on this same board now answers identically (above) the two tiers agree, which is what
// the re-ruling of 2026-07-31 bought: an upgrade never removes undo from *any* board.
} }
@Test("Add-git swaps the substrate — the native stack is discarded, the git trail seeded") @Test("Add-git swaps the substrate — the native stack is discarded, the git trail seeded")