import Foundation

/// **The one pure vocabulary of object validity** — 01-storage-format.md § Validation and healing
/// (settled 2026-07-29), 02-architecture.md ▸ Components ▸ IntegrityRules.
///
/// Every rule in the storage format that refuses, tolerates, recovers or repairs is an instance of
/// one five-verdict taxonomy (`Verdict`), and this type is where the taxonomy's *rules* live: the
/// identity predicate and its canonical form, the reserved-name tables, per-kind index validation,
/// the trash's `kind` discriminator, the on-touch heals, and the typed `Defect` vocabulary the
/// loader reports.
///
/// ### It consolidates rules, it does not relocate enforcement
///
/// **Loader and Writer remain the enforcement points and call in.** The loader still walks and
/// still throws; the Writer still refuses and still writes. What moved here is the *deciding* — so
/// no mechanism re-derives a rule the next one also needs, and adding an object kind (the enhanced
/// schema's `comment`) adds its field table and shape rules in one place rather than a parallel
/// mechanism. A service smeared across the read/write/orchestration boundaries would be worse than
/// the discipline it replaced, which is why nothing here touches a `BoardStore` or schedules
/// anything: the scheduled-heal engine is `HealScheduler`, on the other side of the layering.
///
/// ### Pure
///
/// Every function here is a function of its arguments. Two of them are *about* the filesystem —
/// `placement(ofFolderNamed:inParentNamed:)` and `trashKind(kindValue:hasIdentityShapedChildIndex:)`
/// — and take the facts they need as parameters rather than reading disk themselves, so the rules
/// are pinned by the suite without a filesystem in the way. The one call that does read (the
/// squatter probe, `node(at:)`) is a plain `lstat` classification with no policy in it at all.
public enum IntegrityRules: Sendable {

    // MARK: - The five verdicts

    /// The taxonomy every detectable defect classifies into — exactly one verdict each, and the
    /// verdict fixes everything downstream (surface, write behavior, race posture), so no mechanism
    /// ever re-reasons its posture individually (01-storage-format.md § Validation and healing).
    ///
    /// Nothing switches over this today, deliberately: it is the vocabulary the rules below are
    /// *written in*, and each rule already names its own verdict at its own site. It exists as a
    /// type so that a new rule has to answer "which verdict is this?" before it has anywhere to go.
    public enum Verdict: Sendable, Equatable, CaseIterable {
        /// Fail-fast — the defect defeats rendering or ordering (`BoardLoadError`).
        case refuse
        /// Readable-but-uneditable shapes: the file renders fine and every app write to it fails
        /// loudly, per file (`FrontmatterDocument.UneditableShape`).
        case refuseWrites
        /// Outside the schema's claim — strays, symlinks, case-twins, lane- and board-level
        /// `deleted:`. Preserved verbatim, logged, never rendered (`LoadWarning`).
        case tolerate
        /// A sensible reading exists (the coercion rulebook, last-wins, null-as-missing, the rescue
        /// family): silent, read-side only, bytes preserved (`FieldValue`).
        case coerce
        /// An app-owned invariant is violated *and* a lossless canonical repair exists — **the only
        /// verdict that writes** (`Defect`, healed inline, on touch, or on schedule).
        case heal
    }

    // MARK: - The identity predicate and its canonical form

    /// The hex characters `isIdentityShaped` accepts in each `-`-delimited group — **both cases**,
    /// per the shape-only predicate below.
    private static let identityGroupCharacters = Set("0123456789abcdefABCDEF")

    /// **The identity predicate** — whether `name` has a UUID's shape: hex, `8-4-4-4-12`, **any
    /// case and any version** (01-storage-format.md § Fractal layout ▸ Rules, "Name shape gates
    /// level detection"). Deliberately shape-only: lowercase v4 is the app's *emission* rule, not
    /// the gate.
    ///
    /// - **Any case.** `uuidgen(1)` and `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.
    /// - **Any version.** The version and variant nibbles protect no invariant here — an agent's v7
    ///   is exactly as unique as a v4 — and recognizing the folder-naming *convention* is the job,
    ///   not re-deriving RFC 4122 conformance on every folder of every load.
    ///
    /// Equivalent to "does `UUID(uuidString:)` parse it", kept as a manual scan because that is the
    /// cheaper answer on the hot path and needs no bridging.
    ///
    /// **One rule, one implementation**: `BoardLoader.isUUIDShaped` is this function under the
    /// loader's own spelling, and `BoardWriter` reaches it through that. Recognizing a name is not
    /// the same as *comparing* two of them — see `canonicalIdentity`.
    public static func isIdentityShaped(_ name: String) -> Bool {
        let groups = name.split(separator: "-", omittingEmptySubsequences: false)
        guard groups.map(\.count) == [8, 4, 4, 4, 12] else { return false }
        return groups.allSatisfy { $0.allSatisfy(identityGroupCharacters.contains) }
    }

    /// **The canonical form of an identity** — a folder name reduced to its UUID *value*.
    ///
    /// Identity comparison is UUID-value equality, never string equality (01-storage-format.md
    /// § Fractal layout ▸ Rules, settled): an arriving `55555555-…` and a resident `55555555-…`
    /// spelled uppercase are **one** identity. Every identity-shaped name is ASCII hex and hyphens,
    /// where locale-independent case folding is UUID-value canonicalization exactly.
    ///
    /// **The one derivation** (settled 2026-07-29 — the fold): `ItemID`'s `==`/`hash(into:)`
    /// canonicalize through this function, and so do the Writer's string-level checks, which
    /// compare *paths* rather than model values (`BoardWriter.identities(inBoard:)`, the
    /// import-boundary collision probe, `freshUUIDName`'s `taken` set). The Writer's former private
    /// `canonicalIdentity` was a second copy of this one line; a second copy of a rule this
    /// load-bearing is a bug waiting for the day the two disagree.
    ///
    /// Total on any string: off-shape input compares by its own lowercasing, which is the harmless
    /// reading (`ItemID` stays total for the same reason).
    public static func canonicalIdentity(_ name: String) -> String {
        name.lowercased()
    }

    // MARK: - The reserved-name tables

    /// The board's trash container (01-storage-format.md § Deletion) — a **directory** name.
    public static let trashFolderName = ".trash"

    /// A card's attachment folder (01-storage-format.md § Attachments) — the one folder the app
    /// ever creates under a card. **Fractal**: a comment folder has one too (§ Enhanced schema).
    public static let attachmentsFolderName = "attachments"

    /// The file every level's content lives in.
    public static let indexFileName = "index.md"

    /// **The board's noise definition** (01-storage-format.md § Fractal layout ▸ Rules, ruled
    /// 2026-07-31; 06-history-undo.md ▸ Repository hygiene) — a board-root **file** name, claimed
    /// because the app seeds it on every board and then reads it on every walk: it is the one list of
    /// files the loose-file relocation heal leaves alone, and on a Pro board the same file is what
    /// keeps that noise out of history. Seeded once, never edited afterwards.
    public static let gitignoreFileName = ".gitignore"

    /// A card's **comment thread** (01-storage-format.md § Enhanced schema) — a plain reserved
    /// child, never a level and never identity; the identities are the UUID folders inside it.
    public static let commentsFolderName = "comments"

    /// The card's single comment draft, inside `comments/` — "a reserved dot-named folder holding
    /// ordinary comment schema … excluded from the thread listing" (§ Enhanced schema, ruled
    /// 2026-07-29).
    public static let commentDraftFolderName = ".draft"

    /// The thread's own trash — **the board's name one level down**, deliberately the same spelling:
    /// "the materialized-trash pattern one level down, joining `.draft` in the claimed names"
    /// (§ Enhanced schema). Undo's backing store, never a UI surface.
    public static let commentTrashFolderName = trashFolderName

    /// **The card-level reserved names** (01-storage-format.md § Fractal layout ▸ Rules): the
    /// card's own `index.md` plus the two reserved children.
    ///
    /// **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/`.
    public static let reservedCardChildNames: Set<String> = [
        indexFileName, attachmentsFolderName, commentsFolderName,
    ]

    /// **The same table inside one comment folder** — "a card's anatomy one level down, so the
    /// fractal rules apply verbatim" (01-storage-format.md § Enhanced schema). A comment has no
    /// `comments/` of its own: replies are deliberately deferred and flat is this iteration's rule.
    public static let reservedCommentChildNames: Set<String> = [
        indexFileName, attachmentsFolderName,
    ]

