import Foundation
import Synchronization
import os

/// Walks a board's folder tree and produces an immutable `BoardModel` snapshot — a pure
/// function of the tree (02-architecture.md § Layering ▸ Components). Enforces the fractal
/// layout's fail-fast and skip rules (01-storage-format.md § Fractal layout, Malformed input)
/// so a bad file either loudly rejects the whole load or is cleanly ignored — never a silent
/// partial result.
///
/// Level is position: root `index.md` → board, depth-1 folders → lanes, depth-2 folders →
/// cards. **Name shape gates level detection** (01-storage-format.md § Fractal layout ▸
/// Rules): only a folder whose name has a UUID's shape — hex, `8-4-4-4-12`, **any case and any
/// version** — is a lane/card *candidate* at those depths; see `isUUIDShaped` below for exactly
/// what's checked.
/// Anything else — even a directory holding a perfectly valid `index.md` — is a stray: skipped
/// with a `.nonUUIDFolderIgnored` warning, preserved verbatim on disk, and never descended
/// into. A hand-made `notes/` folder (or a broken `index.md` inside one) can never brick a
/// load; only a UUID-shaped candidate that is itself missing `index.md` still gets the older
/// `.missingIndex` warning, and only a UUID-shaped candidate's `index.md` can fail-fast.
///
/// Reserved child names (`attachments/`, `comments/`) only matter as children *of a card*
/// (01-storage-format.md § Fractal layout ▸ Rules), and cards are leaves *structurally*: the
/// walk stops at depth 2, so nothing below a card is ever a level candidate. Doubly so under the
/// shape rule — `attachments` and `comments` are non-UUID-shaped and would read as strays, not
/// levels, so they never need special-casing against the stray warning.
///
/// **Three reads inside a card folder**, all of them flat directory listings and nothing more —
/// none opens a file's *contents*, descends past one level, warns, or fails a load; each degrades to
/// its empty answer (`[]` or `0`):
///
/// - `attachmentNames(in:)` — `attachments/`, feeding `Card.attachments`. The board window's face
///   needs it before a card window exists (the quiet paperclip indicator — 03-board-ui.md § Card
///   face), and the snapshot is where it reads from.
/// - `commentCount(in:)` — `comments/`, feeding `Card.commentCount` (design ruling 2026-08-09, card
///   e729e30a). A count of identity-shaped children carrying `index.md`, never a parse of one — the
///   distinction that keeps this a directory listing rather than the per-comment read
///   `CommentThread.load` does, and keeps the walk O(cards) exactly as 01-storage-format.md §
///   Enhanced schema's "the board snapshot never loads comment content" already required.
/// - `looseFileNames(in:ignoring:)` — the card folder *itself*, feeding `LoadResult.looseCardFiles`.
///   This is the loose-file carve-out's **detection** half (01-storage-format.md § Fractal layout ▸
///   Rules, settled 2026-07-28): a regular file sitting beside a card's `index.md` belongs in
///   `attachments/`, and the app relocates it. Detection stays read-only *here* — this loader is a
///   pure function of the tree and writes nothing, ever (the Repair precedent); the relocation is a
///   Writer-mediated app write the store schedules off the snapshot
///   (`BoardStore.relocateLooseCardFiles`).
///
/// Everything else about a card folder's contents remains outside this loader's business.
///
/// **A third read, once per walk rather than per card**: the board root's `.gitignore`, which is the
/// noise gate the detection above obeys (§ Rules, ruled 2026-07-31 — `ignoreRules(atBoardRoot:)`). A
/// file it matches is not a defect at all; it keeps the ordinary stray posture.
///
/// Symlinks: a lane/card candidate that is itself a symlink is treated as a stray and never
/// followed, whether it points to a file or a directory — this loader does not resolve
/// cross-volume or cyclic trees.
///
/// ## The trash is a container, not a level
///
/// `<root>/.trash/` is a **reserved, app-claimed board-root name** holding card *and lane* folders
/// interleaved directly (01-storage-format.md § Deletion, resettled 2026-07-28; lanes joined
/// 2026-07-29) — no `index.md` of its own. The walk therefore treats it as a second container beside
/// the lanes, parsing its UUID-shaped children through the same `schema`/`order` rulebook and the
/// same skip-and-warn rules the lane walk uses. Being reserved, it is **never a stray** and never
/// warns; absent, the trash is simply empty.
///
/// **`kind:` decides what each entry is** (`IntegrityRules.trashKind`): depth defines meaning on the
/// live board, but the trash is flat and an empty lane folder is shape-identical to a card folder.
/// A card lands in `BoardModel.trash` parsed like any card; a lane lands in
/// `BoardModel.trashedLanes` as an **opaque unit** — title, rank, and a count of the cards it holds
/// — and its subtree is never walked into the snapshot (03-board-ui.md § Trash).
///
/// ## The migration window
///
/// The tombstone model is retired: no `deleted:` key is ever written again. A key found on a **card**
/// is *migration input* — the card relocates into `.trash/` with the key removed. A key on a lane or
/// on the board is meaningless and stays where it is (ignored + logged, the tolerate tier: lanes
/// stopped migrating on 2026-07-29, when they gained a real trash of their own). Detection is
/// read-only here, the loose-file carve-out's posture exactly: this loader reports what it found
/// through `LoadResult.legacyTombstones` and the store schedules the Writer-mediated fix.
///
/// **Until that write lands, such cards still load through the retiring tombstone path** — a
/// `deleted:`-carrying card stays filed under its lane with `isDeleted` set. That is a deliberate
/// intermediate, not an oversight: the migration's whole promise is "never destroy", and the safe
/// direction while the fix is still pending (it is deferred under any read-only lock, and can be
/// pending for a whole session) is for nothing to silently vanish from view before its folder has
/// actually moved. The window closes per board on the first successful migration write.
public enum BoardLoader: Sendable {

    /// Schema version this app understands; anything higher fails fast
    /// (01-storage-format.md § Malformed input). Internal rather than `private`: read by
    /// `BoardLoadError.Reason.description` below, and it is also the version `BoardWriter`
    /// stamps into files it creates — one symbol, so the app can never write a file its own
    /// loader would reject as newer-than-supported.
    static let supportedSchema = 1

    /// Board-level key for `BoardModel.template` — not schema-owned in the engine's sense
    /// (`FrontmatterKeys.schemaOwned`), because its value is opaque and read raw here rather
    /// than through a typed `FrontmatterDocument` accessor.
    ///
    /// Internal rather than `private` for `indexFileName`'s reason: `TemplateEngine` writes this
    /// key on a Save as Template copy — "the one writer of keyed files is Save as Template"
    /// (09-templates.md ▸ Storage) — and the reader and that one writer must never disagree about
    /// how it is spelled.
    static let templateKey = "template"

    /// Internal rather than `private`: `BoardWriter` names the same file, and the loader and
    /// the writer must never disagree about which file a folder's content lives in.
    ///
    /// The name itself is `IntegrityRules`', with every other reserved name — one table
    /// (02-architecture.md ▸ Components).
    static let indexFileName = IntegrityRules.indexFileName

    /// The materialized trash container at board root (01-storage-format.md § Deletion, resettled
    /// 2026-07-28) — **app-claimed, never a stray**, joining `CLAUDE.md`, `CLAUDE.user.md` and the
    /// seeded `.gitignore` on the claimed list.
    ///
    /// Dot-prefixed, which is doing real work rather than being decoration: `directoryCandidates`
    /// skips hidden entries, so the container can never be mistaken for a lane candidate and can
    /// never earn a `.nonUUIDFolderIgnored` warning. The lane walk skips it by name as well
    /// (`reservedRootNames`) so the rule holds even where hidden-file semantics don't.
    ///
    /// Internal rather than `private`: `BoardWriter` moves folders into and out of this exact
    /// name, and a board can have only one trash. The name is `IntegrityRules`', with the rest of
    /// the claimed-name table.
    static let trashFolderName = IntegrityRules.trashFolderName

    /// Board-root names the app claims, and therefore the names the lane walk skips **without a
    /// stray warning** (01-storage-format.md § Fractal layout ▸ Rules: "Three board-root names are
    /// app-claimed, not strays", plus `.trash/` from § Deletion).
    ///
    /// Only `.trash` is a *folder* and so the only one the directory walk could ever reach; the
    /// files are listed because the claim is about names, and a future check that needs the set
    /// should find it complete rather than build a second one. Compared lowercased, like
    /// `reservedCardChildNames` and for its reason — the filesystem this runs on usually is.
    ///
    /// The table is `IntegrityRules.claimedRootNames`, which also carries what kind of node each
    /// name is allowed to be — the fact the squatter-displacement heal turns on (ruled 2026-07-29).
    static let reservedRootNames: Set<String> = IntegrityRules.claimedRootNameSet

    /// The card-level names the app claims, and therefore the three the loose-file carve-out
    /// never touches (01-storage-format.md § Fractal layout ▸ Rules: "Reserved card-level names
    /// … untouched"): the card's own `index.md` plus the two reserved children. `comments` is
    /// listed because the schema reserves the name, not because anything writes it yet —
    /// `attachments/` is still the one folder this app ever creates under a card.
    ///
    /// **Compared lowercased**, because the filesystem this runs on usually is: a file spelled
    /// `Index.md` *is* the card's index to `fileExists`, and a case-sensitive reservation check
    /// would hand the loose-file relocation a card's own content to move into `attachments/`.
    ///
    /// Internal rather than `private`: `BoardWriter.relocateLooseFiles` refuses the same three
    /// names on its own, so a caller passing a hand-made list cannot reach past this rule. The
    /// table itself is `IntegrityRules`', with every other reserved name.
    static let reservedCardChildNames: Set<String> = IntegrityRules.reservedCardChildNames

    /// The board's noise definition, at board root (01-storage-format.md § Fractal layout ▸ Rules,
    /// ruled 2026-07-31). The name is `IntegrityRules`', with the rest of the claimed names.
    static let gitignoreFileName = IntegrityRules.gitignoreFileName

    /// **The defect paths a skip set can never name** (01-storage-format.md § Malformed input, the
    /// decision surface, settled 2026-07-31): the board root's own `index.md`, and the `"."` the
    /// environmental failures carry.
    ///
    /// The surface never offers Skip at the root — a root `schema` newer than this app "blocks the
    /// whole board (Cancel is the only exit)", and the other three root defects have minted repairs
    /// (create the index, stamp `schema: 1`) rather than a tolerance. Skipping one anyway would mean
    /// building a `BoardModel` out of a board with no root document and no schema, which is not a
    /// board. Policed in `load(boardRoot:skipping:historyRanker:)` so the impossible snapshot is
    /// impossible *here*, rather than by every future caller remembering not to ask for it.
    static let unskippablePaths: Set<String> = [indexFileName, "."]

