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) /// `/comments//` — one posted comment. case comment(ItemID, inCard: ItemID) /// `/comments/.trash//` — one deleted comment, undo's backing store. case trashedComment(ItemID, inCard: ItemID) /// `/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 `/.trash/`; 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 `//`, 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/` versus `comments/`, 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(_ actual: FieldValue, _ 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 } }