    /// What kind of node a name is allowed to be.
    ///
    /// `String`-backed so a defect's signature has a stable token to spell (`description` is prose
    /// for a log line and must stay free to change without re-arming a memo).
    public enum NodeKind: String, Sendable, Equatable, CustomStringConvertible {
        case file
        case directory
        /// Never followed, never traversed, never resolved — `lstat` semantics everywhere in this
        /// app (01-storage-format.md § Fractal layout ▸ Rules). A symlink is a *node that is there*,
        /// whatever it points at, which is why it is its own case rather than the type of its
        /// target.
        case symlink

        public var description: String {
            switch self {
            case .file: "a file"
            case .directory: "a folder"
            case .symlink: "a symbolic link"
            }
        }
    }

    /// **A board-root name the app claims** — the one scope on the verbatim promise
    /// (01-storage-format.md § Fractal layout ▸ Rules: "Three board-root names are app-claimed, not
    /// strays", plus `.trash/` from § Deletion).
    public struct ClaimedName: Sendable, Equatable {
        public let name: String
        /// What the app needs the name to *be*. A node of any other kind is not a resident — it is
        /// an invalid artifact on a name Lanework owns.
        public let expected: NodeKind
        /// Whether a wrong-kinded node on this name is displaced by the scheduled heal (ruled
        /// 2026-07-29 — "Lanework owns the board"), or left exactly where it is.
        ///
        /// `false` for the two names that are *destinations* or that the app can do without:
        /// `CLAUDE.user.md` is where a markerless `CLAUDE.md` is rescued **to**, and freeing a
        /// destination by a second displacement would cascade renames (the settled skip stands —
        /// 08-agent-integration.md); `.gitignore` is seeded once and then the user's to edit
        /// (06-history-undo.md ▸ Repository hygiene).
        ///
        /// **`.gitignore`'s `false` survived the 2026-07-31 re-ruling deliberately**, and the reason
        /// changed underneath it. It used to be "nothing in the app reads it", which stopped being
        /// true the day the file became the loose-file heal's noise gate (01-storage-format.md
        /// § Fractal layout ▸ Rules) — by the level-uniform timing principle (a name "joins the
        /// scheduled class the day the name becomes load-bearing", which is how `comments` graduated)
        /// a folder squatting it would now be a candidate. It stays undisplaced because **the app
        /// degrades to correctness without the file**: an unreadable noise definition means no
        /// exclusions, which is exactly the behaviour boards had before the ruling and exactly what an
        /// empty file — the ruling's own escape hatch — asks for. Nothing breaks while the name is
        /// held, so nothing is displaced; that is the "proactive only when the defect is load-bearing
        /// *now*" half of § Validation and healing. Neither 01 nor 06 asks for a displacement here,
        /// and inventing one would move a node of the user's to buy a courtesy file.
        public let displacesSquatters: Bool
    }

    /// The claimed-name table — **one place**, where these names were scattered across the loader,
    /// the guide, and the trash writer before (02-architecture.md ▸ Components: "the reserved-name
    /// tables … today scattered").
    ///
    /// `CLAUDE.md`'s squatter is displaced by the **agent guide's** own heal, which already owns
    /// that file's whole decision (`AgentGuide.Decision.displaceSquatterThenWrite`); `.trash`'s is
    /// its own scheduled heal, because nothing else ever writes that name.
    ///
    /// **`AGENTS.md` joined 2026-08-09** (card dc1314bb, 08-agent-integration.md ▸ The agent guide):
    /// the byte-identical twin of `CLAUDE.md`, claimed and displaced on exactly the same terms — its
    /// squatter is the agent guide's own heal too (`AgentGuide.install` runs the same decision against
    /// both names), so it is not answered by `squattedClaimedName(atBoardRoot:)` below either.
    public static let claimedRootNames: [ClaimedName] = [
        ClaimedName(name: trashFolderName, expected: .directory, displacesSquatters: true),
        ClaimedName(name: "CLAUDE.md", expected: .file, displacesSquatters: true),
        ClaimedName(name: "AGENTS.md", expected: .file, displacesSquatters: true),
        ClaimedName(name: "CLAUDE.user.md", expected: .file, displacesSquatters: false),
        ClaimedName(name: gitignoreFileName, expected: .file, displacesSquatters: false),
    ]

    /// **The card-level claimed names** — the same table one level down (01-storage-format.md § Fractal
    /// layout ▸ Rules, extended 2026-07-29: "**The rule is level-uniform**: a card's reserved child
    /// names are claimed the same way — a regular file or symlink squatting `attachments` (a directory
    /// name) displaces by the same ladder (`attachments` → `attachments 2`), so imports, Finder drops,
    /// and the sidebar listing never fail one gesture at a time against a squatted name").
    ///
    /// **`comments` graduated with the feature** (the timing principle, run forwards): 01 called the
    /// reserved-but-unconsumed name "the timing principle's own illustration" — "a wrong-kind holder is
    /// a tolerated stray today and joins the scheduled class **the day the name becomes load-bearing**".
    /// That day is this one: a file or symlink wearing `comments` now breaks the card window's whole
    /// thread — no draft can be saved, no comment posted, nothing read — which is exactly the
    /// "proactive when the defect is load-bearing now" condition (§ Validation and healing).
    /// `attachments` was load-bearing already and displaced from the start.
    ///
    /// `index.md` is deliberately not here. It is not a *reserved child* the app protects from
    /// squatters — it is the card's content, and a directory named `index.md` makes the folder an
    /// index-less stray the loader already skips with a warning (§ Fractal layout ▸ Rules). Displacing it
    /// would mean the app deciding a folder's content is a squatter.
    public static let claimedCardChildNames: [ClaimedName] = [
        ClaimedName(name: attachmentsFolderName, expected: .directory, displacesSquatters: true),
        ClaimedName(name: commentsFolderName, expected: .directory, displacesSquatters: true),
    ]

    /// **The names claimed inside a card's `comments/`** — the thread's two lifecycle folders
    /// (01-storage-format.md § Enhanced schema: "`.draft` joins the claimed names (a wrong-kind node
    /// squatting it displaces by the ladder)", and `comments/.trash/` "joining `.draft` in the claimed
    /// names").
    ///
    /// Both displace: a file wearing `.draft` makes the composer unsaveable and a file wearing
    /// `.trash` makes every comment delete fail, so neither is latent while it stands.
    public static let claimedCommentThreadNames: [ClaimedName] = [
        ClaimedName(name: commentDraftFolderName, expected: .directory, displacesSquatters: true),
        ClaimedName(name: commentTrashFolderName, expected: .directory, displacesSquatters: true),
    ]

    /// **The card's table, one level down** — a comment's own `attachments`, claimed exactly as a
    /// card's is (§ Enhanced schema: "displacement of a squatted `attachments`" is named among the
    /// fractal rules that "apply verbatim").
    public static let claimedCommentChildNames: [ClaimedName] = [
        ClaimedName(name: attachmentsFolderName, expected: .directory, displacesSquatters: true),
    ]

    /// The claimed names as the lane walk needs them: lowercased, for a `contains` against a
    /// directory entry. Compared lowercased for `reservedCardChildNames`' reason.
    public static let claimedRootNameSet: Set<String> = Set(claimedRootNames.map { $0.name.lowercased() })

    /// What is sitting at `url`, by `lstat` and nothing else — `nil` when nothing is there.
    ///
    /// **`attributesOfItem`, never `fileExists`**: a dangling symlink is a node that is *there*
    /// (a move onto it would fail, and this app does not touch symlinks anyway), while `fileExists`
    /// follows the link, finds nothing, and would call the name free.
    public static func node(at url: URL) -> NodeKind? {
        guard let type = (try? FileManager.default.attributesOfItem(atPath: url.path))?[.type]
            as? FileAttributeType
        else {
            return nil
        }
        switch type {
        case .typeSymbolicLink: return .symlink
        case .typeDirectory: return .directory
        default: return .file
        }
    }