    private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "loader")

    // MARK: - The noise gate

    /// **The board-root `.gitignore`, parsed** — the one file the loose-file carve-out consults
    /// before calling anything relocatable (01-storage-format.md § Fractal layout ▸ Rules, ruled
    /// 2026-07-31: "`.gitignore` is the noise gate").
    ///
    /// Read **once per walk**, at the top of `load`, and handed down to every card: a board with 400
    /// cards reads this file once, not 400 times. `nil` when the board has none — and `nil` and an
    /// empty file mean exactly the same thing to every caller (nothing is excluded), which is the
    /// ruling's own escape hatch working by construction rather than by a branch.
    ///
    /// **Board root only.** "Nested `.gitignore` files are ordinary strays the heal never consults" —
    /// a `.gitignore` inside a lane or a card is a file like any other, and one inside a card is
    /// itself a hidden entry the carve-out never touches.
    ///
    /// Reading it here does not make the loader impure: this is a read like `index.md`'s, on a file
    /// the walk's result depends on. Nothing is written, and an unreadable or non-UTF-8 file answers
    /// `nil` — "cannot tell" reads as "excludes nothing", which is the direction that loses no file
    /// (an unrelocated stray stays exactly where it is; the alternative would silently move things
    /// the user meant to be noise).
    static func ignoreRules(atBoardRoot root: URL) -> GitignoreRules? {
        guard let data = try? Data(contentsOf: root.appendingPathComponent(gitignoreFileName)),
              let text = String(data: data, encoding: .utf8)
        else {
            return nil
        }
        return GitignoreRules(parsing: text)
    }

    // MARK: - The parse memo

    /// **One `index.md`'s git-index heuristic record** (02-architecture.md § Live-reload resilience,
    /// blessed 2026-07-31: "The walk memoizes its parse, never its result").
    ///
    /// Modification date and byte count, and deliberately nothing else: "The mtime+size trust is the
    /// git-index heuristic; a writer that defeats it — content changed, mtime and size both
    /// preserved — is outside the app's care." No content hashing, because a hash is a read of the
    /// whole file and reading the whole file is the cost the memo exists to avoid.
    ///
    /// Stat'd through `FileManager.attributesOfItem`, **never** `URL.resourceValues`, which caches
    /// its answers on the `URL` instance: a cached mtime would let the memo answer from a stamp taken
    /// a reload ago, which is exactly the staleness the heuristic exists to detect.
    public struct FileStamp: Sendable, Equatable {
        public let modified: Date
        public let size: Int

        /// `nil` where the file cannot be stat'd at all — read as "cannot tell", and therefore as a
        /// memo miss: the walk parses, exactly as it did before the memo existed.
        init?(of url: URL) {
            guard let attributes = try? FileManager.default.attributesOfItem(atPath: url.path),
                  let modified = attributes[.modificationDate] as? Date,
                  let size = attributes[.size] as? NSNumber
            else {
                return nil
            }
            self.modified = modified
            self.size = size.intValue
        }
    }

    /// **The previous walk's parsed documents, indexed by root-relative path** — the memo
    /// (02-architecture.md § Live-reload resilience, blessed 2026-07-31: "the loader may reuse the
    /// previous snapshot's parsed item for any `index.md` whose path, mtime, and size are unchanged
    /// — the previous snapshot *is* the memo").
    ///
    /// **An input to `load`, never hidden state.** The loader is stateless statics and stays that
    /// way: a caller that holds no memo gets a cold walk, and one that holds the last walk's memo
    /// gets the same answer faster. That is the contract stated exactly — "result-purity with cost
    /// unspecified: same tree in, same snapshot out, and the memo can only change how fast".
    ///
    /// ### Scope: the parse, and nothing else
    ///
    /// A hit skips one thing — opening and parsing one file. Everything *derived* from the document
    /// (`schema`, `order`, the coercion trace, the identity dedupe, the trash's `kind`) is recomputed
    /// from it on every walk, unchanged, which is what makes memoized and cold walks indistinguishable
    /// in output rather than merely intended to be.
    ///
    /// **Directory enumeration is never memoized**: folder discovery, attachment listings, loose-file
    /// detection, the trash's entries and the noise gate are read fresh every walk, "because
    /// attachment changes never touch `index.md`" — a memo that covered them would go blind to
    /// precisely the changes the snapshot is supposed to show.
    ///
    /// ### A defect can never be answered from it
    ///
    /// An entry is recorded only where the file parsed **and** its `schema` reading succeeded — the
    /// two steps that can produce a `BoardLoadError` at all. So a defective `index.md` is never in the
    /// memo, which settles both halves of the collect-all walk's question: a file that is broken and
    /// stays broken has nothing to hit and is re-read and re-collected every walk, and a file whose
    /// defect was repaired moved its mtime and size and would miss anyway. The skip channel inherits
    /// this by construction — a skipped path *is* a defect path — so a skip is recomputed from a fresh
    /// parse every walk and can never be decided from a memo.
    ///
    /// Withheld duplicate occurrences *are* recorded, and correctly so: their files parsed cleanly and
    /// only the board-wide dedupe kept them out of the model, and that dedupe runs over the fresh walk
    /// either way.
    public struct ParseMemo: Sendable {
        fileprivate struct Entry: Sendable {
            let stamp: FileStamp
            let document: FrontmatterDocument
        }

        fileprivate var entries: [String: Entry] = [:]

        /// The empty memo — a cold walk. The only one a caller ever constructs; every other comes
        /// out of a `LoadResult`.
        public init() {}

        /// How many documents this memo can answer for. The walk never asks; the suites do.
        public var count: Int { entries.count }

        fileprivate func document(at path: String, stamp: FileStamp) -> FrontmatterDocument? {
            guard let entry = entries[path], entry.stamp == stamp else { return nil }
            return entry.document
        }

        fileprivate mutating func record(_ document: FrontmatterDocument, at path: String, stamp: FileStamp?) {
            guard let stamp else { return }
            entries[path] = Entry(stamp: stamp, document: document)
        }
    }

    /// **What one walk actually read** — the memo's whole claim, made assertable.
    ///
    /// The loader's contract is result-purity with *cost unspecified*, and a cost nothing can observe
    /// is a cost nothing can regress: this is the observation handle, so "a single-file echo re-parses
    /// one file, not the tree" is a test rather than a hope.
    ///
    /// Injected rather than a static tally, for `IdentityHistoryRanker`'s reason: `load` is stateless
    /// statics called from several tasks at once, and a shared counter would be one mutable answer to
    /// a per-walk question. `nil` — every production call — costs nothing at all.
    public final class ParseCounter: Sendable {

        /// One walk's tally: files opened and parsed, and documents answered from the memo.
        public struct Counts: Sendable, Equatable {
            public var parsed = 0
            public var reused = 0
        }

        private let state = Mutex(Counts())

        public init() {}

        public var counts: Counts { state.withLock { $0 } }

        fileprivate func noteParse() { state.withLock { $0.parsed += 1 } }
        fileprivate func noteReuse() { state.withLock { $0.reused += 1 } }
    }

    /// One `index.md`, read through the memo — the memo's only point of contact with the walk.
    ///
    /// A hit is a document the previous walk parsed out of a file whose path, mtime and size have not
    /// moved since; a miss is the ordinary `readDocument(at:path:)`, byte for byte the same call the
    /// loader has always made. The stamp travels back out so the caller can record the document into
    /// *this* walk's memo once its `schema` reading has succeeded — `ParseMemo` states why that, and
    /// not the read, is the recording point.
    ///
    /// A file whose stamp cannot be read (`nil`) is always parsed and never recorded: "cannot tell"
    /// reads as "not memoizable", which is the direction that costs a parse rather than correctness.
    private static func memoizedDocument(
        at url: URL,
        path: String,
        memo: ParseMemo?,
        counter: ParseCounter?
    ) throws(BoardLoadError) -> (document: FrontmatterDocument, stamp: FileStamp?) {
        let stamp = FileStamp(of: url)
        if let stamp, let hit = memo?.document(at: path, stamp: stamp) {
            counter?.noteReuse()
            return (hit, stamp)
        }
        counter?.noteParse()
        return (try readDocument(at: url, path: path), stamp)
    }

    // MARK: - Entry point

    /// Walks the board and answers a snapshot — or **every fail-fast defect the walk found**, as one
    /// aggregate (01-storage-format.md § Malformed input, settled 2026-07-31: "The loader collects
    /// every fail-fast defect in the walk rather than stopping at the first"). One walk, one
    /// `BoardLoadFailure`, and never a chain of modals over the same board.
    ///
    /// ### What collecting means at each level
    ///
    /// - **Environmental failures stay immediate.** An unreadable root, and a root that is a file
    ///   rather than a folder, throw a single-defect aggregate on the spot: there is nothing to walk
    ///   and so nothing to aggregate *with*. The surface would show one row either way.
    /// - **The root's own `index.md` defects are collected and the walk continues** — a missing
    ///   index, unparseable YAML, a missing or malformed `schema`, a `schema` newer than this app.
    ///   Nothing below the root reads the root's document: lanes are enumerated by folder shape,
    ///   `.trash/` is reached by name, and the noise gate is its own file. So a board whose root is
    ///   broken *and* whose lanes are broken reports both in one pass, which is what lets the
    ///   surface state every class at once instead of revealing the next one per repair.
    /// - **A below-root defect skips that item's subtree.** A lane whose `index.md` will not parse
    ///   is recorded and its cards are never enumerated; a card's defect takes that card out. This is
    ///   the designed loop rather than a gap: Repair and Open and Re-check both "re-run the whole
    ///   walk", so a repaired lane re-aggregates with whatever it was hiding, in the *same* surface.
    ///
    /// Defect order is walk order — the root first, then lanes in folder-name order with each lane's
    /// cards inside it, then `.trash/` — so `BoardLoadFailure.primary` is the first thing the walk
    /// met and a grouped surface reads top-down like the tree does.
    ///
    /// - Parameter skipping: **the skip channel** (01-storage-format.md § Malformed input: "Skip is
    ///   user-consented tolerance, loudly marked … per-open decisions, never persisted"). Defect
    ///   *paths* — the same root-relative strings `BoardLoadError.path` carries, e.g.
    ///   `"<lane>/index.md"` — chosen on the decision surface. A skipped path's defect is not
    ///   collected and its item leaves the model with its whole subtree, exactly the shape the
    ///   tolerated missing-`index.md` skip already has; a `LoadWarning.userSkipped` is the loud mark
    ///   the opened board's notice is written from. Nothing persists: the set arrives from one open's
    ///   surface and dies with the call.
    ///
    ///   **Root paths are unskippable** (`unskippablePaths`) — an entry naming the root's own
    ///   `index.md` is ignored and the defect collected anyway.
    ///
    /// - Parameter memo: **the previous walk's parsed documents** (`ParseMemo`, blessed 2026-07-31).
    ///   `nil` — a first load, a template read, a HEAD snapshot — is a cold walk. Passing the last
    ///   walk's memo cannot change a single thing about the result, only how many files this one
    ///   opens; see `ParseMemo` for the scope and for why a defect can never be answered from it.
    ///
    /// - Parameter counter: where this walk tallies what it read (`ParseCounter`). `nil` everywhere
    ///   but the suites.
    public static func load(
        boardRoot: URL,
        skipping: Set<String> = [],
        historyRanker: IdentityHistoryRanker? = nil,
        memo: ParseMemo? = nil,
        counter: ParseCounter? = nil
    ) throws(BoardLoadFailure) -> LoadResult {
        // Environmental, so immediate: a root that cannot be listed has no walk to collect from.
        do throws(BoardLoadError) {
            try checkIsReadableDirectory(boardRoot)
        } catch {
            throw BoardLoadFailure(error)
        }

        var warnings: [LoadWarning] = []
        func warn(_ warning: LoadWarning) {
            warnings.append(warning)
            logger.warning("\(warning.description, privacy: .public)")
        }

        // **The fail-fast aggregate, in walk order** — empty on a board that loads, and the whole of
        // what `BoardLoadFailure` carries when it does not.
        var failures: [BoardLoadError] = []

        // **This walk's own memo, for the next one** (`ParseMemo`). Built as the walk goes and
        // handed out on the `LoadResult`, so the loader keeps no state between calls: what the store
        // passes back in is what came out of the walk before it.
        var freshMemo = ParseMemo()

        /// Records one fail-fast defect — unless this open's user already consented to skipping that
        /// exact path.
        ///
        /// The item leaves the model either way; what the skip decides is whether the defect is
        /// *reported*. Every caller `continue`s past the item immediately after, which is what makes
        /// "skipped" and "broken" one shape in the walk and two only at the surface.
        func record(_ defect: BoardLoadError) {
            if skipping.contains(defect.path), !unskippablePaths.contains(defect.path) {
                warn(.userSkipped(path: defect.path))
                return
            }
            failures.append(defect)
            logger.error("\(defect.description, privacy: .public)")
        }

        // **The root index, collected rather than thrown** — and `nil` on either side of it means
        // exactly one thing: a defect was recorded for it above, so the walk below runs for the sake
        // of the *other* defects it can still find and the guard past the walk never lets a
        // rootless board reach `BoardModel`.
        var boardDocument: FrontmatterDocument?
        var boardSchema: Int?
        let boardIndexURL = boardRoot.appendingPathComponent(indexFileName)
        if FileManager.default.fileExists(atPath: boardIndexURL.path) {
            do throws(BoardLoadError) {
                let read = try memoizedDocument(
                    at: boardIndexURL, path: indexFileName, memo: memo, counter: counter)
                // **The root's own `schema` stays required** (01-storage-format.md § Malformed input,
                // re-ruled 2026-07-31): it is the this-really-is-a-board gate, and the one `schema` on
                // the board that does not read as 1 when absent.
                boardSchema = try validatedRootSchema(in: read.document, path: indexFileName)
                boardDocument = read.document
                // Recorded past the schema gate, never before it — `ParseMemo`'s "a defect can never
                // be answered from it".
                freshMemo.record(read.document, at: indexFileName, stamp: read.stamp)
            } catch {
                record(error)
            }
        } else {
            record(BoardLoadError(path: indexFileName, reason: .boardRootMissingIndex))
        }

        // **The one typed defect stream** (02-architecture.md ▸ Components ▸ IntegrityRules): what
        // this walk found that is pending *work*, as distinct from `warnings`, which is the
        // stray-*tolerance* vocabulary — information, not work. The two ad-hoc repair channels this
        // replaced (loose files, legacy tombstones) are still readable under their own names as
        // views over it (`LoadResult.looseCardFiles`, `.legacyTombstones`).
        var defects: [IntegrityRules.Defect] = []

        // **The coerce tier's trace** (01-storage-format.md § Frontmatter, ruled 2026-07-29: "A
        // no-sensible-reading fallback logs: field, path, and raw text, carried as coerce-tier entries
        // in the integrity service's Defect stream"). Called once per document this walk parses, at
        // every level, because the rule is about *fields* and every level has them — and called here
        // rather than inside `readDocument` for the reason the whole defect stream lives in `load`: the
        // reading functions are pure and total, and the walk is what owns what it found.
        //
        // `logger.info`, not `warning`: the value rendered as its default, nothing is degraded, and the
        // line exists to be findable later rather than to be noticed now ("no banner, no behavior
        // change").
        //
        // `plus` carries the records only the rulebook can make: `order` and `schema` below the root
        // (re-ruled 2026-07-31 — the optional-key readings). They cannot come from
        // `document.coercedFields`, which reads *present* fields that had no sensible reading and by
        // construction knows nothing about a key that is simply absent, nor about which level the
        // file sits at.
        func noteCoercions(
            in document: FrontmatterDocument,
            at path: String,
            plus extra: [CoercedField] = []
        ) {
            let fields = extra + document.coercedFields
            guard !fields.isEmpty else { return }
            defects.append(.coercedFrontmatter(CoercedFrontmatter(path: path, fields: fields)))
            for field in fields {
                logger.info(
                    "\(path, privacy: .public): '\(field.key, privacy: .public)' has no sensible reading — \(field.raw, privacy: .public) — rendering the field's default"
                )
            }
        }

        // Detected before the walk, so a board whose `.trash` is squatted reports it even though
        // the trash read below finds nothing to parse. Read-only here, like every other detection:
        // the displacement is the store's, through the Writer (the Repair precedent).
        if let squatter = IntegrityRules.squattedClaimedName(atBoardRoot: boardRoot) {
            defects.append(.claimedNameSquatted(squatter))
            logger.warning(
                "\(squatter.name, privacy: .public): claimed name held by \(squatter.found.description, privacy: .public) — to be displaced"
            )
        }

        // Both readings of the root's own document, and both skipped when there is no document to
        // read: a board whose root index is already a collected defect has nothing to say about its
        // `deleted` key or its coercions, and the load is going to throw regardless.
        if let boardDocument {
            // Legal per the frontmatter table, meaningless at board level — ignore and log, never
            // tombstone, and **never migrate**: "a `deleted:` key at board level remains meaningless
            // — ignored and logged, preserved verbatim" (01-storage-format.md § Deletion). It is
            // deliberately absent from `legacyTombstones`: there is no item to relocate and no key
            // the app has any business removing from a file it was told to leave alone.
            if !boardDocument.deleted.isMissing {
                warn(.boardLevelDeletedIgnored)
            }

            noteCoercions(in: boardDocument, at: indexFileName)
        }

        // **The noise gate, read once for the whole walk** (01-storage-format.md § Fractal layout ▸
        // Rules, ruled 2026-07-31): the board's `.gitignore` is what decides which loose files are
        // work and which are ordinary strays, and it is one file per board — not one per card.
        let ignoreRules = ignoreRules(atBoardRoot: boardRoot)

        // Every lane the walk read, in folder order — **not** `Lane` values yet. The board-wide
        // identity dedupe below decides which folders render at all, and a `Lane` is built only on
        // the far side of that decision, because a `Lane` carrying a withheld card would be exactly
        // the snapshot the invariant forbids.
        var walkedLanes: [WalkedLane] = []
        // `try?` because `directoryCandidates` never actually throws — an unlistable folder is "no
        // candidates" by its own rule — and because the two other containers in this file already
        // read it exactly this way (`trashCandidates`, `identityShapedChildren`). Nothing here
        // silences a fail-fast: the root's own listability was decided by `checkIsReadableDirectory`.
        for laneURL in (try? directoryCandidates(in: boardRoot)) ?? [] {
            let laneName = laneURL.lastPathComponent
            // The app-claimed board-root names are not strays and must not warn as such. Hidden
            // entries never reach here anyway (`.trash` included), so this is the rule stated
            // rather than the mechanism relied on.
            guard !reservedRootNames.contains(laneName.lowercased()) else { continue }
            guard isUUIDShaped(laneName) else {
                warn(.nonUUIDFolderIgnored(path: laneName))
                continue
            }
            guard hasIndex(laneURL) else {
                warn(.missingIndex(path: laneName))
                continue
            }

            let lanePath = laneName + "/" + indexFileName
            let laneDocument: FrontmatterDocument
            let laneSchema: (schema: Int, coerced: CoercedField?)
            let laneStamp: FileStamp?
            // **A broken lane takes its subtree with it** (the collect-and-skip rule above): the
            // defect is recorded, the lane's cards are not enumerated, and the repair's re-check is
            // what surfaces whatever they were hiding.
            do throws(BoardLoadError) {
                let read = try memoizedDocument(
                    at: laneURL.appendingPathComponent(indexFileName),
                    path: lanePath,
                    memo: memo,
                    counter: counter
                )
                laneDocument = read.document
                laneStamp = read.stamp
                // Below the root both keys are optional (re-ruled 2026-07-31): a missing `schema`
                // reads as 1, a missing or unusable `order` as append-at-end. Both readings are
                // coerce-tier — recorded here, logged, and acted on by nothing until the file's next
                // Writer touch.
                laneSchema = try resolvedSchema(in: laneDocument, path: lanePath)
            } catch {
                record(error)
                continue
            }
            freshMemo.record(laneDocument, at: lanePath, stamp: laneStamp)
            let laneOrder = IntegrityRules.resolvedOrder(in: laneDocument)
            noteCoercions(
                in: laneDocument,
                at: lanePath,
                plus: [laneSchema.coerced, laneOrder.coerced].compactMap { $0 }
            )

            var walkedCards: [WalkedCard] = []
            for cardURL in (try? directoryCandidates(in: laneURL)) ?? [] {
                let cardName = cardURL.lastPathComponent
                let cardRelPath = laneName + "/" + cardName
                guard isUUIDShaped(cardName) else {
                    warn(.nonUUIDFolderIgnored(path: cardRelPath))
                    continue
                }
                guard hasIndex(cardURL) else {
                    warn(.missingIndex(path: cardRelPath))
                    continue
                }

                let card: WalkedCard
                do throws(BoardLoadError) {
                    card = try parseCard(at: cardURL, path: cardRelPath, memo: memo, counter: counter)
                } catch {
                    record(error)
                    continue
                }
                // `parseCard` returning at all means the parse and the `schema` reading both
                // succeeded, which is the recording point one level up spells out longhand.
                freshMemo.record(card.document, at: cardRelPath + "/" + indexFileName, stamp: card.stamp)
                noteCoercions(in: card.document, at: cardRelPath + "/" + indexFileName, plus: card.coercions)

                // **The card-level claimed name** (01-storage-format.md § Fractal layout ▸ Rules,
                // extended 2026-07-29 — "the rule is level-uniform"): a file or symlink wearing
                // `attachments` breaks every import into this card, every Finder drop on it and the
                // window's listing for as long as it stands, so it is scheduled work exactly as a
                // squatted `.trash` is. Detection is read-only here, like every other defect; the
                // displacement is the store's, through the Writer.
                for squatter in IntegrityRules.squattedClaimedNames(inCardAt: cardURL, path: cardRelPath) {
                    defects.append(.claimedNameSquatted(squatter))
                    logger.warning(
                        "\(cardRelPath, privacy: .public)/\(squatter.name, privacy: .public): claimed name held by \(squatter.found.description, privacy: .public) — to be displaced"
                    )
                }

                // Noticed, never acted on: the relocation is the store's, through the Writer. What
                // the board's `.gitignore` excludes never becomes a defect at all — it keeps the
                // stray posture, so there is nothing here for the store to schedule and nothing for
                // a banner to announce.
                let loose = looseFileNames(in: cardURL, ignoring: ignoreRules)
                if !loose.isEmpty {
                    defects.append(.looseCardFiles(LooseCardFiles(
                        laneID: ItemID(rawValue: laneName),
                        cardID: ItemID(rawValue: cardName),
                        title: card.title.value,
                        fileNames: loose
                    )))
                    logger.info("\(cardRelPath, privacy: .public): \(loose.count, privacy: .public) loose file(s) beside index.md — to be relocated into attachments/")
                }

                // Detection only, the loose-file precedent exactly: the relocation into `.trash/`
                // and the key's removal are the store's, through the Writer.
                if card.isDeleted {
                    defects.append(.legacyTombstone(LegacyTombstone(
                        laneID: ItemID(rawValue: laneName),
                        cardID: ItemID(rawValue: cardName),
                        title: card.title.value
                    )))
                    logger.info("\(cardRelPath, privacy: .public): legacy 'deleted' key — card to be relocated into \(trashFolderName, privacy: .public)/")
                }

                walkedCards.append(card)
            }

            // **The append-at-end reading, settled per container** (01-storage-format.md § Ordering,
            // re-ruled 2026-07-31): every card's rank is the one it wrote down, or — where it wrote
            // none — a materialized rank past every ordered sibling, ordered among the other
            // order-less ones by folder name. Settled here, over the lane's whole card list, because
            // that is the smallest scope in which "past every ordered sibling" is a fact.
            let cardOrders = Ranks.resolvedOrders(
                of: walkedCards, stored: \.storedOrder, name: { $0.id.rawValue })
            let cards = zip(walkedCards, cardOrders).map { $0.rendered(order: $1) }

            // **A lane's legacy `deleted:` is tolerate-tier, not work** (01-storage-format.md
            // § Deletion, lane clause re-ruled 2026-07-29): "a lane carrying `deleted:` simply loads
            // live with the key ignored — no migration machinery, no key-strip write, no notice
            // (backward compatibility deliberately not funded …; the key is inert, preserved verbatim
            // like any unhandled key, logged)". A warning, exactly like the board-level key: this was
            // ignored, it is staying exactly where it is, there is nothing to do.
            if !laneDocument.deleted.isMissing {
                warn(.laneLevelDeletedIgnored(path: laneName))
            }

            walkedLanes.append(WalkedLane(
                name: laneName,
                schema: laneSchema.schema,
                storedOrder: laneOrder.order,
                document: laneDocument,
                cards: Ranks.sortedForDisplay(cards, order: \.order, name: { $0.id.rawValue })
            ))
        }

        // The strip's own append-at-end reading — the card rule one level up, and the reason the
        // lane loop above carried a *stored* order: a lane with no rank sorts past every ranked one,
        // which cannot be known until the last lane folder has been read.
        for (index, order) in Ranks.resolvedOrders(
            of: walkedLanes, stored: \.storedOrder, name: \.name
        ).enumerated() {
            walkedLanes[index].order = order
        }

        /// Every trash entry as the walk read it, kind decided but rank not yet — the `WalkedCard`
        /// intermediate's job in the flat container, in a tuple because the split into the snapshot's
        /// two arrays happens on the far side of the append-at-end reading.
        var walkedTrash: [(
            id: ItemID,
            kind: IntegrityRules.ObjectKind,
            schema: Int,
            storedOrder: Double?,
            heldCards: Int,
            attachments: [String],
            commentCount: Int,
            document: FrontmatterDocument
        )] = []
        var trashKinds: [ItemID: IntegrityRules.ObjectKind] = [:]
        for entryURL in trashCandidates(in: boardRoot) {
            let entryName = entryURL.lastPathComponent
            let entryRelPath = trashFolderName + "/" + entryName
            guard isUUIDShaped(entryName) else {
                warn(.nonUUIDFolderIgnored(path: entryRelPath))
                continue
            }
            guard hasIndex(entryURL) else {
                warn(.missingIndex(path: entryRelPath))
                continue
            }
            // The two kinds are read identically — the same optional-`schema`/optional-`order`
            // rulebook below the root — so the parse happens once, before the discriminator, and a
            // schema newer than this app fails fast whichever kind the entry turns out to be.
            let entryPath = entryRelPath + "/" + indexFileName
            let document: FrontmatterDocument
            let schema: (schema: Int, coerced: CoercedField?)
            let stamp: FileStamp?
            // Collected and skipped, the lane arm's rule one container over: a trash entry that will
            // not parse leaves the trash rather than refusing the board, and its own subtree was
            // never walked to begin with (the entry is opaque by design).
            do throws(BoardLoadError) {
                let read = try memoizedDocument(
                    at: entryURL.appendingPathComponent(indexFileName),
                    path: entryPath,
                    memo: memo,
                    counter: counter
                )
                document = read.document
                stamp = read.stamp
                schema = try resolvedSchema(in: document, path: entryPath)
            } catch {
                record(error)
                continue
            }
            freshMemo.record(document, at: entryPath, stamp: stamp)
            let order = IntegrityRules.resolvedOrder(in: document)
            noteCoercions(
                in: document,
                at: entryPath,
                plus: [schema.coerced, order.coerced].compactMap { $0 }
            )

            // **The trash's discriminator, applied where the flat container needs it**
            // (01-storage-format.md § Deletion, re-ruled 2026-07-29): the *value* is trusted
            // outright, and only an unrecognized value or no key at all falls through to shape.
            //
            // The children are listed at most once per entry and only where an answer needs them —
            // the shape fallback asks when `kind` did not, the lane arm asks for the count — so a
            // board of trashed cards written by this app pays for no directory reads at all.
            var listed: [URL]?
            func children() -> [URL] {
                if let listed { return listed }
                let found = identityShapedChildren(of: entryURL)
                listed = found
                return found
            }
            let kind = IntegrityRules.trashKind(
                kindValue: document.kind.value,
                hasIdentityShapedChildIndex: !children().isEmpty
            )
            let id = ItemID(rawValue: entryName)
            trashKinds[id] = kind

            // **The subtree is counted, never walked** (03-board-ui.md § Trash: an opaque unit
            // showing its title and held-card count). The count is the same listing the shape
            // fallback asks for, so a `kind: lane` entry pays for exactly one directory read and a
            // kindless one pays for none extra — and a card pays for its attachment listing and its
            // comment count only, which is why each side is read under its own arm rather than
            // unconditionally.
            //
            // Neither `kind: board` nor `kind: comment` reaches here as itself — `trashKind` treats
            // both as unrecognized and answers by shape — so the non-lane arm is the card answer and
            // nothing else.
            let isLane = kind == .lane
            walkedTrash.append((
                id: id,
                kind: kind,
                schema: schema.schema,
                storedOrder: order.order,
                heldCards: isLane ? children().count : 0,
                attachments: isLane ? [] : attachmentNames(in: entryURL),
                commentCount: isLane ? 0 : commentCount(in: entryURL),
                document: document
            ))
        }

        // **The walk is over, so the aggregate is complete.** Everything below this line assembles a
        // snapshot, and a board with a fail-fast defect in it has no snapshot to assemble — so the
        // throw sits exactly here: late enough that the surface gets every defect the tree holds,
        // early enough that a refused board pays for no dedupe, no sort and no `BoardModel`.
        guard failures.isEmpty else { throw BoardLoadFailure(failures) }

        // The trash's own append-at-end reading, over the container as one flat list. `order` decides
        // nothing about where a trash row *sits* — that is `modified`'s job since 2026-07-31 — but
        // every entry carries a rank for its eventual restore, and an entry that carries none reads
        // like every other order-less file rather than getting a container-specific rule of its own.
        var trash: [Card] = []
        var trashedLanes: [TrashedLane] = []
        /// Every trash entry as the dedupe needs it, kind-blind — the container is one flat list to
        /// the identity rule, whatever the snapshot splits it into. Carried with the two keys the
        /// container's order is stated in (`Ranks.isOrderedForTrash`), never `order`: the trash is
        /// sorted by `modified` descending since 2026-07-31.
        var trashEntries: [(id: ItemID, title: String?, modified: Date?)] = []
        let trashOrders = Ranks.resolvedOrders(
            of: walkedTrash, stored: { $0.storedOrder }, name: { $0.id.rawValue })
        for (entry, order) in zip(walkedTrash, trashOrders) {
            let document = entry.document
            trashEntries.append((
                id: entry.id, title: document.title.value, modified: document.modified.value))
            switch entry.kind {
            case .lane:
                trashedLanes.append(TrashedLane(
                    id: entry.id,
                    schema: entry.schema,
                    title: document.title,
                    modified: document.modified,
                    order: order,
                    heldCards: entry.heldCards,
                    document: document
                ))
            case .card, .board, .comment:
                trash.append(Card(
                    id: entry.id,
                    schema: entry.schema,
                    title: document.title,
                    created: document.created,
                    modified: document.modified,
                    modifiedBy: document.modifiedBy,
                    deleted: document.deleted,
                    background: document.background,
                    icon: document.icon,
                    iconColor: document.iconColor,
                    hero: document.hero,
                    labels: document.labels,
                    order: order,
                    attachments: entry.attachments,
                    commentCount: entry.commentCount,
                    document: document
                ))
            }
        }

        // **The board-wide identity dedupe** (01-storage-format.md § Fractal layout ▸ Rules:
        // "Duplicate ids within a board are never tolerated … the loader keeps exactly one occurrence
        // per id, board-wide"). Live lanes *and* `.trash/`, because board-wide means both containers —
        // and a snapshot carrying two items with equal ids is the one thing SwiftUI's `ForEach` does
        // not tolerate, which is why this is subtractive rather than advisory.
        //
        // Display order is settled here rather than in the `BoardModel` call below, because the
        // dedupe's last tie-break *is* traversal order and the rule needs the occurrences in it.
        let orderedLanes = Ranks.sortedForDisplay(walkedLanes, order: \.order, name: \.name)
        // **The trash's arrays are sorted by `modified` descending**, not by `order` (01-storage-format.md
        // § Deletion, re-ruled 2026-07-31): the trash move rewrites no rank, so the stamp *is* the
        // position. Each kind is sorted by the same comparator the merged `BoardModel.trashEntries`
        // applies, which is what makes a kind-narrowed slice of the column agree with the column.
        let orderedTrash = Ranks.sortedForTrash(
            trash, modified: { $0.modified.value }, title: { $0.title.value }, name: { $0.id.rawValue })
        let orderedTrashedLanes = Ranks.sortedForTrash(
            trashedLanes, modified: { $0.modified.value }, title: { $0.title.value }, name: { $0.id.rawValue })
        // The trash's own display order, **both kinds at once** — the column interleaves them
        // (03-board-ui.md § Trash), and the dedupe's last tie-break is stated in traversal order, so
        // the two kinds are merged before the rule sees them rather than after.
        let orderedTrashEntries = Ranks.sortedForTrash(
            trashEntries, modified: \.modified, title: \.title, name: { $0.id.rawValue })
        let verdict = dedupeIdentities(
            inBoardAt: boardRoot,
            lanes: orderedLanes,
            trash: orderedTrashEntries,
            historyRanker: historyRanker
        )

        // Case twins are the **tolerate** tier: a spelling artifact of the item that rendered, with
        // nothing to do about it (§ Fractal layout ▸ Rules). They warn, exactly like every other
        // skipped stray, and are deliberately absent from `defects`.
        for twin in verdict.caseTwins {
            warn(.caseTwinIgnored(path: twin.path, winner: twin.winner))
        }
        // Content duplicates are **work**: withheld here, reminted by the store's scheduled heal
        // (re-ruled 2026-07-29 — the loader itself still never writes).
        for duplicate in verdict.duplicates {
            defects.append(.duplicateIdentity(duplicate))
            logger.warning(
                "\(duplicate.path, privacy: .public): duplicate id, withheld in favour of \(duplicate.winner, privacy: .public) — to be reminted"
            )
        }

        let withheld = Set(verdict.duplicates.map(\.path) + verdict.caseTwins.map(\.path))

        // Unreachable, and spelled out rather than force-unwrapped: every path that leaves these
        // unset recorded a defect, and the guard above already threw on any defect at all. The
        // stated invariant is "no root document, no board" — a future edit that breaks it should
        // surface as the honest fail-fast rather than as a crash.
        guard let boardDocument, let boardSchema else {
            throw BoardLoadFailure(BoardLoadError(path: indexFileName, reason: .boardRootMissingIndex))
        }

        let model = BoardModel(
            rootURL: boardRoot,
            schema: boardSchema,
            title: boardDocument.title,
            created: boardDocument.created,
            modified: boardDocument.modified,
            modifiedBy: boardDocument.modifiedBy,
            deleted: boardDocument.deleted,
            background: boardDocument.background,
            backgroundImage: boardDocument.backgroundImage,
            icon: boardDocument.icon,
            iconColor: boardDocument.iconColor,
            template: boardDocument.value(for: templateKey),
            lanes: orderedLanes.compactMap { $0.rendered(withholding: withheld) },
            trash: orderedTrash.filter { !withheld.contains(trashFolderName + "/" + $0.id.rawValue) },
            trashedLanes: orderedTrashedLanes.filter { !withheld.contains(trashFolderName + "/" + $0.id.rawValue) },
            document: boardDocument
        )

        return LoadResult(
            model: model,
            warnings: warnings,
            memo: freshMemo,
            defects: defects,
            // Keyed by identity, so a withheld entry's reading has to go with it: two folders sharing
            // an id would otherwise leave a `kind` answer standing for the *other* one — the exact
            // ambiguity the dedupe exists to remove.
            trashKinds: trashKinds.filter { !withheld.contains(trashFolderName + "/" + $0.key.rawValue) }
        )
    }

    // MARK: - The board-wide identity dedupe

    /// Every identity-bearing folder in the board, deduped — the loader's half of the rule whose
    /// *deciding* is `IntegrityRules.dedupe(_:)`.
    ///
    /// **The gate comes first, and it is why this costs nothing on a healthy board**: a pass over the
    /// names alone answers "does any identity appear twice at all", and on the overwhelmingly common
    /// answer — no — nothing further happens: no `creationDateKey` reads, no history probes, no
    /// grouping. The disk reads below are paid for only by a board that actually has a collision.
    ///
    /// **Withheld subtrees are still walked** (settled — the import boundary's finest-grain rule read
    /// for the heal): a withheld *lane*'s cards are ordinary depth-2 occurrences and enter the
    /// inventory like any other, so a collision nested inside a losing lane is its own withheld
    /// occurrence with its own remint. Nothing deeper is an occurrence at all — "level is position",
    /// and a UUID-shaped folder under a card is content.
    ///
    /// **The container side is told to the rule, not derived from the path**: this walk knows which
    /// container it is in, and the container boundary is the rule's *first* tie-break (01-storage-format.md
    /// § Fractal layout ▸ Rules, stated 2026-07-29 — a live occurrence keeps the identity regardless of
    /// age), so re-deriving it from a `".trash/"` prefix downstream would be parsing back a fact that
    /// was in hand here.
    ///
    /// Occurrence order is the traversal order the rule's *last* tie-break is stated in: lanes in
    /// display order, each lane immediately followed by its cards in display order, then `.trash/`'s
    /// flat entries in display order. The trash sits last only because the walk meets it last —
    /// nothing rides on that any more, because the straddle case is decided by rung 0 long before
    /// traversal order is consulted.
    private static func dedupeIdentities(
        inBoardAt root: URL,
        lanes: [WalkedLane],
        trash: [(id: ItemID, title: String?, modified: Date?)],
        historyRanker: IdentityHistoryRanker?
    ) -> IntegrityRules.DedupeVerdict {
        typealias Container = IntegrityRules.IdentityOccurrence.Container
        // (path, name, container, title) in traversal order — the inventory, before anything is read
        // from disk.
        var inventory: [(path: String, name: String, container: Container, title: String?)] = []
        for lane in lanes {
            inventory.append((
                path: lane.name,
                name: lane.name,
                container: .live,
                title: lane.document.title.value
            ))
            for card in lane.cards {
                inventory.append((
                    path: lane.name + "/" + card.id.rawValue,
                    name: card.id.rawValue,
                    container: .live,
                    title: card.title.value
                ))
            }
        }
        for entry in trash {
            inventory.append((
                path: trashFolderName + "/" + entry.id.rawValue,
                name: entry.id.rawValue,
                container: .trashed,
                title: entry.title
            ))
        }

        // The gate.
        var seen: Set<String> = []
        var collides = false
        for entry in inventory where !seen.insert(IntegrityRules.canonicalIdentity(entry.name)).inserted {
            collides = true
            break
        }
        guard collides else { return IntegrityRules.DedupeVerdict(duplicates: [], caseTwins: []) }

        return IntegrityRules.dedupe(inventory.map { entry in
            IntegrityRules.IdentityOccurrence(
                path: entry.path,
                name: entry.name,
                container: entry.container,
                title: entry.title,
                birth: birthDate(of: root.appendingPathComponent(entry.path, isDirectory: true)),
                historyRank: historyRanker?.rank(entry.path)
            )
        })
    }

    /// A folder's filesystem birth date, `nil` when the volume does not keep one or the read fails —
    /// the second rung of the earlier-occurrence-wins ladder (§ Fractal layout ▸ Rules: "without
    /// history, the older folder (filesystem birth date) wins").
    ///
    /// `nil` is a first-class answer rather than a fallback date: an unreadable birth date must drop
    /// the comparison to traversal order, and substituting `.distantPast` here would silently make an
    /// unreadable folder *win* every comparison it entered.
    private static func birthDate(of folder: URL) -> Date? {
        (try? folder.resourceValues(forKeys: [.creationDateKey]))?.creationDate
    }

    /// One lane as the walk read it, before the dedupe decided what renders — a `Lane` minus the
    /// decision, which is the only reason it exists rather than the walk building `Lane` values
    /// directly.
    ///
    /// It carries the folder *name* rather than an `ItemID` because the dedupe's whole subject is
    /// spelling and identity being different questions, and the verbatim name is what answers both.
    private struct WalkedLane {
        let name: String
        let schema: Int
        /// The rank the file actually carries, `nil` where it carries none this app can use.
        let storedOrder: Double?
        /// The append-at-end reading, filled in once every sibling lane has been read
        /// (`Ranks.resolvedOrders(of:stored:name:)` — 01-storage-format.md § Ordering, re-ruled
        /// 2026-07-31). A `var` on a private walk value for the same reason the type exists at all:
        /// the container settles it, and the `Lane` is built on the far side of that.
        var order: Double = 0
        let document: FrontmatterDocument
        /// Already in display order — the traversal the dedupe's last tie-break is stated in.
        let cards: [Card]

        /// The `Lane` this becomes, or `nil` when the lane folder itself is withheld — a withheld
        /// lane takes its subtree out of the snapshot with it, and its cards' own collisions were
        /// already decided (they are occurrences in their own right).
        func rendered(withholding withheld: Set<String>) -> Lane? {
            guard !withheld.contains(name) else { return nil }
            return Lane(
                id: ItemID(rawValue: name),
                schema: schema,
                title: document.title,
                created: document.created,
                modified: document.modified,
                modifiedBy: document.modifiedBy,
                deleted: document.deleted,
                background: document.background,
                icon: document.icon,
                iconColor: document.iconColor,
                order: order,
                width: document.width,
                collapsed: document.collapsed,
                cards: cards.filter { !withheld.contains(name + "/" + $0.id.rawValue) },
                document: document
            )
        }
    }

    // MARK: - The earlier-occurrence-wins history seam

    /// **Where git path history once plugged into the duplicate-id winner rule** (01-storage-format.md
    /// § Fractal layout ▸ Rules: "on git boards, the path history already tracks outranks the
    /// newcomer (both tracked: the path that entered history first)") — kept as a seam, unfilled,
    /// since app-managed git was excised entirely (`strategy/01-git-excision.md`, 2026-08-08).
    ///
    /// A seam rather than an implementation because the first rung of that ladder was unreachable
    /// without git even before the excision: the free tier ran no git machinery at all
    /// (12-editions.md ▸ The inert posture), so the loader consults an injected ranker and falls
    /// through to birth date and traversal order when there is none — which is every board today, no
    /// exceptions.
    ///
    /// Deliberately one closure and no protocol: the loader asks one question — "how early did this
    /// path enter history" — and pro-m1's implementation once answered it from `git log
    /// --diff-filter=A --follow`-shaped plumbing behind the provider seam, before that plumbing left
    /// with the excision. `nil` means "untracked, or no history here", which the rule reads as
    /// *outranked by anything tracked*; nothing installs a ranker today, so every path reads `nil`.
    ///
    /// - Parameter rank: keyed by the occurrence's **board-root-relative path**, which is what a
    ///   repo's path history knows; lower is earlier.
    public struct IdentityHistoryRanker: Sendable {
        public let rank: @Sendable (String) -> Int?

        public init(rank: @escaping @Sendable (String) -> Int?) {
            self.rank = rank
        }
    }

    /// A folder's **identity-shaped children holding their own `index.md`** — the cards a trash
    /// entry would render as, which is two answers in one listing (01-storage-format.md § Deletion:
    /// "UUID-shaped children with their own `index.md` → lane … else card"; 03-board-ui.md § Trash:
    /// the row's held-card count).
    ///
    /// Deliberately not a parse: this asks what the folder *holds*, not whether anything inside it
    /// would load. A trashed lane's cards are never enumerated as levels — the walk stops at a trash
    /// entry exactly as it stops at a card under a lane — so the count is a fact about the freight
    /// and the entry stays opaque.
    private static func identityShapedChildren(of folder: URL) -> [URL] {
        let children = (try? directoryCandidates(in: folder)) ?? []
        return children.filter { isUUIDShaped($0.lastPathComponent) && hasIndex($0) }
    }

    /// One card as the walk read it, before its container's ranks resolved — a `Card` minus the
    /// append-at-end reading, which cannot be settled until every sibling's stored `order` is known
    /// (01-storage-format.md § Ordering, re-ruled 2026-07-31). `WalkedLane`'s shape, one level down
    /// and for the same kind of reason: a value the container decides is not a value the item can
    /// carry while it is still being read.
    private struct WalkedCard {
        let id: ItemID
        let schema: Int
        /// The rank the file actually carries, `nil` where it carries none this app can use — the
        /// input to `Ranks.resolvedOrders(of:stored:name:)`.
        let storedOrder: Double?
        let attachments: [String]
        let commentCount: Int
        let document: FrontmatterDocument
        /// This card's coerce-tier records for the strict fields, which only the rulebook can make
        /// (a missing key leaves no trace in `document.coercedFields`).
        let coercions: [CoercedField]
        /// What this card's `index.md` looked like to `stat(2)` as the walk read it — the key the
        /// next walk's memo hit is decided by, `nil` where the file could not be stat'd at all.
        let stamp: FileStamp?

        var title: FieldValue<String> { document.title }
        var isDeleted: Bool { !document.deleted.isMissing }

        func rendered(order: Double) -> Card {
            Card(
                id: id,
                schema: schema,
                title: document.title,
                created: document.created,
                modified: document.modified,
                modifiedBy: document.modifiedBy,
                deleted: document.deleted,
                background: document.background,
                icon: document.icon,
                iconColor: document.iconColor,
                hero: document.hero,
                labels: document.labels,
                order: order,
                attachments: attachments,
                commentCount: commentCount,
                document: document
            )
        }
    }

    /// One card folder read into a `WalkedCard` — the lane walk's card parse.
    ///
    /// The trash's own walk reads its entries inline instead, because the container is flat and its
    /// kind is `kind:`'s to answer before a `Card` can be built at all: the two share their
    /// `schema`/`order` rulebook (`IntegrityRules`, one rule) rather than sharing a function that
    /// has already decided what it is reading.
    ///
    /// `path` is root-relative and names the *folder*; the errors this throws name its `index.md`.
    /// Callers guard `isUUIDShaped` and `hasIndex` first, exactly as the lane walk always has.
    ///
    /// The **attachment listing and the comment count stay fresh** here, memo or no memo (`ParseMemo`
    /// ▸ Scope): a hit spares this card's `index.md` read and nothing else, because a file arriving
    /// in `attachments/` or a comment arriving in `comments/` never touches `index.md`, and a card
    /// whose paperclip or comment chip went stale would be the memo lying about the tree.
    private static func parseCard(
        at cardURL: URL,
        path: String,
        memo: ParseMemo?,
        counter: ParseCounter?
    ) throws(BoardLoadError) -> WalkedCard {
        let cardPath = path + "/" + indexFileName
        let read = try memoizedDocument(
            at: cardURL.appendingPathComponent(indexFileName),
            path: cardPath,
            memo: memo,
            counter: counter
        )
        let document = read.document
        let schema = try resolvedSchema(in: document, path: cardPath)
        let order = IntegrityRules.resolvedOrder(in: document)

        return WalkedCard(
            id: ItemID(rawValue: cardURL.lastPathComponent),
            schema: schema.schema,
            storedOrder: order.order,
            attachments: attachmentNames(in: cardURL),
            commentCount: commentCount(in: cardURL),
            document: document,
            coercions: [schema.coerced, order.coerced].compactMap { $0 },
            stamp: read.stamp
        )
    }

    /// The candidate entry folders inside `<root>/.trash/`, or `[]` when there is no trash.
    ///
    /// **Absent is empty, not an error** — the container is minted by the first delete, so most
    /// boards never have one, and a board without a trash is a board with an empty trash.
    ///
    /// **A `.trash` that is not a plain directory yields nothing**: a file by that name, or a
    /// *symlink* — "symlinks are never traversed" (01-storage-format.md § Fractal layout ▸ Rules),
    /// and a symlinked trash would render bytes living outside the board that FSEvents never
    /// reports. Not a `LoadWarning`: that is the stray vocabulary, and a claimed name is not a
    /// stray. Since 2026-07-29 it is not merely logged either — the walk reports it as a
    /// `Defect.claimedNameSquatted` (detected up in `load`, before the lanes) and a scheduled heal
    /// displaces it. Until that heal lands the loader keeps this empty-trash read, which is exactly
    /// the "window measured in one reload, not a standing state" the ruling accepts.
    ///
    /// Entries are `directoryCandidates` — hidden entries and symlinks already excluded, in
    /// folder-name order — so the trash gets the same stray tolerance every other container gets.
    /// **Nothing below an entry is ever enumerated as a level**: the walk stops at a trash entry
    /// exactly as it stops at a card under a lane, so a lane-shaped folder here reads as one opaque
    /// entry (a trashed lane, by `kind` or by shape) and its cards are counted rather than walked.
    private static func trashCandidates(in boardRoot: URL) -> [URL] {
        let trashURL = boardRoot.appendingPathComponent(trashFolderName, isDirectory: true)
        guard let values = try? trashURL.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey]) else {
            return []
        }
        guard values.isDirectory == true, values.isSymbolicLink != true else {
            logger.warning("\(trashFolderName, privacy: .public): not a plain directory, treated as an empty trash until the heal displaces it")
            return []
        }
        return (try? directoryCandidates(in: trashURL)) ?? []
    }

    // MARK: - Filesystem helpers

    private static func checkIsReadableDirectory(_ url: URL) throws(BoardLoadError) {
        var isDirectory: ObjCBool = false
        guard FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory) else {
            throw BoardLoadError(path: ".", reason: .unreadableRoot(message: "no such file or directory"))
        }
        guard isDirectory.boolValue else {
            throw BoardLoadError(path: ".", reason: .notADirectory)
        }
    }

    private static func hasIndex(_ folder: URL) -> Bool {
        FileManager.default.fileExists(atPath: folder.appendingPathComponent(indexFileName).path)
    }

    /// The names of `<card>/attachments/`'s **top-level regular files** — the flat view
    /// 01-storage-format.md § Attachments specifies ("top-level files only"; "subfolders are
    /// tolerated, preserved verbatim … and not surfaced"). `[]` when there is no `attachments/`.
    ///
    /// Three exclusions, the same three `directoryCandidates` makes and for the same reasons:
    /// hidden entries (`.DS_Store` and friends are not the user's attachments), directories (a
    /// subfolder stays reachable through Reveal in Finder and through body-relative paths, but
    /// never appears as an attachment), and symlinks (this loader resolves nothing — the same
    /// stance the level walk takes).
    ///
    /// **Finder order** (`localizedStandardCompare`), so `"shot 2.png"` sorts before
    /// `"shot 10.png"`: the order has to be stable across loads, and where it is already the
    /// sidebar's order it may as well be the same one.
    ///
    /// Failure is silent: an unlistable directory yields `[]`. Fail-fast is reserved for
    /// structure (01-storage-format.md § Malformed input), and this field decorates a card — a
    /// permissions race here must never be the reason a whole board refuses to open.
    ///
    /// Internal rather than `private`: `BoardWriter.listAttachments` — the card window sidebar's
    /// authoritative listing — answers through this same function behind its own card-folder
    /// guard, so the face and the sidebar can never disagree about what a card's attachments are.
    /// It is also why the folder name is read off `BoardWriter`, which owns it as the one folder
    /// the app ever creates under a card.
    static func attachmentNames(in cardFolder: URL) -> [String] {
        let folder = cardFolder.appendingPathComponent(
            BoardWriter.attachmentsFolderName, isDirectory: true
        )
        guard let entries = try? FileManager.default.contentsOfDirectory(
            at: folder,
            includingPropertiesForKeys: [.isRegularFileKey, .isSymbolicLinkKey],
            options: [.skipsHiddenFiles]
        ) else {
            return []
        }

        return entries
            .filter { url in
                guard let values = try? url.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey]) else {
                    return false
                }
                return values.isRegularFile == true && values.isSymbolicLink != true
            }
            .map(\.lastPathComponent)
            .sorted { $0.localizedStandardCompare($1) == .orderedAscending }
    }

    /// The number of comments `<card>/comments/` holds — **a readdir, not a parse** (design ruling
    /// 2026-08-09, card e729e30a; WISHLIST #9's own suggested shape). `0` when there is no
    /// `comments/` at all, which is the overwhelmingly common card.
    ///
    /// **The same predicate `identityShapedChildren(of:)` already uses for a trash entry's held-card
    /// count**: children of the folder that are both identity-shaped and carry their own `index.md`
    /// — no YAML opened, no frontmatter parsed. `.draft` and `.trash/` need no special-casing here
    /// either: both are dot-prefixed, and `directoryCandidates` (`identityShapedChildren`'s own
    /// source) skips hidden entries, exactly the exclusion `CommentThread.load` documents for the
    /// same two folders.
    ///
    /// **Diverges from `CommentThread.load`'s parsed `comments.count` in exactly one case**: a
    /// folder whose `index.md` exists but fails to parse (not UTF-8, unparseable YAML) is a `Stray`
    /// the thread read excludes by actually opening and rejecting it — a cost this count does not
    /// pay, because paying it for every card on every load is precisely the O(cards × parsed
    /// comments) walk 01-storage-format.md § Enhanced schema keeps out of the snapshot. The chip may
    /// then read one comment high until that one folder is fixed or removed; every well-formed
    /// comment, and every card with no malformed one, agrees with the pane exactly.
    ///
    /// Internal rather than `private`, `attachmentNames(in:)`'s own reason: nothing outside this file
    /// calls it today, but the count belongs beside the enumeration it is built from
    /// (`identityShapedChildren`), not duplicated at a second call site later.
    static func commentCount(in cardFolder: URL) -> Int {
        identityShapedChildren(of: CommentThread.folder(inCard: cardFolder)).count
    }

    /// A card folder's **loose top-level files** — the one carve-out to uniform stray tolerance
    /// (01-storage-format.md § Fractal layout ▸ Rules, settled 2026-07-28, "Lanework-owns-the-board"):
    /// "a regular file sitting beside a card's `index.md` (not `attachments/`, not a reserved name)
    /// belongs in `attachments/`, and the app moves it there".
    ///
    /// **This function only notices.** It opens nothing, moves nothing, and creates nothing; the
    /// relocation is `BoardWriter.relocateLooseFiles`, run through the store's write bracket. A load
    /// is a pure function of the tree and stays one.
    ///
    /// Five exclusions, three of them `attachmentNames(in:)`' own and for its reasons:
    ///
    /// - **Directories.** The carve-out is exactly *files*. A stray folder in a card — a nested
    ///   clone, a hand-made subfolder — keeps the verbatim posture, because "relocating a directory
    ///   into the flat attachment model would be wrong".
    /// - **Symlinks**, never touched and never traversed (§ Rules) — the same stance the level walk
    ///   takes. `isSymbolicLink` is checked *beside* `isRegularFile` rather than trusted to imply
    ///   it, exactly as `directoryCandidates` does, so a link pointing at a file is excluded on its
    ///   own account.
    /// - **Hidden entries.** `.DS_Store` and friends are not the user's files, and relocating one
    ///   would surface it in a card's attachment list — the loudest possible way to be wrong about
    ///   a file nobody wrote on purpose. It is also what keeps a crashed write's dot-prefixed
    ///   residue out of the relocation.
    ///
    ///   **This survived the noise-gate ruling** (2026-07-31), which retired "nothing hardcoded"
    ///   exclusions in favour of the seeded `.gitignore`, because it is not a noise heuristic and
    ///   never was a `.DS_Store` special case: `attachmentNames(in:)` skips hidden entries too, so a
    ///   relocated hidden file would land in a folder whose listing can never show it — the move
    ///   would take a file the user can see in Finder beside `index.md` and put it somewhere the app
    ///   is structurally unable to surface. The carve-out exists to put a card's files where the app
    ///   *shows* them; a hidden file has no such destination. What the ruling retires is the app
    ///   having a second opinion about *which* visible files are noise, and it never had one.
    /// - **The reserved card-level names** (`reservedCardChildNames`), case-insensitively.
    /// - **Whatever the board's `.gitignore` excludes** (§ Rules, ruled 2026-07-31 — the noise
    ///   gate): "a file matching the board-root `.gitignore` … keeps the ordinary stray posture:
    ///   skipped, preserved verbatim, logged, never relocated, never announced". Matched against the
    ///   file's **board-relative** path (`<lane>/<card>/<name>`), because that is the path git would
    ///   match and because an anchored pattern (`/notes.txt`) has to mean the board root rather than
    ///   every card in it. `rules` is `nil` on a board that carries no such file, which excludes
    ///   nothing — the pre-ruling behaviour, and the same answer an empty file gives.
    ///
    /// Finder order (`localizedStandardCompare`), like every other name listing here, so the notice
    /// the store posts names files the way the board would sort them.
    ///
    /// Failure is silent (`[]`): a permissions race here must never be the reason a board refuses
    /// to open, and "nothing to relocate" is the safe reading of "cannot tell".
    /// `rules` has no default on purpose: the gate is the ruling, and a caller that could forget to
    /// pass it would be a second, quieter definition of what counts as noise.
    static func looseFileNames(in cardFolder: URL, ignoring rules: GitignoreRules?) -> [String] {
        guard let entries = try? FileManager.default.contentsOfDirectory(
            at: cardFolder,
            includingPropertiesForKeys: [.isRegularFileKey, .isSymbolicLinkKey],
            options: [.skipsHiddenFiles]
        ) else {
            return []
        }

        let cardPath = boardRelativeCardPath(of: cardFolder)
        return entries
            .filter { url in
                guard !reservedCardChildNames.contains(url.lastPathComponent.lowercased()),
                      let values = try? url.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey]),
                      values.isRegularFile == true, values.isSymbolicLink != true
                else {
                    return false
                }
                // The noise gate. Logged rather than silent, because "skipped, preserved verbatim,
                // logged" is the stray posture this file is being *given*, and a file that stays put
                // for a reason nobody can see is the one the user files a bug about.
                let name = url.lastPathComponent
                guard rules?.isIgnored(relativePath: cardPath + "/" + name) == true else { return true }
                logger.info(
                    "\(cardPath, privacy: .public)/\(name, privacy: .public): matched by the board's \(gitignoreFileName, privacy: .public) — left where it is, not relocated"
                )
                return false
            }
            .map(\.lastPathComponent)
            .sorted { $0.localizedStandardCompare($1) == .orderedAscending }
    }

    /// A card folder's board-relative path — `<lane>/<card>` — read off the URL rather than passed
    /// in, so the one caller that has no walk behind it (the Writer's import-boundary normalization)
    /// gets the same answer the walk would have given.
    ///
    /// The depth is fixed by the schema: a card is `<root>/<lane>/<card>` and nothing else
    /// (`BoardWriter.checkIsCardFolder` enforces exactly this shape before any relocation), so two
    /// components are the whole of it.
    private static func boardRelativeCardPath(of cardFolder: URL) -> String {
        cardFolder.deletingLastPathComponent().lastPathComponent + "/" + cardFolder.lastPathComponent
    }

    /// Whether `name` has a UUID's shape — hex, `8-4-4-4-12`, **any case and any version** —
    /// gating lane/card level detection (01-storage-format.md § Fractal layout ▸ Rules, "Name
    /// shape gates level detection"). This is *the* identity predicate, and it is deliberately
    /// **shape-only**: lowercase v4 is the app's emission rule, not the gate.
    ///
    /// - **Any case.** `uuidgen(1)` and Swift's own `UUID().uuidString` both print *uppercase*,
    ///   so a strict lowercase gate would turn an agent's standard-tool card into a silently
    ///   skipped stray — the worst failure mode for a files-first app. Accept liberally, emit
    ///   conservatively: `BoardWriter` still writes only lowercase v4 and never renames an
    ///   existing folder to canonicalize it.
    /// - **Any version.** The version (13th hex digit) and variant (17th hex digit) nibbles are
    ///   **not** validated: they protect no invariant here — an agent's v7 is exactly as unique
    ///   as a v4 — and the loader's job is recognizing the folder-naming *convention*, not
    ///   re-deriving RFC 4122 conformance every load.
    ///
    /// Equivalent to "does `UUID(uuidString:)` parse it", which is how 01-storage-format.md
    /// states the rule; kept as a manual scan because that is the cheaper answer on the hot path
    /// (every folder of every load) and needs no bridging.
    ///
    /// Recognizing a name is not the same as *comparing* two of them: identity comparison is
    /// UUID-*value* equality, so two case-spellings of one UUID are one identity everywhere —
    /// see `ItemID` (`BoardModel.swift`), which stores the folder's exact spelling but compares
    /// canonically.
    ///
    /// Internal rather than `private`: `BoardWriter.renumberVisibleChildren` walks the same
    /// candidates the loader walked, and level detection has to be one rule, not two.
    ///
    /// **The rule itself is `IntegrityRules.isIdentityShaped`** (settled 2026-07-29 — the one
    /// vocabulary of object validity). This is the loader's spelling of it and nothing more: one
    /// predicate, one implementation, no parallel derivation.
    static func isUUIDShaped(_ name: String) -> Bool {
        IntegrityRules.isIdentityShaped(name)
    }

    /// Direct subdirectories of `folder`, in deterministic (folder-name) order, excluding
    /// hidden entries (`.DS_Store`, `.git`, …) and symlinks — the loader's uniform stray
    /// tolerance (01-storage-format.md § Fractal layout ▸ Rules). Stray *files* are excluded
    /// here too: only directories are level candidates at all, and the caller further narrows
    /// those to actual lane/card candidates by name shape (`isUUIDShaped`) before doing
    /// anything else with them.
    ///
    /// **`.trash/` is hidden, so it never appears among a board root's candidates** — which is
    /// exactly right: it is a container, not a level, and the walk reaches it by name instead
    /// (`trashCandidates(in:)`). This same call then enumerates *inside* it, so the trash's own
    /// children get identical stray tolerance.
    ///
    /// An unreadable non-root folder (permission changed mid-walk, races) degrades to "no
    /// candidates" rather than failing the whole load — fail-fast is reserved for the board
    /// root and for malformed `index.md` content, not transient directory-listing races below
    /// it.
    /// Internal rather than `private`: `BoardWriter.renumberVisibleChildren` enumerates
    /// siblings through this same door, so the writer's idea of "the children" can never drift
    /// from the loader's. It is also why `BoardWriter`'s temp files are dot-prefixed — the
    /// `.skipsHiddenFiles` here is what makes a crashed write's residue invisible to a load.
    static func directoryCandidates(in folder: URL) throws(BoardLoadError) -> [URL] {
        guard let entries = try? FileManager.default.contentsOfDirectory(
            at: folder,
            includingPropertiesForKeys: [.isDirectoryKey, .isSymbolicLinkKey],
            options: [.skipsHiddenFiles]
        ) else {
            return []
        }

        return entries
            .filter { url in
                guard let values = try? url.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey]) else {
                    return false
                }
                return values.isDirectory == true && values.isSymbolicLink != true
            }
            .sorted { $0.lastPathComponent < $1.lastPathComponent }
    }

    // MARK: - Document reading + field validation

    /// **Strict, byte-faithful UTF-8** — the same decode `BoardWriter` uses, and for the same
    /// reason: Foundation's NSString-backed `String(contentsOf:encoding:)` silently strips a
    /// leading BOM, which would let a BOM'd file *load* here and then refuse every write over
    /// in `BoardWriter` — a baffling split. 01-storage-format.md § Fractal layout ▸ Rules is
    /// explicit that a BOM'd file is rejected at load (it fails the frontmatter delimiter);
    /// decoding byte-faithfully is what makes that stated rejection actually happen.
    private static func readDocument(at url: URL, path: String) throws(BoardLoadError) -> FrontmatterDocument {
        let data: Data
        do {
            data = try Data(contentsOf: url)
        } catch {
            throw BoardLoadError(
                path: path,
                reason: .unparseableYAML(message: "could not read file: \(error.localizedDescription)", line: nil)
            )
        }
        return try parseDocument(data, path: path)
    }

    /// The decode-and-parse half of `readDocument(at:path:)`, over bytes rather than a URL.
    ///
    /// Split out for the raw-source outlet, which validates bytes that are **not on disk yet**
    /// (`validateCardIndex`) — and split rather than copied on purpose: "Apply validates through the
    /// same fail-fast parse the loader uses" (05-card-window.md ▸ Raw source outlet) is only true if
    /// it is literally the same function. The strict UTF-8 decode is half of what that buys — a BOM'd
    /// or non-UTF-8 proposal is rejected here by the same two lines that reject one on disk
    /// (01-storage-format.md § Fractal layout ▸ Rules).
    static func parseDocument(_ data: Data, path: String) throws(BoardLoadError) -> FrontmatterDocument {
        guard let text = String(validating: data, as: UTF8.self) else {
            throw BoardLoadError(path: path, reason: .unparseableYAML(message: "file is not UTF-8", line: nil))
        }

        do {
            return try FrontmatterDocument.parse(text)
        } catch {
            let line: Int? = if case let .unparseableYAML(_, line) = error { line } else { nil }
            throw BoardLoadError(path: path, reason: .unparseableYAML(message: error.description, line: line))
        }
    }

    /// Whether `data` would load as a **card's** `index.md` — the raw-source Apply's gate
    /// (05-card-window.md ▸ Raw source outlet: "Apply validates through the same fail-fast parse the
    /// loader uses (detailed alert on error, stays in source mode) before writing byte-for-byte").
    ///
    /// **Exactly the checks `load(boardRoot:)` runs on a card**, in its order and through its own
    /// functions: decode + parse (`parseDocument`), then `schema` at the below-the-root reading
    /// (missing reads as 1; malformed, or newer than this app, still refuses). Nothing card-shaped
    /// is checked beyond that, because nothing else *is*: `title` is optional, **`order` is optional
    /// too** since 2026-07-31 — a card applied without one lands at its lane's bottom and gains a
    /// rank on its next touch — unknown keys are the whole point of the outlet, and the body is free
    /// text.
    ///
    /// It deliberately does **not** check `uneditableShape`: that refusal exists for surgical
    /// span edits (`BoardWriter.updateIndex`), and raw source replaces the whole file — a flow-mapping
    /// frontmatter is precisely one of the things the escape hatch exists to let a user rewrite.
    ///
    /// The error is the loader's own, undiluted, so the alert can show the taxonomy's display text
    /// (line numbers included) rather than a re-worded copy.
    ///
    /// - Parameter path: what the error names — `indexFileName` from every call site today, which is
    ///   what the card window's alert is about.
    ///
    /// **The rule is `IntegrityRules.validateIndex(_:path:kind:supportedSchema:)`**, generalized per
    /// kind (02-architecture.md ▸ Components). This spelling stays because it is what the card
    /// window asks — "would this load as a card?" — and because pinning the kind at the call site is
    /// what keeps the outlet's gate from drifting when a second kind gains one.
    public static func validateCardIndex(_ data: Data, path: String) throws(BoardLoadError) -> FrontmatterDocument {
        try IntegrityRules.validateIndex(data, path: path, kind: .card, supportedSchema: supportedSchema)
    }

    /// The per-field validators are `IntegrityRules`' — the rulebook (02-architecture.md ▸
    /// Components). These forward so the walk above reads as it always did.
    private static func validatedRootSchema(
        in document: FrontmatterDocument, path: String
    ) throws(BoardLoadError) -> Int {
        try IntegrityRules.validatedRootSchema(in: document, path: path, supportedSchema: supportedSchema)
    }

    /// `schema` below the root — missing reads as 1, coerce-tier (re-ruled 2026-07-31).
    private static func resolvedSchema(
        in document: FrontmatterDocument, path: String
    ) throws(BoardLoadError) -> (schema: Int, coerced: CoercedField?) {
        try IntegrityRules.resolvedSchema(in: document, path: path, supportedSchema: supportedSchema)
    }
}

