Files
lanework/Kanban/History/HistoryStaleness.swift
rzen d076427ee0 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
2026-07-31 21:19:42 -04:00

354 lines
18 KiB
Swift

import Foundation
// MARK: - ExpectedField
/// One field a step's write left holding a known value — the unit 13-native-undo.md ▸ Rules'
/// **field-level** predicate compares.
///
/// "Each step registers both sides of its write anyway (the before-value is the inverse; the
/// after-value is what its write set), so validation compares the targeted field's current value
/// against the expected after-value" (settled, ruled 2026-07-27). These cases are therefore exactly
/// the fields the app's own inverses write and no others: a step declares what *it* set, never what
/// it merely read, so a foreign edit to a field this gesture never touched cannot skip anything.
///
/// **`nil` means the key is absent**, which is a real after-value throughout this app rather than a
/// missing one: an emptied rename removes `title`, a width of one unit removes `width`, and the None
/// well removes `background` (the remove-at-default family). A **malformed** value on disk matches
/// neither an absence nor a value — it is not something this app writes, so finding one is finding
/// somebody else's edit.
public enum ExpectedField: Sendable, Equatable {
/// `title` — renames at every level.
case title(String?)
/// `order` — every reorder, every move's landing rank, and the rank half of a drag-restore.
case order(Double)
/// `width` — the lane resize. `nil` is the one-unit default, whose key the write removes.
case width(Int?)
/// `background` — the styling gesture's colour dimension.
case background(String?)
/// `icon` — the styling gesture's symbol dimension.
case icon(String?)
/// The body span, **byte for byte** — the Edit session's step, and the one inverse in the app
/// whose fidelity is not field-level (13: "body steps compare bytes").
case body(String)
/// Which field this is, ignoring the value it carries — the key a fold merges on
/// (`CardWindowUndo`): two writes to `background` inside one card-window session are one field
/// with a first and a last value, while a write to `background` and one to `icon` are two.
var kind: Kind {
switch self {
case .title: .title
case .order: .order
case .width: .width
case .background: .background
case .icon: .icon
case .body: .body
}
}
/// The field names, as a comparable value — deliberately not the `String` keys, which are
/// `FrontmatterKeys`' business and would tie a fold to the file format.
enum Kind: Hashable, Sendable {
case title
case order
case width
case background
case icon
case body
}
}
// 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
/// left it in.
///
/// Two halves, both of them 13's: **existence** ("target folder gone ... → the step is skipped"),
/// and the **field-level** comparison above. A step carries one of these per item it touched, so a
/// multi-card move validates three targets and a single rename validates one — which is the whole of
/// "a foreign change to an unrelated item must not skip anything": an item no step named is an item
/// no expectation mentions.
///
/// ### The container side rides in the folder path
///
/// **The folder's *path* is the parent check**, and since the trash was materialized that check is
/// also the container check (03-board-ui.md § Trash, resettled 2026-07-28). A delete step's undo
/// expects its card at `<root>/.trash/<id>`; a foreign restore moves the folder out, so nothing is
/// at that path and the existence half already answers "the card is not in the trash any more".
/// The mirror holds: the redo expects it back at `<root>/<lane>/<id>`, where a foreign re-delete
/// 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 {
/// 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
/// The fields the step's write set, with the values it set them to. Empty for a step whose
/// whole subject *is* existence — a create, a lane delete.
public let fields: [ExpectedField]
/// Whether anything is at this path.
public enum Presence: Sendable, Equatable {
/// There, with a readable `index.md`. Which container that is, is the path's own answer.
case present
/// Not there at all: the folder is gone. What an undone create and an undone lane delete
/// leave, and what a redone one expects to find before putting it back.
case absent
}
public init(anchor: HistoryAnchor, presence: Presence, fields: [ExpectedField]) {
self.anchor = anchor
self.presence = presence
self.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(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(anchor: .path(folder), presence: .present, fields: fields)
}
public static func absent(_ folder: URL) -> HistoryExpectation {
HistoryExpectation(anchor: .path(folder), presence: .absent, fields: [])
}
}
// MARK: - HistoryStaleness
/// The staleness predicate: does the board still look the way this step's write left it?
/// (13-native-undo.md ▸ Rules ▸ staleness validation.)
///
/// ### It reads disk, not the snapshot
///
/// 13 says "re-checks its target against the current snapshot at ⌘Z time", and *current* is the
/// load-bearing word: the store's `snapshot` is by construction one reload behind every write the
/// app makes (the one-way flow means a write is only visible once the watcher round-trips it), so
/// validating against it would make a second ⌘Z pressed inside the debounce window compare against a
/// board that still shows the first one's *pre*-state — every rapid undo run would false-skip. Disk
/// is what "current" can honestly mean at the instant a step is crossed, and it is also what an
/// inverse is about to write to.
///
/// ### Lazily, never eagerly
///
/// Nothing here is called by the watcher, the ledger, or any background sweep: "invalidation is lazy
/// (settled — ruled 2026-07-27): staleness is discovered at ⌘Z time, never by background pruning ...
/// The stack always looks full". This type has exactly one caller, `BoardStore.cross`, one line
/// before the inverse would have been written.
///
/// ### It needs no board root
///
/// 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.
///
/// - 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, 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, at folder: URL) -> Bool {
guard expectation.presence != .absent else {
return !FileManager.default.fileExists(atPath: folder.path)
}
guard let document = index(at: folder) else { return false }
return expectation.fields.allSatisfy { matches($0, in: document) }
}
// MARK: Fields
static func matches(_ field: ExpectedField, in document: FrontmatterDocument) -> Bool {
switch field {
case let .title(expected): equal(document.title, expected)
case let .order(expected): document.order.value == expected
case let .width(expected): equal(document.width, expected)
case let .background(expected): equal(document.background, expected)
case let .icon(expected): equal(document.icon, expected)
case let .body(expected): document.body == expected
}
}
/// A present value matches a present expectation by value; an absence matches an absence.
///
/// **A malformed field matches nothing**, deliberately: `FieldValue.malformed` is a shape this
/// app never writes (`background: [a, b]` is somebody's hand edit), so a step that expected its
/// own removed key must not read one as "gone" and clobber it.
private static func equal<Value: Sendable & Equatable>(_ actual: FieldValue<Value>, _ expected: Value?) -> Bool {
switch (actual, expected) {
case (.missing, nil): true
case let (.valid(value), .some(expected)): value == expected
default: false
}
}
// MARK: Reading
/// The item's `index.md` as the app reads it, or `nil` when there is no readable, parseable one
/// there — a folder that is gone, an indexless folder, bytes that are not UTF-8, frontmatter
/// somebody has just broken.
private static func index(at folder: URL) -> FrontmatterDocument? {
let indexURL = folder.appendingPathComponent(BoardLoader.indexFileName)
guard let data = try? Data(contentsOf: indexURL),
let text = String(validating: data, as: UTF8.self),
let document = try? FrontmatterDocument.parse(text)
else { return nil }
return document
}
}