    /// The claimed-name defect at a board root, or `nil` when every claimed name is free or held by
    /// the right kind of node — the **detection** half of the squatter-displacement ruling
    /// (01-storage-format.md § Fractal layout ▸ Rules, ruled 2026-07-29).
    ///
    /// Only names whose `displacesSquatters` is `true` can produce one, and only `.trash` is
    /// answered here: `CLAUDE.md` and its `AGENTS.md` twin's squatters are the agent guide's,
    /// detected by `AgentGuide.inspect` in the same read that decides everything else about each
    /// file, so answering either here too would be two mechanisms racing to displace one node.
    public static func squattedClaimedName(atBoardRoot root: URL) -> ClaimedNameSquatter? {
        guard let claimed = claimedRootNames.first(where: { $0.name == trashFolderName }),
              let found = node(at: root.appendingPathComponent(claimed.name)),
              found != claimed.expected
        else {
            return nil
        }
        return ClaimedNameSquatter(name: claimed.name, found: found, expected: claimed.expected)
    }

    /// The claimed-name defects inside one **card** folder — the level-uniform half of the same ruling
    /// (`claimedCardChildNames`, extended 2026-07-29).
    ///
    /// Only names whose `displacesSquatters` is `true` can produce one, which today means `attachments`
    /// and only `attachments`: a `comments` held by the wrong kind of node is a tolerated stray until
    /// the feature consumes the name.
    ///
    /// **A plural answer, unlike the board root's**, because the reason the root's is singular does not
    /// apply here: there, the second displacing name (`CLAUDE.md`) is the agent guide's own to heal, so
    /// answering it twice would be two mechanisms racing one node. Nothing else owns a card's children,
    /// so this returns every offender it finds and the table stays the only thing to edit when
    /// `comments` graduates.
    ///
    /// - Parameter path: the card folder's path **relative to the board root**, carried into the defect
    ///   so the write lands wherever the board lives at heal time (`LooseCardFiles`' convention).
    public static func squattedClaimedNames(inCardAt cardFolder: URL, path: String) -> [ClaimedNameSquatter] {
        squatters(among: claimedCardChildNames, in: cardFolder, at: .card(path: path))
    }

    /// The claimed-name defects inside one card's **`comments/`** — `.draft` and `.trash`
    /// (`claimedCommentThreadNames`, 01-storage-format.md § Enhanced schema).
    ///
    /// **Window-scoped, unlike its card-level twin**: the board walk stays O(cards) and never opens a
    /// thread (§ Enhanced schema — "Comments are window-scoped, outside the board snapshot"), so this
    /// is asked by the card window's own thread read, not by the loader.
    ///
    /// - Parameter cardPath: the card folder's path relative to the board root, so the heal lands
    ///   wherever the board lives at write time (`LooseCardFiles`' convention).
    public static func squattedClaimedNames(
        inCommentThreadAt threadFolder: URL,
        cardPath: String
    ) -> [ClaimedNameSquatter] {
        squatters(among: claimedCommentThreadNames, in: threadFolder, at: .commentThread(cardPath: cardPath))
    }

    /// The claimed-name defects inside **one comment folder** — its `attachments`, the card's rule
    /// read one level down (`claimedCommentChildNames`). Window-scoped, for its sibling's reason.
    ///
    /// - Parameter path: the comment folder's path relative to the board root.
    public static func squattedClaimedNames(inCommentAt commentFolder: URL, path: String) -> [ClaimedNameSquatter] {
        squatters(among: claimedCommentChildNames, in: commentFolder, at: .comment(path: path))
    }

    /// The shared body of the three plural detections: one table, one folder, one location. The board
    /// root's singular answer stays its own, for the reason its doc comment gives.
    private static func squatters(
        among table: [ClaimedName],
        in folder: URL,
        at location: ClaimedNameSquatter.Location
    ) -> [ClaimedNameSquatter] {
        table.compactMap { claimed in
            guard claimed.displacesSquatters,
                  let found = node(at: folder.appendingPathComponent(claimed.name)),
                  found != claimed.expected
            else {
                return nil
            }
            return ClaimedNameSquatter(
                name: claimed.name,
                found: found,
                expected: claimed.expected,
                location: location
            )
        }
    }

    // MARK: - Object kinds

    /// The kinds the schema knows (01-storage-format.md § Frontmatter ▸ Common to all levels, the
    /// `kind` row). `comment` joined them with the enhanced schema's storage (§ Enhanced schema, the
    /// `kind: comment` field table) — the first kind whose position is not a *level*: a comment lives
    /// under a card's `comments/`, which is a reserved child rather than a depth.
    public enum ObjectKind: String, Sendable, Equatable, CaseIterable {
        case board
        case lane
        case card
        case comment
    }

    /// Where a folder's **position** places it — "level is position" (01-storage-format.md
    /// § Fractal layout) as a value, decided from two names and nothing else.
    public enum Placement: Sendable, Equatable {
        /// Its parent is identity-shaped, so it is a card: a lane's children are the only
        /// identity-bearing folders under an identity-bearing folder.
        case card
        /// Identity-shaped, under something that is neither a lane nor the trash — a lane.
        case lane
        /// Under a card's `comments/` — a posted comment, or the `.draft` that is one in every
        /// respect but its name (01-storage-format.md § Enhanced schema: "ordinary comment schema").
        case comment
        /// Inside `.trash/`, where the container is flat and **position cannot answer**: use
        /// `trashKind(kindValue:hasIdentityShapedChildIndex:)`.
        case insideTrash
        /// Position says nothing. A board root reaches this (its folder name is a Finder document
        /// name, not an identity), and so does any hand-named folder — which is why the answer is
        /// "unknown" rather than "board": guessing here would stamp `kind: board` onto whatever a
        /// caller happened to point at.
        case unknown
    }

    /// The placement rule, in the order the questions can be answered.
    ///
    /// The trash check sits *between* the two identity checks deliberately: a trashed card and a
    /// live lane are both identity-shaped folders whose parent is not, and the container is the only
    /// thing that tells them apart.
    ///
    /// **The `comments/` check sits ahead of the trash's**, and ahead of the lane's for the reason
    /// that matters: a posted comment is an identity-shaped folder whose parent is not identity-shaped
    /// — shape-identical to a lane — so without it every comment would read as a lane and the on-touch
    /// backfill would stamp `kind: lane` into a thread.
    ///
    /// **One ambiguity two names cannot resolve**: `comments/.trash/<uuid>` answers `.insideTrash`
    /// here, because `.trash` is the board's spelling one level down. `placement(ofFolder:)` is the
    /// form that can tell them apart, and every caller holding a URL should use it.
    public static func placement(ofFolderNamed name: String, inParentNamed parent: String) -> Placement {
        if isIdentityShaped(parent) { return .card }
        if parent.lowercased() == commentsFolderName { return .comment }
        if parent.lowercased() == trashFolderName { return .insideTrash }
        if isIdentityShaped(name) { return .lane }
        return .unknown
    }

    /// The same rule read off a **URL** — the only form that can tell the board's `.trash/` from a
    /// comment thread's own one level down, since the two share a name and the two-name rule sees
    /// only the name.
    public static func placement(ofFolder url: URL) -> Placement {
        let parent = url.deletingLastPathComponent()
        let placement = placement(ofFolderNamed: url.lastPathComponent, inParentNamed: parent.lastPathComponent)
        guard placement == .insideTrash,
              parent.deletingLastPathComponent().lastPathComponent.lowercased() == commentsFolderName
        else {
            return placement
        }
        return .comment
    }

    /// **The trash's `kind` discriminator** (01-storage-format.md § Deletion, re-ruled 2026-07-29 —
    /// the value-names-the-kind posture): depth defines meaning on the live board, but the trash is
    /// flat, and an empty lane folder is shape-identical to a card folder.
    ///
    /// **The value is trusted outright** — `kind: lane` → lane, `kind: card` → card — so an external
    /// writer's `kind: lane` on what looks card-shaped is honored, never policed and never
    /// corroborated. An unrecognized value or no key at all falls through to **shape**:
    /// identity-shaped children with their own `index.md` → lane (the key backfills on the next
    /// touch), else card.
    ///
    /// `kind: board` in the trash is *not* a third answer, and neither is `kind: comment`: neither is
    /// a thing that can be in the *board's* trash (a deleted comment moves into its own thread's
    /// `comments/.trash/`, 01-storage-format.md § Enhanced schema), so both values are unrecognized
    /// here and shape decides — the same shrug an arbitrary string gets.
    ///
    /// The shape half is `@autoclosure` so that the rule stays a pure function of two facts while
    /// its caller pays for the directory listing **only when the value did not answer** — which on
    /// a board written by this app is never.
    public static func trashKind(
        kindValue: String?,
        hasIdentityShapedChildIndex: @autoclosure () -> Bool
    ) -> ObjectKind {
        switch kindValue.flatMap(ObjectKind.init(rawValue:)) {
        case .lane: return .lane
        case .card: return .card
        case .board, .comment, nil: return hasIdentityShapedChildIndex() ? .lane : .card
        }
    }

