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) } // 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 and liveness** ("target folder gone ... → the step is /// skipped"; "existence/liveness for create/delete/restore steps"), 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 folder's *path* is the parent check.** A move's step expects the card at its destination /// path; a card that a foreign writer moved elsewhere leaves nothing at that path, so the ordinary /// existence half already answers "moved away" without a parent field of its own. public struct HistoryExpectation: Sendable, Equatable { /// Where the item this step wrote to should be — the destination for a move, the item's own /// folder for everything else, and the board root for the board's own rename and styling. public let folder: URL /// Whether the item should be there, and if so on which side of the tombstone. 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 delete, a Put Back. public let fields: [ExpectedField] /// Where an item stands, as the trash's own three-way reading of it. public enum Presence: Sendable, Equatable { /// There, and rendered: no `deleted:` on the item **or on any ancestor**. Liveness is /// effective, the same ancestor walk `BoardStore.liveItem` and the card windows' fate rule /// apply — a card under a tombstoned lane renders nowhere, so it is as gone as a deleted one. case live /// There, and tombstoned — a trash row, or a card hidden under a tombstoned lane. case tombstoned /// Not there at all: the folder is gone. What an undone create leaves, and what a redone one /// expects to find before putting it back. case absent } public init(folder: URL, presence: Presence, fields: [ExpectedField]) { self.folder = folder self.presence = presence self.fields = fields } /// The item is live and its fields say what the step set them to. public static func live(_ folder: URL, _ fields: ExpectedField...) -> HistoryExpectation { HistoryExpectation(folder: folder, presence: .live, 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 `.live(folder)` stays unambiguous. public static func live(_ folder: URL, fields: [ExpectedField]) -> HistoryExpectation { HistoryExpectation(folder: folder, presence: .live, fields: fields) } /// The item is tombstoned and its fields say what the step set them to. public static func tombstoned(_ folder: URL, _ fields: ExpectedField...) -> HistoryExpectation { HistoryExpectation(folder: folder, presence: .tombstoned, fields: fields) } /// Nothing is at this path. public static func absent(_ folder: URL) -> HistoryExpectation { HistoryExpectation(folder: folder, presence: .absent, fields: []) } /// The variant for a step whose target's liveness is not known until the gesture runs — the Edit /// session's, which is registered against a card that may have been tombstoned out from under /// the buffer (05-card-window.md ▸ Deletion & lifecycle). public static func item( _ folder: URL, tombstoned: Bool, _ fields: ExpectedField... ) -> HistoryExpectation { HistoryExpectation(folder: folder, presence: tombstoned ? .tombstoned : .live, fields: 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. public enum HistoryStaleness { /// Whether every target a step named still holds what that step left there. /// /// `root` is the board's current root, which the liveness walk stops at — a lane's parent. public static func isCurrent(_ expectations: [HistoryExpectation], under root: URL) -> Bool { expectations.allSatisfy { isCurrent($0, under: root) } } /// One target's answer. /// /// A file that cannot be read or parsed fails every expectation but `.absent`: 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, under root: URL) -> Bool { guard expectation.presence != .absent else { return !FileManager.default.fileExists(atPath: expectation.folder.path) } guard let document = index(at: expectation.folder) else { return false } let tombstoned = isEffectivelyTombstoned(expectation.folder, document: document, under: root) guard tombstoned == (expectation.presence == .tombstoned) 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: Liveness /// Whether the item at `folder` renders — **presence of `deleted:`, not its validity** /// (`Lane.isDeleted`'s rule), walked up through the ancestors the way every other liveness /// question in this app is. /// /// The board root is never tombstoned however its own frontmatter reads: a board-level `deleted:` /// is a tolerated load *warning* (01-storage-format.md § Deletion), not a state that hides the /// board from itself. private static func isEffectivelyTombstoned( _ folder: URL, document: FrontmatterDocument, under root: URL ) -> Bool { guard !isRoot(folder, root) else { return false } guard document.deleted.isMissing else { return true } // Lane and card are the only levels below the root, so this walks at most twice; the bound // is there so a folder that is not under this root at all (a step registered before a // mid-session root change) ends rather than climbing to `/`. var parent = folder.deletingLastPathComponent() for _ in 0 ..< 4 { guard !isRoot(parent, root) else { return false } guard let ancestor = index(at: parent) else { return false } if !ancestor.deleted.isMissing { return true } parent = parent.deletingLastPathComponent() } return false } private static func isRoot(_ folder: URL, _ root: URL) -> Bool { folder.standardizedFileURL.path == root.standardizedFileURL.path } // 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 } }