// MARK: - Result

/// A successful load: the snapshot, anything tolerated-but-notable encountered along the way, and
/// the pending work the walk found. `warnings` is also logged as it accumulates
/// (`os.Logger(subsystem: "dev.rzen.indie.Kanban", category: "loader")`) so it shows up in Console
/// even if a caller never inspects it.
public struct LoadResult: Sendable {
    public var model: BoardModel
    public var warnings: [LoadWarning]

    /// **What this walk parsed, ready to be the next walk's memo** (`BoardLoader.ParseMemo`, blessed
    /// 2026-07-31).
    ///
    /// It rides out here rather than being derived from `model` for two reasons. The stamps are not
    /// in the snapshot and never will be — mtime and size are facts about files, not about a board —
    /// and the documents that *are* in the snapshot would have to be re-indexed by path to be usable,
    /// which is the walk's own knowledge being thrown away and re-derived. Carrying both together
    /// keeps the loader a pure function whose caller holds the whole of what the next call may reuse.
    ///
    /// A caller that ignores it gets a cold walk every time, which is exactly what
    /// `TemplateEngine` and every first load do.
    public var memo: BoardLoader.ParseMemo = BoardLoader.ParseMemo()

    /// **The typed defect stream** — everything this walk found that is pending *work*
    /// (02-architecture.md ▸ Components ▸ IntegrityRules, settled 2026-07-29). One channel, not
    /// three: loose card files, legacy `deleted:` keys, and a claimed board-root name held by the
    /// wrong kind of node all classify as `IntegrityRules.Defect`, carry their own signature, and
    /// are healed by the one engine (`HealScheduler`).
    ///
    /// **Deliberately not `warnings`**, which stays the *tolerate*-tier vocabulary: "this was
    /// ignored, it is staying exactly where it is, there is nothing to do". A defect says the
    /// opposite — it is work, and the store acts on it. Folding the two would also throw away
    /// everything a heal needs (which lane, which card, which title, which names) and force it to be
    /// re-derived from a display string.
    ///
    /// Nothing renders this. Order is the walk's: the board root's claimed names, then, lane by
    /// lane, each lane's cards and then the lane itself.
    ///
    /// **Cards in `.trash/` are deliberately not walked for loose files in this version.** The
    /// loose-file defect is keyed by lane (`LooseCardFiles.laneID`, the store's path key) and a
    /// trashed card has no lane; widening the key is the store-side change that belongs with the
    /// store-side scheduling. Loose files beside a trashed card's `index.md` therefore keep the
    /// ordinary stray posture — tolerated, preserved verbatim — and are tidied the moment the card
    /// is restored into a lane, which is the only state in which they matter.
    ///
    /// Board-level `deleted:` never appears here — it is meaningless, ignored and logged
    /// (`LoadWarning.boardLevelDeletedIgnored`), and nothing about it is the app's to rewrite.
    public var defects: [IntegrityRules.Defect] = []