    // MARK: - Per-field validation (the rulebook)

    /// `schema` **at the board root**, validated: present, well-formed, not newer than this app
    /// (01-storage-format.md § Malformed input).
    ///
    /// **The root keeps its requirement** (re-ruled 2026-07-31): the root's stamp is the board's
    /// identity claim — the this-really-is-a-board gate — so a root without one is the loud
    /// rejection it always was, and the decision surface's minted repair (`stamp schema: 1`) is
    /// what fixes it. Below the root the key is optional and defaults instead
    /// (`resolvedSchema(in:path:supportedSchema:)`).
    public static func validatedRootSchema(
        in document: FrontmatterDocument,
        path: String,
        supportedSchema: Int
    ) throws(BoardLoadError) -> Int {
        switch document.schema {
        case .missing:
            throw BoardLoadError(path: path, reason: .missingSchema)
        case let .malformed(raw):
            throw BoardLoadError(path: path, reason: .malformedSchema(raw: raw))
        case let .valid(value):
            try checkNotNewerThanApp(value, path: path, supportedSchema: supportedSchema)
            return value
        }
    }

    /// `schema` **below the board root**, read: a missing (or explicitly null) key reads as **1**
    /// (01-storage-format.md § Frontmatter, re-ruled 2026-07-31 — "below the root a missing `schema`
    /// reads as 1 — coerce-tier, logged, bytes preserved — because the walk validating the file
    /// against schema 1 is exactly what makes the reading reliable").
    ///
    /// Two things stay fail-fast here, and both are the same rule they always were:
    ///
    /// - **A schema newer than the app**, at any level: there is no honest reading of a file written
    ///   by a Lanework that knows keys this one does not, and defaulting would be guessing at content
    ///   rather than recovering it.
    /// - **A malformed `schema`** — `schema: one`, `schema: [1]`. The ruling made the *absent* key
    ///   optional, not the unreadable one: absence is what a hand-filed item legitimately looks like,
    ///   while a value that is there and unreadable is a claim the app cannot check, and reading it
    ///   as 1 would be inventing agreement.
    ///
    /// - Returns: the reading, plus the coerce-tier record when one was made — the field, and the
    ///   text as written (`nil` when the key was present and valid, which is every file this app
    ///   ever wrote). The *path* is the loader's to attach (`CoercedFrontmatter`).
    public static func resolvedSchema(
        in document: FrontmatterDocument,
        path: String,
        supportedSchema: Int
    ) throws(BoardLoadError) -> (schema: Int, coerced: CoercedField?) {
        switch document.schema {
        case .missing:
            return (defaultSchema, coercedRecord(for: FrontmatterKeys.schema, in: document, raw: nil))
        case let .malformed(raw):
            throw BoardLoadError(path: path, reason: .malformedSchema(raw: raw))
        case let .valid(value):
            try checkNotNewerThanApp(value, path: path, supportedSchema: supportedSchema)
            return (value, nil)
        }
    }

    /// The reading a `schema`-less file below the root gets — the schema this document *is* being
    /// validated against, which is the argument for the default rather than a coincidence.
    public static let defaultSchema = 1

    private static func checkNotNewerThanApp(
        _ value: Int,
        path: String,
        supportedSchema: Int
    ) throws(BoardLoadError) {
        guard value <= supportedSchema else {
            throw BoardLoadError(path: path, reason: .schemaNewerThanApp(found: value))
        }
    }

    /// `order` **below the board root**, read: `nil` where the file carries no rank this app can
    /// use — which the caller reads as **append-at-end** (`Ranks.resolvedOrders(of:stored:name:)`).
    ///
    /// **Four shapes, one reading** (01-storage-format.md § Ordering, re-ruled 2026-07-31 —
    /// "Missing or unusable `order` reads as append-at-end"): a missing key, an explicitly null one
    /// (`order:` bare, `order: null` — the null-as-missing rule), a non-numeric value, and a
    /// non-finite one (`.nan`, `.inf`, which has no place in the total order the tie-break and
    /// midpoint math assume). None of them is a load failure any more; all of them are coerce-tier —
    /// bytes preserved verbatim, the reading logged, the rank materializing on the file's first
    /// touch (`healOnTouch`).
    ///
    /// It never throws, and has no `path`: with nothing left to refuse, there is nothing to name.
    ///
    /// **The board root never asks.** A board carries no rank — it is not among siblings — so the
    /// key is meaningless there and this is simply not called on it, exactly as it never was.
    public static func resolvedOrder(
        in document: FrontmatterDocument
    ) -> (order: Double?, coerced: CoercedField?) {
        switch document.order {
        case .valid(let value):
            return (value, nil)
        case .malformed(let raw):
            return (nil, coercedRecord(for: FrontmatterKeys.order, in: document, raw: raw))
        case .missing:
            return (nil, coercedRecord(for: FrontmatterKeys.order, in: document, raw: nil))
        }
    }

    /// One coerce-tier record: the field and **the value exactly as written**, which for an absent
    /// key is the empty string — there is no text to record, and inventing a rendering of one would
    /// defeat the record's whole purpose (a shape observed in the wild, promotable later to a
    /// heuristic heal). An explicitly-null key answers with its own (empty) source span, so
    /// "started the key and never gave it a value" is preserved as exactly that.
    private static func coercedRecord(
        for key: String,
        in document: FrontmatterDocument,
        raw: String?
    ) -> CoercedField {
        CoercedField(key: key, raw: raw ?? document.rawValue(for: key) ?? "")
    }

    /// **Per-kind index validation** — the loader's own checks, in its own order, over bytes that
    /// need not be on disk yet (02-architecture.md ▸ Components: "the card validator generalized per
    /// kind — board, lane, card, the enhanced schema's comment when it lands").
    ///
    /// Exactly the checks `BoardLoader.load` runs on an object of that kind, through its own
    /// functions: decode + parse, then `schema` — at the root's strictness for a board, at the
    /// below-the-root reading for everything else. Nothing further is checked, because nothing else
    /// *is*: `title` is optional, `order` is optional below the root since 2026-07-31 (a rankless
    /// card applied through the raw-source outlet lands at its lane's bottom and gains a rank on its
    /// next touch, exactly as a hand-filed one does), unknown keys are the point of the outlet the
    /// card validator serves, and the body is free text.
    ///
    /// It deliberately does **not** check `uneditableShape`: that refusal exists for surgical span
    /// edits, and the raw-source Apply this serves replaces the whole file — a flow-mapping
    /// frontmatter is precisely one of the things the escape hatch exists to let a user rewrite.
    public static func validateIndex(
        _ data: Data,
        path: String,
        kind: ObjectKind,
        supportedSchema: Int
    ) throws(BoardLoadError) -> FrontmatterDocument {
        let document = try BoardLoader.parseDocument(data, path: path)
        switch kind {
        case .board:
            _ = try validatedRootSchema(in: document, path: path, supportedSchema: supportedSchema)
        case .lane, .card, .comment:
            _ = try resolvedSchema(in: document, path: path, supportedSchema: supportedSchema)
        }
        return document
    }

    /// Why this document refuses every app write, or `nil` when it can be edited in place — the
    /// **refuse-writes** verdict's whole rule (01-storage-format.md § Frontmatter, the
    /// readable-but-uneditable shapes).
    ///
    /// The analysis itself lives on `FrontmatterDocument`, computed at parse from the span
    /// structure it alone has; naming it here is what puts the verdict in the vocabulary rather than
    /// leaving it as a property one call site happens to read.
    public static func uneditableShape(of document: FrontmatterDocument) -> FrontmatterDocument.UneditableShape? {
        document.uneditableShape
    }

    // MARK: - On-touch heals

    /// A latent defect fixed by folding into a write that is **already rewriting that file**
    /// (01-storage-format.md § Validation and healing: "on-touch when the defect is latent"). Never
    /// a scheduled sweep, never a write of its own — an on-touch heal rides its host write's single
    /// atomic rewrite and its host's `modified` stamp, and composes no event of its own.
    public enum OnTouchHeal: Sendable, Equatable {
        /// `kind` was missing and has been stamped with the object's own kind (re-ruled 2026-07-29
        /// — the common-schema row). **On-touch only, never a scheduled backfill sweep**: the key is
        /// consequential only inside `.trash/`, and a sweep would rewrite every file on the board to
        /// add a key that is redundant with position everywhere else.
        case kindBackfilled(ObjectKind)

        /// `order` was missing or unusable and has been stamped with the rank the board was
        /// **already rendering it at** — the append-at-end reading, materialized
        /// (01-storage-format.md § Ordering, re-ruled 2026-07-31: "The rank materializes on touch:
        /// the first Writer rewrite of the file stamps a real rank").
        ///
        /// Nothing moves on screen when it lands, and that is the design rather than a happy
        /// accident: the stamped value is `Ranks.resolvedOrders`' own answer for this file, so the
        /// heal writes down what the reading already said. It is on-touch for the reason the whole
        /// class is — the defect is latent, the file is being rewritten anyway, and a sweep that
        /// rewrote a board to add ranks nobody asked for would be churn.
        ///
        /// **Unlike `kind`, an unusable *present* value is replaced.** `order: banana` and
        /// `order: .nan` are read as order-less (the value-names-the-kind posture has no twin here:
        /// a rank must be a number for the midpoint math to mean anything), so the stamp is the same
        /// heal in both cases and the unreadable text does not survive the rewrite.
        case rankStamped(Double)

        /// A key written twice collapsed to its winning (last) occurrence — the span editor's
        /// duplicate-key twin removal (`FrontmatterDocument.set`). Named here because it *is* an
        /// on-touch heal and was only ever documented as an editing detail: last-wins is the read
        /// rule, and the write that touches the key is where the twins stop being able to resurrect.
        case duplicateKeyTwinsRemoved(key: String)

        /// A value that needed quoting got it on its first app write — the colon rescue's
        /// quote-on-first-write (`FrontmatterValue.emitScalar`). The same class as the twin removal
        /// and named for the same reason: the app writes the value correctly the first time it has
        /// any reason to write it at all, and never on a file it was not already rewriting.
        case quotedOnFirstWrite(key: String)
    }

    /// **The on-touch heal seam**: the pending latent work on the document a write is already
    /// rewriting, applied (02-architecture.md ▸ Components: "on-touch heals live at the Writer's
    /// `updateIndex` seam, which consults IntegrityRules for pending on-touch work on the file it is
    /// rewriting").
    ///
    /// Two of the four members are applied *here* — the `kind` backfill and the rank stamp; the
    /// other two are applied by `FrontmatterDocument`'s own editor on every key it writes, and are
    /// named in `OnTouchHeal` rather than re-implemented. That is the honest shape of "the same
    /// class, named, no behavior change".
    ///
    /// - **`kind`: missing only.** A present `kind` is never rewritten, never corroborated, and
    ///   never stripped — the value names the kind and consumers trust it outright. An explicit
    ///   `kind:` with nothing after it reads as missing, like every other null (the null-as-missing
    ///   rule), and so backfills.
    /// - **`kind == nil` stamps nothing.** Position cannot always answer (`Placement.unknown`), and
    ///   a guessed kind written to disk would be worse than an absent one: the trash discriminator
    ///   trusts what it finds.
    /// - **`order`: missing *or unusable*.** The reading is stated over usability
    ///   (`resolvedOrder(in:)`), so the stamp is too.
    /// - **`rank == nil` stamps nothing** — the board root (which has no siblings and no rank), a
    ///   comment (which never gains one — "Ordering is chronology, not ranks"), and any folder whose
    ///   position has no answer. The same posture as `kind`'s: a rank invented for something that is
    ///   not a lane or a card would be a fact nobody asked for.
    /// - Parameters kind, rank: `@autoclosure` so a caller whose answer costs a directory listing
    ///   (the trash's shape fallback; the rank's own sibling scan) pays for it only on a file that
    ///   actually needs the heal — which, on a board this app wrote, is never.
    @discardableResult
    public static func healOnTouch(
        _ document: inout FrontmatterDocument,
        kind: @autoclosure () -> ObjectKind?,
        rank: @autoclosure () -> Double? = nil
    ) -> [OnTouchHeal] {
        var applied: [OnTouchHeal] = []
        if document.kind.isMissing, let kind = kind() {
            document.set(FrontmatterKeys.kind, to: .string(kind.rawValue))
            applied.append(.kindBackfilled(kind))
        }
        if document.order.value == nil, let rank = rank() {
            document.set(FrontmatterKeys.order, to: .double(rank))
            applied.append(.rankStamped(rank))
        }
        return applied
    }

    // MARK: - The typed defect stream

    /// **One defect, typed** — what the loader reports as pending *work*, as distinct from the
    /// tolerate-tier `LoadWarning`s it reports as information (02-architecture.md ▸ Components:
    /// "`LoadResult`'s ad-hoc repair channels (loose files, legacy tombstones) become one typed
    /// defect stream; tolerate-tier warnings stay warnings").
    ///
    /// The distinction is the whole reason the two channels are not one: a warning says "this was
    /// ignored, it is staying exactly where it is, there is nothing to do", and a defect says the
    /// opposite. Folding defects into the warning channel would also throw away everything the heal
    /// needs (which lane, which card, which title, which names) and force it to be re-derived from a
    /// display string.
    public enum Defect: Sendable, Equatable {
        /// A card holding files that belong in its `attachments/` (the loose-file carve-out).
        case looseCardFiles(LooseCardFiles)
        /// A **card** carrying a legacy `deleted:` key (the retired tombstone model's surviving
        /// migration input; a lane's key is inert and tolerated instead).
        case legacyTombstone(LegacyTombstone)
        /// A claimed board-root name held by the wrong kind of node (ruled 2026-07-29).
        case claimedNameSquatted(ClaimedNameSquatter)
        /// A later occurrence of an identity the board already carries — withheld from the snapshot
        /// and reminted by the scheduled heal (re-ruled 2026-07-29 — the silent remint).
        case duplicateIdentity(DuplicateIdentity)

        /// **A coerce-tier fallback**: one file's lenient fields that had no sensible reading and
        /// rendered as their defaults (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").
        ///
        /// **The one case in this enum that is not work**, which is the ruling rather than a
        /// contradiction: the design puts coerce-tier fallbacks in *this* stream on purpose, because
        /// this is where "an observed-in-the-wild shape can later be promoted to a heuristic heal or a
        /// notice" — and the promotion would happen right here, by giving the case a heal class. Until
        /// then it has none (`healClass` answers `nil`), raises no banner, and changes no behavior: the
        /// value already rendered as its default and the bytes on disk are untouched. It is
        /// observability, carried in the vocabulary that would act on it if the app ever decided to.
        case coercedFrontmatter(CoercedFrontmatter)

        /// The scheduled-heal classes, which are also the engine's memo keys and its
        /// banner-posture rows (`HealScheduler`).
        ///
        /// `staleAgentGuide` has no `Defect` case, and that asymmetry is honest rather than an
        /// oversight: the guide's defect is a property of one board-root file's *version marker*,
        /// read at the moment of healing (`AgentGuide.inspect`), not something a tree walk reports.
        /// It is a class here because the engine treats it exactly like the others — same gates,
        /// same memo, same clear-on-success.
        ///
        /// `missingGitignore` is the third, and the guide's twin in every respect: the board's noise
        /// definition is one board-root file's presence, read at the moment of healing
        /// (`BoardWriter.seedGitignoreIfAbsent`) rather than reported by a tree walk — "a board
        /// missing the file gains it by scheduled heal at open (the guide-refresh cadence)"
        /// (06-history-undo.md ▸ Repository hygiene, re-ruled 2026-07-31).
        ///
        /// `commentTrashResidue` is the second such class, for the same reason one level down: the
        /// residue is whatever a crashed session left in one card's `comments/.trash/`, read at the
        /// moment the card window opens (01-storage-format.md § Enhanced schema — "purged when the
        /// card window closes; crash residue sweeps at the next card-window open, armed-then-cleared
        /// like every heal memo"). The board walk never opens a thread, so no tree walk could report
        /// it.
        ///
        /// `orphanedBackgroundImages` joins this no-`Defect`-case group for the identical reason: what
        /// is orphaned is a property of **two** board-root readings taken together at the moment of
        /// healing — the `.backgrounds/` folder's current listing and `background.image`'s current
        /// value (`BoardStore.tidyBackgroundImages`, ruled 2026-08-09) — not something a tree walk over
        /// lanes and cards could carry forward to the heal that acts on it.
        public enum Class: Sendable, Equatable, Hashable, CaseIterable {
            case looseCardFiles
            case legacyTombstone
            case claimedNameSquatted
            case duplicateIdentity
            case staleAgentGuide
            case missingGitignore
            case commentTrashResidue
            case orphanedBackgroundImages
        }