    /// What each `.trash/` entry **is**, by the trash's own discriminator
    /// (`IntegrityRules.trashKind`; 01-storage-format.md § Deletion, re-ruled 2026-07-29): the
    /// `kind` value trusted outright, falling through to shape only when it does not answer.
    ///
    /// **The reading the snapshot's own split is made of**: `BoardModel.trash` holds the entries
    /// this called a card and `BoardModel.trashedLanes` the ones it called a lane. It is carried out
    /// of the load as well because it is the *verdict* rather than its consequence — a suite pins
    /// the discriminator directly, and a consumer asking "what did this entry read as" gets the
    /// answer without inferring it from which array the entry landed in. Keyed by identity, so it
    /// survives the display sort.
    public var trashKinds: [ItemID: IntegrityRules.ObjectKind] = [:]

    /// The cards this walk found holding loose files — a **view over `defects`**, under the name it
    /// has always had (01-storage-format.md § Fractal layout ▸ Rules, settled 2026-07-28).
    ///
    /// In the order the walk met them, which is the order the relocation writes them in.
    public var looseCardFiles: [LooseCardFiles] {
        defects.compactMap { if case let .looseCardFiles(work) = $0 { work } else { nil } }
    }

    /// The legacy `deleted:` keys this walk found on **cards** — the retired tombstone model's
    /// surviving migration input, as a **view over `defects`** (01-storage-format.md § Deletion).
    ///
    /// A lane's key is not here and never will be: it is inert, tolerated, and reported as a
    /// `LoadWarning.laneLevelDeletedIgnored` (lane clause re-ruled 2026-07-29). Order is the walk's,
    /// lane by lane.
    public var legacyTombstones: [LegacyTombstone] {
        defects.compactMap { if case let .legacyTombstone(work) = $0 { work } else { nil } }
    }