        /// The scheduled-heal class this defect belongs to, or **`nil` where there is no heal** — the
        /// coerce tier (`coercedFrontmatter`), which is carried for observability and acted on by
        /// nothing. Optional rather than a synthetic class, because a class *is* a memo key and a
        /// banner-posture row in the engine (`HealScheduler`): inventing one for work that does not
        /// exist would arm a memo against a repair nobody wrote.
        public var healClass: Class? {
            switch self {
            case .looseCardFiles: .looseCardFiles
            case .legacyTombstone: .legacyTombstone
            case .claimedNameSquatted: .claimedNameSquatted
            case .duplicateIdentity: .duplicateIdentity
            case .coercedFrontmatter: nil
            }
        }

        /// **The defect's identity as a comparable string** — the memo's unit (01-storage-format.md
        /// § Validation and healing: "re-armed only by a changed defect signature").
        ///
        /// What matters is the *identity* of the work, never the order the walk happened to meet it
        /// in, which is why the engine compares sets of these rather than arrays of defects: two
        /// loads of an unchanged tree must compare equal even if a lane's folder-name ordering
        /// shifted underneath them.
        public var signatures: [String] {
            switch self {
            case let .looseCardFiles(work):
                work.fileNames.map { "loose:\(work.laneID.rawValue)/\(work.cardID.rawValue)/\($0)" }
            case let .legacyTombstone(work):
                ["tombstone:\(work.laneID.rawValue)/\(work.cardID.rawValue)"]
            case let .claimedNameSquatted(work):
                // The node *kind* is part of the picture: a squatter replaced by a different kind
                // of squatter is a new defect, and a heal that failed on one has no claim to have
                // failed on the other. The location leads, so two cards squatting `attachments` are two
                // pieces of work — and a board-root squatter signs exactly as it always did.
                ["claimed:\(work.location.signatureComponent)\(work.name):\(work.found.rawValue)"]
            case let .duplicateIdentity(work):
                // The *identity* is part of the picture beside the path: the same folder losing a
                // different collision (its winner reminted, a third copy landing) is new work, and a
                // heal that failed on one has no claim to have failed on the other.
                ["duplicate:\(work.path):\(work.identity)"]
            case let .coercedFrontmatter(work):
                // One signature per field, `looseCardFiles`' shape: the unit of the observation is a
                // field, and a file whose `width` healed while its `icon` did not is a changed picture.
                // Nothing memoizes these today — there is no heal to guard — but a signature is what a
                // defect *is* in this vocabulary, and omitting it would make this case the one members
                // of the stream cannot be compared by.
                work.fields.map { "coerce:\(work.path):\($0.key)" }
            }
        }
    }

    // MARK: - The board-wide identity dedupe

    /// One identity-bearing folder the walk met — the **input** to `dedupe(_:)`, and everything the
    /// rule needs about an occurrence (01-storage-format.md § Fractal layout ▸ Rules, "Duplicate ids
    /// within a board are never tolerated").
    ///
    /// Occurrences are exactly the schema's identity-bearing folders: depth-1 lanes, depth-2 cards,
    /// and `.trash/`'s flat entries. Nothing deeper is one — "level is position", and a UUID-shaped
    /// folder under a card is content, not an identity.
    public struct IdentityOccurrence: Sendable, Equatable {

        /// **Which side of the container boundary an occurrence sits on** — the board or `.trash/`
        /// (02-architecture.md ▸ Live-reload resilience already makes "container side" vocabulary:
        /// re-resolution matches UUID *and* container side).
        ///
        /// It is a field rather than something derived from `path` here because `IntegrityRules` takes
        /// the facts it needs as parameters rather than parsing paths or reading disk — the loader
        /// knows which container it walked, and telling the rule beats re-deriving it from a string.
        public enum Container: Sendable, Equatable {
            /// A lane, or a card under a lane — something the board renders.
            case live
            /// A flat `.trash/` entry — a card or a trashed lane.
            case trashed
        }

        /// The folder's path **relative to the board root** — `"<lane>"`, `"<lane>/<card>"`,
        /// `".trash/<card>"`.
        ///
        /// A path rather than an `ItemID` pair, and that is forced rather than chosen: the whole
        /// subject here is *two folders carrying one id*, so an id-keyed payload would be ambiguous
        /// about which of them it names. The path is the only unambiguous key a duplicate has, and it
        /// stays relative for `LooseCardFiles`' reason — the write joins it onto the store's
        /// *current* root, so a board renamed mid-session heals at its new location.
        public let path: String
        /// The folder name exactly as it is spelled on disk. Case matters here and only here: the
        /// case-twin collapse compares spellings, everything else compares identities.
        public let name: String
        /// Which container this occurrence was walked in — **the first tie-break**, ahead of history
        /// and age alike (01-storage-format.md § Fractal layout ▸ Rules, stated 2026-07-29).
        public let container: Container
        /// The item's title as written, `nil` for an untitled one — "Untitled" is a rendering, never
        /// a value (03-board-ui.md § Card face), so the phrasing layer decides what to call it.
        public let title: String?
        /// The filesystem birth date (`.creationDateKey`), `nil` when it cannot be read — the
        /// **second** rung of the earlier-occurrence-wins ladder.
        public let birth: Date?
        /// Where a history ranker once placed this path — `nil` unconditionally today, since
        /// app-managed git was excised entirely (`strategy/01-git-excision.md`, 2026-08-08) and
        /// nothing installs `BoardLoader.IdentityHistoryRanker` any more. The **first** rung when
        /// populated; nil-safe by design, so the ladder falls straight through to birth date now.
        public let historyRank: Int?

        public init(
            path: String,
            name: String,
            container: Container,
            title: String?,
            birth: Date?,
            historyRank: Int?
        ) {
            self.path = path
            self.name = name
            self.container = container
            self.title = title
            self.birth = birth
            self.historyRank = historyRank
        }

        /// The occurrence's identity — its name reduced to a UUID *value*.
        public var identity: String { canonicalIdentity(name) }
    }

    /// What the dedupe decided: the two classes of loser, each in traversal order.
    ///
    /// **The two are deliberately different verdicts**, not one list with a flag: a case twin is
    /// *tolerated* (a spelling artifact of the same item — logged, preserved, never rendered, never
    /// touched) and a content duplicate is *healed* (a copy the app remints). Collapsing them would
    /// mean either announcing spelling or reminting content the user never made.
    public struct DedupeVerdict: Sendable, Equatable {
        /// The later occurrences withheld from the snapshot — the heal's work.
        public let duplicates: [DuplicateIdentity]
        /// The case-spelled twins skipped silently — the tolerate tier's work, which is none.
        public let caseTwins: [CaseTwin]
    }