    /// The claimed board-root name found held by the wrong kind of node, if any — a **view over
    /// `defects`** (01-storage-format.md § Fractal layout ▸ Rules, ruled 2026-07-29).
    public var claimedNameSquatters: [ClaimedNameSquatter] {
        defects.compactMap { if case let .claimedNameSquatted(work) = $0 { work } else { nil } }
    }

    /// The later occurrences this walk withheld from `model` — a **view over `defects`**
    /// (01-storage-format.md § Fractal layout ▸ Rules; re-ruled 2026-07-29 — the silent remint).
    ///
    /// **The one defect the snapshot is already missing.** Every other defect names something that is
    /// both on disk and in the model; a withheld duplicate is on disk and deliberately *not* in the
    /// model, which is what makes the one-item-per-id invariant hold by construction rather than by
    /// hope. In traversal order — which is the order the remint writes them and the notice names them.
    public var duplicateIdentities: [DuplicateIdentity] {
        defects.compactMap { if case let .duplicateIdentity(work) = $0 { work } else { nil } }
    }

    /// Every file whose lenient fields fell back to their defaults — a **view over `defects`**
    /// (01-storage-format.md § Frontmatter, ruled 2026-07-29).
    ///
    /// **Nothing consumes it, and that is the point**: the coerce tier changes no behavior, so this is
    /// the observability handle — a suite pins it, a developer reads the log lines it produced, and the
    /// day a shape shows up often enough to deserve a heuristic heal, this is where the evidence
    /// already is. In walk order: the board, then each lane and its cards, then the trash.
    public var coercedFrontmatter: [CoercedFrontmatter] {
        defects.compactMap { if case let .coercedFrontmatter(work) = $0 { work } else { nil } }
    }
}

/// A tolerated anomaly the loader kept going past. Never blocks a load — see `BoardLoadError`
/// for what does.
public enum LoadWarning: Sendable, Equatable, CustomStringConvertible {
    /// A **UUID-shaped** folder below the board root has no `index.md` — skipped, not
    /// fail-fast (an interrupted two-step create must not brick the board). `path` is relative
    /// to the board root. Only reachable for a folder that passed `isUUIDShaped`; a
    /// non-UUID-shaped folder missing `index.md` gets `.nonUUIDFolderIgnored` instead, never
    /// this case.
    case missingIndex(path: String)

    /// A lane/card-depth folder whose name doesn't have a UUID's shape (`isUUIDShaped` — hex,
    /// `8-4-4-4-12`, any case, any version) —
    /// skipped, not fail-fast, regardless of whether it holds a valid `index.md`, a broken one,
    /// or none at all (01-storage-format.md § Fractal layout ▸ Rules, "Name shape gates level
    /// detection"). Preserved verbatim on disk, never descended into. `path` is relative to the
    /// board root.
    case nonUUIDFolderIgnored(path: String)

    /// A board-level `deleted:` key is legal per the frontmatter table but meaningless
    /// (01-storage-format.md § Deletion) — ignored, never tombstones the board.
    case boardLevelDeletedIgnored