    /// **The board-wide dedupe** — one occurrence per identity, decided from an occurrence list and
    /// nothing else (01-storage-format.md § Fractal layout ▸ Rules, settled; the silent-remint
    /// re-ruling of 2026-07-29 changed what happens *after* this, never what it decides).
    ///
    /// A snapshot must never carry two items with equal ids — SwiftUI's `ForEach` does not tolerate
    /// it — so this runs on every load and its answer is subtractive: every group of occurrences
    /// sharing one identity keeps exactly one, and every other member is named here.
    ///
    /// ### The two classes, in the order they apply
    ///
    /// 1. **Case-spelled twins collapse first, and silently.** Occurrences of one identity whose
    ///    name *strings* differ can only differ in case (they are the same hex under
    ///    `canonicalIdentity`), which makes them spelling artifacts of one item rather than copies:
    ///    one spelling wins and every other takes the **stray** posture — skipped with a pointed log
    ///    line, preserved verbatim, never rendered, never reminted. Reminting one would *create*
    ///    duplicate content the user never made.
    ///
    ///    **The winning spelling is chosen under the container preference too** (stated 2026-07-29):
    ///    spellings carried by at least one *live* occurrence are the candidates, and only among those
    ///    — or among all of them when the whole group is trashed — does canonical-all-lowercase-else-
    ///    lexicographically-first decide. Without that filter a live card spelled `AAAA…` would lose
    ///    the spelling contest to its own lowercase ghost in the trash and be *skipped*, which is the
    ///    straddle case reading the rule backwards: the visible card never loses to its own ghost.
    /// 2. **Then earlier-occurrence-wins across what is left**, which all share one spelling and so
    ///    necessarily sit under different parents — a hand copy keeping its UUID. The earliest
    ///    occurrence renders; every later one is **withheld** and healed.
    ///
    /// A consequence worth naming: an occurrence that is *both* — a hand copy whose case was also
    /// hand-changed — degrades to the silent case-twin posture and is never reminted. That is the
    /// spelling-artifacts-stay-silent ruling read literally, and the conservative direction: the
    /// board renders one item per id either way, and the app declines to mint identity for a folder
    /// whose spelling says "the same item, typed differently".
    ///
    /// ### The ladder
    ///
    /// 0. **The container boundary** (`container`) — **the first tie-break, ahead of history and age
    ///    alike** (01-storage-format.md § Fractal layout ▸ Rules, stated 2026-07-29): "when occurrences
    ///    straddle live and trashed, the **live occurrence keeps the identity** regardless of age".
    ///    The realistic straddle is a restore done as a *copy* — an ⌥-drag out of the trash in Finder,
    ///    an agent that copies instead of moves — where the ghost left behind is genuinely the older
    ///    folder and often the tracked one, so every other rung would withhold the very card the user
    ///    just restored and render its ghost instead. The heal remints the trashed occurrence. The same
    ///    preference governs a trashed lane sharing a live lane's UUID.
    /// 1. **History rank** (`historyRank`): once git path history — both tracked, the path that
    ///    entered history first wins; one tracked, it outranks the newcomer outright. Always `nil`
    ///    today, no ranker installed since the git excision (`strategy/01-git-excision.md`,
    ///    2026-08-08); the ladder falls through to rung 2.
    /// 2. **Filesystem birth date** (`birth`): the older folder wins. Only consulted when *both*
    ///    dates are readable and they differ — one unreadable date is no comparison at all.
    /// 3. **Deterministic traversal order**, which is `occurrences`' own order and therefore the
    ///    caller's contract: lane `order`, then card `order`, then the folder-name tie-break
    ///    (`BoardLoader` passes them exactly so).
    ///
    /// Rungs 1–3 are the *earlier-occurrence-wins* rule; rung 0 is not about age at all, which is why
    /// it sits outside and above it.
    ///
    /// Pure, like everything here: the container, the birth dates and the history ranks are read by
    /// the loader and arrive as values, so the whole rule is pinned by the suite without a filesystem
    /// or a repo in the way.
    public static func dedupe(_ occurrences: [IdentityOccurrence]) -> DedupeVerdict {
        // Grouped by identity, first-seen order preserved — determinism starts here, because a
        // dictionary's own iteration order is not one.
        var members: [String: [Int]] = [:]
        var identities: [String] = []
        for (index, occurrence) in occurrences.enumerated() {
            let identity = occurrence.identity
            if members[identity] == nil { identities.append(identity) }
            members[identity, default: []].append(index)
        }

        var duplicates: [(index: Int, work: DuplicateIdentity)] = []
        var caseTwins: [(index: Int, work: CaseTwin)] = []

        for identity in identities {
            guard let group = members[identity], group.count > 1 else { continue }

            // 1. The winning *spelling*, under the container preference first: a spelling some live
            //    occurrence carries outranks one only trash ghosts carry, and the canonical-else-
            //    lexicographic rule then decides among the candidates. A wholly trashed group has no
            //    live candidates and falls through to all of them, unchanged. `identity` is the
            //    all-lowercase form by construction, so "is the canonical spelling present" is one
            //    membership test either way.
            let spellings = Set(group.map { occurrences[$0].name })
            let liveSpellings = Set(
                group.lazy.filter { occurrences[$0].container == .live }.map { occurrences[$0].name }
            )
            let candidates = liveSpellings.isEmpty ? spellings : liveSpellings
            let canonical = candidates.contains(identity) ? identity : candidates.sorted()[0]

            // 2. Earlier-occurrence-wins among the canonical spelling's occurrences. `sorted` is not
            //    guaranteed stable, so the traversal index is the comparator's own last rung rather
            //    than something left to the sort.
            let contenders = group.filter { occurrences[$0].name == canonical }
            let ranked = contenders.sorted { entered(occurrences[$0], at: $0, before: occurrences[$1], at: $1) }
            let winner = occurrences[ranked[0]].path

            for index in group where occurrences[index].name != canonical {
                caseTwins.append((index, CaseTwin(path: occurrences[index].path, winner: winner)))
            }
            for index in ranked.dropFirst() {
                duplicates.append((index, DuplicateIdentity(
                    path: occurrences[index].path,
                    identity: identity,
                    title: occurrences[index].title,
                    winner: winner
                )))
            }
        }

        // Traversal order across groups too: the notice's subjects and the log's lines read in the
        // order the board is laid out, not in the order a dictionary happened to hand out identities.
        return DedupeVerdict(
            duplicates: duplicates.sorted { $0.index < $1.index }.map(\.work),
            caseTwins: caseTwins.sorted { $0.index < $1.index }.map(\.work)
        )
    }

    /// The precedence comparator — the four-rung ladder above, and the whole of the winner rule.
    ///
    /// Named for its majority (`entered … before …` is earlier-occurrence-wins' own phrasing) even
    /// though rung 0 is not about entry order at all: the container preference is stated as *the first
    /// tie-break*, so it belongs in the one comparator rather than as a pre-partition the callers of
    /// this rule would each have to remember.
    private static func entered(
        _ lhs: IdentityOccurrence,
        at lhsIndex: Int,
        before rhs: IdentityOccurrence,
        at rhsIndex: Int
    ) -> Bool {
        // 0. The container boundary, ahead of everything: the visible card never loses to its own
        //    ghost, however much older or better-tracked the ghost is.
        if lhs.container != rhs.container { return lhs.container == .live }
        switch (lhs.historyRank, rhs.historyRank) {
        case let (left?, right?):
            // Both tracked: the path that entered history first.
            if left != right { return left < right }
        case (.some, .none):
            // "The path history already tracks outranks the newcomer" — read literally.
            return true
        case (.none, .some):
            return false
        case (.none, .none):
            break
        }
        if let left = lhs.birth, let right = rhs.birth, left != right { return left < right }
        return lhsIndex < rhsIndex
    }
}

// MARK: - The defect payloads

/// One card found holding files that belong in its `attachments/` — everything the relocation and
/// its notice need, and nothing more (01-storage-format.md § Fractal layout ▸ Rules, settled
/// 2026-07-28, "Lanework-owns-the-board").
///
/// `title` is the card's as written, `nil` for an untitled one: "Untitled" is a rendering, never a
/// value (03-board-ui.md § Card face), so the phrasing layer decides what to call it. The path is
/// carried as its two identity components rather than as a URL, `ItemPath`'s convention, so the
/// write derives its path from the store's *current* root.
public struct LooseCardFiles: Sendable, Equatable {
    public let laneID: ItemID
    public let cardID: ItemID
    public let title: String?
    /// The loose files' names, in Finder order (`BoardLoader.looseFileNames`). Never empty — a card
    /// with nothing loose contributes no defect at all.
    public let fileNames: [String]

    public init(laneID: ItemID, cardID: ItemID, title: String?, fileNames: [String]) {
        self.laneID = laneID
        self.cardID = cardID
        self.title = title
        self.fileNames = fileNames
    }
}

/// One **card** found carrying a legacy `deleted:` key — everything its migration and notice need,
/// and nothing more (01-storage-format.md § Deletion: "a card carrying `deleted:` is relocated into
/// `.trash/` (key removed)").
///
/// **Cards only, since 2026-07-29.** The rule used to have two halves, and the lane half is retired
/// wholesale: "a lane carrying `deleted:` simply loads live with the key ignored — no migration
/// machinery, no key-strip write, no notice". A lane's key is now the tolerate tier's business
/// (`LoadWarning.laneLevelDeletedIgnored`), which is why there is no `kind` here to switch on: the
/// one surviving act is a move, and a payload that could spell the other one would be a defect
/// nothing can heal.
///
/// The path is carried as its identity components rather than as a URL — `LooseCardFiles`'
/// convention, for its reason. `title` is the card's as written, `nil` for an untitled one.
public struct LegacyTombstone: Sendable, Equatable {