    /// A **lane** carrying a legacy `deleted:` key (01-storage-format.md § Deletion, lane clause
    /// re-ruled 2026-07-29): the lane loads live and the key is ignored — "no migration machinery,
    /// no key-strip write, no notice", preserved verbatim like any unhandled key.
    ///
    /// Its home is here rather than in the defect stream because that is exactly the tolerate tier's
    /// verdict on it: this was ignored, it is staying exactly where it is, there is nothing to do.
    /// A *card*'s key is still work and still a `LegacyTombstone` defect — the two halves of the old
    /// migration parted company with the ruling. `path` is relative to the board root.
    case laneLevelDeletedIgnored(path: String)

    /// A folder whose name is a **case-spelled twin** of another occurrence of the same identity —
    /// one item typed two ways (01-storage-format.md § Fractal layout ▸ Rules): a spelling some *live*
    /// occurrence carries wins over one only trash ghosts carry (the container preference, stated
    /// 2026-07-29), and among the candidates the canonical all-lowercase spelling wins where present,
    /// else the lexicographically first. This is the loser. Skipped, preserved verbatim, never
    /// rendered — **and never reminted**: it is a spelling artifact of the item that rendered, not a
    /// copy, so there is nothing to heal.
    ///
    /// Its home is here rather than in the defect stream precisely because of that: a warning says
    /// "this was ignored, it is staying exactly where it is, there is nothing to do", which is the
    /// whole of the tolerate tier's verdict on it. Both paths are root-relative.
    case caseTwinIgnored(path: String, winner: String)

    /// A fail-fast defect the **user chose to skip** on the decision surface (01-storage-format.md
    /// § Malformed input, ruled 2026-07-31: "Skip is user-consented tolerance, loudly marked").
    ///
    /// The item loads out of the board — subtree and all, the tolerated missing-`index.md` skip's
    /// exact shape — and the file stays on disk untouched, tolerated-invisible like a stray. This is
    /// the loud mark: "the opened board carries a warning-tone notice naming the skipped items", and
    /// this warning is what that notice is written from.
    ///
    /// It is a warning rather than a defect for the tolerate tier's own reason — nothing is pending,
    /// the app has no business rewriting a file the user told it to leave alone — with one honest
    /// difference from its neighbours here: the tolerance was *consented to* this open rather than
    /// decided by a rule. Which is also why nothing about it persists: the skip set arrived with one
    /// `load` call, "the next open of a still-broken board presents the surface again".
    ///
    /// `path` is the **defect's** path — the offending `index.md`, root-relative — because that is
    /// what the surface's row named and what its Reveal in Finder resolved against.
    case userSkipped(path: String)

    public var description: String {
        switch self {
        case let .missingIndex(path):
            "\(path): folder has no index.md, skipped"
        case let .nonUUIDFolderIgnored(path):
            "\(path): folder name is not UUID-shaped, ignored as a stray"
        case .boardLevelDeletedIgnored:
            "index.md: board-level 'deleted' key is meaningless, ignored"
        case let .laneLevelDeletedIgnored(path):
            "\(path): lane-level 'deleted' key is inert, ignored — the lane loads live"
        case let .caseTwinIgnored(path, winner):
            "\(path): case-spelled twin of \(winner), ignored as a spelling artifact"
        case let .userSkipped(path):
            "\(path): skipped at the user's request — the board loaded without it"
        }
    }
}

// MARK: - Error

/// **Everything one walk refused**, as one value (01-storage-format.md § Malformed input, settled
/// 2026-07-31: "The loader collects every fail-fast defect in the walk rather than stopping at the
/// first … one aggregated surface presents them all").
///
/// The aggregate exists so no surface ever has to run a walk per defect: the decision surface groups
/// `defects` by class, the reload banner reads `primary` and counts the rest, and a re-check simply
/// produces a new one. `BoardLoadError` stays the per-defect record — the vocabulary every row,
/// banner and announcement is written against — and this type adds nothing to it but plurality.
///
/// **Never empty.** A failure with no defect is not a failure; `load` returns its `LoadResult` in
/// that case, which is what makes "throwing this means the walk produced no snapshot at all" still
/// true, defect by defect.
///
/// Ordered by the walk: the root first, then lanes in folder-name order with their cards inside
/// them, then `.trash/`.
public struct BoardLoadFailure: Error, Sendable, Equatable, CustomStringConvertible {

    /// Every fail-fast defect the walk collected, in walk order. Non-empty by construction.
    public let defects: [BoardLoadError]

    public init(_ defects: [BoardLoadError]) {
        precondition(!defects.isEmpty, "a BoardLoadFailure with no defect is not a failure")
        self.defects = defects
    }

    /// The single-defect aggregate — the environmental failures, and every place that has exactly
    /// one thing to say.
    public init(_ defect: BoardLoadError) {
        self.defects = [defect]
    }

    /// **The defect a one-line surface shows**: the first in walk order. The banner strip, the
    /// welcome window's failure row and the template chooser's unloadable row each have room for one
    /// sentence, and the first thing the walk met is the one that names the outermost problem —
    /// a broken root before the lanes under it.
    public var primary: BoardLoadError { defects[0] }

    /// The primary defect's own sentence, with the rest counted rather than listed — a log line and
    /// a diagnostic string, not a headline (`BannerCenter` owns the phrasing users read).
    ///
    /// A single-defect failure reads *exactly* as its `BoardLoadError` always did, which is what
    /// keeps every existing one-defect surface saying what it said before this type existed.
    public var description: String {
        defects.count == 1
            ? primary.description
            : "\(primary.description) (and \(defects.count - 1) more)"
    }
}

/// A fail-fast structural failure loading a board — loud and specific: `path` (relative to
/// the board root where one exists) plus `reason` says exactly what's wrong.
///
/// **One defect, not the whole refusal.** A walk collects every one of these it meets and hands them
/// over together as a `BoardLoadFailure` (01-storage-format.md § Malformed input, settled
/// 2026-07-31); this stays the record a single decision-surface row, banner or announcement is
/// written against, and the unit the skip channel names by `path`.
public struct BoardLoadError: Error, Sendable, Equatable, CustomStringConvertible {
    public let path: String
    public let reason: Reason

    public var description: String { "\(path): \(reason.description)" }

    public enum Reason: Sendable, Equatable, CustomStringConvertible {
        /// The board root itself has no `index.md` — unlike every level below it, this is not
        /// skip-and-warn: there is no board without one.
        case boardRootMissingIndex
        /// Wraps any `FrontmatterError` from parsing — bad delimiters, bad YAML, a
        /// frontmatter block that isn't a mapping. `line` is 1-based within the file when the
        /// underlying error carries one.
        case unparseableYAML(message: String, line: Int?)
        /// **The board root's own missing `schema`** — the this-really-is-a-board gate (re-ruled
        /// 2026-07-31). Below the root a missing `schema` reads as 1 instead, coerce-tier.
        case missingSchema
        case malformedSchema(raw: String)
        /// `schema` is present, valid, and greater than this app's `supportedSchema`.
        case schemaNewerThanApp(found: Int)

        /// **Retired, and nothing throws these any more** (01-storage-format.md § Ordering and
        /// § Malformed input, re-ruled 2026-07-31): below the board root a missing, null, non-numeric
        /// or non-finite `order` reads as append-at-end — coerce-tier, logged, bytes preserved
        /// (`IntegrityRules.resolvedOrder`) — and the board root never carried a rank to begin with.
        ///
        /// They stay in the vocabulary rather than being deleted because this enum *is* the
        /// load-failure surface every banner, announcement and decision-surface row is written
        /// against, and a reason that can no longer occur costs those surfaces nothing while removing
        /// one would rewrite them for a rule that changed underneath, not for a shape they render
        /// differently.
        case missingOrder
        case malformedOrder(raw: String)
        /// The board root exists but is a file, not a directory.
        case notADirectory
        /// The board root doesn't exist, or its contents couldn't be listed.
        case unreadableRoot(message: String)

        public var description: String {
            switch self {
            case .boardRootMissingIndex:
                "board root is missing index.md"
            case let .unparseableYAML(message, line):
                if let line {
                    "unparseable YAML at line \(line): \(message)"
                } else {
                    "unparseable YAML: \(message)"
                }
            case .missingSchema:
                "missing required 'schema' field"
            case let .malformedSchema(raw):
                "malformed 'schema' field: \(raw)"
            case let .schemaNewerThanApp(found):
                "schema \(found) is newer than this app supports (schema \(BoardLoader.supportedSchema))"
            case .missingOrder:
                "missing required 'order' field"
            case let .malformedOrder(raw):
                "malformed 'order' field: \(raw)"
            case .notADirectory:
                "board root is not a directory"
            case let .unreadableRoot(message):
                "board root is unreadable: \(message)"
            }
        }
    }
}