    /// The card's **containing** lane — the context the relocation needs to find the folder at all.
    public let laneID: ItemID

    /// The card itself.
    public let cardID: ItemID

    public let title: String?

    public init(laneID: ItemID, cardID: ItemID, title: String?) {
        self.laneID = laneID
        self.cardID = cardID
        self.title = title
    }
}

/// A claimed board-root name held by the wrong kind of node — a file or symlink squatting `.trash`,
/// a directory or symlink squatting `CLAUDE.md` (01-storage-format.md § Fractal layout ▸ Rules,
/// ruled 2026-07-29: "A claimed name held by the wrong kind of node is Lanework's to heal — by
/// displacement").
///
/// **An invalid artifact, not a resident.** The heal moves it aside via the Finder-style rename
/// ladder (`.trash` → `.trash 2`), preserved verbatim and never destroyed, with a warning-tone
/// notice naming old and new — the invariant that survives is displacement-never-destruction.
/// **The rule is level-uniform** (extended 2026-07-29): a card's `attachments` is claimed exactly as the
/// board root's `.trash` is, and displaces by the same ladder — which is why `location` exists rather
/// than a second defect type. The heals compose: the displaced file, now an ordinary loose file, rides
/// the next loose-file relocation into the real `attachments/`.
public struct ClaimedNameSquatter: Sendable, Equatable {

    /// **Which claimed name this is** — the board root's, or one card's reserved child.
    ///
    /// A path rather than a URL, relative to the board root, so the heal joins it onto the store's
    /// *current* root and a board renamed mid-session heals at its new location (`LooseCardFiles`' and
    /// `IdentityOccurrence.path`'s convention).
    public enum Location: Sendable, Equatable {
        case boardRoot
        case card(path: String)
        /// A card's `comments/` container — where `.draft` and `.trash` are claimed
        /// (01-storage-format.md § Enhanced schema). `cardPath` names the *card*, so the one thing a
        /// caller has to know is where the card is, exactly as `.card` asks.
        case commentThread(cardPath: String)
        /// One comment's own folder — where `attachments` is claimed, the card's rule read one level
        /// down. `path` is the comment folder's, root-relative.
        case comment(path: String)

        /// The folder the claimed name lives in, under `root`.
        public func folder(under root: URL) -> URL {
            switch self {
            case .boardRoot:
                root
            case let .card(path), let .comment(path):
                root.appendingPathComponent(path, isDirectory: true)
            case let .commentThread(cardPath):
                root
                    .appendingPathComponent(cardPath, isDirectory: true)
                    .appendingPathComponent(IntegrityRules.commentsFolderName, isDirectory: true)
            }
        }

        /// The location as a signature component — `""` for the board root, so the existing root-level
        /// signature spelling is unchanged and only a nested defect adds path segments.
        var signatureComponent: String {
            switch self {
            case .boardRoot: ""
            case let .card(path), let .comment(path): path + "/"
            case let .commentThread(cardPath): cardPath + "/" + IntegrityRules.commentsFolderName + "/"
            }
        }
    }

    /// The claimed name, exactly as the app spells it (`.trash`, `CLAUDE.md`, `attachments`).
    public let name: String
    /// What is actually sitting there — never followed if it is a symlink.
    public let found: IntegrityRules.NodeKind
    /// What the app needs the name to be.
    public let expected: IntegrityRules.NodeKind
    /// Where the name lives. Defaults to the board root, which is where every squatter was before the
    /// rule went level-uniform — so the root-level call sites and their tests read exactly as they did.
    public let location: Location

    public init(
        name: String,
        found: IntegrityRules.NodeKind,
        expected: IntegrityRules.NodeKind,
        location: Location = .boardRoot
    ) {
        self.name = name
        self.found = found
        self.expected = expected
        self.location = location
    }
}

/// A **later occurrence** of an identity the board already carries — a folder hand-copied in Finder
/// keeping its UUID (01-storage-format.md § Fractal layout ▸ Rules: "Duplicate ids within a board are
/// never tolerated … Every later occurrence is withheld from rendering — preserved verbatim, pointed
/// log line").
///
/// **Withheld, then reminted.** The loader keeps it out of every snapshot, which is what makes the
/// one-item-per-id invariant hold by construction — SwiftUI's `ForEach` does not tolerate two equal
/// ids — and the scheduled heal then gives it the fresh identity the import boundary would have
/// minted, after which it renders as an ordinary item (re-ruled 2026-07-29: a silent heal, superseding
/// the former user-gated Repair banner — "Lanework owns the board and re-mints object UUIDs at will").
///
/// Nothing on disk is lost in the meantime: the folder, its `index.md`, its children and its strays
/// are exactly where they were, and the withheld window is one heal cycle rather than a standing
/// condition (02-architecture.md ▸ Live-reload resilience).
public struct DuplicateIdentity: Sendable, Equatable {
    /// The withheld folder's path relative to the board root — see `IdentityOccurrence.path` for why
    /// a duplicate is keyed by path and not by id.
    public let path: String
    /// The identity both occurrences share, canonically (lowercased). Part of the heal's signature:
    /// the same folder losing a *different* collision is new work.
    public let identity: String
    /// The withheld item's title as written, `nil` for an untitled one — what the notice names.
    public let title: String?
    /// The path of the occurrence that won — the log line's other half, and the answer to the only
    /// question the log line owes ("withheld in favour of *what*").
    public let winner: String

    public init(path: String, identity: String, title: String?, winner: String) {
        self.path = path
        self.identity = identity
        self.title = title
        self.winner = winner
    }
}

/// One file's **coerce-tier fallbacks**: the lenient fields whose value had no sensible reading, so the
/// field rendered as its default (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 — the one place where an observed-in-the-wild shape can later be
/// promoted to a heuristic heal or a notice; no banner, no behavior change").
///
/// **Per file, not per field** — `LooseCardFiles`' shape and for its reason: the walk meets a document
/// once and reads all of its fields there, so one record per `index.md` is what the loader naturally
/// has, and a caller that wants per-field granularity has `fields` (and the per-field `signatures`).
///
/// The path is root-relative, as every load-side path in this app is (`BoardLoadError.path`,
/// `IdentityOccurrence.path`) — it names the `index.md`, because that is the file whose bytes were
/// read and the thing a developer would open.
public struct CoercedFrontmatter: Sendable, Equatable {
    /// The `index.md`'s path relative to the board root — `"index.md"`, `"<lane>/index.md"`,
    /// `"<lane>/<card>/index.md"`, `".trash/<card>/index.md"`.
    public let path: String
    /// The fields that fell back, in schema order. Never empty — a document that read cleanly
    /// contributes no defect at all.
    public let fields: [CoercedField]

    public init(path: String, fields: [CoercedField]) {
        self.path = path
        self.fields = fields
    }
}

/// A folder whose name is a **case-spelled twin** of another occurrence of the same identity — one
/// item typed two ways, not two items (01-storage-format.md § Fractal layout ▸ Rules: "the canonical
/// all-lowercase spelling wins where present, else the lexicographically first spelling; the loser
/// takes the stray posture — skipped with a pointed log line, preserved verbatim, never rendered").
///
/// The winning *spelling* is picked under the container preference first (stated 2026-07-29 — see
/// `IntegrityRules.dedupe(_:)`), so a live card is never skipped in favour of its own trashed ghost's
/// spelling. Which side wins is all that changed: a twin is still silent either way.
///
/// **Not a `Defect`, and that is the ruling rather than an omission**: this is the *tolerate* tier —
/// there is nothing to do. The twin is a spelling artifact of the item that rendered, so reminting it
/// would create duplicate content the user never made, and announcing it would surface spelling as a
/// problem. It reaches the caller as a `LoadWarning`, where every other tolerated stray lives.
public struct CaseTwin: Sendable, Equatable {
    /// The skipped folder's path relative to the board root.
    public let path: String
    /// The path of the occurrence whose spelling won — what the log line names it a twin *of*.
    public let winner: String

    public init(path: String, winner: String) {
        self.path = path
        self.winner = winner
    }
}
