import Foundation
import os

/// Turns a mutation into a filesystem operation — the single point through which every write
/// the app makes reaches disk (02-architecture.md § Layering ▸ Components). Stateless by
/// construction: there is no in-flight buffer, no queue, no coalescing. **A write is done when
/// the rename completes**, and a failed write is a failure the caller sees, so views — which
/// render only what is on disk — can never show phantom state (02-architecture.md §
/// Write-failure surfacing).
///
/// Four rules from 01-storage-format.md live here and are not negotiable per call site:
///
/// - **Atomic writes** (§ Fractal layout ▸ Rules): temp file, rename over `index.md`. Every
///   write, no exceptions — a reader (this app's watcher, an agent, git) never sees a partial
///   file, and a crash mid-write leaves the previous content intact.
/// - **Round-trip, never re-serialize**: mutations go through `FrontmatterDocument`, which
///   edits by line span, so unknown keys and their order, comments, blank lines, line endings,
///   and the body survive every write by construction rather than by remembering to preserve
///   them.
/// - **`modified` stamped, `modified-by` cleared** (§ Frontmatter): on every app-mediated write
///   path that rewrites *content*. Absence of `modified-by` means "the board's user, via the app";
///   the file is being rewritten anyway, so clearing an external writer's self-reported stamp costs
///   nothing. The one class of write that does neither is the **order-only rewrite**
///   (`WriteOperation.rewritesOrderOnly` — the reorders-don't-stamp rule).
/// - **Encoding** (§ Fractal layout ▸ Rules): writes are BOM-less UTF-8; reads are strict
///   UTF-8, and a file that does not decode is a loud, specific error rather than a
///   lossy best guess.
public enum BoardWriter: Sendable {

    // MARK: - The uniform per-file mutation

    /// Rewrites one item's `index.md`: read fresh, refuse what cannot be edited, apply `edits`,
    /// stamp, write atomically.
    ///
    /// The order of the four steps is the contract:
    ///
    /// 1. **Read fresh from disk**, never from a snapshot. The snapshot a caller is holding may
    ///    be seconds stale — an agent or a hand-editor may have rewritten the file since — and
    ///    the round-trip guarantee is only worth anything against the bytes actually there.
    /// 2. **Refuse an uneditable shape before `edits` runs** (`FrontmatterDocument.uneditableShape`):
    ///    the settled readable-but-uneditable rule. Such a file loads and renders fine, but a
    ///    surgical edit of it cannot be expressed, so the write fails loudly instead of
    ///    corrupting it. Refusing up front also means `edits` never observes a document it
    ///    cannot affect.
    /// 3. **`edits`, then the stamps** — `modified` set and `modified-by` removed *after* the
    ///    caller's closure, so the stamp always wins over anything the closure did with those
    ///    two keys, and no call site has to remember them.
    /// 4. **Atomic replace.**
    ///
    /// ## The reorders-don't-stamp predicate
    ///
    /// Step 3 is **skipped for an order-only rewrite** (01-storage-format.md § Frontmatter ▸
    /// `modified`'s scope — ruled 2026-07-29 as moves-don't-stamp, refined 2026-07-30 to the
    /// container-change predicate). `order` is logically the *container's* property — a relationship
    /// among a lane's members that the format happens to store inside each member's file — so a
    /// rewrite that only restates position touches no content: no `modified` stamp, and no
    /// `modified-by` clear (the two are paired; attribution cannot change when content didn't).
    ///
    /// **The predicate is the operation's, not a parameter** (`WriteOperation.rewritesOrderOnly`):
    /// the vocabulary already draws the line this rule needs — `.reorder` is by construction the
    /// same-container case (`moveItem` decides it from the two URLs before it touches disk) and
    /// `.renumberChildren` is the whole-lane rescale. Deriving it here rather than asking each call
    /// site means no caller can forget, and the rule stays one exhaustive switch a suite can pin
    /// without a filesystem.
    ///
    /// **There is no trash branch anywhere**, deliberately: a move into or out of `.trash/` changes
    /// the item's container, so it stamps for the same reason a cross-lane move does. The trash move
    /// is the container rule's plainest instance rather than an exception to a rule about moves.
    ///
    /// The **one path that deliberately bypasses this** is the card window's raw-source Apply
    /// (05-card-window.md): it writes the user's text byte-for-byte and does *not* clear a
    /// `modified-by` the user typed or kept — the validated-then-verbatim contract outranks the
    /// clearing rule (01-storage-format.md § Frontmatter). That path goes through
    /// `atomicReplace` directly; it does not belong here.
    ///
    /// ## The on-touch heal seam
    ///
    /// **This is where latent defects are fixed** (01-storage-format.md § Validation and healing;
    /// 02-architecture.md ▸ Components ▸ HealScheduler: "on-touch heals live at the Writer's
    /// `updateIndex` seam, which consults IntegrityRules for pending on-touch work on the file it is
    /// rewriting"). Between `edits` and the stamps, `IntegrityRules.healOnTouch` backfills a missing
    /// `kind` — the file is being rewritten anyway, so the fix costs nothing and rides the host
    /// write's single atomic rewrite, its `modified` stamp, and its commit. There is deliberately no
    /// scheduled sweep for it (re-ruled 2026-07-29): outside `.trash/` the key is redundant with
    /// position, and rewriting a whole board to add one would be churn for nothing.
    ///
    /// The other two members of that class need no line here because they are already the editor's:
    /// `FrontmatterDocument.set` collapses duplicate-key twins on every key it writes, and
    /// `FrontmatterValue.emitScalar` quotes a value that needs it on first write. They are *named*
    /// in `IntegrityRules.OnTouchHeal` rather than re-implemented — same class, no behavior change.
    ///
    /// - Parameter kind: the object's kind where the caller knows it and position cannot answer —
    ///   **the board root**, whose folder name is a Finder document name rather than an identity.
    ///   `nil`, the default, derives it from position (`IntegrityRules.placement`), and stamps
    ///   nothing when position has no answer: a guessed kind on disk would be worse than an absent
    ///   one, because the trash's discriminator trusts what it finds.
    /// - Parameter stampedAt: the value `modified` takes, for the **one** write whose stamp is not
    ///   "now": the legacy tombstone migration, which stamps from the `deleted:` timestamp it is
    ///   retiring so a board's real deletion order survives into the trash's `modified`-descending
    ///   sort (01-storage-format.md § Deletion). `nil`, the default, is `Date()` — every other
    ///   caller. It is a parameter rather than something the `edits` closure could do because the
    ///   stamps deliberately run *after* `edits` and outrank it.
    public static func updateIndex(
        inItemFolder folder: URL,
        kind: IntegrityRules.ObjectKind? = nil,
        stampedAt: Date? = nil,
        operation: WriteOperation,
        edits: (inout FrontmatterDocument) -> Void
    ) throws(BoardWriteError) {
        let indexURL = folder.appendingPathComponent(BoardLoader.indexFileName)
        var document = try readDocument(at: indexURL, operation: operation)
        // The read just above is *the* title-enrichment point for every case that funnels
        // through here (renumber, delete, restore, style, and the tail of move/copy): shadow
        // the parameter so every failure from here on — the uneditable-shape refusal, the
        // atomic replace — names the item.
        let operation = operation.withTitle(document.title.value)
        try checkEditable(document, at: indexURL, operation: operation)

        let orderBefore = document.order
        edits(&document)

        let itemKind = kind ?? derivedKind(ofItemFolder: folder)

        // **The inline sibling stamp** (01-storage-format.md § Ordering, re-ruled 2026-07-31): a
        // write that rewrites this item's *rank* is placement math landing, and placement math ranks
        // against the append-at-end reading of any order-less sibling — so those siblings are made to
        // hold that reading, here, inside the caller's own bracket and commit. Sequence-preserving
        // and a no-op on every board this app wrote; see `stampAppendAtEndOrders`.
        //
        // Two exclusions. `.renumberChildren` *is* the rescale — it rewrites every sibling with a
        // fresh rank already, and re-entering here would recurse. Anything that is not a lane or a
        // card has no rank ladder to be placed in: a board root's siblings are other people's
        // folders, and a comment's are chronology.
        if document.order != orderBefore, operation != .renumberChildren, itemKind == .lane || itemKind == .card {
            try stampAppendAtEndOrders(
                of: folder.deletingLastPathComponent(), excluding: folder, operation: operation)
        }

        // After `edits`, so a caller that wrote its own `kind` or `order` is left alone, and before
        // the stamps, which outrank everything for their own reason.
        IntegrityRules.healOnTouch(
            &document,
            kind: itemKind,
            rank: appendAtEndRank(ofItemFolder: folder, kind: itemKind)
        )
        // The reorders-don't-stamp predicate, read off the operation. An order-only rewrite restates
        // the container's own arrangement and leaves both provenance keys exactly as it found them —
        // a standing `modified-by` survives a reorder, which is the pairing 01 spells out.
        if !operation.rewritesOrderOnly {
            document.set(FrontmatterKeys.modified, to: .date(stampedAt ?? Date()))
            document.remove(FrontmatterKeys.modifiedBy)
        }

        try atomicReplace(text: document.serialized(), at: indexURL, operation: operation)
    }

    /// The kind of the item in `folder`, read off its **position** — "level is position"
    /// (01-storage-format.md § Fractal layout), with the trash's flat container falling through to
    /// the discriminator that exists for exactly that (`IntegrityRules.trashKind`).
    ///
    /// `nil` where position has no answer, which on a real board is only the board root (whose
    /// writers name their kind outright) and off it is any hand-named folder — a test fixture, a
    /// caller pointed at something that is not a level. Answering "board" there instead would stamp
    /// a kind onto whatever was pointed at, which is the one thing the value-names-the-kind posture
    /// cannot afford.
    private static func derivedKind(ofItemFolder folder: URL) -> IntegrityRules.ObjectKind? {
        // The URL form, deliberately: it is the only one that can tell the board's `.trash/` from a
        // comment thread's own `comments/.trash/`, which share a name.
        switch IntegrityRules.placement(ofFolder: folder) {
        case .card:
            return .card
        case .lane:
            return .lane
        case .comment:
            return .comment
        case .insideTrash:
            // The value cannot have answered — a document carrying `kind` is never backfilled, so
            // this is only reached for one that does not — which is precisely when shape decides.
            return IntegrityRules.trashKind(
                kindValue: nil,
                hasIdentityShapedChildIndex: childCandidates(of: folder).contains {
                    FileManager.default.fileExists(
                        atPath: $0.appendingPathComponent(BoardLoader.indexFileName).path
                    )
                }
            )
        case .unknown:
            return nil
        }
    }

    /// **The rank an order-less file reads as** — the on-touch stamp's value (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").
    ///
    /// It is `Ranks.resolvedOrders`' answer for this folder among its siblings — the very rank the
    /// board is already rendering it at — so the stamp writes down what the reading already said and
    /// nothing moves on screen. The loader and this share the function, which is what makes that
    /// true rather than merely intended.
    ///
    /// `nil` — stamp nothing — in four cases, each of them "this file has no rank to have":
    ///
    /// - **The kind has no ladder**: a board root (no siblings), a comment ("Ordering is chronology,
    ///   not ranks"), or a folder whose position has no answer at all.
    /// - **The sibling scan failed** (an unreadable neighbour, a parse error). An on-touch heal is
    ///   opportunistic by construction — it rides a host write that has its own reason to succeed —
    ///   so a failure here declines to stamp rather than failing that write. The next touch tries
    ///   again.
    /// - **The item is not among its own visible siblings**: it carries a legacy `deleted:` key, and
    ///   a tombstone is inert to ordering (§ Deletion). The migration relocates it; a rank invented
    ///   on the way would be a fact about a position it no longer has.
    ///
    /// Never called on a file that already carries a usable `order` — `healOnTouch` takes it as an
    /// `@autoclosure`, so the directory of reads below is paid for only where it is the answer.
    private static func appendAtEndRank(
        ofItemFolder folder: URL,
        kind: IntegrityRules.ObjectKind?
    ) -> Double? {
        switch kind {
        case .lane, .card: break
        case .board, .comment, nil: return nil
        }
        // The operation is only what a failure would be *named*, and this one discards failures.
        guard let siblings = try? visibleSiblings(
            of: folder.deletingLastPathComponent(),
            operation: .renumberChildren,
            requireEditable: false
        ) else {
            return nil
        }
        let identity = IntegrityRules.canonicalIdentity(folder.lastPathComponent)
        return siblings
            .first { IntegrityRules.canonicalIdentity($0.folder.lastPathComponent) == identity }?
            .order
    }

    // MARK: - Atomic replace

    /// Writes `text` over `fileURL` atomically: a hidden temp file in the **same directory**,
    /// then a rename over the destination.
    ///
    /// Same directory because a rename is only atomic within one filesystem — a temp in
    /// `NSTemporaryDirectory()` could land on another volume and degrade to a copy. Dot-prefixed
    /// (`.index.md.lanework-<uuid>`) because `BoardLoader.directoryCandidates` skips hidden
    /// entries: residue from a crashed write is invisible to a load rather than a stray warning
    /// or, worse, a candidate. The UUID keeps concurrent writers off each other's temp file.
    ///
    /// `Data(text.utf8)` is BOM-less UTF-8 by construction — the encoding contract, with no
    /// encoder to configure and no failure case to handle. On any failure the temp file is
    /// removed best-effort and `.io` is thrown: the destination is either the old bytes or the
    /// new ones, never a mix, and never a directory littered with half-written files.
    static func atomicReplace(text: String, at fileURL: URL, operation: WriteOperation) throws(BoardWriteError) {
        try atomicWrite(Data(text.utf8), at: fileURL, operation: operation)
        // **The receipt, dropped after the bytes land and before the call returns** (the
        // EchoLedger's contract, 02-architecture.md ▸ Components). This one line covers every
        // `index.md` in the app: `updateIndex` funnels here, and so do create, materialize,
        // recreate, the task-marker flip, the body save and the raw-source Apply.
        EchoLedger.current?.recordWrite(at: fileURL, text: text)
    }

    /// The temp-and-rename itself, with no opinion about what the bytes are — shared by the text
    /// path above and by `writeBoardImage`, so there is one atomic write in the app rather than two
    /// that could drift on the temp name, the cleanup or the `rename(2)`.
    ///
    /// It drops **no receipt**: what a write means to the echo ledger differs between an `index.md`
    /// and a generated image, so each caller records its own.
    private static func atomicWrite(_ data: Data, at fileURL: URL, operation: WriteOperation) throws(BoardWriteError) {
        let directory = fileURL.deletingLastPathComponent()
        let tempURL = directory.appendingPathComponent(".\(fileURL.lastPathComponent).lanework-\(UUID().uuidString)")

        do {
            try data.write(to: tempURL)
        } catch {
            try? FileManager.default.removeItem(at: tempURL)
            throw BoardWriteError(
                operation: operation,
                path: fileURL.path,
                reason: .io(message: "could not write temporary file: \(error.localizedDescription)")
            )
        }

        // POSIX `rename` rather than `FileManager.replaceItemAt`: it atomically overwrites an
        // existing destination *and* handles one that does not exist yet (the create paths),
        // without inventing a second temp file of its own.
        let status = tempURL.withUnsafeFileSystemRepresentation { source in
            fileURL.withUnsafeFileSystemRepresentation { destination in
                guard let source, let destination else { return EINVAL }
                return rename(source, destination) == 0 ? 0 : errno
            }
        }
        guard status == 0 else {
            try? FileManager.default.removeItem(at: tempURL)
            throw BoardWriteError(
                operation: operation,
                path: fileURL.path,
                reason: .io(message: "could not replace file: \(String(cString: strerror(status)))")
            )
        }
    }

    // MARK: - Generated board artwork

    /// **Writes a generated background image into `root`** — the one path in the app that puts
    /// *bytes the app composed* on disk rather than text it edited (03-board-ui.md § Styling ▸
    /// Capabilities, the `background.image` half; `FacetsGenerator`).
    ///
    /// `root` is the board root for a legacy-shaped write and `.backgrounds/` for every current one
    /// (`BoardBackdrop.backgroundsFolderName`, ruled 2026-08-09 — `BoardStore.boardImageName` decides
    /// which); either way this call does not care, it just writes `name` under whatever folder it is
    /// given.
    ///
    /// It is `atomicReplace` with a different payload and the same four properties, which is the
    /// point of it existing here rather than at the store: hidden dot-temp in the **same folder**, a
    /// POSIX rename over the destination, best-effort cleanup on failure, and a receipt so the churn
    /// classifies as the app's rather than as a foreign write. A board whose backdrop is being
    /// regenerated is a board whose renderer may be mid-decode on the old file, and a rename is the
    /// only way to hand it either the old bytes or the new ones and never a truncated file.
    ///
    /// **`root` is created if it is not there yet.** The board root always is, so this is a no-op on
    /// that call; `.backgrounds/` is not, the first time any board writes to it, and asking every
    /// caller to remember the `mkdir` would just be one more way to get it wrong.
    ///
    /// **Overwriting is the caller's decision, expressed as a name.** This writes whatever name it is
    /// given, so the policy — reuse ours, or step aside from somebody else's file — lives in one
    /// place at the store (`BoardStore.applyGeneratedBackground`) rather than being half here and
    /// half there. `name` must be a bare filename; a path is refused rather than resolved, because a
    /// background that could be written outside `root` is the mirror of the containment rule
    /// `BoardBackdrop.imageURL(named:inBoardRoot:)` already enforces on the read side — `root` itself
    /// is the caller's to keep inside the board.
    ///
    /// - Returns: the name written, so a caller can chain straight into the frontmatter write
    ///   without restating it.
    @discardableResult
    public static func writeBoardImage(
        data: Data,
        named name: String,
        inRoot root: URL,
        operation: WriteOperation
    ) throws(BoardWriteError) -> String {
        guard !name.isEmpty, !name.contains("/"), name != ".", name != ".." else {
            throw BoardWriteError(
                operation: operation,
                path: root.appendingPathComponent(name).path,
                reason: .io(message: "'\(name)' is not a file name a board image can be written under")
            )
        }
        do {
            try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
        } catch {
            throw BoardWriteError(
                operation: operation,
                path: root.path,
                reason: .io(message: "could not create '\(root.lastPathComponent)': \(error.localizedDescription)")
            )
        }
        let fileURL = root.appendingPathComponent(name)
        try atomicWrite(data, at: fileURL, operation: operation)
        // The bytes are already in hand, so this is the hash-what-you-wrote form rather than
        // `recordImport`'s read-it-back-and-hope — see `EchoLedger.recordImport(at:)` for the
        // difference and why the app prefers this side of it wherever it can.
        EchoLedger.current?.recordWrite(at: fileURL, data: data)
        return name
    }

    // MARK: - Create

    /// Creates a new board: the folder (if it does not already exist) and its `index.md` — the
    /// write side of `.kanban` packaging (01-storage-format.md § Document packaging). Two
    /// callers: template instantiation, which passes the user-chosen document name as `title`
    /// so the window title and the Finder name start out matching (§ Board naming), and a bare
    /// "New Board" flow, which passes `nil` and lets the UI fall back to the folder name.
    ///
    /// - `rootURL` is created if missing (`withIntermediateDirectories: true`); an existing
    ///   *empty* folder is simply filled in, since that is exactly what an interrupted create or
    ///   a hand-made folder looks like.
    /// - **Refuses to clobber an existing board**: if `index.md` is already there, the call
    ///   fails `.io` naming the path and the existing file is never touched — a create must
    ///   never overwrite a board that already exists.
    /// - `title == nil` writes no `title` key at all — the folder-name fallback (§ Board
    ///   naming) is a *missing* key, not an empty string, which would be a real (if blank) title.
    /// - Key order: `schema`, `title` (only when supplied), `created`, `modified`. No `order` —
    ///   that field belongs to lanes and cards, never the board root.
    /// - Extension-less board folders are exactly as legal a target as a `.kanban`-suffixed one
    ///   (§ Document packaging, "Extension-less board folders still open") — this call never
    ///   looks at `rootURL`'s extension.
    /// - **The board is born with its noise definition** (06-history-undo.md ▸ Repository hygiene,
    ///   re-ruled 2026-07-31: "Board creation writes the minimal seed"): `.gitignore` lands beside
    ///   `index.md`, after it, so the file that makes a folder a board is written first and a failure
    ///   to seed can never leave a half-made board. Seeding is `seedGitignoreIfAbsent`'s, so a
    ///   creation into a folder that somehow already carries one leaves it alone.
    /// - **And with a current agent guide** (08-agent-integration.md ▸ The agent guide; ruled
    ///   2026-08-09, extending the option-C recommendation to every `createBoard` caller): right
    ///   after the `.gitignore` seed, `AgentGuide.install(atBoardRoot:)` runs too, so a board never
    ///   lives even a moment without a current-version `CLAUDE.md` — no dependency on a later Mac
    ///   open to write the first copy. `KanbanMobile`'s `BoardIndexStore.createBoard` calls this same
    ///   method, so a phone-made board gets the identical guarantee for free. Routed through
    ///   `install` rather than a hand-rolled write on purpose: that is what carries over the guide's
    ///   own promises (never-downgrade, the `CLAUDE.user.md` rescue, squatter displacement, and
    ///   `EchoLedger.current?.markHeal(at:)`'s attribution exclusion) to this new call site without
    ///   re-deriving any of them. A failure here is a genuine `BoardWriteError` and throws like any
    ///   other step; there is no new banner or notice class for it.
    /// - Parameter operation: what the *caller* was doing, for the banner's sake — `.createBoard` for
    ///   every gesture that makes a board, and `.mintBoardIndex` for the decision surface's repair of
    ///   a board folder that has everything except the file that says it is one
    ///   (01-storage-format.md § Malformed input). The mechanics are identical, which is exactly why
    ///   the repair reuses this method rather than growing a second one; only the sentence a failure
    ///   would produce differs, and that sentence is the vocabulary's whole job.
    public static func createBoard(
        at rootURL: URL,
        title: String?,
        operation: WriteOperation = .createBoard
    ) throws(BoardWriteError) {
        let indexURL = rootURL.appendingPathComponent(BoardLoader.indexFileName)

        guard !FileManager.default.fileExists(atPath: indexURL.path) else {
            throw BoardWriteError(operation: operation, path: indexURL.path, reason: .io(message: "a board already exists here"))
        }

        do {
            try FileManager.default.createDirectory(at: rootURL, withIntermediateDirectories: true)
        } catch {
            throw BoardWriteError(
                operation: operation,
                path: rootURL.path,
                reason: .io(message: "could not create board folder: \(error.localizedDescription)")
            )
        }

        try atomicReplace(
            text: newDocumentText(title: title, order: nil, kind: .board),
            at: indexURL,
            operation: operation
        )
        try seedGitignoreIfAbsent(atBoardRoot: rootURL)
        try AgentGuide.install(atBoardRoot: rootURL)
    }

    /// Creates a lane in a board: mints a fresh lowercase-UUIDv4 folder directly under
    /// `rootURL`, appends it after the board's current visible lanes, and writes its
    /// `index.md`. Returns the new identity. See `createChild(inParent:title:operation:)` for
    /// the shared mechanics.
    public static func createLane(inBoard rootURL: URL, title: String?) throws(BoardWriteError) -> ItemID {
        try createChild(inParent: rootURL, title: title, kind: .lane, operation: .createLane)
    }

    /// Creates a card in a lane: mints a fresh lowercase-UUIDv4 folder directly under
    /// `laneURL`, appends it after the lane's current visible cards, and writes its `index.md`.
    /// Returns the new identity. See `createChild(inParent:title:operation:)` for the shared
    /// mechanics.
    public static func createCard(inLane laneURL: URL, title: String?) throws(BoardWriteError) -> ItemID {
        try createChild(inParent: laneURL, title: title, kind: .card, operation: .createCard)
    }

    /// The shared body of `createLane`/`createCard` — a lane under a board and a card under a
    /// lane are the same operation one level apart (01-storage-format.md § Fractal layout,
    /// "Level is position"): check the parent, place the new identity after the current visible
    /// siblings, mint it, write its file.
    ///
    /// - **The parent must already exist as a directory.** Anything else — missing, or a file
    ///   where a folder belongs — is a loud `.unreadable` naming `parentFolder` up front, never
    ///   a silent `mkdir` into a broken location.
    /// - **Order is `max + 1024` among visible siblings** (`Ranks.append(toVisible:)`), read by
    ///   `visibleSiblings(of:operation:)` — the identical strict scan `renumberVisibleChildren`
    ///   uses, so the two can never disagree about who counts as a sibling. An empty parent's
    ///   first child lands at `1024`, the board convention (01-storage-format.md § Ordering).
    /// - **Two-step creation is inherent, not a shortcut taken here**: the folder is created,
    ///   then `index.md` is written, as two separate filesystem operations — there is no atomic
    ///   "create a directory with content already in it" primitive to reach for. A crash between
    ///   the two leaves a UUID-shaped folder with no `index.md`, exactly the shape
    ///   `BoardLoader`'s `.missingIndex` skip-and-warn rule (and `visibleSiblings`'s own
    ///   `fileExists` check) already tolerate — see that type's doc comment.
    private static func createChild(
        inParent parentFolder: URL,
        title: String?,
        kind: IntegrityRules.ObjectKind,
        operation: WriteOperation
    ) throws(BoardWriteError) -> ItemID {
        try checkIsDirectory(parentFolder, describedAs: "parent folder", operation: operation)

        // **The inline sibling stamp, before the append** (01-storage-format.md § Ordering, re-ruled
        // 2026-07-31). A create is placement math like any other — "after the current visible
        // siblings" is a rank *relative to* the last of them — so an order-less sibling has to hold
        // the rank this append is computed against, or the new item would file itself above a card
        // that re-materializes past it on the next load. It is the one placement that cannot ride
        // `updateIndex`'s hook: this path mints a file rather than rewriting one.
        //
        // `excluding: nil` — the new identity does not exist yet, so there is nothing to skip, and
        // the container the stamp reads is exactly the one the append is about to be computed over.
        let siblings = try stampAppendAtEndOrders(of: parentFolder, excluding: nil, operation: operation)
        let order = Ranks.append(toVisible: siblings.map(\.order))

        let folder = try mintUUIDFolder(in: parentFolder, operation: operation)
        let indexURL = folder.appendingPathComponent(BoardLoader.indexFileName)
        try atomicReplace(
            text: newDocumentText(title: title, order: order, kind: kind),
            at: indexURL,
            operation: operation
        )

        return ItemID(rawValue: folder.lastPathComponent)
    }

    /// The frontmatter text for a file the app is minting outright — never a rewrite, so this
    /// goes through `FrontmatterDocument(body:)` and `set`, not `updateIndex`: there is no prior
    /// document to read fresh, refuse an uneditable shape from, or stamp over. `created` and
    /// `modified` share one `Date()` so the two stamps are identical, not merely close;
    /// `modified-by` is never written, matching the engine's absence-means-app-authored
    /// convention (§ Frontmatter). Key order — `schema`, `title` (only when supplied), `order`
    /// (only when supplied — `nil` for a board, always present for a lane/card), `created`,
    /// `modified`, `kind` — is simply the order `set` is called in, since each call appends a fresh
    /// key before the closing delimiter of an otherwise-empty document. `kind` goes last because
    /// that is where the common table puts it, and because it is where the on-touch backfill appends
    /// one on an older file: a created object and a healed one end up spelled the same way.
    ///
    /// **`kind` is written at creation of every object** (01-storage-format.md § Frontmatter,
    /// re-ruled 2026-07-29 — "consistency across the schema, even where position already answers").
    /// Bundled and user templates carry files without it; they gain it lazily through the on-touch
    /// backfill on the first write that rewrites them, which is exactly what that heal is for — no
    /// template migration.
    private static func newDocumentText(
        title: String?,
        order: Double?,
        kind: IntegrityRules.ObjectKind
    ) -> String {
        var document = FrontmatterDocument(body: "")
        document.set(FrontmatterKeys.schema, to: .int(BoardLoader.supportedSchema))
        if let title {
            document.set(FrontmatterKeys.title, to: .string(title))
        }
        if let order {
            document.set(FrontmatterKeys.order, to: .double(order))
        }
        let now = Date()
        document.set(FrontmatterKeys.created, to: .date(now))
        document.set(FrontmatterKeys.modified, to: .date(now))
        document.set(FrontmatterKeys.kind, to: .string(kind.rawValue))
        return document.serialized()
    }

    /// A fresh lowercase-UUIDv4 folder under `parentFolder` — the create path's mint, which
    /// materializes the folder as well as naming it. The naming rule itself lives in
    /// `freshUUIDName(in:avoiding:)`, shared with the move and copy paths so every identity the
    /// app mints is minted one way.
    private static func mintUUIDFolder(in parentFolder: URL, operation: WriteOperation) throws(BoardWriteError) -> URL {
        let candidate = parentFolder.appendingPathComponent(
            freshUUIDName(in: parentFolder, avoiding: []),
            isDirectory: true
        )
        do {
            try FileManager.default.createDirectory(at: candidate, withIntermediateDirectories: false)
        } catch {
            throw BoardWriteError(
                operation: operation,
                path: candidate.path,
                reason: .io(message: "could not create folder: \(error.localizedDescription)")
            )
        }
        return candidate
    }

    /// A fresh lowercase-UUIDv4 folder *name* for `parentFolder` — the folder-naming convention
    /// itself (01-storage-format.md § Fractal layout ▸ Rules, "Folder names are lowercase
    /// UUIDv4"). `UUID().uuidString` is uppercase; `.lowercased()` is the app's **emission**
    /// rule — accept liberally, emit conservatively. The loader's gate
    /// (`BoardLoader.isUUIDShaped`) accepts either case, so this lowercasing is a convention the
    /// app holds itself to, not something a reader depends on.
    ///
    /// Two exclusions, both re-minted rather than assumed away: a name already on disk in
    /// `parentFolder` (so the caller's `createDirectory`/`moveItem`/`copyItem` cannot lose a
    /// race with an existing entry), and any name in `taken` — the identities a collision repair
    /// is minting *away* from, which are not necessarily on disk here. **`taken` is canonical**
    /// (lowercased, `IntegrityRules.canonicalIdentity` — the one canonicalization, shared with `ItemID`), which is what makes the `contains` a UUID-*value*
    /// probe: the minted name is lowercase, so it can only match a canonical set. A freshly
    /// minted UUID hitting either is astronomically unlikely — 122 bits of randomness per mint —
    /// but the loop body is trivial precisely because the case it handles essentially never fires.
    /// Internal rather than `private`, with `renameFolder` and for its reason: a posted comment's
    /// identity is minted by this exact rule.
    static func freshUUIDName(in parentFolder: URL, avoiding taken: Set<String>) -> String {
        var name: String
        repeat {
            name = UUID().uuidString.lowercased()
        } while taken.contains(name)
            || FileManager.default.fileExists(atPath: parentFolder.appendingPathComponent(name).path)
        return name
    }

    /// The folder-exists check every operation runs before touching the filesystem any further —
    /// shared instead of folded into its callers because "does this folder exist" has nothing to
    /// do with siblings, minting, or moving, and inlining it would bury the one thing a caller
    /// most needs to see at a glance: a missing or wrong-shaped folder is rejected before
    /// anything else happens. `role` names it the way the failing user action would ("parent
    /// folder", "item folder") — the error goes straight into the write-failure banner.
    /// **Internal rather than `private`**: the comment writer (`CommentWriter.swift`) is this
    /// file's own extension one level down and runs the identical pre-flights.
    static func checkIsDirectory(
        _ url: URL,
        describedAs role: String,
        operation: WriteOperation
    ) throws(BoardWriteError) {
        var isDirectory: ObjCBool = false
        guard FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory) else {
            throw BoardWriteError(operation: operation, path: url.path, reason: .unreadable(message: "\(role) does not exist"))
        }
        guard isDirectory.boolValue else {
            throw BoardWriteError(operation: operation, path: url.path, reason: .unreadable(message: "\(role) is not a directory"))
        }
    }

    // MARK: - The board's noise definition

    /// **The whole of the seeded `.gitignore`** (06-history-undo.md ▸ Repository hygiene, re-ruled
    /// 2026-07-31: "Board creation writes the minimal seed — `.DS_Store` plus the writer's temp
    /// pattern (`.*.lanework-*`)").
    ///
    /// Two lines, and each one earns its place:
    ///
    /// - **`.DS_Store`** — the Finder writes one into every folder a user looks at, and on a board
    ///   that means one per lane and one per card, each churning as icons and window positions move.
    ///   Once committed by the whole-tree stage on a git board, forever, under the user's own name;
    ///   app-managed git is gone now (`strategy/01-git-excision.md`), and on every board it is the
    ///   plainest thing the loose-file heal must not treat as a card's attachment.
    /// - **`.*.lanework-*`** — this file's own temp pattern, and the one entry the app is entitled to
    ///   have an opinion about: `atomicReplace` writes `.<name>.lanework-<uuid>` beside its target,
    ///   so a crashed write's residue is the app's litter rather than the user's file. The two must
    ///   stay in step; changing the temp name means changing this line.
    ///
    /// Nothing else. "Fine-tuning what counts as noise over time means fine-tuning the seed" — and
    /// after the seed the file is the user's, never edited by the app again.
    ///
    /// Files the app creates end with LF (01-storage-format.md § Encoding and line endings).
    public static let gitignoreSeed = ".DS_Store\n.*.lanework-*\n"

    /// **Puts the seed on a board that has no `.gitignore`, and does nothing to one that has**
    /// (06-history-undo.md ▸ Repository hygiene, re-ruled 2026-07-31: "`.gitignore` seeded on every
    /// board, never touched after").
    ///
    /// The three properties are the feature:
    ///
    /// - **Only when the name is free.** `IntegrityRules.node(at:)` — `lstat`, never `fileExists` —
    ///   so a *directory* or a **dangling symlink** wearing the name is left alone too, rather than
    ///   written through. A board that already carries a `.gitignore` (from a template, a clone, the
    ///   user, or an earlier open) is left byte for byte alone, and an **empty** one is honored
    ///   exactly as it stands: it is the ruling's own escape hatch for wanting no exclusions.
    /// - **Never an edit.** There is no merge, no append, no "and while we're here". The app writes
    ///   this file at most once per board and then reads it forever.
    /// - **Universal.** Board creation calls it, template instantiation calls it, and a scheduled
    ///   heal calls it at every open for boards born before the ruling — repo-nested boards included,
    ///   because the file serves the heal there, not any app-managed git.
    ///
    /// A foreign deletion is answered by re-seeding on the next open, which is what makes the
    /// scheduled heal's memo (armed before the attempt, cleared on success) load-bearing rather than
    /// tidy: the picture "missing" must stay healable.
    ///
    /// - Returns: whether this call wrote the file — `false` when the name was already taken, which
    ///   is a success with nothing to do (the heal's disk re-verify, and the reason a second open
    ///   costs no write).
    @discardableResult
    public static func seedGitignoreIfAbsent(atBoardRoot root: URL) throws(BoardWriteError) -> Bool {
        let url = root.appendingPathComponent(IntegrityRules.gitignoreFileName)
        guard IntegrityRules.node(at: url) == nil else { return false }
        try atomicReplace(text: gitignoreSeed, at: url, operation: .seedGitignore)
        // Heal-marked, on the agent guide's reasoning exactly: this is app-initiated work on a file
        // the user did not create, and its commit is its own (06-history-undo.md ▸ Commit messages —
        // a non-snapshot path composes "Update '.gitignore'").
        EchoLedger.current?.markHeal(at: url)
        return true
    }

    // MARK: - Renumber

    /// Renumbers a parent's visible children to whole multiples of 1024 — the renumber fallback
    /// for exhausted midpoint precision (01-storage-format.md § Ordering), and **the sole
    /// exception to "a reorder rewrites only the moved item"**. Uniform across levels: the
    /// parent is a lane (renumbering its cards) or the board root (renumbering its lanes).
    ///
    /// - **Runs over loaded, valid children** (`visibleSiblings(of:operation:)`): one that fails
    ///   to parse, or that lacks a usable `order`, fails the whole operation before anything is
    ///   written. A renumber is bookkeeping inside a user action that already succeeded in
    ///   principle — it must not be the thing that discovers a broken sibling halfway through
    ///   rewriting the lane.
    /// - **Display order is the assignment order** (`Ranks.isOrderedForDisplay`: `order`
    ///   ascending, folder name breaking ties) — the same rule the loader sorts by, so a
    ///   renumber is guaranteed to be sequence-preserving: nothing visibly moves. An order-less
    ///   sibling takes part on its append-at-end reading (`visibleSiblings`) and comes out of the
    ///   batch with a real rank, which makes the rescale the heavier sibling of
    ///   `stampAppendAtEndOrders` rather than a second rule.
    /// - **Nothing is stamped.** A rescale is order-only, so no sibling's `modified` moves and no
    ///   sibling's `modified-by` is cleared (01-storage-format.md § Ordering, verbatim: "order-only
    ///   rewrites, so no `modified` stamp and no `modified-by` clear"). That falls out of
    ///   `.renumberChildren` answering `rewritesOrderOnly` rather than being arranged here — which is
    ///   what keeps a whole lane's worth of bookkeeping from looking like a whole lane's worth of
    ///   edits to the card window, to a future auto-purge, and to an agent's own attribution.
    ///
    /// Each child's rewrite is atomic; the batch is not. An interrupted renumber leaves some
    /// siblings renumbered and some not — every `order` still a valid float, display order
    /// still deterministic, and the next renumber finishes the job. That is the accepted cost
    /// noted in § Ordering, which the deterministic tie-break exists to make harmless.
    public static func renumberVisibleChildren(of parentFolder: URL) throws(BoardWriteError) {
        let operation = WriteOperation.renumberChildren
        let visible = try visibleSiblings(of: parentFolder, operation: operation, requireEditable: true)

        let ordered = Ranks.sortedForDisplay(visible, order: { $0.order }, name: { $0.folder.lastPathComponent })
        for (child, rank) in zip(ordered, Ranks.renumbered(count: ordered.count)) {
            try updateIndex(inItemFolder: child.folder, operation: operation) { document in
                document.set(FrontmatterKeys.order, to: .double(rank))
            }
        }
    }

    /// The strict visible-sibling scan shared by `renumberVisibleChildren` and the create
    /// operations' order assignment — extracted into one place so the two can never disagree
    /// about who counts as a sibling. Walks `parentFolder`'s UUID-shaped children holding an
    /// `index.md` (`BoardLoader.directoryCandidates`/`isUUIDShaped` — the same level-detection
    /// rule the loader uses), each parsed strictly:
    ///
    /// - **Tombstones are inert to ordering** (§ Deletion): a child whose `deleted` key is
    ///   *present* is not counted — presence, not validity, is the test, exactly as
    ///   `Lane`/`Card.isDeleted` reads it (an explicit `deleted: null` is absence to both).
    /// - **Strays are untouched**: non-UUID-shaped folders, and UUID-shaped folders without an
    ///   `index.md` (an interrupted two-step create — the loader's own `.missingIndex` warning
    ///   tolerates exactly this), are skipped here for the same reasons `BoardLoader` skips them.
    /// - **A missing or unusable `order` is no longer a failure** (01-storage-format.md § Ordering,
    ///   re-ruled 2026-07-31): the sibling reads as append-at-end, exactly as the loader reads it
    ///   (`Ranks.resolvedOrders(of:stored:name:)`), and `order` carries that reading while
    ///   `hasStoredOrder` records whether the file actually says so. The scan is still strict about
    ///   everything it was strict about before — an unreadable or unparseable sibling still fails the
    ///   whole operation before anything is written, which is the discover-before-you-write guarantee
    ///   `renumberVisibleChildren`'s batch depends on.
    /// - `requireEditable` scopes the readable-but-uneditable pre-flight to the caller that
    ///   will actually *rewrite* the siblings: renumber passes `true` (it must not discover an
    ///   unwritable sibling halfway through the batch), the creates pass `false` — a create
    ///   only *reads* its siblings' orders, and a flow-mapping sibling that loads and renders
    ///   normally (01-storage-format.md § Frontmatter) must not block creating a new item
    ///   beside it.
    ///
    /// The returned ranks are **in listing order, not display order**; every caller that needs the
    /// latter sorts through `Ranks.sortedForDisplay`, as they always did.
    private static func visibleSiblings(
        of parentFolder: URL,
        operation: WriteOperation,
        requireEditable: Bool
    ) throws(BoardWriteError) -> [(folder: URL, order: Double, hasStoredOrder: Bool)] {
        let candidates: [URL]
        do {
            candidates = try BoardLoader.directoryCandidates(in: parentFolder)
        } catch {
            throw BoardWriteError(
                operation: operation,
                path: parentFolder.path,
                reason: .unreadable(message: error.description)
            )
        }

        var visible: [(folder: URL, storedOrder: Double?)] = []
        for folder in candidates where BoardLoader.isUUIDShaped(folder.lastPathComponent) {
            let indexURL = folder.appendingPathComponent(BoardLoader.indexFileName)
            guard FileManager.default.fileExists(atPath: indexURL.path) else { continue }

            let document = try readDocument(at: indexURL, operation: operation)
            guard document.deleted.isMissing else { continue }
            if requireEditable {
                try checkEditable(document, at: indexURL, operation: operation)
            }
            visible.append((folder: folder, storedOrder: IntegrityRules.resolvedOrder(in: document).order))
        }

        // **The loader's own reading, computed from the same bytes** — which is what lets a placement
        // the app computed against the *snapshot* be made true on disk by simply stamping it
        // (`stampAppendAtEndOrders(of:excluding:operation:)`).
        let resolved = Ranks.resolvedOrders(
            of: visible, stored: { $0.storedOrder }, name: { $0.folder.lastPathComponent })
        return zip(visible, resolved).map {
            (folder: $0.folder, order: $1, hasStoredOrder: $0.storedOrder != nil)
        }
    }

    // MARK: - The append-at-end reading's inline stamp

    /// **Writes down the append-at-end reading for every order-less child of `parentFolder`**
    /// (01-storage-format.md § Ordering, re-ruled 2026-07-31: "placement math that must rank an item
    /// *relative to* an order-less sibling stamps that sibling inline, inside the gesture's bracket
    /// and commit — the midpoint-renumber precedent").
    ///
    /// ## Why a placement cannot skip it
    ///
    /// An order-less file renders at a rank nobody wrote down. Placement math ranks the arriving item
    /// against that rank — a midpoint between two order-less siblings, an append past the last one —
    /// and the answer is only *true on disk* if the siblings hold the ranks the math assumed. Without
    /// the stamp the arriving item would be filed against ranks that evaporate on the next load, and
    /// the order-less siblings would re-materialize past it: the card lands in the wrong place,
    /// silently. Stamping is what closes that gap, and it is the same shape as the renumber that
    /// closes the midpoint-exhaustion gap — inline, inside the caller's write bracket, composing no
    /// event of its own.
    ///
    /// ## Why nothing moves when it lands
    ///
    /// The value written is `Ranks.resolvedOrders`' answer for that file — the rank the board was
    /// *already rendering it at*. So this is sequence-preserving by construction, exactly like a
    /// renumber, and it is idempotent: run it twice and the second call finds nothing to do.
    ///
    /// ## Cost, stated
    ///
    /// One `index.md` read per visible sibling, on any write that rewrites a rank — the same read the
    /// reload following that write performs anyway. A board this app wrote has no order-less children
    /// at all, so the pass writes nothing; it is the hand-filed and agent-filed cases that pay.
    ///
    /// ## The reading is the container's, and the caller must not narrow it
    ///
    /// `excluding` skips the **write**, never the reading: the ranks stamped are resolved over the
    /// container exactly as the loader resolved them, placed item included. Narrowing the resolution
    /// would re-base the materialized ranks against a different maximum than the snapshot the
    /// placement was computed from, and the two would disagree about where the order-less siblings
    /// sit. The corollary is that a placement whose item is **not yet in the container** — a
    /// cross-parent move, a copy, a create — must stamp *before* the folder lands, so that what this
    /// reads is what the loader read. Each of those call sites does.
    ///
    /// - Parameter excluding: the item the caller is about to place, when it is already a child here.
    ///   It is skipped because its own rank is about to be written by the caller anyway, and stamping
    ///   it first would be two writes to one file inside one gesture.
    /// - Returns: the container's visible siblings and their resolved ranks — the same values a
    ///   re-scan would answer, so a caller that needs them next (the creates' append) can use these
    ///   rather than reading the directory twice.
    @discardableResult
    static func stampAppendAtEndOrders(
        of parentFolder: URL,
        excluding placed: URL?,
        operation: WriteOperation
    ) throws(BoardWriteError) -> [(folder: URL, order: Double, hasStoredOrder: Bool)] {
        // `requireEditable: false`: only the order-less siblings are rewritten, so only *they* owe
        // the pre-flight — a flow-mapping sibling that already carries a rank must not block a
        // placement it has nothing to do with. `updateIndex` refuses an uneditable one loudly, which
        // is the honest answer for a file the placement genuinely depends on.
        let siblings = try visibleSiblings(of: parentFolder, operation: operation, requireEditable: false)
        let excluded = placed.map { IntegrityRules.canonicalIdentity($0.lastPathComponent) }

        for sibling in siblings where !sibling.hasStoredOrder {
            guard IntegrityRules.canonicalIdentity(sibling.folder.lastPathComponent) != excluded else { continue }
            // `.renumberChildren`: this *is* a rescale of the container's own arrangement — order-only,
            // so no `modified` stamp and no `modified-by` clear (the reorders-don't-stamp rule), and
            // the one operation `updateIndex` does not re-enter this pass for.
            try updateIndex(inItemFolder: sibling.folder, operation: .renumberChildren) { document in
                document.set(FrontmatterKeys.order, to: .double(sibling.order))
            }
        }
        return siblings
    }

    // MARK: - Move

    /// Moves a lane or card — and the whole tree beneath it — under another parent, in the same
    /// board or across boards. **A move is a physical folder move and the UUID travels
    /// unchanged** (01-storage-format.md § Fractal layout ▸ Rules, "Identity lifecycle: moves
    /// keep the UUID, copies mint fresh ones"): nothing below the moved root is read or
    /// rewritten, so nested cards, `attachments/`, and strays arrive byte-identical by
    /// construction rather than by copying carefully.
    ///
    /// Exactly one file is rewritten — the moved root's `index.md`, and only to carry its new
    /// `order` (§ Ordering, "a reorder rewrites only the moved item's `index.md`"). `order` is the
    /// caller's explicit rank (a drop between two siblings), or `nil` to append after the
    /// destination's visible siblings.
    ///
    /// **Whether that rewrite stamps is the container question** (§ Frontmatter ▸ `modified`'s scope,
    /// refined 2026-07-30 — `WriteOperation.rewritesOrderOnly`), and this call is where it is
    /// answered for every move in the app: the same-parent degenerate path below is a `.reorder` and
    /// rewrites `order` alone, while a real move — cross-lane, cross-board, into or out of `.trash/`
    /// — is a `.move` and stamps `modified` and clears `modified-by` like any content write. The
    /// branch that already exists for the *rank arithmetic* is therefore the whole of the stamping
    /// rule too; there is no second test, and pointedly no trash case.
    ///
    /// **The import boundary is the one place within-board uniqueness is enforced** (§ Fractal
    /// layout ▸ Rules). `sourceBoardRoot` and `destinationBoardRoot` are compared by resolved,
    /// standardized path; when they differ, this is an import, and an arriving UUID that already
    /// exists anywhere in the destination board — **tombstones included, because they are on
    /// disk** — is degraded to a copy: a fresh UUID is minted for that folder and nothing else.
    /// Degradation is **per folder, at the finest grain**: a lane arriving with one colliding
    /// card is still a lane *move*, and only that card arrives reminted. A remint is an identity
    /// repair, not an edit — the folder is renamed and its files' bytes are never touched, so
    /// the reminted item's `modified`, `modified-by`, and history are exactly what the source
    /// had. Every repair is reported in `MoveResult.reminted`; a same-board move never remints,
    /// and the same UUID living in two boards is not an error anywhere else — boards are
    /// **independent identity namespaces**, and forks only ever meet here.
    ///
    /// The order of operations is the contract:
    ///
    /// 1. **Validate before anything else**: source and destination parent must be existing
    ///    directories, and the source must be UUID-shaped — this writer moves lanes and cards,
    ///    and a stray is not one (§ Fractal layout ▸ Rules, "Name shape gates level detection").
    /// 2. **Pre-flight the moved root's editability** (`readDocument` + `checkEditable`): the
    ///    move must rewrite that file at the destination, so a file that cannot be round-tripped
    ///    refuses *before* the folder moves — discover-before-you-write, the same guarantee
    ///    `renumberVisibleChildren` depends on. Only the root needs it: a move never rewrites a
    ///    nested file, so an uneditable card inside a moved lane is none of this call's business.
    /// 3. **Compute the destination `order` before the move**, off the destination's visible
    ///    siblings — while the moved item is still elsewhere and so cannot count itself.
    /// 4. **Scan the destination board's identities** (depth 1 and 2, `directoryCandidates` +
    ///    `isUUIDShaped`; strays skipped, tombstones kept) — but only on an import, since a
    ///    same-board move cannot collide with anything but itself. The scan and every probe
    ///    against it are **by UUID value, not spelling** (`identities(inBoard:)` /
    ///    `IntegrityRules.canonicalIdentity`): an arriving `55555555-…` collides with a resident `55555555-…`
    ///    spelled in uppercase, because those are one identity (§ Fractal layout ▸ Rules).
    /// 5. **Move the folder** (`FileManager.moveItem`, which degrades to copy+remove across
    ///    volumes). A colliding *root* is renamed by moving it straight to its minted name
    ///    rather than moving and then renaming: one filesystem operation instead of two, and it
    ///    is also the only way the repair works when the collision is a sibling sitting in the
    ///    very destination parent — a plain move onto an existing name fails outright.
    /// 6. **Repair the arrived tree's children** — the moved root's direct UUID-shaped children,
    ///    which is every level a compound arrival can carry (a lane's cards; a card has no
    ///    UUID-shaped children).
    /// 7. **Rewrite the moved root's `index.md`** with the rank from step 3.
    ///
    /// **Atomic per filesystem operation, not per gesture** — the accepted edge. A rename that
    /// fails mid-repair, or the `updateIndex` that fails once the folder has already arrived,
    /// leaves the item at the destination with a stale `order` and possibly a partly repaired
    /// tree. Every value on disk is still valid, the failure is surfaced verbatim through the
    /// thrown error (02-architecture.md § Write-failure surfacing), and the caller's reload
    /// shows the true state — nothing is silently retried or rolled back behind the user's back.
    ///
    /// **A move whose destination is the item's current parent degrades to a plain reorder** —
    /// no folder to move, no boundary to cross, just the `order` rewrite (step 7) with the
    /// appended rank computed over the siblings *excluding the item itself*, which unlike every
    /// other move is already there to be miscounted. A drop that lands back in its own lane is
    /// the same gesture as one that lands elsewhere; the writer doing the right degenerate
    /// thing keeps the API total instead of leaking a `FileManager` name collision.
    public static func moveItem(
        at sourceFolder: URL,
        toParent destinationParent: URL,
        sourceBoardRoot: URL,
        destinationBoardRoot: URL,
        order: Double?
    ) throws(BoardWriteError) -> MoveResult {
        let sourceName = sourceFolder.lastPathComponent

        // Which case this is — an ordinary move, or the same-parent degenerate reorder — is
        // decided from the two URLs alone, before anything on disk is even looked at, so the
        // right vocabulary word is in hand for every pre-flight check that follows rather than
        // being retrofitted once the branch below is reached.
        let isReorder = isSameLocation(sourceFolder.deletingLastPathComponent(), destinationParent)
        var operation: WriteOperation = isReorder ? .reorder(title: nil) : .move(title: nil)

        try checkIsDirectory(sourceFolder, describedAs: "item folder", operation: operation)
        try checkIsDirectory(destinationParent, describedAs: "destination parent folder", operation: operation)
        try checkIsUUIDShaped(sourceFolder, operation: operation)
        // The pre-flight's own read is where this move/reorder learns the moved root's title;
        // every failure from here on in this call reuses the enriched value.
        operation = try checkIndexIsRewritable(inItemFolder: sourceFolder, operation: operation)

        if isReorder {
            let rank: Double
            if let order {
                rank = order
            } else {
                let siblings = try visibleSiblings(of: destinationParent, operation: operation, requireEditable: false)
                // "Which sibling is the item itself" is an identity question, so it is asked by
                // UUID value (`canonicalIdentity`), not by spelling: the caller's URL and the
                // directory listing can disagree in case for one and the same folder.
                let selfIdentity = IntegrityRules.canonicalIdentity(sourceName)
                rank = Ranks.append(
                    toVisible: siblings
                        .filter { IntegrityRules.canonicalIdentity($0.folder.lastPathComponent) != selfIdentity }
                        .map(\.order)
                )
            }
            try updateIndex(inItemFolder: sourceFolder, operation: operation) { document in
                document.set(FrontmatterKeys.order, to: .double(rank))
            }
            return MoveResult(id: ItemID(rawValue: sourceName), reminted: [])
        }

        // **Before the folder lands** (01-storage-format.md § Ordering, re-ruled 2026-07-31): the
        // destination's order-less children are stamped with the reading they had *without* this
        // item in the container — which is the reading the caller's placement math was computed
        // against, since the snapshot it read did not hold this item here either. Doing it after the
        // move would re-base those ranks against the arriving item's own (foreign-lane) rank.
        // The same-parent degenerate reorder above needs none of this: it never leaves the container,
        // so `updateIndex`'s own hook stamps against the unchanged reading.
        try stampAppendAtEndOrders(of: destinationParent, excluding: nil, operation: operation)
        let rank = try destinationOrder(order, inParent: destinationParent, operation: operation)

        let isImport = !isSameLocation(sourceBoardRoot, destinationBoardRoot)
        let existing = isImport ? identities(inBoard: destinationBoardRoot) : []
        var reserved = existing
        var reminted: [MoveResult.Remint] = []

        var arrivedName = sourceName
        if existing.contains(IntegrityRules.canonicalIdentity(sourceName)) {
            arrivedName = freshUUIDName(in: destinationParent, avoiding: reserved)
            reserved.insert(arrivedName)
            reminted.append(MoveResult.Remint(from: ItemID(rawValue: sourceName), to: ItemID(rawValue: arrivedName)))
        }
        let arrivedRoot = destinationParent.appendingPathComponent(arrivedName, isDirectory: true)

        do {
            try FileManager.default.moveItem(at: sourceFolder, to: arrivedRoot)
        } catch {
            throw BoardWriteError(
                operation: operation,
                path: sourceFolder.path,
                reason: .io(message: "could not move folder: \(error.localizedDescription)")
            )
        }
        // The old→new pair. The `updateIndex` below then supersedes the arrived `index.md`'s own
        // receipt, which is the truth about the file the reload will read.
        EchoLedger.current?.recordMove(from: sourceFolder, to: arrivedRoot)

        if isImport {
            let children = childCandidates(of: arrivedRoot)
            reserved.formUnion(children.map { IntegrityRules.canonicalIdentity($0.lastPathComponent) })
            for child in children where existing.contains(IntegrityRules.canonicalIdentity(child.lastPathComponent)) {
                let fresh = freshUUIDName(in: arrivedRoot, avoiding: reserved)
                reserved.insert(fresh)
                try renameFolder(child, toSiblingNamed: fresh, operation: operation)
                reminted.append(MoveResult.Remint(
                    from: ItemID(rawValue: child.lastPathComponent),
                    to: ItemID(rawValue: fresh)
                ))
            }
        }

        try updateIndex(inItemFolder: arrivedRoot, operation: operation) { document in
            document.set(FrontmatterKeys.order, to: .double(rank))
        }

        return MoveResult(id: ItemID(rawValue: arrivedName), reminted: reminted)
    }

    /// Every identity a board already holds: its UUID-shaped lane folders and their UUID-shaped
    /// card folders — the two depths where identity lives (§ Fractal layout, "Level is
    /// position"). **Tombstoned items count**: a tombstone is a live folder on disk carrying a
    /// `deleted:` key, and arriving on top of one would be exactly the duplicate the import
    /// boundary exists to prevent (a Put Back would then resurrect a twin). Strays do not count
    /// — they are not identities at all — and neither does anything deeper: a card has no
    /// UUID-shaped children, and a UUID-shaped folder under `attachments/` is content, not a
    /// level.
    ///
    /// Reads through the loader's own door (`directoryCandidates`), so the writer's idea of what
    /// a board contains can never drift from the loader's. An unreadable folder yields no
    /// candidates rather than failing: the same degradation the loader applies below the root,
    /// and the conservative direction here — a missed identity remints nothing, and a duplicate
    /// UUID in one board is the unspecified-behavior case the design already names, not a
    /// corruption.
    /// **Canonical, not verbatim**: every name is lowercased on the way in (`IntegrityRules.canonicalIdentity`),
    /// and every probe against the returned set must be too. Identity comparison is UUID-*value*
    /// equality, never string equality (§ Fractal layout ▸ Rules, settled) — an arriving
    /// `55555555-…` and a resident `55555555-…` spelled uppercase are **one** identity, and a
    /// verbatim set would miss exactly that collision and let a duplicate UUID into the board.
    private static func identities(inBoard boardRoot: URL) -> Set<String> {
        Set(identityOccurrences(inBoard: boardRoot))
    }

    /// The same walk as a **bag rather than a set** — every identity-bearing folder's canonical name,
    /// duplicates included, which is what lets the duplicate-id remint ask "does anything else still
    /// carry this identity" instead of merely "is it present" (`remintDuplicateIdentity`).
    ///
    /// The two exist as one walk deliberately: a re-verify that read the board differently from the
    /// collision probe would be a second definition of "what this board contains".
    private static func identityOccurrences(inBoard boardRoot: URL) -> [String] {
        var identities: [String] = []
        for lane in childCandidates(of: boardRoot) {
            identities.append(IntegrityRules.canonicalIdentity(lane.lastPathComponent))
            for card in childCandidates(of: lane) {
                identities.append(IntegrityRules.canonicalIdentity(card.lastPathComponent))
            }
        }
        // **The trash counts.** Board-wide uniqueness spans both containers (01-storage-format.md
        // § Fractal layout ▸ Rules — "Duplicate ids within a board are never tolerated"), and a
        // trashed card is an ordinary card in a special place: arriving on top of one would put
        // two folders with one identity in the board, and the moment the user dragged the trashed
        // one back out the snapshot would carry the duplicate the loader is forbidden to hold.
        // This is also what makes `deleteCardToTrash`'s "collision is impossible" true rather than
        // hopeful: an import that would have produced the twin was reminted before it landed.
        for card in childCandidates(of: trashFolder(inBoard: boardRoot)) {
            identities.append(IntegrityRules.canonicalIdentity(card.lastPathComponent))
        }
        return identities
    }

    /// A folder's UUID-shaped subfolders in deterministic order — `directoryCandidates` (hidden
    /// entries and symlinks already excluded) narrowed by `isUUIDShaped`, which is the loader's
    /// level-detection rule and therefore the only definition of "an identity-bearing child"
    /// this writer is allowed to have.
    /// Internal rather than `private`: a comment thread's own children are enumerated by the same
    /// rule, one level down.
    static func childCandidates(of folder: URL) -> [URL] {
        let candidates = (try? BoardLoader.directoryCandidates(in: folder)) ?? []
        return candidates.filter { BoardLoader.isUUIDShaped($0.lastPathComponent) }
    }

    /// Renames a folder in place, keeping its parent — the whole of an identity repair, and of a
    /// copy's remint. The folder's contents, `index.md` included, are never opened.
    ///
    /// Internal rather than `private`: posting a comment is this rename and nothing else
    /// (`comments/.draft/` → a fresh identity), and its undo is the same rename read backwards.
    static func renameFolder(
        _ folder: URL,
        toSiblingNamed name: String,
        operation: WriteOperation
    ) throws(BoardWriteError) {
        let destination = folder.deletingLastPathComponent().appendingPathComponent(name, isDirectory: true)
        do {
            try FileManager.default.moveItem(at: folder, to: destination)
        } catch {
            throw BoardWriteError(
                operation: operation,
                path: folder.path,
                reason: .io(message: "could not rename folder: \(error.localizedDescription)")
            )
        }
        // A remint is a folder move like any other as far as provenance goes — the identity
        // changed, so the arriving item is a different item, and the pair says where it came from.
        EchoLedger.current?.recordMove(from: folder, to: destination)
    }

    /// Whether two URLs name the same place on disk — symlinks resolved, `..`/`.` standardized
    /// away. The import boundary turns on this one comparison, so it is deliberately about
    /// *location*, not spelling: `/tmp/B.kanban` and `/private/tmp/B.kanban/.` are one board,
    /// and treating them as two would remint every arriving item for nothing.
    /// Internal rather than `private`: the comment purge checks its container the same way.
    static func isSameLocation(_ lhs: URL, _ rhs: URL) -> Bool {
        lhs.resolvingSymlinksInPath().standardizedFileURL.path
            == rhs.resolvingSymlinksInPath().standardizedFileURL.path
    }

    // MARK: - Copy

    /// Copies a lane or card — and the whole tree beneath it — under another parent, minting
    /// **fresh UUIDs for every folder it materializes** (01-storage-format.md § Fractal layout ▸
    /// Rules, "Identity lifecycle: moves keep the UUID, copies mint fresh ones"). The copy is a
    /// new identity at every level: a copied lane's cards are new cards, not the same cards seen
    /// twice. Returns the new root's identity. This is the item-level copy — ⌘C/⌘V, ⌥-drag
    /// duplicate, the cross-board drag default; whole-board copies (Save as Template, File ▸
    /// Duplicate) keep their GUIDs and are not this call.
    ///
    /// Everything travels verbatim: `attachments/` and its subfolders, strays, bodies, unknown
    /// keys, comments, line endings. Only the schema's provenance stamps move, per `stamps`:
    ///
    /// - `.fork` — an ordinary copy **keeps `created`** (it is a fork of something that really
    ///   was created then) while `modified` is stamped and `modified-by` cleared, because a
    ///   paste or a duplicate is an app write (§ Frontmatter).
    /// - `.born` — template instantiation stamps `created` **and** `modified` fresh, from one
    ///   `Date` for the whole tree: a card made from a template is born today, not forked from
    ///   the template (09-templates.md).
    ///
    /// Plus the tracker sever, which is the copy contract's third clause: every folder this
    /// materializes drops the reserved `remote`/`remote-state` keys, at every level
    /// (`applyCopyContract`).
    ///
    /// ## A copy is a transaction (ruled 2026-07-29)
    ///
    /// **The whole subtree is preflighted for stampability before anything is materialized**, and a
    /// copy that cannot honor the contract on one nested card refuses whole, loudly, naming that card
    /// (01-storage-format.md § Frontmatter: "every copy flow that rewrites descendants' `index.md` …
    /// preflights the entire subtree and refuses whole, loudly, naming the offending item — never a
    /// partial copy, never a silently unstamped descendant").
    ///
    /// This **retired the former root-strict/nested-lenient split**, which copied an unreadable or
    /// readable-but-uneditable nested `index.md` byte-verbatim and simply skipped its stamp. The
    /// leniency read as kindness and was in fact the one verdict 01's doctrine forbids: "leniency is
    /// recovering recoverable issues through reliable heuristics — never accepting loss that could
    /// surprise the user … 'proceed partially, lose a little' is never a verdict". A silently
    /// unstamped descendant carries a stale `modified-by` and, since 2026-07-29, a *live tracker
    /// claim* into a second local object — which is exactly the surprise. The finest-grain precedent
    /// covers identity collisions (a per-folder repair that loses nothing), not skipped contract work.
    ///
    /// The preflight runs over the **source**, so a refusal costs nothing on disk: nothing is copied,
    /// nothing is renamed, nothing has to be cleaned up. A folder with **no `index.md`** —
    /// interrupted-create residue — is not an offense: it is copied and reminted like any other and
    /// simply has nothing to stamp, the same skip the loader applies to it (`.missingIndex`).
    ///
    /// **All-or-nothing at the destination**, unlike a move: any failure once copying has begun
    /// removes the partially copied tree best-effort and rethrows, because a half-copied item is
    /// pure residue — nothing was there before, so there is no true state for a reload to show. After
    /// a clean preflight the only thing left to fail is disk, and that is what this catch is for.
    /// The source is never touched on any path.
    public static func copyItem(
        at sourceFolder: URL,
        toParent destinationParent: URL,
        order: Double?,
        stamps: CopyStamps
    ) throws(BoardWriteError) -> ItemID {
        var operation: WriteOperation = .copy(title: nil)
        try checkIsDirectory(sourceFolder, describedAs: "item folder", operation: operation)
        try checkIsDirectory(destinationParent, describedAs: "destination parent folder", operation: operation)
        try checkIsUUIDShaped(sourceFolder, operation: operation)
        // The pre-flight's own read is where this copy learns the source root's title; every
        // failure from here on in this call — including inside the materialized-but-not-yet-
        // stamped tree below — reuses the enriched value.
        operation = try checkIndexIsRewritable(inItemFolder: sourceFolder, operation: operation)
        // The transaction's preflight, over the *source*: a subtree that cannot honor the copy
        // contract refuses here, where nothing has been materialized and there is nothing to undo.
        try checkCopiedDescendantsAreStampable(of: sourceFolder, operation: operation)

        // `moveItem`'s pre-arrival stamp, for the same reason: the copy is not in the destination's
        // reading yet, and the placement it is about to take was computed against a reading that did
        // not hold it (01-storage-format.md § Ordering, re-ruled 2026-07-31).
        try stampAppendAtEndOrders(of: destinationParent, excluding: nil, operation: operation)
        let rank = try destinationOrder(order, inParent: destinationParent, operation: operation)

        let rootName = freshUUIDName(in: destinationParent, avoiding: [])
        let root = destinationParent.appendingPathComponent(rootName, isDirectory: true)
        do {
            try FileManager.default.copyItem(at: sourceFolder, to: root)
        } catch {
            try? FileManager.default.removeItem(at: root)
            throw BoardWriteError(
                operation: operation,
                path: sourceFolder.path,
                reason: .io(message: "could not copy folder: \(error.localizedDescription)")
            )
        }

        do {
            // **`comments/.trash/` never crosses a copy boundary** (01-storage-format.md § Enhanced
            // schema: "stripped at every copy boundary … a copy must not carry ghosts no window
            // session will ever purge"). Before the remint, so the strip walks the paths the source
            // had rather than minted ones nobody has seen.
            try stripCommentTrash(under: root, operation: operation)

            var copied: [URL] = []
            try remintDescendants(of: root, collecting: &copied, operation: operation)

            let now = Date()
            try updateIndex(inItemFolder: root, operation: operation) { document in
                applyCopyContract(to: &document, stamps: stamps, now: now)
                document.set(FrontmatterKeys.order, to: .double(rank))
            }
            for folder in copied {
                try stampCopiedDescendant(at: folder, stamps: stamps, now: now, operation: operation)
            }
        } catch {
            try? FileManager.default.removeItem(at: root)
            throw error
        }

        return ItemID(rawValue: rootName)
    }

    /// Renames every UUID-shaped folder beneath `folder` to a fresh mint, depth first, and
    /// collects where they ended up — "copies mint fresh UUIDs for **every** folder they
    /// materialize", which for a copied lane means its cards too. Recursion rather than a
    /// two-level walk because the rule is about folders, not levels; non-UUID-shaped folders
    /// (`attachments/` and anything under it, strays) are neither renamed nor descended into,
    /// exactly as the loader treats them.
    ///
    /// Each child is renamed *before* being descended into, so the collected URLs are the final
    /// ones — a caller can stamp them without re-deriving any path. The mint only has to avoid
    /// what is on disk in that folder: the siblings not yet renamed are still there under their
    /// original names, and `freshUUIDName` checks for exactly that.
    ///
    /// **Internal rather than `private`**: template instantiation (`TemplateEngine`) is the same
    /// remint one level up — "mint fresh GUIDs for every lane/card folder" (09-templates.md
    /// ▸ Instantiation) is `copyItem`'s rule applied to a whole board rather than to one item, and
    /// pointing this at a copied *board root* is literally that. A second implementation of "which
    /// folders are identities" is exactly what must not exist.
    /// **The thread comes too** (01-storage-format.md § Enhanced schema: "**Copies carry the thread**
    /// — a copy is a fork, and dropping a subtree would be the one place a copy loses content; comment
    /// folders remint like every copied folder"). `comments/` is not identity-shaped, so the recursion
    /// above cannot reach through it; the second loop is that one step, and it does not recurse
    /// because a comment has no identity-bearing children of its own (flat, this iteration).
    ///
    /// **`comments/.draft/` is not reminted, and that is not an omission**: it is a dot-named folder,
    /// so `directoryCandidates` never offers it, and it has no identity to mint away from. It carries
    /// verbatim, which is the ruling ("copies and the trash carry it like any comment folder
    /// (fork-lossless)"). `comments/.trash/` is gone before this runs (`stripCommentTrash`).
    static func remintDescendants(
        of folder: URL,
        collecting copied: inout [URL],
        operation: WriteOperation
    ) throws(BoardWriteError) {
        for child in childCandidates(of: folder) {
            let fresh = freshUUIDName(in: folder, avoiding: [])
            try renameFolder(child, toSiblingNamed: fresh, operation: operation)

            let renamed = folder.appendingPathComponent(fresh, isDirectory: true)
            copied.append(renamed)
            try remintDescendants(of: renamed, collecting: &copied, operation: operation)
        }

        let thread = folder.appendingPathComponent(IntegrityRules.commentsFolderName, isDirectory: true)
        for comment in childCandidates(of: thread) {
            let fresh = freshUUIDName(in: thread, avoiding: [])
            try renameFolder(comment, toSiblingNamed: fresh, operation: operation)
            copied.append(thread.appendingPathComponent(fresh, isDirectory: true))
        }
    }

    /// **Removes every `comments/.trash/` in a copied tree** — the copy boundary's strip
    /// (01-storage-format.md § Enhanced schema, ruled 2026-07-29). Called on the *destination*, so
    /// nothing a user still owns is ever removed by it; the source's thread trash stays exactly where
    /// it is, waiting for its own window's close purge.
    ///
    /// The reach is the root plus its identity-bearing descendants, which is every folder that can
    /// have a thread: a comment has none, and a board root's own `comments/` would be a stray.
    ///
    /// **Internal rather than `private`**: the clipboard's staging snapshot is a copy boundary the
    /// Writer does not perform (`ClipboardStore.stage`) and strips through this same call — "stripped
    /// at every copy boundary (clipboard staging, Duplicate, Save as Template)" is only one rule if it
    /// is one function.
    static func stripCommentTrash(under folder: URL, operation: WriteOperation) throws(BoardWriteError) {
        for item in [folder] + identityDescendants(of: folder) {
            let trash = item
                .appendingPathComponent(IntegrityRules.commentsFolderName, isDirectory: true)
                .appendingPathComponent(IntegrityRules.commentTrashFolderName, isDirectory: true)
            guard IntegrityRules.node(at: trash) != nil else { continue }
            do {
                try FileManager.default.removeItem(at: trash)
            } catch {
                throw BoardWriteError(
                    operation: operation,
                    path: trash.path,
                    reason: .io(message: "could not remove the copied comment trash: \(error.localizedDescription)")
                )
            }
            EchoLedger.current?.recordDeletion(at: trash)
        }
    }

    /// **The copy contract's frontmatter edits**, applied to every folder an item-level copy
    /// materializes — root and descendants alike, in one place so the two can never disagree about
    /// what a copy owes.
    ///
    /// Two clauses, and `updateIndex` adds the third:
    ///
    /// - **`created` per `stamps`** — `.fork` keeps it (a copy really was created when its original
    ///   was), `.born` restamps it, because a board or card made from a template is born today
    ///   (09-templates.md).
    /// - **The reserved tracker keys go** — `remote` and `remote-state`, at every level
    ///   (01-storage-format.md § Fractal layout ▸ Rules, ruled 2026-07-29: "**Item-level copies sever
    ///   tracker identity** … because two local objects must never both claim to be the same remote
    ///   object"). Content preserved, mapping severed. Nothing reads the keys until Teams, and that is
    ///   the argument rather than an objection: the copies made today are the boards Teams will meet,
    ///   so the sever costs nothing now and spares a stale double-claim later. `FrontmatterDocument.remove`
    ///   takes every occurrence, so a hand-duplicated key cannot leave a twin behind to resurrect the
    ///   claim.
    /// - **`modified` stamped and `modified-by` cleared** come from `updateIndex`, because a copy is an
    ///   app write like any other (§ Frontmatter) — not something this function has to remember.
    ///
    /// `order` is deliberately absent: the copied *root* takes its new rank from its caller, and a
    /// nested item keeps its rank among its own siblings, which travelled with it.
    ///
    /// **Whole-board forks do not call this at all** — Duplicate and Save as Template carry bytes
    /// verbatim, GUIDs, timestamps and tracker keys included (01 ▸ Identity lifecycle's carve-out).
    static func applyCopyContract(
        to document: inout FrontmatterDocument,
        stamps: CopyStamps,
        now: Date
    ) {
        if case .born = stamps {
            document.set(FrontmatterKeys.created, to: .date(now))
        }
        document.remove(FrontmatterKeys.remote)
        document.remove(FrontmatterKeys.remoteState)
    }

    /// **The copy transaction's preflight**: every identity-bearing folder beneath `folder` whose
    /// `index.md` the copy contract will rewrite, checked for readability and editability *before*
    /// anything is materialized — and the first offender refuses the whole copy, named
    /// (01-storage-format.md § Frontmatter, ruled 2026-07-29: "preflights the entire subtree and
    /// refuses whole, loudly, naming the offending item").
    ///
    /// **Naming the offender is the point**, so the thrown error is re-enriched with *that* item's
    /// title rather than the copy root's: a refusal reading "Couldn't copy 'Sprint 12'" when the
    /// unwritable file is one card inside it would send the user looking in the wrong place. A file
    /// that cannot be read at all has no title to offer, and its path — which the error always
    /// carries — is then the whole of what can honestly be said about it.
    ///
    /// **A folder with no `index.md` is not an offense** and is skipped: it is interrupted-create
    /// residue, the loader skips it too (`.missingIndex`), and there is no contract work to fail.
    ///
    /// **The preflight reaches comment depth** (01-storage-format.md § Frontmatter and § Enhanced
    /// schema, ruled 2026-07-31 — reversing the 2026-07-30 carve-out that kept comments out of it):
    /// "a comment whose frontmatter cannot take the stamp refuses the copy exactly like a card or
    /// lane — never-refuse … is a *load* posture, and a user-initiated copy is a transaction, not a
    /// load". The board still always loads with a broken annotation on it; the *gesture* may refuse,
    /// and refusing is what keeps the sever rule true — no copy carries a live `remote` claim or a
    /// stale `modified-by` because one comment could not be rewritten.
    ///
    /// The walk is therefore `copyContractDescendants`' — every folder `remintDescendants` will
    /// materialize and `stampCopiedDescendant` will then rewrite, comments included — and the
    /// refusal names the comment's *card*, which is what `operation.withTitle` produces here: the
    /// enrichment reads the offending file's own title, and a comment has none, so the operation
    /// keeps the copy root's. (The error always carries the path, which is what points at the
    /// annotation itself.)
    ///
    /// **Internal rather than `private`**: template instantiation preflights its own tree with this,
    /// for `remintDescendants`' reason — one definition of what a copy owes its descendants.
    static func checkCopiedDescendantsAreStampable(
        of folder: URL,
        operation: WriteOperation
    ) throws(BoardWriteError) {
        for descendant in copyContractDescendants(of: folder) {
            let indexURL = descendant.appendingPathComponent(BoardLoader.indexFileName)
            guard FileManager.default.fileExists(atPath: indexURL.path) else { continue }
            let document = try readDocument(at: indexURL, operation: operation)
            try checkEditable(document, at: indexURL, operation: operation.withTitle(document.title.value))
        }
    }

    /// Every **card or lane** beneath `folder`, depth first — `remintDescendants`' recursion with the
    /// renaming taken out, so the preflight and the remint cannot disagree about which folders a copy
    /// materializes as *items*. Comment folders are not here (they hang off `comments/`, which is not
    /// identity-shaped), which is exactly the right reach for `stripCommentTrash`, since a thread
    /// lives under a card and nowhere else.
    private static func identityDescendants(of folder: URL) -> [URL] {
        var found: [URL] = []
        for child in childCandidates(of: folder) {
            found.append(child)
            found.append(contentsOf: identityDescendants(of: child))
        }
        return found
    }

    /// **Every folder below `folder` whose `index.md` the copy contract rewrites** — the items *and*
    /// their comments, which is `remintDescendants`' own reach read as a list.
    ///
    /// The root's own thread is in it: `checkIndexIsRewritable` preflights the copy root itself, but
    /// nothing preflights the comments hanging off it, and the copy stamps those too. Two walks that
    /// disagreed about which folders a copy touches is exactly the drift a mid-flight failure after a
    /// clean preflight would be.
    private static func copyContractDescendants(of folder: URL) -> [URL] {
        func comments(of item: URL) -> [URL] {
            childCandidates(of: item.appendingPathComponent(
                IntegrityRules.commentsFolderName, isDirectory: true))
        }
        var found = comments(of: folder)
        for item in identityDescendants(of: folder) {
            found.append(item)
            found.append(contentsOf: comments(of: item))
        }
        return found
    }

    /// Stamps one copied folder below the root — **strictly**, since the preflight has already cleared
    /// the whole subtree (`checkCopiedDescendantsAreStampable`): an `index.md` that cannot be read or
    /// edited here is a disk failure between the two reads, not a shape to tolerate, and it fails the
    /// copy like any other mid-flight failure (whose partial result the caller removes wholesale).
    ///
    /// The former best-effort posture — copy it verbatim, skip its stamp — is retired with the
    /// root-strict/nested-lenient split (see `copyItem`): a silently unstamped descendant is a
    /// descendant still carrying somebody else's `modified-by` and, since 2026-07-29, somebody else's
    /// tracker claim.
    ///
    /// A folder with **no `index.md`** is still skipped, and for a different reason entirely: there is
    /// nothing there to stamp (interrupted-create residue, which the loader skips too).
    ///
    /// **A comment takes the same strict path as a card** (ruled 2026-07-31): the former lenient
    /// branch — stamp best-effort, log, copy verbatim on failure — retired with the preflight's
    /// comment carve-out, because the preflight now clears comments too and a failure here is a disk
    /// failure at either depth. `kind` is left to position (`IntegrityRules.placement`), which
    /// answers `.comment` for a folder under a card's `comments/`.
    ///
    /// **Internal rather than `private`**, with `remintDescendants` and for its reason: an
    /// instantiated board's lanes and cards are stamped by this exact rule.
    static func stampCopiedDescendant(
        at folder: URL,
        stamps: CopyStamps,
        now: Date,
        operation: WriteOperation
    ) throws(BoardWriteError) {
        let indexURL = folder.appendingPathComponent(BoardLoader.indexFileName)
        guard FileManager.default.fileExists(atPath: indexURL.path) else { return }

        try updateIndex(inItemFolder: folder, operation: operation) { document in
            applyCopyContract(to: &document, stamps: stamps, now: now)
        }
    }

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

    // MARK: - The materialized trash

    /// `<boardRoot>/.trash/` — the board's trash container, named but not created.
    /// One place, so the loader's walk and every write below can never disagree about where the
    /// trash is (the name itself is `BoardLoader.trashFolderName`, which is where the reserved-name
    /// rule lives).
    static func trashFolder(inBoard boardRoot: URL) -> URL {
        boardRoot.appendingPathComponent(BoardLoader.trashFolderName, isDirectory: true)
    }

    /// **Deleting a card: a physical move into `<board-root>/.trash/`** (01-storage-format.md
    /// § Deletion, resettled 2026-07-28; 03-board-ui.md § Trash). The tombstone is retired — no
    /// key is written, nothing is flagged, and the card becomes "an ordinary card in a special
    /// place".
    ///
    /// The sequence, which is the contract:
    ///
    /// 1. **`cardFolder` must be a card** (`checkIsCardFolder`, the stricter guard: UUID-shaped
    ///    *under* a UUID-shaped parent). A board root and a stray are refused here, and a lane takes
    ///    the sibling door (`deleteLaneToTrash`) — same move, different guard and a different `kind`
    ///    to stamp, which is exactly the pair `.trash/`'s flat container needs told apart.
    /// 2. **Pre-flight the card's `index.md`** (`checkIndexIsRewritable`) — the move rewrites it
    ///    at the destination, so a file that cannot be round-tripped refuses *before* the folder
    ///    travels. `moveItem`'s discover-before-you-write rule, for its reason.
    /// 3. **`.trash/` is created if absent** — it is minted by the first delete, so most boards
    ///    meet it here.
    /// 4. **Move the folder.** Nothing beneath it is read or rewritten, so `attachments/`, strays
    ///    and every byte arrive unchanged, exactly as in an ordinary move.
    /// 5. **Stamp** — and nothing else.
    ///
    /// **No rank is minted, and `order` is not rewritten at all** (01-storage-format.md § Deletion,
    /// re-ruled 2026-07-31, retiring the arrival rank mint; 03-board-ui.md § Trash): the trash sorts
    /// by `modified` descending, "and that stamp *is* the position — newest-first falls out with no
    /// `order` rewrite at all; a delete is a pure folder move plus the stamp, and the item's `order`
    /// key rides along untouched (which is also what undo's move-back restores for free)". The
    /// parameter this call used to take is gone with the rule, and so is the store-side ladder that
    /// computed it: there is no snapshot question left for the caller to answer.
    ///
    /// **The `modified` stamp is therefore the whole write** — and it needs no exception to earn it.
    /// The trash move changes the card's *container*, which is the whole predicate
    /// (`WriteOperation.rewritesOrderOnly`, refined 2026-07-30): "deletion is an edit to the item's
    /// story", so it stamps exactly as a cross-lane move does. It falls out of `updateIndex` rather
    /// than being asked for, which is why step 5 carries no argument — and why the
    /// reorders-don't-stamp rule needs no trash carve-out to coexist with this call. Its second job
    /// is the `kind` the flat container needs, backfilled on the same touch.
    ///
    /// **Collision inside `.trash/` is impossible by construction**, and it is checked anyway. The
    /// card is a resident of this very board, and board-wide uniqueness now spans lanes *and* the
    /// trash (`identities(inBoard:)`), so no folder of that name can already be in there — the
    /// import boundary reminted any arriving twin before it ever landed. Should one exist regardless
    /// (a hand copy, an interrupted move), the move fails loudly through `FileManager` rather than
    /// clobbering it: this call never remints, because the identity is exactly what a later restore
    /// and the undo stack are holding on to.
    ///
    /// - Returns: the card's identity, unchanged — a delete moves a folder, it does not rename one.
    @discardableResult
    public static func deleteCardToTrash(
        at cardFolder: URL,
        inBoard boardRoot: URL
    ) throws(BoardWriteError) -> ItemID {
        try moveIntoTrash(
            at: cardFolder,
            inBoard: boardRoot,
            kind: .card,
            operation: .delete(title: nil),
            removingLegacyKey: false
        )
    }

    /// **Deleting a lane: the same physical move into `<board-root>/.trash/`** (01-storage-format.md
    /// § Deletion, lanes joined 2026-07-29 — "retiring the design's sole destructive delete";
    /// 03-board-ui.md § Trash: "deleting a lane moves its folder — subtree intact — into `.trash/`,
    /// exactly as a card moves").
    ///
    /// `deleteCardToTrash`'s body with two differences, and they are the whole of what a lane is:
    ///
    /// - **The guard is `checkIsLaneFolder`** — UUID-shaped directly under a board root, which
    ///   refuses a card, a board root, a stray, and notably a folder already in `.trash/`.
    /// - **`kind: lane` is stamped**, not derived. The stamping rewrite is an `updateIndex` on a
    ///   folder that is by then *inside* `.trash/`, where position cannot answer and shape would
    ///   answer *wrongly* for the one lane that most needs the key: an **empty** lane is
    ///   shape-identical to a card (01's own "honest limit"). The caller knows what it moved, so it
    ///   says so — which is also the backfill the ruling asks of this write ("`kind: lane` …
    ///   backfilled on touch when absent … the trash move's `modified` stamp included").
    ///
    /// **The subtree rides along untouched**: nothing beneath the lane is read or rewritten, so its
    /// cards, their `attachments/` and every stray arrive byte-identical and come back with it on
    /// restore — which is what makes the inverse an ordinary move rather than a replay of captured
    /// bytes (13-native-undo.md ▸ Interaction with the trash).
    ///
    /// - Returns: the lane's identity, unchanged.
    @discardableResult
    public static func deleteLaneToTrash(
        at laneFolder: URL,
        inBoard boardRoot: URL
    ) throws(BoardWriteError) -> ItemID {
        try moveIntoTrash(
            at: laneFolder,
            inBoard: boardRoot,
            kind: .lane,
            operation: .delete(title: nil),
            removingLegacyKey: false
        )
    }

    /// **Migrating a legacy tombstoned card**: the same physical move into `.trash/`, plus the
    /// surgical removal of the `deleted:` key that put it there (01-storage-format.md § Deletion:
    /// "a card carrying `deleted:` is relocated into `.trash/` (key removed)").
    ///
    /// `deleteCardToTrash` with one extra edit, and written as such rather than as a parameter on
    /// the public delete: the two are different events with different vocabulary — one is the user
    /// pressing ⌫, the other is the app tidying a board written by an older version — and a
    /// failure must say which (`WriteOperation.migrateTombstone`).
    ///
    /// The removal is `FrontmatterDocument.remove`, so it takes **every** occurrence of the key:
    /// a hand-duplicated `deleted:` line cannot leave a twin behind that would re-migrate the card
    /// on the next load. Nothing else in the file is touched — unknown keys, comments, blank lines,
    /// line endings and the body are the same bytes they were, and the stamps are the only other
    /// writes, exactly as for an ordinary delete.
    ///
    /// **The stamp is the retiring key's own timestamp where it parses** (01-storage-format.md
    /// § Deletion, re-ruled 2026-07-31: "the migration move stamps `modified` **from the legacy
    /// `deleted:` timestamp** where parseable — the deletion time is when the card entered the
    /// trash, so real deletion order survives into the `modified`-descending sort — and from
    /// migration time otherwise"). It is the one write in the app whose `modified` is not "now",
    /// and it is why the legacy value is read *before* the move rather than left to the closure:
    /// `updateIndex`'s stamps deliberately outrank anything `edits` does.
    @discardableResult
    public static func migrateTombstonedCard(
        at cardFolder: URL,
        inBoard boardRoot: URL
    ) throws(BoardWriteError) -> ItemID {
        try moveIntoTrash(
            at: cardFolder,
            inBoard: boardRoot,
            kind: .card,
            operation: .migrateTombstone(title: nil),
            removingLegacyKey: true
        )
    }

    /// The shared body of the three moves into `.trash/` — `deleteCardToTrash`, `deleteLaneToTrash`
    /// and `migrateTombstonedCard`. See the first for the sequence, the second for what a lane's
    /// `kind` is doing here, and the third for what `removingLegacyKey` adds.
    ///
    /// **`kind` is both the guard and the stamp**: it picks which shape check the item must pass on
    /// the way out, and it is handed to `updateIndex` so the arrived entry's `kind` is written from
    /// what the caller *moved* rather than guessed from what the flat container makes it look like.
    private static func moveIntoTrash(
        at itemFolder: URL,
        inBoard boardRoot: URL,
        kind: IntegrityRules.ObjectKind,
        operation initialOperation: WriteOperation,
        removingLegacyKey: Bool
    ) throws(BoardWriteError) -> ItemID {
        var operation = initialOperation
        try checkIsDirectory(itemFolder, describedAs: kind == .lane ? "lane folder" : "card folder", operation: operation)
        try checkIsDirectory(boardRoot, describedAs: "board folder", operation: operation)
        switch kind {
        // `.board` and `.comment` are unreachable — the three callers pass `.card` or `.lane`, and
        // neither a board nor a comment is a thing the *board's* trash ever holds (a deleted comment
        // moves into its own thread's `comments/.trash/`). Both take the card guard, which refuses
        // them both loudly rather than letting an unexpected caller through.
        case .lane: try checkIsLaneFolder(itemFolder, operation: operation)
        case .card, .board, .comment: try checkIsCardFolder(itemFolder, operation: operation)
        }
        // `checkIndexIsRewritable`'s two checks, spelled out rather than called, for one reason: the
        // migration needs the *document* this read produces — the legacy `deleted:` timestamp it is
        // about to remove becomes the `modified` stamp (`migrateTombstonedCard`), and it has to be
        // read while the file is still at its old path.
        let sourceIndex = itemFolder.appendingPathComponent(BoardLoader.indexFileName)
        let source = try readDocument(at: sourceIndex, operation: operation)
        operation = operation.withTitle(source.title.value)
        try checkEditable(source, at: sourceIndex, operation: operation)
        // Parseable legacy stamp → the deletion time it recorded; malformed, absent, or an ordinary
        // delete → `nil`, which is `updateIndex`'s "now".
        let stamp = removingLegacyKey ? source.deleted.value : nil

        let trash = trashFolder(inBoard: boardRoot)
        do {
            try FileManager.default.createDirectory(at: trash, withIntermediateDirectories: true)
        } catch {
            throw BoardWriteError(
                operation: operation,
                path: trash.path,
                reason: .io(message: "could not create the trash folder: \(error.localizedDescription)")
            )
        }

        let name = itemFolder.lastPathComponent
        let arrived = trash.appendingPathComponent(name, isDirectory: true)
        do {
            try FileManager.default.moveItem(at: itemFolder, to: arrived)
        } catch {
            throw BoardWriteError(
                operation: operation,
                path: itemFolder.path,
                reason: .io(message: "could not move folder into the trash: \(error.localizedDescription)")
            )
        }
        // A delete is a move into `.trash/` on disk (01-storage-format.md § Deletion), so the
        // receipt is the move pair — and it reads correctly from either end: the board side sees an
        // absence where the item was, the shown-trash side sees an arrival where it went.
        EchoLedger.current?.recordMove(from: itemFolder, to: arrived)

        // The whole of the arrival write: the stamp (which is the row's position), the `kind` the
        // flat container discriminates on, and — for the migration alone — the retiring key. `order`
        // is deliberately absent; it rides along as the item left it.
        try updateIndex(inItemFolder: arrived, kind: kind, stampedAt: stamp, operation: operation) { document in
            if removingLegacyKey {
                document.remove(FrontmatterKeys.deleted)
            }
        }
        if removingLegacyKey {
            // Heal-marked: the migration is work the app started on its own, and its paths commit
            // separately (06-history-undo.md ▸ Commit messages, ruled 2026-07-29). The ordinary
            // delete this shares a body with is a *gesture* and is deliberately not marked.
            EchoLedger.current?.markHeal(at: arrived)
            EchoLedger.current?.markHeal(at: arrived.appendingPathComponent(BoardLoader.indexFileName))
        }

        return ItemID(rawValue: name)
    }

    /// Permanently removes one **entry** from the trash — the trash's own **Delete**
    /// (03-board-ui.md § Trash: "on a trash selection, Delete (⌫/⌘⌫) is permanent … in the trash it
    /// removes the folder").
    ///
    /// `purgeItem` with the container checked: the folder must actually sit in this board's
    /// `.trash/`, so a mis-aimed permanent delete cannot reach a live item. `purgeItem`
    /// (unconstrained) is for the create-undo's own removal, not this call.
    ///
    /// **A trashed lane purges whole, freight and all** (lanes joined the trash 2026-07-29): the
    /// removal is recursive, so "permanent delete … walks lane subtrees" needs no walk of its own
    /// here — what the confirmation must *count* before this runs is `TrashModel`'s job, off the
    /// snapshot. Which kind the entry is therefore never comes up: the container and the shape are
    /// the whole check, exactly as they were when only cards lived here.
    ///
    /// An already-gone folder is success, `purgeItem`'s rule.
    public static func purgeTrashEntry(at entryFolder: URL, inBoard boardRoot: URL) throws(BoardWriteError) {
        let operation = WriteOperation.purge(title: nil)
        guard FileManager.default.fileExists(atPath: entryFolder.path) else { return }

        try checkIsUUIDShaped(entryFolder, operation: operation)
        guard isSameLocation(entryFolder.deletingLastPathComponent(), trashFolder(inBoard: boardRoot)) else {
            throw BoardWriteError(
                operation: operation,
                path: entryFolder.path,
                reason: .unreadable(message: "folder is not in this board's trash")
            )
        }

        do {
            try FileManager.default.removeItem(at: entryFolder)
        } catch {
            throw BoardWriteError(
                operation: operation,
                path: entryFolder.path,
                reason: .io(message: "could not remove folder: \(error.localizedDescription)")
            )
        }
        EchoLedger.current?.recordDeletion(at: entryFolder)
    }

    /// **Empty Trash** (⇧⌘⌫, 03-board-ui.md § Trash): permanently removes every entry in
    /// `<board-root>/.trash/` — cards and trashed lanes alike, **lane subtrees walked** by the
    /// recursive removal. Returns what it removed, in folder-name order.
    ///
    /// **The entry folders, not the container.** The design says it "purges the whole `.trash/`",
    /// and the entries are the whole of it in every board the app produces — but the container is a
    /// real folder a hand-editor can put things in, and stray tolerance ("preserved verbatim,
    /// never rendered") does not stop applying because the folder is the app's. Removing only what
    /// the loader recognizes as an entry keeps the count honest (the confirmation names cards and
    /// lane freight) and keeps this command from being the one place in the app that destroys a file
    /// nobody ever saw. The emptied container is left standing; the next delete would only recreate
    /// it.
    ///
    /// **Search-independent**, by construction: this walks the folder, never a filtered view.
    ///
    /// Removal is per entry, in order, and a failure stops the batch and throws — everything
    /// already removed stays removed, `importAttachments`' rule. A board with no trash at all
    /// removes nothing and returns `[]`.
    @discardableResult
    public static func emptyTrash(inBoard boardRoot: URL) throws(BoardWriteError) -> [ItemID] {
        let operation = WriteOperation.purge(title: nil)
        var purged: [ItemID] = []
        for entry in childCandidates(of: trashFolder(inBoard: boardRoot)) {
            do {
                try FileManager.default.removeItem(at: entry)
            } catch {
                throw BoardWriteError(
                    operation: operation,
                    path: entry.path,
                    reason: .io(message: "could not remove folder: \(error.localizedDescription)")
                )
            }
            EchoLedger.current?.recordDeletion(at: entry)
            purged.append(ItemID(rawValue: entry.lastPathComponent))
        }
        return purged
    }

    /// Refuses any folder that is not a **lane**: UUID-shaped, directly under a board root.
    ///
    /// The mirror of `checkIsCardFolder`, and it needs one clause that one does not. A lane is
    /// `<root>/<lane>` and a card is `<root>/<lane>/<card>`, so "parent is not UUID-shaped" tells
    /// the two apart — except that a **trash card** is `<root>/.trash/<card>`, whose parent is not
    /// UUID-shaped either. Naming the container explicitly is what keeps a permanent delete of a
    /// trashed card from being reachable through the lane-delete door.
    private static func checkIsLaneFolder(_ folder: URL, operation: WriteOperation) throws(BoardWriteError) {
        try checkIsUUIDShaped(folder, operation: operation)
        let parentName = folder.deletingLastPathComponent().lastPathComponent
        guard !BoardLoader.isUUIDShaped(parentName), parentName != BoardLoader.trashFolderName else {
            throw BoardWriteError(
                operation: operation,
                path: folder.path,
                reason: .unreadable(message: "folder is not a lane: only a lane is deleted whole")
            )
        }
    }

    /// Physical removal — the create-undo's own primitive (13-native-undo.md: "create → remove the
    /// created folder"): deletes the folder tree from disk. Irreversible, and distinct from the
    /// ordinary delete, which is a *move* into `.trash/` — this call does **not** require the item to
    /// be in the trash first, since an undone create's folder was never trashed to begin with.
    ///
    /// **A folder that is already gone is success, not an error** — checked first, before the
    /// shape guard below. A Finder deletion converges on exactly the end state a purge would
    /// produce (01-storage-format.md § Deletion, "a folder that disappears ... is also a
    /// delete"), so there is nothing left here to distinguish: a stray path that never existed
    /// and a once-real item someone already threw away in Finder both purge cleanly, silently,
    /// without inspecting what used to be there.
    ///
    /// When the folder *does* exist, `checkIsUUIDShaped` guards the same unreachability
    /// `moveItem`/`copyItem` rely on: a board root or a stray never purges through this
    /// call, only a lane or a card.
    public static func purgeItem(at itemFolder: URL) throws(BoardWriteError) {
        let operation = WriteOperation.purge(title: nil)
        guard FileManager.default.fileExists(atPath: itemFolder.path) else { return }

        try checkIsUUIDShaped(itemFolder, operation: operation)
        do {
            try FileManager.default.removeItem(at: itemFolder)
        } catch {
            throw BoardWriteError(
                operation: operation,
                path: itemFolder.path,
                reason: .io(message: "could not remove folder: \(error.localizedDescription)")
            )
        }
        EchoLedger.current?.recordDeletion(at: itemFolder)
    }

    // MARK: - Undoing a create

    /// An item's `index.md` as literal text — the bytes a create hands to its own redo
    /// (13-native-undo.md ▸ Rules: "create → remove the created folder").
    ///
    /// **Why the create path reads back what it just wrote.** The inverse of a create is a physical
    /// removal, so the only way ⇧⌘Z can put the item back *with its identity* is for the step to be
    /// holding the file's bytes — captured at the moment of the create, which is the write redo
    /// re-performs (13: the redo closure replays the original). `readRawSource(ofCard:)` is the same
    /// read one level narrower — it is the raw-source outlet's, and refuses anything that is not a
    /// card folder — so this one exists rather than widening that contract for a caller with a
    /// different reason.
    ///
    /// Strict UTF-8 like every read here: a file that does not decode is a loud error, never a lossy
    /// guess. `operation` is the create being captured for, so a failure names that gesture.
    public static func readIndexText(
        ofItem itemFolder: URL,
        operation: WriteOperation
    ) throws(BoardWriteError) -> String {
        let indexURL = itemFolder.appendingPathComponent(BoardLoader.indexFileName)
        let data: Data
        do {
            data = try Data(contentsOf: indexURL)
        } catch {
            throw BoardWriteError(
                operation: operation,
                path: indexURL.path,
                reason: .unreadable(message: "could not read file: \(error.localizedDescription)")
            )
        }
        guard let text = String(validating: data, as: UTF8.self) else {
            throw BoardWriteError(operation: operation, path: indexURL.path, reason: .unreadable(message: "file is not UTF-8"))
        }
        return text
    }

    /// Puts a removed item's folder back, at its own path and with its own bytes — the redo half of
    /// an undone create (13-native-undo.md ▸ Rules).
    ///
    /// **The identity is the point.** `createLane`/`createCard` mint a fresh UUID, so they cannot
    /// replay a create: redoing through them
    /// would produce a *different* item, and every step registered above this one on the stack
    /// (a rename, a move, a body edit of that very card) would then name nothing. This call takes the
    /// path as given.
    ///
    /// - **The parent must already exist** (`withIntermediateDirectories: false`): a redo whose lane
    ///   has since been purged must fail rather than conjure a bare lane folder around the card.
    /// - **It refuses to clobber**, `createBoard`'s rule: a folder already at this path fails loudly
    ///   rather than overwriting whatever is living there now.
    /// - **The bytes are written verbatim**, raw-source Apply's rule and for its reason: they are not
    ///   composed here, they are replayed, so nothing is stamped and nothing is re-serialized.
    ///
    /// `operation` is the caller's own vocabulary word — `.createLane` or `.createCard`, whichever
    /// create is being replayed — so a failure banners as the gesture the user is redoing.
    public static func recreateItem(
        at itemFolder: URL,
        indexText: String,
        operation: WriteOperation
    ) throws(BoardWriteError) {
        try checkIsUUIDShaped(itemFolder, operation: operation)
        guard !FileManager.default.fileExists(atPath: itemFolder.path) else {
            throw BoardWriteError(
                operation: operation,
                path: itemFolder.path,
                reason: .io(message: "something already exists here")
            )
        }
        do {
            try FileManager.default.createDirectory(at: itemFolder, withIntermediateDirectories: false)
        } catch {
            throw BoardWriteError(
                operation: operation,
                path: itemFolder.path,
                reason: .io(message: "could not create folder: \(error.localizedDescription)")
            )
        }
        do throws(BoardWriteError) {
            try atomicReplace(
                text: indexText,
                at: itemFolder.appendingPathComponent(BoardLoader.indexFileName),
                operation: operation
            )
        } catch {
            // `copyItem`'s all-or-nothing rule: a half-made folder is pure residue, since
            // nothing was there before.
            try? FileManager.default.removeItem(at: itemFolder)
            throw error
        }
    }

    // MARK: - Task checkboxes

    /// Flips one task-list checkbox in an item's body — **a single-byte edit, and the only write
    /// in the app that touches the body at all** (05-card-window.md ▸ Preview: "clicking a
    /// `- [ ]` / `- [x]` checkbox flips exactly that marker in the source — a single-character
    /// textual edit; every other byte of the body is untouched").
    ///
    /// It is `updateIndex`'s four steps with one addition, and the addition is why it is written
    /// out here rather than expressed as an `edits` closure: the flip can **refuse**, and
    /// `updateIndex`'s closure cannot. Everything else is identical and deliberately so — read
    /// fresh from disk, refuse an uneditable frontmatter shape, edit, stamp `modified` and clear
    /// `modified-by`, replace atomically. A toggle is "an ordinary user edit — the standard atomic
    /// write" (05) — once "auto-committed and undoable on git boards" too, before app-managed git was
    /// excised (`strategy/01-git-excision.md`, 2026-08-08) — not a special case of anything.
    ///
    /// ### The offset, and why it is re-checked
    ///
    /// `bodyOffset` is a UTF-8 byte offset into the **body** (`FrontmatterDocument.body`, the text
    /// after the closing delimiter) naming the character *between* the brackets — the offset
    /// `BodyTask.markerOffset` carried out of the parse that drew the box the user clicked.
    ///
    /// That parse ran against a snapshot; this call reads disk. In between, an agent, a hand edit
    /// or a pull may have rewritten the file — the same staleness `updateIndex`'s read-fresh rule
    /// exists for, except that here the *target* is a byte offset rather than a key, and a stale
    /// key merely rewrites the wrong value while a stale offset would drop an `x` into the middle
    /// of a sentence. So `BodyMarkup.flippingTaskMarker` re-verifies the brackets, the marker, and
    /// the state the user saw before anything is written, and `.staleTarget` refuses when any of
    /// the three has moved. The refusal is loud (the banner) rather than silent: the user clicked
    /// a box and it did not tick.
    ///
    /// **`checked` is what the user saw, not what they want** — the flip's direction is derived
    /// from it, which is what makes a double-click land on one net toggle instead of racing.
    public static func toggleTaskMarker(
        inItemFolder folder: URL,
        bodyOffset: Int,
        checked: Bool
    ) throws(BoardWriteError) {
        var operation = WriteOperation.toggleTask(title: nil)
        try checkIsDirectory(folder, describedAs: "item folder", operation: operation)
        // Lanes and cards both have bodies; a board root's is its description, and no surface
        // previews it. The same shape guard every other item write leans on keeps this call off it.
        try checkIsUUIDShaped(folder, operation: operation)

        let indexURL = folder.appendingPathComponent(BoardLoader.indexFileName)
        var document = try readDocument(at: indexURL, operation: operation)
        operation = operation.withTitle(document.title.value)
        try checkEditable(document, at: indexURL, operation: operation)

        guard let flipped = BodyMarkup.flippingTaskMarker(in: document.body, at: bodyOffset, expecting: checked) else {
            throw BoardWriteError(
                operation: operation,
                path: indexURL.path,
                reason: .staleTarget(message: "this checkbox is no longer where it was — the file changed")
            )
        }

        document.body = flipped
        document.set(FrontmatterKeys.modified, to: .date(Date()))
        document.remove(FrontmatterKeys.modifiedBy)

        try atomicReplace(text: document.serialized(), at: indexURL, operation: operation)
    }

    // MARK: - Card body

    /// Replaces an item's **body span** — everything after the frontmatter's closing delimiter — and
    /// leaves every frontmatter byte exactly as it was. The card window's Edit buffer landing on
    /// disk (05-card-window.md ▸ Edit: "Saved on a ~700 ms debounce; flushed on leaving Edit,
    /// entering source mode, and window close").
    ///
    /// ### Byte-honest by the same construction as everything else here
    ///
    /// `FrontmatterDocument` keeps the file's raw text and re-emits it as
    /// `openingDelimiter + spans + closingDelimiter + body`, so assigning `body` is *only* a
    /// replacement of the body span: unknown keys, their order, comments, blank lines and line
    /// endings above the delimiter are the same bytes they were, because nothing re-serialized them.
    /// That is `toggleTaskMarker`'s idiom exactly — this call is that one widened from a single
    /// character to the whole span, and it shares its four steps: read fresh from disk, refuse an
    /// uneditable frontmatter shape, edit, stamp `modified` and clear `modified-by`, replace
    /// atomically.
    ///
    /// **The stamp is not optional and not a policy choice here**: a body rewrite *is* an `index.md`
    /// rewrite, and every app-mediated `index.md` rewrite stamps (01-storage-format.md §
    /// Frontmatter). The raw-source Apply is the one path that keeps a `modified-by`, and it does
    /// not come through here.
    ///
    /// ### The gate, and why it lives in the Writer as well as in the session
    ///
    /// **An untouched body is never re-serialized** (05 ▸ Write rules): if the text on disk already
    /// equals `body`, this returns `false` having opened the file and touched nothing — no stamp, no
    /// temp file, no rename, and therefore an untouched `mtime`. The card window's Edit session
    /// gates on the same comparison before it ever calls (its three gates: untouched, reverted, and
    /// the echo of an external edit), so in practice this one never fires; it is here because the
    /// guarantee is about *bytes on disk*, and the layer that owns the bytes is the layer that can
    /// promise it against every caller, including a future one.
    ///
    /// **It is not a staleness check.** A body that changed under the buffer is written over
    /// deliberately — "dirty buffer wins ... deliberate last-writer-wins" (05 ▸ Write rules) — which
    /// is why nothing here compares against what the caller last saw. Only *equality* refuses, and
    /// equality refuses because the write would be a no-op that stamped `modified` anyway.
    ///
    /// - Returns: `true` when bytes were written, `false` when the body on disk already matched.
    @discardableResult
    public static func writeBody(inItemFolder folder: URL, body: String) throws(BoardWriteError) -> Bool {
        var operation = WriteOperation.editBody(title: nil)
        try checkIsDirectory(folder, describedAs: "item folder", operation: operation)
        // The same shape guard `toggleTaskMarker` leans on, for its reason: a board root's body is
        // its description and no editor in this app opens it, so only lanes and cards are reachable.
        try checkIsUUIDShaped(folder, operation: operation)

        let indexURL = folder.appendingPathComponent(BoardLoader.indexFileName)
        var document = try readDocument(at: indexURL, operation: operation)
        operation = operation.withTitle(document.title.value)
        try checkEditable(document, at: indexURL, operation: operation)

        guard document.body != body else { return false }

        document.body = body
        document.set(FrontmatterKeys.modified, to: .date(Date()))
        document.remove(FrontmatterKeys.modifiedBy)

        try atomicReplace(text: document.serialized(), at: indexURL, operation: operation)
        return true
    }

    // MARK: - Raw source

    /// Reads a card's `index.md` as **literal text** — what the raw-source outlet opens
    /// (05-card-window.md ▸ Raw source outlet: "swaps the entire content area … for the literal
    /// on-disk `index.md` (frontmatter and all)"; "Entering source mode flushes any pending title/body
    /// edits first, then reads the file fresh from disk").
    ///
    /// **Fresh from disk, never from a snapshot** — the same rule `updateIndex` opens with, and here
    /// it is the feature rather than a precaution: the user asked to see the file, and a stale
    /// in-memory rendering of it is the one thing this surface must never show.
    ///
    /// **It does not parse.** Every other read in this file parses because it is about to edit a
    /// document; this one hands the bytes to a text editor. A file whose frontmatter an agent has
    /// just broken is exactly what the outlet exists to let a human fix, and refusing to *open* it
    /// would close the only door to the repair. Apply validates on the way back out.
    ///
    /// The one refusal is **encoding**: bytes that are not UTF-8 cannot be shown as text without
    /// inventing characters, and applying that invention would rewrite the file into a transcoding
    /// the user never asked for. Strict `String(validating:as:)`, so a BOM survives into the buffer
    /// visibly rather than being silently swallowed and silently re-added (01-storage-format.md §
    /// Fractal layout ▸ Rules).
    public static func readRawSource(ofCard cardFolder: URL) throws(BoardWriteError) -> String {
        let operation = WriteOperation.rawSource(title: nil)
        try checkIsDirectory(cardFolder, describedAs: "card folder", operation: operation)
        // A card's `index.md` and only a card's: the outlet is the card window's, and a lane or a
        // board root reached through it would put a surface with no window behind it on disk.
        try checkIsCardFolder(cardFolder, operation: operation)

        let indexURL = cardFolder.appendingPathComponent(BoardLoader.indexFileName)
        let data: Data
        do {
            data = try Data(contentsOf: indexURL)
        } catch {
            throw BoardWriteError(
                operation: operation,
                path: indexURL.path,
                reason: .unreadable(message: "could not read file: \(error.localizedDescription)")
            )
        }
        guard let text = String(validating: data, as: UTF8.self) else {
            throw BoardWriteError(operation: operation, path: indexURL.path, reason: .unreadable(message: "file is not UTF-8"))
        }
        return text
    }

    /// Writes a card's `index.md` **byte-for-byte from the user's text** — the raw-source Apply
    /// (05-card-window.md ▸ Raw source outlet), and the one write in this app that is not a document
    /// edit at all.
    ///
    /// ### Validated, then verbatim
    ///
    /// 1. **Validate through the loader's own fail-fast parse** (`BoardLoader.validateCardIndex`):
    ///    decode, parse, `schema`, `order`. The card window has already run this to raise its alert;
    ///    it runs again here for `writeBody`'s reason — the layer that owns the bytes is the layer
    ///    that can promise the app never writes a file its own loader would refuse to load, against
    ///    every caller including a future one.
    /// 2. **Write the user's text exactly.** `atomicReplace` emits `Data(text.utf8)`, so what lands is
    ///    the buffer's own bytes: unknown keys, comments, key order, blank lines, line endings and a
    ///    missing final newline all survive because nothing re-serialized them — not because anything
    ///    here remembered to preserve them.
    ///
    /// ### No stamps. Deliberately, and stated twice in the design
    ///
    /// This path does **not** set `modified` and does **not** clear `modified-by`, and it is the only
    /// write in the app of which both are true. 01-storage-format.md § Frontmatter settles each
    /// explicitly: `modified` "updates on every app write that rewrites the item's `index.md`, and
    /// only those … Two designed app writes therefore don't bump it, deliberately: **raw-source
    /// Apply** writes the validated buffer byte-for-byte (the verbatim contract outranks stamping)";
    /// and `modified-by` gets "One carve-out: **raw-source Apply** … writes byte-for-byte and does
    /// *not* clear a stamp the user typed or kept". 05 says the same from the other side ("including
    /// a `modified-by` stamp the user typed or kept — Apply is the one app write that doesn't clear
    /// it").
    ///
    /// The reasoning is worth keeping next to the code: every other write here is *composed* by the
    /// app — the user asked for a rename, a move, a body edit, and the app decided which bytes express
    /// it, so stamping is the app reporting its own authorship. Here the user typed the bytes. A stamp
    /// would be the app editing a file it was told to write literally, and "byte-for-byte" would be
    /// false in the one place the whole feature rests on it.
    ///
    /// ### The equality gate
    ///
    /// Text identical to what is already on disk writes nothing and returns `false` — `writeBody`'s
    /// untouched gate, applied to the whole file instead of the body span. Apply on a buffer the user
    /// only read must not churn `mtime` or wake every watcher.
    ///
    /// - Returns: `true` when bytes were written, `false` when the file already read exactly like
    ///   `text`.
    @discardableResult
    public static func writeRawSource(inCard cardFolder: URL, text: String) throws(BoardWriteError) -> Bool {
        var operation = WriteOperation.rawSource(title: nil)
        try checkIsDirectory(cardFolder, describedAs: "card folder", operation: operation)
        try checkIsCardFolder(cardFolder, operation: operation)

        let indexURL = cardFolder.appendingPathComponent(BoardLoader.indexFileName)
        // Best-effort, and both of its uses tolerate failure: the equality gate treats "cannot read"
        // as "not equal" (write it), and the title enrichment falls back to the untitled phrasing.
        let current = try? Data(contentsOf: indexURL)
        if let current, let document = try? BoardLoader.parseDocument(current, path: BoardLoader.indexFileName) {
            // The title as the file *currently* reads, not as the buffer proposes it — `.rename`'s
            // rule: a failed write names the card the user is still looking at in the title bar,
            // rather than a name that never landed.
            operation = operation.withTitle(document.title.value)
        }

        let proposed = Data(text.utf8)
        do throws(BoardLoadError) {
            _ = try BoardLoader.validateCardIndex(proposed, path: BoardLoader.indexFileName)
        } catch {
            throw BoardWriteError(operation: operation, path: indexURL.path, reason: .invalidSource(error))
        }

        guard current != proposed else { return false }

        try atomicReplace(text: text, at: indexURL, operation: operation)
        return true
    }

    // MARK: - Attachments

    /// The one folder this app ever creates under a card — every other subfolder under
    /// `attachments/` is tolerated but never made or named by the app (01-storage-format.md §
    /// Attachments). Internal rather than `private`: `importAttachments` and `listAttachments`
    /// must never disagree about which folder holds a card's files. The name itself is
    /// `IntegrityRules`', with the rest of the reserved-name tables.
    static let attachmentsFolderName = IntegrityRules.attachmentsFolderName

    /// Imports files into a card's `attachments/` folder, creating it on first import — the
    /// write side of 01-storage-format.md § Attachments. **Never refuses the drop**: a name
    /// already taken is auto-renamed Finder-style (`shot.png` → `shot 2.png` → `shot 3.png`, …)
    /// rather than overwritten or bounced.
    ///
    /// A multi-file drop imports **in order, one finished copy at a time** — not a transaction
    /// across the batch: the first failure stops it and throws naming the failing source file,
    /// but every file already imported stays landed, because each import already completed as
    /// its own write before the next one starts. Returns the landed names, in input order, for
    /// exactly the files that made it in.
    ///
    /// The order of checks is the contract:
    ///
    /// 1. **`cardFolder` must be an existing, UUID-shaped directory** — attachments belong to
    ///    cards, the same shape guard `moveItem`/`copyItem`/`purgeItem` lean on
    ///    (`checkIsUUIDShaped`): a lane or a board root is refused before anything else happens.
    /// 2. **`attachments/` is created if missing** (`.io` naming `cardFolder` on failure) — the
    ///    one exception to "subfolders are never created by the app" (§ Attachments); every
    ///    other folder under it is the user's or a hand-editor's, left alone.
    /// 3. **Each source is validated before it is touched**: must exist and be a regular file —
    ///    not a directory, which is out of this call's scope entirely — refused `.unreadable`
    ///    naming the source *before* any copy for that file is attempted, so a batch never
    ///    partially copies something it was about to refuse.
    /// 4. **The collision-free name is decided, then copied to directly** — no temp name and
    ///    rename, unlike `atomicReplace`: `FileManager.copyItem` lands the bytes straight under
    ///    the final name. `copyItem` is not atomic, so on any failure the partial destination is
    ///    removed best-effort and `.io` is thrown naming the source file — "no half-copied
    ///    attachment is ever left in `attachments/`" (02-architecture.md § Write-failure
    ///    surfacing) is a promise about the *failure path*, not a stronger atomicity claim
    ///    `copyItem` cannot make. A crash mid-copy (process death, not a caught `Error`) can
    ///    still leave a partial file on disk — the accepted limit of a non-atomic copy, the same
    ///    one an ordinary Finder copy has.
    public static func importAttachments(
        _ sourceURLs: [URL],
        intoCard cardFolder: URL
    ) throws(BoardWriteError) -> [ImportedAttachment] {
        // Before the per-file loop starts, no single source is implicated yet — the first
        // source's own (original, pre-collision-rename) name stands in for the batch; an empty
        // `sourceURLs` (nothing was actually dropped) falls back to the empty string rather than
        // crashing on `first!`.
        let batchOperation = WriteOperation.importAttachment(filename: sourceURLs.first?.lastPathComponent ?? "")
        try checkIsDirectory(cardFolder, describedAs: "card folder", operation: batchOperation)
        try checkIsUUIDShaped(cardFolder, operation: batchOperation)

        return try importFiles(sourceURLs, intoAttachmentsOf: cardFolder, batchOperation: batchOperation)
    }

    /// **Steps 2–4 of `importAttachments`, with the shape guard left to the caller** — the copy
    /// itself, which is identical wherever an `attachments/` folder hangs.
    ///
    /// It exists because a *comment* has an `attachments/` too (01-storage-format.md § Enhanced
    /// schema — "a card's anatomy one level down"), and comment attachments author in-window
    /// (05-card-window.md ▸ The comments column, ruled 2026-07-29). The alternative was a second
    /// importer beside this one, which is exactly what `CardAttachments`' own note forbids for the
    /// card level: "one import path, one set of banners, one Finder-style collision rename". The
    /// generalization is therefore the *smallest* one that keeps that true — the folder shape is what
    /// differs between a card and a comment, and it is the only thing the callers still decide.
    ///
    /// Everything the batch promises is here rather than at either entry point: `attachments/` minted
    /// on first import, each source validated before it is touched, the Finder-style collision-free
    /// name, the non-atomic copy with best-effort cleanup, and the import receipt.
    static func importFiles(
        _ sourceURLs: [URL],
        intoAttachmentsOf itemFolder: URL,
        batchOperation: WriteOperation
    ) throws(BoardWriteError) -> [ImportedAttachment] {
        let attachmentsFolder = itemFolder.appendingPathComponent(attachmentsFolderName, isDirectory: true)
        do {
            try FileManager.default.createDirectory(at: attachmentsFolder, withIntermediateDirectories: true)
        } catch {
            throw BoardWriteError(
                operation: batchOperation,
                path: itemFolder.path,
                reason: .io(message: "could not create attachments folder: \(error.localizedDescription)")
            )
        }

        var landed: [ImportedAttachment] = []
        for sourceURL in sourceURLs {
            // Each source names itself — the ORIGINAL filename, not the Finder-style renamed one
            // decided a few lines down, because the operation describes what the user dropped.
            let operation = WriteOperation.importAttachment(filename: sourceURL.lastPathComponent)
            var isDirectory: ObjCBool = false
            guard FileManager.default.fileExists(atPath: sourceURL.path, isDirectory: &isDirectory) else {
                throw BoardWriteError(
                    operation: operation,
                    path: sourceURL.path,
                    reason: .unreadable(message: "file does not exist")
                )
            }
            guard !isDirectory.boolValue else {
                throw BoardWriteError(
                    operation: operation,
                    path: sourceURL.path,
                    reason: .unreadable(message: "is a folder, not a file — importing a folder is not supported")
                )
            }

            let name = freshAttachmentName(for: sourceURL.lastPathComponent, in: attachmentsFolder)
            let destinationURL = attachmentsFolder.appendingPathComponent(name)
            do {
                try FileManager.default.copyItem(at: sourceURL, to: destinationURL)
                // "Attachment imports hash during the copy" — see `EchoLedger.recordImport(at:)`
                // for the one place that phrase and `FileManager.copyItem` do not quite meet.
                EchoLedger.current?.recordImport(at: destinationURL)
            } catch {
                try? FileManager.default.removeItem(at: destinationURL)
                throw BoardWriteError(
                    operation: operation,
                    path: sourceURL.path,
                    reason: .io(message: "could not copy file: \(error.localizedDescription)")
                )
            }
            landed.append(ImportedAttachment(sourceURL: sourceURL, fileName: name))
        }
        return landed
    }

    /// Moves loose files out of a card folder and into its `attachments/` — the **write half** of
    /// 01-storage-format.md's loose-file carve-out (§ Fractal layout ▸ Rules, settled 2026-07-28,
    /// "Lanework-owns-the-board"): "a regular file sitting beside a card's `index.md` … belongs in
    /// `attachments/`, and the app moves it there — Finder-style rename on collision".
    ///
    /// **A move, not a copy** — the file is not being imported from somewhere else, it is being put
    /// where it already belonged, and leaving a second copy beside `index.md` would leave the very
    /// thing this call exists to clear. `FileManager.moveItem` within one folder is a `rename(2)`:
    /// atomic, and byte-preserving by not touching bytes at all.
    ///
    /// **`index.md` is never opened.** Relocating a stray says nothing about the card's content, so
    /// no `modified` stamp is written and no frontmatter is read — which is also why a card whose
    /// frontmatter is uneditable (a flow mapping) still gets its files tidied.
    ///
    /// `names` is the caller's list — the loader's `looseFileNames` at the store, or this file's own
    /// `normalizeLooseFiles(inCard:)` at the import boundary — and **every name is re-checked
    /// against disk before it is touched** (`isRelocatable`). A name that has stopped being a plain
    /// non-hidden regular file since it was listed, or that names a reserved child, or that is not a
    /// bare filename at all, is **skipped silently**: the reload is the authority on what is there,
    /// and a file the user deleted between the walk and the write is not a failure to report. That
    /// re-check is also what makes the rule "folders and symlinks are never relocated" a property of
    /// this call rather than of its callers.
    ///
    /// The batch is `importAttachments`' shape exactly: in order, one finished move at a time, the
    /// first failure stopping it and throwing while everything already moved stays moved. Returns
    /// what actually landed, in input order — `sourceURL` naming the file where it sat, `fileName`
    /// the (possibly Finder-renamed) name it took inside `attachments/`.
    ///
    /// `attachments/` is created only when something is actually going to move into it, so a card
    /// whose loose files all vanished under the write is left exactly as it was — no empty folder
    /// minted for nothing.
    ///
    /// **`cardFolder` must really be a card** (`checkIsCardFolder`, which is stricter than the
    /// UUID-shape guard the rest of this file uses): a lane's own loose files keep the verbatim
    /// posture, and no other write in the app has to tell the two levels apart.
    ///
    /// - Parameter healMarked: whether the receipts this drops are marked as a heal's
    ///   (06-history-undo.md ▸ Commit messages, ruled 2026-07-29). `true` for the scheduled
    ///   relocation, which is app-initiated work that commits separately; `false` for the
    ///   import-boundary normalization below, which is **inline** — it batches with the gesture that
    ///   triggered it and belongs in that gesture's commit, not in a heal's.
    @discardableResult
    public static func relocateLooseFiles(
        _ names: [String],
        inCard cardFolder: URL,
        healMarked: Bool = true
    ) throws(BoardWriteError) -> [ImportedAttachment] {
        guard !names.isEmpty else { return [] }

        // Before the per-file loop starts no single file is implicated yet — the first name stands
        // in for the batch, exactly as `importAttachments` lets its first source name it.
        let batchOperation = WriteOperation.relocateLooseFile(filename: names[0])
        try checkIsDirectory(cardFolder, describedAs: "card folder", operation: batchOperation)
        try checkIsCardFolder(cardFolder, operation: batchOperation)

        let relocatable = names.filter { isRelocatable($0, in: cardFolder) }
        guard !relocatable.isEmpty else { return [] }

        let attachmentsFolder = cardFolder.appendingPathComponent(attachmentsFolderName, isDirectory: true)
        do {
            try FileManager.default.createDirectory(at: attachmentsFolder, withIntermediateDirectories: true)
        } catch {
            throw BoardWriteError(
                operation: batchOperation,
                path: cardFolder.path,
                reason: .io(message: "could not create attachments folder: \(error.localizedDescription)")
            )
        }

        var moved: [ImportedAttachment] = []
        for name in relocatable {
            // Each file names itself — the ORIGINAL name, not the Finder-style renamed one decided
            // on the next line, for `importAttachments`' reason: the operation describes the file
            // the user (or their agent) actually wrote.
            let operation = WriteOperation.relocateLooseFile(filename: name)
            let sourceURL = cardFolder.appendingPathComponent(name)
            let landed = freshAttachmentName(for: name, in: attachmentsFolder)
            let landedURL = attachmentsFolder.appendingPathComponent(landed)
            do {
                try FileManager.default.moveItem(at: sourceURL, to: landedURL)
                // A move pair, not a write: the bytes were not touched, only their place — and the
                // pair is what tells the classifier the loose file's disappearance was the app's.
                // Heal-marked: the relocation is app-initiated work whose paths commit separately
                // (06-history-undo.md ▸ Commit messages, ruled 2026-07-29).
                EchoLedger.current?.recordMove(from: sourceURL, to: landedURL)
                if healMarked { EchoLedger.current?.markHeal(at: landedURL) }
            } catch {
                throw BoardWriteError(
                    operation: operation,
                    path: sourceURL.path,
                    reason: .io(message: "could not move file into attachments: \(error.localizedDescription)")
                )
            }
            moved.append(ImportedAttachment(sourceURL: sourceURL, fileName: landed))
        }
        return moved
    }

    /// Discovers *and* relocates — the loose-file rule applied at an **import boundary**, where
    /// there is no loader round trip to discover through (04-interactions.md ▸ Clipboard, settled
    /// 2026-07-28: "A paste is an import boundary, so normalization applies … loose files the staged
    /// snapshot carries beside a card's `index.md` land in the pasted card's `attachments/`,
    /// Finder-renamed on collision — nothing the snapshot preserved is dropped on arrival").
    ///
    /// The pasted card therefore lands **already normalized**, rather than arriving loose and being
    /// tidied a reload later: the write is happening anyway, and one that leaves work behind for the
    /// carve-out to find would also post the carve-out's notice — a warning row about a mess the
    /// user's own paste made and the app immediately cleaned up.
    ///
    /// A card with nothing loose is one directory listing and no write at all.
    ///
    /// **The destination board's noise gate applies here too** (01-storage-format.md § Fractal layout
    /// ▸ Rules, ruled 2026-07-31): the arriving card is already at its final path, so its files are
    /// the destination board's to have an opinion about — and a paste that swept a file the very next
    /// walk would have left alone would be the app disagreeing with itself one gesture apart. The
    /// rules are read from the board root the card's own path implies (`checkIsCardFolder` proves the
    /// `<root>/<lane>/<card>` shape before anything moves), which for a batch means one small file
    /// read per card; the lane-level face below reads it once for the whole lane.
    @discardableResult
    public static func normalizeLooseFiles(inCard cardFolder: URL) throws(BoardWriteError) -> [ImportedAttachment] {
        try normalizeLooseFiles(
            inCard: cardFolder,
            ignoring: BoardLoader.ignoreRules(atBoardRoot: boardRoot(ofCard: cardFolder))
        )
    }

    /// The rules-in-hand form, so a batch reads the board's `.gitignore` once rather than per card.
    @discardableResult
    static func normalizeLooseFiles(
        inCard cardFolder: URL,
        ignoring rules: GitignoreRules?
    ) throws(BoardWriteError) -> [ImportedAttachment] {
        // `healMarked: false` — an **inline** heal batches with the gesture that triggered it
        // (01-storage-format.md § Validation and healing), so its paths are the paste's, not a
        // heal's, and splitting them out would name a commit for work the user asked for.
        try relocateLooseFiles(
            BoardLoader.looseFileNames(in: cardFolder, ignoring: rules),
            inCard: cardFolder,
            healMarked: false
        )
    }

    /// The lane-level face of the same import-boundary normalization: every card of an arriving
    /// lane, in folder order.
    ///
    /// Children are `childCandidates` — the loader's own level detection — so a stray *folder*
    /// inside the arriving lane is neither descended into nor tidied, and nothing below a card is
    /// reached: the carve-out is card-level and one level deep, exactly as 01 states it.
    @discardableResult
    public static func normalizeLooseFiles(inLane laneFolder: URL) throws(BoardWriteError) -> [ImportedAttachment] {
        let rules = BoardLoader.ignoreRules(atBoardRoot: laneFolder.deletingLastPathComponent())
        var moved: [ImportedAttachment] = []
        for card in childCandidates(of: laneFolder) {
            moved.append(contentsOf: try normalizeLooseFiles(inCard: card, ignoring: rules))
        }
        return moved
    }

    /// The board root a card folder implies — `<root>/<lane>/<card>` with two components taken off.
    ///
    /// Purely arithmetic on the path, and deliberately not a validation: the callers that reach it
    /// run `checkIsCardFolder` on the same URL, and a folder that is not a card has no board root to
    /// derive anyway — the rules read from wherever this points would simply find no file.
    private static func boardRoot(ofCard cardFolder: URL) -> URL {
        cardFolder.deletingLastPathComponent().deletingLastPathComponent()
    }

    /// Refuses any folder that is not a **card**: UUID-shaped, *under* a UUID-shaped parent.
    ///
    /// `checkIsUUIDShaped` is the guard every other item write leans on, and it is the wrong one
    /// here because it cannot tell a lane from a card — both are UUID-shaped, which is exactly the
    /// distinction the carve-out turns on ("everything at board or lane level keeps the verbatim
    /// posture"; "board/lane-level strays … are legitimate residents"). Pointing the relocation at a
    /// lane would sweep a hand-editor's `notes.txt` into an `attachments/` folder no lane should
    /// ever have.
    ///
    /// The parent test is exact rather than heuristic because 01-storage-format.md § Fractal layout
    /// fixes the depth: a card is `<root>/<lane>/<card>` and a lane is `<root>/<lane>`, so a
    /// UUID-shaped folder whose parent is *also* UUID-shaped is a card and nothing else. It is the
    /// same reading `BoardStore.boardRoot(ofCardFolder:)` already derives a root from.
    /// Internal rather than `private`: every comment write is *about* a card, and reaches it
    /// through this same guard.
    static func checkIsCardFolder(_ folder: URL, operation: WriteOperation) throws(BoardWriteError) {
        try checkIsUUIDShaped(folder, operation: operation)
        guard BoardLoader.isUUIDShaped(folder.deletingLastPathComponent().lastPathComponent) else {
            throw BoardWriteError(
                operation: operation,
                path: folder.path,
                reason: .unreadable(message: "folder is not a card: only a card's own files are relocated")
            )
        }
    }

    /// Whether `name` inside `cardFolder` is a file this app may relocate: a bare filename (never a
    /// path), not hidden, not one of the reserved card-level names, and — read from disk, at write
    /// time — a regular file that is not a symlink.
    ///
    /// The four name rules restate the loader's listing exclusions rather than trusting them,
    /// because `relocateLooseFiles` takes a caller's list: this is where "the carve-out is exactly
    /// that narrow" stops being a convention and becomes something the filesystem-touching code
    /// enforces on its own.
    private static func isRelocatable(_ name: String, in cardFolder: URL) -> Bool {
        guard !name.isEmpty,
              !name.hasPrefix("."),
              !name.contains("/"),
              !BoardLoader.reservedCardChildNames.contains(name.lowercased()),
              let values = try? cardFolder
                  .appendingPathComponent(name)
                  .resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey])
        else {
            return false
        }
        return values.isRegularFile == true && values.isSymbolicLink != true
    }

    /// The Finder-style collision-free name for `originalName` landing in `folder`: the name
    /// itself when nothing on disk claims it yet, else the base name suffixed `" 2"`, `" 3"`, …
    /// — counting up from 2 against what is on disk *at decision time*, one collision at a time.
    ///
    /// Splits `originalName` the way `URL` itself does (`deletingPathExtension`/
    /// `pathExtension`), which is also exactly how Finder does it: an extension-less name
    /// suffixes directly (`"notes"` → `"notes 2"`), and a multi-dot name splits after the
    /// *last* dot (`"archive.tar.gz"` → `"archive.tar 2.gz"`, not `"archive 2.tar.gz"`) — both
    /// accepted as what Finder itself produces, not worked around.
    ///
    /// `fileExists` is the one test, and it is true for a directory as much as a file — a
    /// same-named *subfolder* blocks the name exactly like a file would, so an import never
    /// overwrites, renames, or descends into one; it just renames the incoming file instead.
    ///
    /// **The ladder itself is `freshName(for:in:)`**, shared with the claimed-name displacement
    /// (`.trash` → `.trash 2`, ruled 2026-07-29): "Finder-style rename on collision" is one rule
    /// wherever the app has to find a free name, and two copies of it would be two ladders to keep
    /// climbing the same way.
    private static func freshAttachmentName(for originalName: String, in folder: URL) -> String {
        freshName(for: originalName, in: folder)
    }

    /// The Finder-style collision-free name for `originalName` inside `folder` — see
    /// `freshAttachmentName(for:in:)` for the splitting rules, which are Finder's own.
    ///
    /// **`lstat` semantics, not `stat`**: a *dangling symlink* holds its name as firmly as any other
    /// node, and `fileExists` — which follows links — would call that name free and hand the caller
    /// a move that fails. `IntegrityRules.node(at:)` is the one probe, which is also what makes this
    /// safe for the displacement, whose whole subject may itself be a symlink.
    static func freshName(for originalName: String, in folder: URL) -> String {
        guard IntegrityRules.node(at: folder.appendingPathComponent(originalName)) != nil else {
            return originalName
        }

        let nameURL = URL(fileURLWithPath: originalName)
        let base = nameURL.deletingPathExtension().lastPathComponent
        let ext = nameURL.pathExtension

        var counter = 2
        while true {
            let candidate = ext.isEmpty ? "\(base) \(counter)" : "\(base) \(counter).\(ext)"
            guard IntegrityRules.node(at: folder.appendingPathComponent(candidate)) != nil else {
                return candidate
            }
            counter += 1
        }
    }

    /// **Displaces a squatter off a claimed name** — the write half of the claimed-names ruling
    /// (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").
    ///
    /// A rename and nothing else: the node moves to its Finder-ladder name in the same folder
    /// (`.trash` → `.trash 2`), **preserved verbatim, never destroyed** — the invariant that survives
    /// the ruling is displacement-never-destruction. Its contents are never opened, and a symlink is
    /// moved *as a link*, never followed (`FileManager.moveItem` renames the link itself).
    ///
    /// **It re-verifies against disk** (§ Validation and healing: "every scheduled heal re-verifies
    /// its defect against disk at write time and no-ops when it is gone"): `nil` when the name is
    /// free again, or when the right kind of node is now sitting there — losing the race to a
    /// foreign fix is success, never an error.
    ///
    /// **It stamps nothing.** A heal that only renames never opens an `index.md`, so the existing
    /// write discipline decides and there is no rule to add (§ Validation and healing).
    ///
    /// **Level-uniform** (extended 2026-07-29): the squatter's own `location` names the folder the
    /// claimed name lives in — the board root, or a card's folder for a file wearing `attachments`. That
    /// is the whole of the difference, which is the point of the ruling: one ladder, one notice, one
    /// write, whichever level the name is claimed at. A card whose folder has since gone takes the
    /// re-verification's `nil` path like any other vanished defect.
    ///
    /// - Parameter root: the board root. The squatter's location is resolved against it, so a board
    ///   renamed since the load heals at its new location.
    /// - Returns: the name the squatter now has, or `nil` when the defect was already gone.
    @discardableResult
    public static func displaceClaimedName(
        _ squatter: ClaimedNameSquatter,
        atBoardRoot root: URL
    ) throws(BoardWriteError) -> String? {
        let operation = WriteOperation.displaceClaimedName(name: squatter.name)
        let container = squatter.location.folder(under: root)
        let occupied = container.appendingPathComponent(squatter.name)
        guard let found = IntegrityRules.node(at: occupied), found != squatter.expected else {
            return nil
        }

        let freed = freshName(for: squatter.name, in: container)
        let destination = container.appendingPathComponent(freed)
        do {
            try FileManager.default.moveItem(at: occupied, to: destination)
        } catch {
            throw BoardWriteError(
                operation: operation,
                path: occupied.path,
                reason: .io(message: "could not move it aside to '\(freed)': \(error.localizedDescription)")
            )
        }
        // A folder move like any other as far as provenance goes — and heal-marked, because the app
        // started this on its own (06-history-undo.md ▸ Commit messages, the heal class).
        EchoLedger.current?.recordMove(from: occupied, to: destination)
        EchoLedger.current?.markHeal(at: destination)
        return freed
    }

    /// **Remints a withheld duplicate identity** — the write half of the duplicate-id heal
    /// (01-storage-format.md § Fractal layout ▸ Rules, re-ruled 2026-07-29: "the heal gives each
    /// withheld occurrence the fresh identity the import boundary would have minted").
    ///
    /// Copy semantics applied at detection: a hand copy in Finder *was* a copy, so it gets what a
    /// copy gets — a fresh lowercase v4 folder name, minted away from every identity in the board.
    /// After it lands, the folder renders as an ordinary item.
    ///
    /// **A rename and nothing else.** The folder keeps its parent; its `index.md`, its frontmatter,
    /// its children, its attachments and its strays are never opened. That is not a carve-out but the
    /// existing write discipline answering: a heal that only renames stamps nothing — no `modified`,
    /// no cleared `modified-by` — because this is an identity repair, not an edit (§ Validation and
    /// healing).
    ///
    /// **It re-verifies against disk, twice over** (§ Validation and healing: "every scheduled heal
    /// re-verifies its defect against disk at write time and no-ops when it is gone"), and each check
    /// answers `nil` — success, never an error:
    ///
    /// 1. The folder is still there, still a directory, and still spelled with the identity the
    ///    detection named. A folder already reminted (this heal running twice, another device's heal
    ///    arriving first) fails here.
    /// 2. **Something else still carries that identity.** The winner may have been hand-deleted or
    ///    moved out since the load, in which case this folder is no longer a duplicate of anything and
    ///    reminting it would change an identity for no reason at all — the vanished-duplicate race,
    ///    read from the surviving side.
    ///
    /// **Heal-marked**, because the app started it on its own: once the receipt that split the remint
    /// into its own commit on git boards, named for the Repair verb (06-history-undo.md ▸ Commit
    /// messages, retired with app-managed git, `strategy/01-git-excision.md`) — the receipt still
    /// exists for whatever consumes it next. Undo never sees it — heals are not gestures
    /// (13-native-undo.md).
    ///
    /// - Parameter duplicate: the withheld occurrence, `path` relative to `boardRoot` so the write
    ///   lands wherever the board lives *now*.
    /// - Returns: the fresh identity, or `nil` when the defect was already gone.
    @discardableResult
    public static func remintDuplicateIdentity(
        _ duplicate: DuplicateIdentity,
        inBoard boardRoot: URL
    ) throws(BoardWriteError) -> ItemID? {
        let operation = WriteOperation.repairDuplicateID(title: duplicate.title)
        let folder = boardRoot.appendingPathComponent(duplicate.path, isDirectory: true)

        // 1. Still there, still a folder, still carrying the identity that lost.
        guard IntegrityRules.node(at: folder) == .directory,
              IntegrityRules.canonicalIdentity(folder.lastPathComponent) == duplicate.identity
        else {
            return nil
        }
        // 2. Still a duplicate *of something*. One occurrence is this folder itself, so the identity
        //    has to appear at least twice for the defect to still stand.
        let occurrences = identityOccurrences(inBoard: boardRoot)
        guard occurrences.filter({ $0 == duplicate.identity }).count > 1 else { return nil }

        // Minted away from every identity in the board, not merely from this parent's children: the
        // point of the remint is board-wide uniqueness, and a fresh name colliding with a folder two
        // lanes over would trade one duplicate for another.
        let fresh = freshUUIDName(in: folder.deletingLastPathComponent(), avoiding: Set(occurrences))
        try renameFolder(folder, toSiblingNamed: fresh, operation: operation)
        // The move pair is `renameFolder`'s; the heal mark is this call's, because the remint is
        // app-initiated work whose paths commit separately (06-history-undo.md ▸ Commit messages).
        EchoLedger.current?.markHeal(
            at: folder.deletingLastPathComponent().appendingPathComponent(fresh, isDirectory: true)
        )
        return ItemID(rawValue: fresh)
    }

    /// The card's flat attachment listing (01-storage-format.md § Attachments, "the app's
    /// attachment surfaces … are flat: top-level files only"): the top-level *files* of
    /// `attachments/`, subfolders and hidden files excluded, symlinks excluded, sorted
    /// `localizedStandardCompare` — Finder order, so `"shot 2.png"` sorts before `"shot
    /// 10.png"` rather than after it.
    ///
    /// A **missing** `attachments/` lists as `[]`, not an error: nothing has been imported yet
    /// is an ordinary state, not a malformed one. `cardFolder` itself missing, or not a
    /// directory, *is* `.unreadable` — that is a caller asking about a card that isn't there,
    /// not an empty listing. Purely a read: nothing here ever creates `attachments/` or
    /// disturbs anything inside it, subfolders included.
    ///
    /// The listing itself is `BoardLoader.attachmentNames(in:)`, which is also what fills
    /// `Card.attachments` for the board window's face indicator. **One function, so the sidebar
    /// and the face can never disagree** about a card's attachments or their order.
    /// What this call adds over that one is the card-folder guard and this API's error
    /// vocabulary — the difference between a surface that *acts* on the files and two that
    /// merely show them.
    public static func listAttachments(ofCard cardFolder: URL) throws(BoardWriteError) -> [String] {
        let operation = WriteOperation.listAttachments
        try checkIsDirectory(cardFolder, describedAs: "card folder", operation: operation)
        return BoardLoader.attachmentNames(in: cardFolder)
    }

    /// Moves one of a card's attachments to the **system** Trash — the attachment row's Remove and
    /// its ⌫ twin (05-card-window.md ▸ Attachments: "context menu Open / Reveal in Finder / Remove
    /// (moves to the **system** Trash, never hard-deletes …)").
    ///
    /// ### `trashItem`, and never `removeItem`
    ///
    /// The design says *system* Trash and means it: a board's own tombstone quasi-lane is a different
    /// trash with a different vocabulary (03-board-ui.md § Trash's naming constraint), and an
    /// attachment removed here is recoverable exactly the way a file dragged out of a Finder window
    /// is — by the user, in Finder, with no help from this app. `FileManager.trashItem` is that
    /// sentence; `removeItem` would be a hard delete of the user's own file, which this app never
    /// does to an attachment.
    ///
    /// ### The listing is the guard
    ///
    /// `name` is checked against `BoardLoader.attachmentNames(in:)` — **the very set the sidebar
    /// shows** — before anything is touched, which is what makes the whole class of "the caller
    /// passed something else" unreachable in one line rather than four: a path (`../index.md`), a
    /// subfolder, a hidden file, a symlink, and the empty string are all simply not in the listing,
    /// and none of them can be trashed through this call. It is also `relocateLooseFiles`' re-check
    /// rule applied here — the caller's name is re-read against disk at write time, not trusted from
    /// whenever the row was drawn.
    ///
    /// ### A name that is no longer there is not a failure
    ///
    /// It returns `false` and writes nothing, `relocateLooseFiles`' rule again: "the reload is the
    /// authority on what is there, and a file the user deleted between the walk and the write is not
    /// a failure to report". A ⌫ racing an external delete of the same file is exactly that race, and
    /// a banner for it would name a removal the user got anyway.
    ///
    /// - Returns: where the file now sits **inside the Trash**, or `nil` when there was nothing to
    ///   move. The URL is returned rather than discarded because it is the only proof this call
    ///   makes that the file still exists at all — the difference between the promise ("never
    ///   hard-deletes") and a `removeItem` that would look identical from `attachments/`.
    @discardableResult
    public static func removeAttachment(
        named name: String,
        fromCard cardFolder: URL
    ) throws(BoardWriteError) -> URL? {
        let operation = WriteOperation.removeAttachment(filename: name)
        try checkIsDirectory(cardFolder, describedAs: "card folder", operation: operation)
        try checkIsUUIDShaped(cardFolder, operation: operation)

        return try trashAttachment(named: name, fromItemFolder: cardFolder, operation: operation)
    }

    /// `removeAttachment`'s body with the shape guard left to the caller — `importFiles`' split, for
    /// its reason: a comment's authoring chips carry Remove too, "to the **system** Trash — the
    /// sidebar row's rule" (05-card-window.md ▸ The comments column), and one Trash-not-delete
    /// promise is worth more than two implementations of it.
    ///
    /// The listing check, the never-hard-delete guarantee and the a-name-that-is-gone-is-not-a-failure
    /// rule all live here, so they hold at both levels without either caller restating them.
    static func trashAttachment(
        named name: String,
        fromItemFolder itemFolder: URL,
        operation: WriteOperation
    ) throws(BoardWriteError) -> URL? {
        guard BoardLoader.attachmentNames(in: itemFolder).contains(name) else { return nil }

        let fileURL = itemFolder
            .appendingPathComponent(attachmentsFolderName, isDirectory: true)
            .appendingPathComponent(name)
        var trashedURL: NSURL?
        do {
            try FileManager.default.trashItem(at: fileURL, resultingItemURL: &trashedURL)
            // Where the file went is the *system* Trash, outside any board — so from this board's
            // point of view it is an absence, exactly as a purge is.
            EchoLedger.current?.recordDeletion(at: fileURL)
        } catch {
            throw BoardWriteError(
                operation: operation,
                path: fileURL.path,
                reason: .io(message: "could not move file to the Trash: \(error.localizedDescription)")
            )
        }
        return trashedURL as URL?
    }

    /// Moves a file out of `.backgrounds/` to the **system** Trash — `trashAttachment`'s body one
    /// folder over, and the shared primitive both orphan-tidy mechanisms use (01-storage-format.md §
    /// Validation and healing, ruled 2026-08-09): the replace-in-place trim a background apply makes
    /// on the file it just repointed or unset away from (`BoardStore.applyGeneratedBackground`,
    /// `applyPastedBackground`, `applySolidBackground`), and the open-time sweep of whatever
    /// `.backgrounds/` holds that the board's current `background.image` no longer names
    /// (`BoardStore.tidyBackgroundImages`).
    ///
    /// **`FileManager.trashItem`, never `removeItem`** — `trashAttachment`'s own reasoning, applied to
    /// a file the app rather than the user put there: an app-written background is recoverable exactly
    /// the way a trashed attachment is, by the user, in Finder, with no help from this app, which is
    /// the ruling's own "removal via `FileManager.trashItem` (recoverable, matches
    /// attachment-removal precedent)".
    ///
    /// **A name no longer present in `backgroundsFolder` is not a failure** — `trashAttachment`'s rule
    /// again: the reload (or, for the in-flow caller, the disk itself a moment later) is the authority
    /// on what is there, and a file that vanished between the scan and the write — a hand delete, a
    /// losing race with a foreign write — contributes nothing to report.
    ///
    /// - Parameter healMarked: whether the receipt this drops is marked as a heal's
    ///   (06-history-undo.md ▸ Commit messages; `relocateLooseFiles`' own parameter, restated here).
    ///   `true` for the scheduled sweep, which is app-initiated work discovered on its own; `false` for
    ///   the in-flow trim, which rides inside the gesture that triggered it rather than a heal's own
    ///   bracket.
    /// - Returns: where the file now sits inside the Trash, or `nil` when there was nothing to move.
    @discardableResult
    static func trashBoardImage(
        named name: String,
        inFolder backgroundsFolder: URL,
        healMarked: Bool,
        operation: WriteOperation
    ) throws(BoardWriteError) -> URL? {
        let fileURL = backgroundsFolder.appendingPathComponent(name)
        guard FileManager.default.fileExists(atPath: fileURL.path) else { return nil }

        var trashedURL: NSURL?
        do {
            try FileManager.default.trashItem(at: fileURL, resultingItemURL: &trashedURL)
            EchoLedger.current?.recordDeletion(at: fileURL)
            if healMarked {
                EchoLedger.current?.markHeal(at: fileURL)
            }
        } catch {
            throw BoardWriteError(
                operation: operation,
                path: fileURL.path,
                reason: .io(message: "could not move file to the Trash: \(error.localizedDescription)")
            )
        }
        return trashedURL as URL?
    }

    // MARK: - Move/copy pre-flight

    /// The rank a moved or copied root lands on: the caller's explicit value — a drop between
    /// two siblings, whose midpoint only the caller knows — or `max + 1024` over the
    /// destination's visible siblings when it has none (`Ranks.append(toVisible:)`, tombstones
    /// inert). Computed *before* the item arrives, so it cannot count itself as its own sibling.
    ///
    /// `requireEditable: false` for the same reason the creates pass it: this only *reads* the
    /// siblings' orders. A flow-mapping sibling that loads and renders normally must not block
    /// dropping an item beside it.
    private static func destinationOrder(
        _ order: Double?,
        inParent parentFolder: URL,
        operation: WriteOperation
    ) throws(BoardWriteError) -> Double {
        if let order { return order }
        let siblings = try visibleSiblings(of: parentFolder, operation: operation, requireEditable: false)
        return Ranks.append(toVisible: siblings.map(\.order))
    }

    /// Refuses a folder that is not a lane or a card. Level detection is by name shape
    /// (01-storage-format.md § Fractal layout ▸ Rules), so a stray — `notes/`, a truncated or
    /// non-hex UUID, a hand-made folder — is not an item, and moving, copying, deleting, restoring, or
    /// purging one as if it were would invent (or destroy) an identity the loader would
    /// otherwise just ignore. Shared by every operation that must never reach a board root: a
    /// board root's folder name is never UUID-shaped (§ Board naming), so this one check is
    /// what makes board-root deletion/restore/purge structurally unreachable at this layer.
    private static func checkIsUUIDShaped(_ folder: URL, operation: WriteOperation) throws(BoardWriteError) {
        guard BoardLoader.isUUIDShaped(folder.lastPathComponent) else {
            throw BoardWriteError(
                operation: operation,
                path: folder.path,
                reason: .unreadable(message: "folder name is not UUID-shaped: only lanes and cards are valid here")
            )
        }
    }

    /// The discover-before-you-write pre-flight `moveItem` and `copyItem` both run on the root
    /// they are about to relocate: that file *will* be rewritten at the destination (its `order`,
    /// plus the stamps), so one that cannot be read or cannot be edited in place refuses the
    /// whole gesture while nothing has happened yet — rather than after the folder has already
    /// travelled, or with a copy already materialized.
    ///
    /// Returns `operation` enriched with the title this same read just learned
    /// (`WriteOperation.withTitle`) — the caller's *only* read of the moved/copied root before it
    /// travels, so this is the one place `moveItem`/`copyItem` can learn it at all. Returned
    /// rather than discarded so every failure after the pre-flight passes (the `FileManager`
    /// move/copy itself, the post-arrival `updateIndex`) also names the item.
    /// Internal rather than `private`: the comment writer's own discover-before-you-write pre-flight.
    static func checkIndexIsRewritable(
        inItemFolder folder: URL,
        operation: WriteOperation
    ) throws(BoardWriteError) -> WriteOperation {
        let indexURL = folder.appendingPathComponent(BoardLoader.indexFileName)
        let document = try readDocument(at: indexURL, operation: operation)
        let operation = operation.withTitle(document.title.value)
        try checkEditable(document, at: indexURL, operation: operation)
        return operation
    }

    // MARK: - Reading

    /// Reads and parses an `index.md` for rewriting. **Strict, byte-faithful UTF-8**:
    /// `String(validating:as:)` rejects malformed sequences outright and — unlike Foundation's
    /// NSString-backed decoders — does not silently swallow a leading BOM, which would turn a
    /// rewrite of a BOM'd file into a whole-file byte change. A file that does not decode, or
    /// whose frontmatter does not parse, is `.unreadable` with the specifics: the app declines
    /// to write a file it cannot round-trip (01-storage-format.md § Fractal layout ▸ Rules).
    /// Internal rather than `private`: the comment writer reads through the same door, so the two
    /// cannot disagree about what "could not be read" means.
    static func readDocument(at url: URL, operation: WriteOperation) throws(BoardWriteError) -> FrontmatterDocument {
        let data: Data
        do {
            data = try Data(contentsOf: url)
        } catch {
            throw BoardWriteError(
                operation: operation,
                path: url.path,
                reason: .unreadable(message: "could not read file: \(error.localizedDescription)")
            )
        }

        guard let text = String(validating: data, as: UTF8.self) else {
            throw BoardWriteError(operation: operation, path: url.path, reason: .unreadable(message: "file is not UTF-8"))
        }

        do {
            return try FrontmatterDocument.parse(text)
        } catch {
            throw BoardWriteError(operation: operation, path: url.path, reason: .unreadable(message: error.description))
        }
    }

    /// Internal rather than `private`, with `readDocument` and for its reason.
    static func checkEditable(
        _ document: FrontmatterDocument,
        at url: URL,
        operation: WriteOperation
    ) throws(BoardWriteError) {
        if let shape = document.uneditableShape {
            throw BoardWriteError(operation: operation, path: url.path, reason: .uneditableFrontmatter(shape))
        }
    }
}

// MARK: - Move and copy vocabulary

/// What a move did to identity: where the item ended up, plus every remint the import boundary
/// performed on the way in (01-storage-format.md § Fractal layout ▸ Rules, "Identity lifecycle").
///
/// `id` is the source identity unchanged for every ordinary move — same board or not — and the
/// minted one when the arriving root itself collided. `reminted` is empty for every move that
/// crossed no import boundary, and for every import that found no collision; when it is not,
/// each entry is a folder whose UUID the destination board already held, repaired in place.
/// Callers need both: `id` to select or scroll to the moved item afterwards, `reminted` to
/// re-point anything that was holding the old identities (a selection, an open card window).
///
/// Content is not part of this: a remint renames a folder and touches no bytes, so nothing in
/// here implies an edit.
public struct MoveResult: Sendable, Equatable {
    public let id: ItemID
    public let reminted: [Remint]

    public struct Remint: Sendable, Equatable {
        public let from: ItemID
        public let to: ItemID
    }
}

/// How a copy stamps the files it materializes — the one axis on which the two kinds of copy
/// differ (01-storage-format.md § Fractal layout ▸ Rules; § Frontmatter).
public enum CopyStamps: Sendable {
    /// An ordinary copy — paste, duplicate, a cross-board drag: **keeps `created`**, because a
    /// fork really was created when its original was, stamps `modified`, and clears
    /// `modified-by` (a copy is an app write).
    case fork

    /// Template instantiation (09-templates.md): stamps `created` **and** `modified` fresh and
    /// strips `modified-by` — the result is born today, not forked from the template.
    case born
}

// MARK: - Attachment vocabulary

/// One imported attachment: where it came from and the name it landed under. `fileName` is the
/// post-collision-rename name — `sourceURL.lastPathComponent` when nothing on disk claimed it,
/// the Finder-style `"… 2"` (or higher) variant otherwise (`importAttachments`). `sourceURL` is
/// carried through unchanged so a caller can report the batch (which files came from where)
/// without re-deriving it from input order.
public struct ImportedAttachment: Sendable, Equatable {
    public let sourceURL: URL
    public let fileName: String
}

// MARK: - Write operation vocabulary

/// What the Writer was doing when it failed — a closed vocabulary, not a string (settled,
/// 02-architecture.md § Write-failure surfacing). The banner layer switches exhaustively over
/// this to phrase user-facing text, so a new operation here is a compile-time hole there, never
/// a silent default. `title` is the affected item's title where the operation learned it before
/// failing (nil when the failure struck before the title could be read).
public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
    case createBoard
    case createLane
    case createCard
    case move(title: String?)
    case reorder(title: String?)
    case copy(title: String?)

    /// ⌘V — **a paste that refused before it wrote anything** (04-interactions.md ▸ Clipboard,
    /// re-ruled 2026-07-29: "A paste whose staged snapshot is missing or unreadable refuses loudly —
    /// never degrades … the paste produces **nothing**, and a one-shot failure banner names it from
    /// the manifest's metadata").
    ///
    /// **Its own case rather than a fold into `.copy`**, on `.rename`'s and `.duplicateBoard`'s
    /// reasoning: the user pressed *Paste*, and a banner telling them the app "couldn't copy 'Fix
    /// login'" would name a gesture they never made. It is also the one operation in this vocabulary
    /// the Writer itself never performs — a paste's *arrivals* are `.copy` and `.move` — because the
    /// refusal happens at the clipboard's preflight, before any arrival is materialized; the
    /// vocabulary grows with the surfaces, and the surface here is the refusal.
    ///
    /// `title` is the offending entry's, read off the manifest's own metadata (which is exactly what
    /// the embedded `index.md` is kept for now that it is never a materialization source), `nil` for
    /// an untitled item.
    case paste(title: String?)
    /// ⌫ / ⌘⌫ — a card (`deleteCardToTrash`) or a lane (`deleteLaneToTrash`) moving into `.trash/`.
    /// One word for one gesture: since lanes rejoined the trash (2026-07-29) both are the same
    /// physical move, and neither is destructive.
    ///
    /// **There is no `restore` case**: restoring is an ordinary move out (`moveItem`), so a failed
    /// restore says the app couldn't *move* the card — which is exactly what it couldn't do
    /// (03-board-ui.md § Trash, resettled 2026-07-28 — Put Back is retired with the tombstone model).
    case delete(title: String?)
    case purge(title: String?)

    /// A legacy `deleted:` key being migrated away — a **card** relocating into `.trash/` with the
    /// key removed (01-storage-format.md § Deletion: "cards migrate, lanes ignore"; the lane half
    /// retired 2026-07-29 with the lane trash, so this operation has one performer left).
    ///
    /// Its own case rather than a fold into `.delete` or `.move`, on the vocabulary's standing
    /// reasoning and `.relocateLooseFile`'s in particular: this is work the *app* started on its
    /// own, on a board an older version wrote, and a banner telling the user the app "couldn't
    /// delete 'Fix login'" would name a gesture they never made.
    case migrateTombstone(title: String?)
    case style(title: String?)      // updateIndex on behalf of styling flows (03-board-ui.md)

    /// **A card's `labels` list being rewritten** — the sidebar section's chips and the context menu's
    /// submenu, the two surfaces the key's 2026-08-09 activation shipped with
    /// (`FrontmatterKeys.labels`; `BoardStore.setLabels(_:onCard:on:)`).
    ///
    /// **Its own case rather than a fold into `.style`**, on the vocabulary's standing reasoning. A
    /// restyle picks an appearance; this edits what the card *is about*, and 06-history-undo.md's
    /// commit-message composer has had a separate word for it since long before there was a control —
    /// "Relabel card 'X'" (§ Commit messages, named there among the three keys "invisible in the UI
    /// though they are"). The word already existed; this is the gesture arriving to claim it. A banner
    /// telling someone the app "couldn't style 'Fix login'" after they clicked a label row would name a
    /// control they never touched.
    ///
    /// **One case for both directions**, unlike `.collapse`/`.expand`: adding and removing a label are
    /// the same control pressed twice — a chip's ⊗ and a menu row's checkmark both just rewrite the
    /// list — where collapse and expand are two menu rows with two words. `title` is the card's, filled
    /// in by `updateIndex` off the document it reads.
    case relabel(title: String?)

    /// **A generated board background landing** — the PNG written into the board folder and the
    /// `background` mapping's two subkeys pointed at it, one bracket
    /// (`BoardStore.applyGeneratedBackground`; `FacetsGenerator`).
    ///
    /// Its own case rather than a fold into `.style`, on the vocabulary's standing reasoning: a
    /// restyle picks a value out of a grid of wells, while this **writes a file into the user's board
    /// folder** — a different act with a different failure ("the disk is full" means something else
    /// when a megabyte of picture is involved), and the one styling gesture whose undo cannot put
    /// everything back (see the store's own note on the overwritten bytes).
    ///
    /// **No payload**, for `.mintBoardIndex`'s reason: there is one background per board, the user is
    /// looking at the board while they press the control, and the board's title would name a thing
    /// nobody could confuse for another.
    case setBoardBackground
    case resize(title: String?)     // a lane's `width` — the edge drag and the stepper alike (03-board-ui.md § Lane)

    /// **A lane folded to its slim strip** — the `collapsed: true` write (03-board-ui.md § Lane ▸
    /// Collapsed lanes; `BoardStore.setLaneCollapsed`).
    ///
    /// **Its own case rather than a fold into `.resize`**, on the vocabulary's standing reasoning: the
    /// two keys sit side by side in a lane's frontmatter and both change how wide the lane draws, but
    /// the user pressed *Collapse Lane* — and a banner telling them the app "couldn't resize 'Todo'"
    /// would name a gesture they never made and a control (the stepper, the edge drag) they never
    /// touched.
    ///
    /// **And its own case rather than one shared with `.expand`**, which is `.delete`/`.restore`'s
    /// split one rung down: the two directions are two menu rows with two words, and neither
    /// direction's failure may be announced in the other's verb.
    case collapse(title: String?)

    /// **A collapsed lane unfolded** — the write that *removes* `collapsed` (the remove-at-default
    /// family; `FrontmatterKeys.collapsed`). See `.collapse` for why the pair is two cases.
    case expand(title: String?)
    /// An inline title editor's commit — the third inline editor's write (04-interactions.md ▸
    /// Grammar). Its own case rather than a fold into `.style`: "the vocabulary grows with the
    /// surfaces" is settled (02-architecture.md § Write-failure surfacing, which names
    /// "Couldn't rename 'Fix login'…" verbatim), and a rename that failed must not tell the user
    /// the app could not *restyle* something. `title` is the item's title as it stood **before**
    /// the edit — `updateIndex` enriches it off the document it just read — which is the name the
    /// user is still looking at when the banner appears.
    case rename(title: String?)

    /// File ▸ Duplicate — the whole-board copy (03-board-ui.md § Welcome screen & templates). Its
    /// own case rather than a fold into `.copy`, on `.rename`'s reasoning: the user pressed
    /// *Duplicate*, and a banner telling them the app could not "copy the item" would name neither
    /// the command nor the thing. `title` is the board's display name.
    case duplicateBoard(title: String?)

    /// File ▸ Save as Template — the whole-board copy into the user templates store, and the
    /// `template:` key stamped on the copy (09-templates.md ▸ Save as Template). Its own case beside
    /// `.duplicateBoard`, on that case's own reasoning: both copy a board wholesale, but the user
    /// pressed a different command with a different outcome, and a banner saying the app could not
    /// "duplicate" a board when no duplicate was asked for would name a gesture that never happened.
    /// `title` is the board's display name.
    case saveAsTemplate(title: String?)

    /// File ▸ Share… — the whole board staged into a `.zip` for `NSSharingServicePicker`
    /// (design ruling 2026-08-09, card 72691b11; `BoardShareStager`). Its own case beside
    /// `.duplicateBoard` and `.saveAsTemplate`, on both their reasoning: a third command copies a
    /// board wholesale for a third reason, and a banner saying the app could not "duplicate" or
    /// "save as template" a board nobody asked to duplicate or template would name gestures that
    /// never happened. `title` is the board's display name.
    case shareBoard(title: String?)

    /// File ▸ Export ▸ … — the board written out as one foreign document (15-import-export.md;
    /// `BoardExporter.write`). Its own case beside the three copy-the-whole-board commands above, on
    /// their standing reasoning: a fourth command produces a fourth kind of artifact, and a banner
    /// saying the app could not "share" or "duplicate" a board nobody asked to share or duplicate would
    /// name a gesture that never happened.
    ///
    /// **It is the one operation in this vocabulary that writes outside a board**, which is also why it
    /// says nothing about the board's own files: the export failed, the board is exactly as it was.
    /// `title` is the board's display name.
    case exportBoard(title: String?)

    /// File ▸ Import Board… — a foreign document read, and the fresh board built from it
    /// (15-import-export.md; `BoardImporter`). It covers the *read* half only: once the parse succeeds,
    /// the tree is built by the ordinary create path and its failures speak as `.createBoard`,
    /// `.createLane` and the rest, which is the honest account of what went wrong.
    ///
    /// **It carries a filename rather than a title**, on `.importAttachment`'s precedent and for its
    /// reason: the thing the user picked is a file, they are looking at its name in the panel they just
    /// dismissed, and the board it was going to become does not exist yet to have a title.
    case importBoard(fileName: String)

    case importAttachment(filename: String)
    case listAttachments

    /// An attachment being moved to the **system** Trash — the card window's attachment Remove and
    /// its ⌫ twin (05-card-window.md ▸ Attachments).
    ///
    /// Its own case rather than a fold into `.delete`, on the vocabulary's standing reasoning and
    /// then some: `.delete` is the board's *tombstone*, and this app has two trashes on purpose —
    /// "Finder's 'Move to Trash' phrasing is reserved for the system Trash; board deletion says
    /// 'Delete'" (03-board-ui.md § Trash). A banner saying the app "couldn't delete 'shot.png'"
    /// would claim the board's own trash took a hand in a file only Finder can give back.
    /// `filename` is the attachment's name as the sidebar shows it.
    case removeAttachment(filename: String)

    case renumberChildren           // order-maintenance sweep (compaction)

    /// A loose file being moved out of a card folder into its `attachments/` — the loose-file
    /// carve-out's write (01-storage-format.md § Fractal layout ▸ Rules, settled 2026-07-28).
    ///
    /// Its own case rather than a fold into `.importAttachment`, on `.rename`'s and
    /// `.duplicateBoard`'s reasoning: nothing was *imported* — no file crossed into the board, the
    /// user dropped nothing, and a banner saying the app "couldn't import 'notes.txt'" would
    /// describe a gesture that never happened. `filename` is the name as it sat beside `index.md`,
    /// never the Finder-renamed one it would have landed under.
    case relocateLooseFile(filename: String)

    /// A `.backgrounds/` file being moved to the **system** Trash because nothing names it any more —
    /// the orphan tidy's write, both mechanisms (01-storage-format.md § Validation and healing, ruled
    /// 2026-08-09): the replace-in-place trim a background apply makes on the file it just repointed
    /// or unset away from, and the open-time sweep of whatever `.backgrounds/` holds that the board's
    /// current `background.image` no longer names.
    ///
    /// Its own case on `.relocateLooseFile`'s reasoning, doubled: this is work the *app* started on
    /// its own, on a file the user never named, and a banner saying the app "couldn't move 'facets
    /// 2.png' to the Trash" without this case would borrow `.removeAttachment`'s sentence for a
    /// gesture — a click on a Remove row — the user never made. In practice only the sweep's own
    /// failure ever reaches a banner: the in-flow trim swallows whatever this throws (`try?`, the
    /// ruling's own "temp-file posture"), so this case's phrasing exists for the sweep and for the
    /// developer-facing `description` either mechanism's failure would log. `filename` is the bare
    /// name inside `.backgrounds/`, the one the sweep's own successful notice would have used.
    case tidyBackgroundImage(filename: String)

    /// The board-root `CLAUDE.md` being written or upgraded — and the rescue move that precedes it
    /// when a markerless one has to be displaced to `CLAUDE.user.md` (08-agent-integration.md ▸ The
    /// agent guide; `AgentGuide`).
    ///
    /// **No payload**, unlike every other case that names something: there is exactly one guide per
    /// board, its filename is fixed, and it is not an item with a title. Its own case on
    /// `.relocateLooseFile`'s reasoning, doubled — this is work the *app* started on its own, for a
    /// file the user did not create, does not own and may not know exists; folding it into any
    /// gesture's phrasing would name an act that never happened.
    ///
    /// One case rather than two (the move and the write) deliberately: they ride one bracket and
    /// have one outcome the user could care about — the board's guide is not up to date — and a
    /// second phrasing for "couldn't move a file you have never seen" would explain nothing.
    case agentGuide

    /// The board-root `.gitignore` being seeded — at creation, at instantiation, and by the
    /// scheduled heal that gives a board born before the ruling the file it now needs
    /// (06-history-undo.md ▸ Repository hygiene, retired with app-managed git,
    /// `strategy/01-git-excision.md`; `seedGitignoreIfAbsent`).
    ///
    /// **No payload**, for `.agentGuide`'s reason: there is one such file per board, its name is
    /// fixed, and it is not an item with a title. Its own case beside `.agentGuide` rather than a
    /// fold into it, on the vocabulary's standing reasoning — one is the agent guide and the other is
    /// the board's noise definition, and a banner saying the app "couldn't update the agent guide"
    /// after a failed seed would name a file that was never touched.
    ///
    /// It never describes an *edit*: the app writes this file only when nothing holds the name.
    case seedGitignore

    /// **The decision surface's minted board index** (01-storage-format.md § Malformed input, settled
    /// 2026-07-31): "*Board root without `index.md`* — minted repair, the default: create a board
    /// index (folder-name title, `schema: 1`)".
    ///
    /// Its own case rather than a fold into `.createBoard`, on the vocabulary's standing reasoning:
    /// nothing is being *created* — the board is right there, with its lanes and its cards, and the
    /// user opened it — so a banner saying the app "couldn't create the board" would name a gesture
    /// nobody made and a thing that already exists. What is missing is the one file that says the
    /// folder is a board, and that is what this names.
    ///
    /// **No payload**, for `.seedGitignore`'s reason: there is one such file per board, its name is
    /// fixed, and the board it belongs to has no title yet — reading one is precisely what the
    /// missing file prevents.
    case mintBoardIndex

    /// **The decision surface's `schema: 1` stamp** (01-storage-format.md § Malformed input): "*Board
    /// root missing `schema`* — stamp `schema: 1`, the default: reliable exactly because the walk just
    /// validated the file against schema 1."
    ///
    /// Its own case beside `.mintBoardIndex` rather than a fold into it — the no-folding rule the
    /// whole vocabulary is built on — because the two repairs have different outcomes a user could
    /// care about: one writes a file that was not there, the other adds a key to a file that was. A
    /// banner saying the app could not create this board's `index.md` when the file is sitting in
    /// Finder would be actively wrong.
    ///
    /// **No payload**, for `.mintBoardIndex`'s reason exactly.
    case stampSchema

    /// A wrong-kinded node being moved off a board-root name the app claims — a file or symlink
    /// squatting `.trash` (01-storage-format.md § Fractal layout ▸ Rules, ruled 2026-07-29: "moved
    /// aside by a scheduled heal via the Finder-style rename ladder").
    ///
    /// Its own case on `.relocateLooseFile`'s and `.agentGuide`'s reasoning: this is work the *app*
    /// started on its own, on a node the user may not know is a problem, and a banner saying the app
    /// "couldn't delete" or "couldn't move" something would name a gesture that never happened.
    /// `name` is the claimed name as the app spells it — the name the user would recognize.
    ///
    /// The `CLAUDE.md` squatter takes `.agentGuide` instead, because the guide's own write bracket
    /// owns that file end to end and has one outcome the user could care about.
    case displaceClaimedName(name: String)

    /// A withheld duplicate id being reminted — the duplicate-id heal's write (01-storage-format.md
    /// § Fractal layout ▸ Rules, re-ruled 2026-07-29 from its former banner gate).
    ///
    /// **Named for the Repair verb**, which is 06-history-undo.md's vocabulary for exactly this act
    /// ("Repair duplicate of 'Fix login'" — the heal commit's own name) and survived the re-ruling
    /// intact: what changed is who starts it, not what it is called.
    ///
    /// Its own case on `.relocateLooseFile`'s and `.displaceClaimedName`'s reasoning: this is work the
    /// *app* started on its own, on a folder the user copied in Finder without knowing it would
    /// collide, and a banner saying the app "couldn't move the item" would name a gesture that never
    /// happened. `title` is the withheld item's as the load found it — the name the user would
    /// recognize, and the one the successful notice uses.
    case repairDuplicateID(title: String?)

    /// A Preview task-list checkbox being ticked or unticked (05-card-window.md ▸ Preview).
    ///
    /// Its own case rather than a fold into `.rename`'s or `.style`'s neighbourhood, on the
    /// vocabulary's standing reasoning: the user clicked a checkbox, and a banner telling them the
    /// app could not "restyle" or "rename" the card would name a gesture that never happened. It is
    /// also the app's only *body* write, which is worth being able to see in a log at a glance.
    case toggleTask(title: String?)

    /// The card window's Edit buffer being saved — the debounced tick, the flush that leaves Edit,
    /// and the flush that closes the window (05-card-window.md ▸ Edit).
    ///
    /// Its own case beside `.toggleTask` rather than folded into it, on the vocabulary's standing
    /// reasoning: both write a body, but one is a checkbox the user ticked and the other is prose
    /// they typed, and a banner telling someone the app "couldn't tick the checkbox" after they
    /// wrote three paragraphs would name a gesture that never happened. `title` is the card's title
    /// as the read that preceded the write found it — the name on the window they are typing in.
    case editBody(title: String?)

    /// The card window's raw-source Apply — the whole `index.md` replaced with the text the user
    /// typed (05-card-window.md ▸ Raw source outlet).
    ///
    /// Its own case beside `.editBody`, on the vocabulary's standing reasoning and then some: this is
    /// not a body write and not a frontmatter edit but the *file* being written, and it is the one
    /// operation whose bytes the app did not compose. A banner saying the app "couldn't save the
    /// card" would describe the Edit buffer the user was not in. The word the user pressed is
    /// **Apply**.
    ///
    /// `title` is the card's title as the file *currently* reads it — the name on the window — never
    /// the one the buffer proposes, which may be a title that never landed (`.rename`'s rule). It is
    /// also the operation `readRawSource` carries on the way *in*; that read's failures reach an
    /// alert rather than the banner, so the Apply phrasing is never shown for one.
    case rawSource(title: String?)

    // MARK: The comment family
    //
    // Five operations, one path-shaped verb family (06-history-undo.md ▸ Commit messages, the
    // vocabulary 01-storage-format.md § Enhanced schema names: "Comment on '⟨card title⟩'" / "Edit
    // comment on…" / "Delete comment on…" / "Draft comment on '⟨card⟩'"). **Every one of them carries
    // the *card's* title, not the comment's** — a comment has no `title` key at all (§ Enhanced
    // schema: "No `title`, no `order`"), and the thing a user recognizes is the card they are
    // commenting on. That is also why all five are identity in `withTitle`: there is no document to
    // enrich from, and the value arrives already filled in from the window the gesture came from.

    /// The composer's slow-cadence save into `comments/.draft/` — blur, window close, quit, and the
    /// ~30 s tick (05-card-window.md ▸ The comments column: "Draft saves are slow-cadence, never
    /// prompted"). Its own case rather than a fold into `.editComment`, on the vocabulary's standing
    /// reasoning: a draft is not yet a comment, and telling someone the app "couldn't edit a comment"
    /// after they typed one that has never been posted would name a thing that does not exist.
    case saveCommentDraft(title: String?)

    /// ⌘↩ — the draft renamed to a fresh identity and restamped, one bracket (§ Enhanced schema:
    /// "posting renames it to a fresh lowercase UUID and restamps `created`/`modified` in the same
    /// bracket — chronology is post time, not drafting time — one commit").
    case postComment(title: String?)

    /// An inline comment edit session's save — the body-edit session in miniature (05 ▸ The comments
    /// column). Its own case beside `.editBody` for that case's reason: both write a body, but one is
    /// the card the window is about and the other is one annotation on it.
    case editComment(title: String?)

    /// A comment moving into `comments/.trash/` — "delete = move into `comments/.trash/`", immediate,
    /// no confirm, undone by the ordinary move back (§ Enhanced schema; 13-native-undo.md).
    ///
    /// **The inverse rides this same case**, deliberately: the restore is a move with no gesture of
    /// its own — the user pressed ⌘Z on a delete — and `.delete`'s own "there is no `restore` case"
    /// note is the precedent one level up.
    case deleteComment(title: String?)

    /// `comments/.trash/` emptied whole — the container-wide purge
    /// (`BoardWriter.purgeCommentTrash(inCard:)`), which 13-native-undo.md retired from production on
    /// 2026-08-06: both purge consumers now remove per entry behind the ownership gate, through
    /// `purgeItem(at:)` and therefore under `.purge`. Kept with its primitive — see that method.
    ///
    /// **No payload, unlike its four siblings**, and for `.agentGuide`'s reason: this is bookkeeping
    /// the app does on its own over a folder that is "never a UI surface", with one outcome nobody
    /// asked for and nothing to name. The path-shaped verb family has no word for it either.
    case purgeCommentTrash

    /// Fills in the title once the Writer has read it off the document the operation is acting
    /// on — identity for the cases with no title slot at all: `createBoard`/`createLane`/
    /// `createCard` are minting a file, not reading one; `importAttachment`, `removeAttachment` and
    /// `relocateLooseFile` carry a filename, which is the name the user is looking at and the only
    /// one their banner should say; `listAttachments`, `renumberChildren` and `agentGuide` name no
    /// single item.
    /// Called once, right where the operation's `readDocument` succeeds — `updateIndex` itself
    /// (which covers every case that funnels through it: renumber, delete, restore, style, and
    /// the tail end of move/copy) and the move/copy pre-flight, before the folder travels or the
    /// copy materializes. Every failure past that point reuses the enriched value, because the
    /// case is immutable once a caller has it in hand — there is nothing to "forget" later.
    public func withTitle(_ title: String?) -> WriteOperation {
        switch self {
        // `.repairDuplicateID` is identity here even though it carries a title: the remint never
        // opens an `index.md` — it is a rename — so there is no `readDocument` to enrich from, and
        // its title arrives already filled in from the load that detected the duplicate.
        // The comment family is identity for `.repairDuplicateID`'s reason, doubled: a comment's
        // `index.md` carries no `title` to enrich from, and the title these five hold is the *card's*,
        // filled in by the caller from the window the gesture came from.
        // The two decision-surface repairs join this list for `.createBoard`'s and
        // `.seedGitignore`'s reasons at once: neither carries a title slot, and the board they
        // repair has no readable title to enrich from — a root with no `index.md` has no document
        // at all, and one with no `schema` is the file the walk just refused.
        // `.setBoardBackground` joins them on `.mintBoardIndex`'s reasoning: it carries no title
        // slot, and the board it writes to is the one the user is looking at.
        // `.importBoard` joins them on `.importAttachment`'s reasoning: it carries a filename, which is
        // the name the user is looking at and the only one its banner should say — and there is no
        // document to enrich from anyway, since the failure it describes is the read of one.
        case .createBoard, .createLane, .createCard, .importAttachment, .importBoard, .listAttachments,
             .removeAttachment, .renumberChildren, .relocateLooseFile, .tidyBackgroundImage, .agentGuide,
             .seedGitignore, .mintBoardIndex, .stampSchema, .setBoardBackground,
             .displaceClaimedName, .repairDuplicateID, .saveCommentDraft, .postComment,
             .editComment, .deleteComment, .purgeCommentTrash:
            self
        case .move: .move(title: title)
        case .reorder: .reorder(title: title)
        case .copy: .copy(title: title)
        // Identity, like `.repairDuplicateID` and for its reason: a refused paste never opens an
        // `index.md`, so there is no `readDocument` to enrich from — its title arrives already filled
        // in from the manifest entry the refusal names.
        case .paste: self
        case .delete: .delete(title: title)
        case .purge: .purge(title: title)
        case .migrateTombstone: .migrateTombstone(title: title)
        case .style: .style(title: title)
        case .relabel: .relabel(title: title)
        case .resize: .resize(title: title)
        case .collapse: .collapse(title: title)
        case .expand: .expand(title: title)
        case .rename: .rename(title: title)
        case .duplicateBoard: .duplicateBoard(title: title)
        case .saveAsTemplate: .saveAsTemplate(title: title)
        case .shareBoard: .shareBoard(title: title)
        case .exportBoard: .exportBoard(title: title)
        case .toggleTask: .toggleTask(title: title)
        case .editBody: .editBody(title: title)
        case .rawSource: .rawSource(title: title)
        }
    }

    /// **Whether this rewrite only restates the item's position among its container's members** —
    /// the reorders-don't-stamp predicate (01-storage-format.md § Frontmatter ▸ `modified`'s scope,
    /// ruled 2026-07-29 as moves-don't-stamp, refined 2026-07-30).
    ///
    /// One question decides it: **does the rewrite change the item's container?** If it does — a
    /// cross-lane move, a cross-board arrival, a move into or out of `.trash/` — the item's story
    /// changed (which lane a card lives in is state) and the write stamps `modified` and clears
    /// `modified-by` like any content write. If it does not — a card reordered among its lane's
    /// siblings, a lane reordered on the board, a renumber's whole-lane rescale — only `order` is
    /// rewritten: no stamp, no clear. Where an item *stands in line* is presentation, and `order` is
    /// logically the container's property that the format happens to store inside the member's file.
    ///
    /// **The vocabulary already draws the line**, which is why this is a property here rather than a
    /// flag threaded through `updateIndex`'s call sites:
    ///
    /// - `.reorder` is the same-container case by construction — `moveItem` decides it from the source
    ///   parent and the destination parent before it touches disk (`isSameLocation`), and the two
    ///   inverse paths that rewrite a rank directly (`BoardStore.setOrder`, the within-lane sort) are
    ///   same-container for the same reason: a lane's parent is the board root, and a card the sort
    ///   permutes never leaves its lane.
    /// - `.renumberChildren` is the midpoint-exhaustion rescale — "order-only rewrites, so no
    ///   `modified` stamp and no `modified-by` clear" (01 § Ordering, verbatim).
    /// - **Everything else stamps.** `.move` covers every container change including the trash's, and
    ///   there is deliberately **no trash case anywhere**: the trash move stamps because every
    ///   container change stamps, so a branch for it would be a second rule saying the same thing.
    ///
    /// Exhaustive with no `default`, like every other switch over this enum: a new operation has to
    /// answer "does this rewrite content?" before it compiles.
    public var rewritesOrderOnly: Bool {
        switch self {
        case .reorder, .renumberChildren:
            true
        // The comment family writes content, never a rank: a comment has no `order` at all
        // (01-storage-format.md § Enhanced schema), so there is nothing here for the order-only
        // reading to be about — and `.deleteComment`'s move into `comments/.trash/` stamps for the
        // plain container reason its board-level twin does.
        case .createBoard, .createLane, .createCard, .move, .copy, .delete, .purge, .migrateTombstone,
             .style, .relabel, .resize, .collapse, .expand, .rename, .duplicateBoard, .saveAsTemplate, .shareBoard, .paste,
             .exportBoard, .importBoard,
             .importAttachment,
             .listAttachments, .removeAttachment, .relocateLooseFile, .tidyBackgroundImage, .agentGuide,
             .seedGitignore, .mintBoardIndex, .stampSchema, .setBoardBackground,
             .displaceClaimedName,
             .repairDuplicateID, .toggleTask, .editBody, .rawSource, .saveCommentDraft, .postComment,
             .editComment, .deleteComment, .purgeCommentTrash:
            false
        }
    }

    /// A short imperative phrase — `"move 'Fix login'"`, `"create card"`, `"import attachment
    /// 'photo.png'"` — for logs and diagnostics **only**: `BoardWriteError.description` (test
    /// failures, `po error`, console output), never the banner's text. The banner owns every
    /// word a user sees and switches exhaustively over the case itself to produce it
    /// (02-architecture.md § Write-failure surfacing); this description exists purely so a
    /// developer reading a raw error gets English without the banner layer's help.
    public var description: String {
        switch self {
        case .createBoard: "create board"
        case .createLane: "create lane"
        case .createCard: "create card"
        case let .move(title): Self.phrase("move", title)
        case let .reorder(title): Self.phrase("reorder", title)
        case let .copy(title): Self.phrase("copy", title)
        case let .paste(title): Self.phrase("paste", title)
        case let .delete(title): Self.phrase("delete", title)
        case let .purge(title): Self.phrase("purge", title)
        case let .migrateTombstone(title): Self.phrase("migrate the legacy 'deleted' key on", title)
        case let .style(title): Self.phrase("style", title)
        case let .relabel(title): Self.phrase("relabel", title)
        case .setBoardBackground: "set this board's background"
        case let .resize(title): Self.phrase("resize", title)
        case let .collapse(title): Self.phrase("collapse", title)
        case let .expand(title): Self.phrase("expand", title)
        case let .rename(title): Self.phrase("rename", title)
        case let .duplicateBoard(title): Self.phrase("duplicate board", title)
        case let .saveAsTemplate(title): Self.phrase("save as template", title)
        case let .shareBoard(title): Self.phrase("share board", title)
        case let .exportBoard(title): Self.phrase("export board", title)
        case let .importBoard(fileName): "import board from '\(fileName)'"
        case let .importAttachment(filename): "import attachment '\(filename)'"
        case .listAttachments: "list attachments"
        case let .removeAttachment(filename): "move attachment '\(filename)' to the Trash"
        case .renumberChildren: "renumber children"
        case let .relocateLooseFile(filename): "relocate loose file '\(filename)'"
        case let .tidyBackgroundImage(filename): "tidy background image '\(filename)'"
        case .agentGuide: "update the agent guide"
        case .seedGitignore: "seed the board's .gitignore"
        case .mintBoardIndex: "create this board's index.md"
        case .stampSchema: "stamp this board's schema"
        case let .displaceClaimedName(name): "move a stray '\(name)' aside"
        case let .repairDuplicateID(title): Self.phrase("repair the duplicate id of", title)
        case let .toggleTask(title): Self.phrase("toggle a checkbox in", title)
        case let .editBody(title): Self.phrase("save the body of", title)
        case let .rawSource(title): Self.phrase("apply source changes to", title)
        // The card's title, never the comment's — see the family's own note above.
        case let .saveCommentDraft(title): Self.phrase("save the comment draft on", title)
        case let .postComment(title): Self.phrase("post a comment on", title)
        case let .editComment(title): Self.phrase("edit a comment on", title)
        case let .deleteComment(title): Self.phrase("delete a comment on", title)
        case .purgeCommentTrash: "purge deleted comments"
        }
    }

    private static func phrase(_ verb: String, _ title: String?) -> String {
        guard let title else { return verb }
        return "\(verb) '\(title)'"
    }
}

// MARK: - Error

/// A write that did not happen, said out loud: which operation, which file, and why —
/// the vocabulary 02-architecture.md § Write-failure surfacing renders in the banner
/// ("Couldn't move 'Fix login' — disk full"). Nothing here is swallowed or retried behind the
/// user's back; a one-shot action fails once and waits for them to act again.
public struct BoardWriteError: Error, Sendable, Equatable, CustomStringConvertible {
    /// What was being attempted when the write failed — the closed `WriteOperation` vocabulary,
    /// not a string (settled, 02-architecture.md § Write-failure surfacing): the banner switches
    /// exhaustively over this case to produce its user-facing phrasing, so this field carries no
    /// English of its own — that lives only in `WriteOperation.description`, for logs.
    public let operation: WriteOperation

    /// The file or folder involved. Absolute at this layer: the writer works in URLs and has no
    /// board root to be relative to (contrast `BoardLoadError.path`, which is root-relative).
    public let path: String

    public let reason: Reason

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

    public enum Reason: Sendable, Equatable, CustomStringConvertible {
        /// The file is missing, is not UTF-8, or its frontmatter does not parse — `message`
        /// carries the specifics. A rewrite the app cannot round-trip is not attempted.
        case unreadable(message: String)

        /// The settled readable-but-uneditable refusal (01-storage-format.md § Frontmatter):
        /// the file loads and renders, but its frontmatter has a shape the surgical editor
        /// cannot address, so writing it would risk corruption. Names the shape.
        case uneditableFrontmatter(FrontmatterDocument.UneditableShape)

        /// Any I/O failure writing the temp file or renaming it into place — disk full,
        /// permissions, volume error. The destination still holds its previous bytes.
        case io(message: String)

        /// **The bytes the edit aimed at are not what the caller was shown.** The file read
        /// cleanly and its frontmatter parsed — this is not `.unreadable` — but the surgical
        /// target moved: the checkbox at that offset is gone, or is already in the state the
        /// click would have produced (05-card-window.md ▸ Preview, `toggleTaskMarker`).
        ///
        /// Its own reason because the app's one *offset-addressed* write is the one place where
        /// "read fresh from disk" is not enough on its own: every other write names a key, and a
        /// key that moved is still the same key.
        case staleTarget(message: String)

        /// **The text the raw-source outlet was asked to write would not load** (05-card-window.md ▸
        /// Raw source outlet: "Apply validates through the same fail-fast parse the loader uses …
        /// before writing byte-for-byte"). Carries the loader's own error — path, reason and line
        /// number — because the alert that shows it is the design's "detailed alert", and a
        /// re-worded copy would be a second, worse taxonomy.
        ///
        /// Distinct from `.unreadable`, which is about the file **on disk**: here disk is fine and
        /// the *proposal* is not, so nothing was attempted and nothing changed.
        case invalidSource(BoardLoadError)

        /// **The bytes a paste was to reproduce are not there** — the staged clipboard snapshot is
        /// missing or unreadable, so the paste produces nothing rather than a hollowed item
        /// (04-interactions.md ▸ Clipboard, re-ruled 2026-07-29 — Finder's invariant, and 01's
        /// leniency doctrine: "proceed partially, lose a little" is never a verdict).
        ///
        /// **Payload-free on purpose.** There is exactly one thing to say about it, the banner owns
        /// the words (`BannerCenter.causePhrase`), and the offending item is already named by the
        /// operation's own title — so a free-form message here could only be a second, worse copy of
        /// a sentence that lives one layer up. That also keeps it distinct from `.unreadable`, whose
        /// message is a developer's diagnostic about a file the app *did* open.
        case clipboardContentGone

        public var description: String {
            switch self {
            case let .unreadable(message):
                "unreadable: \(message)"
            case let .uneditableFrontmatter(shape):
                "frontmatter cannot be edited in place: \(shape.description)"
            case let .io(message):
                message
            case let .staleTarget(message):
                message
            case let .invalidSource(error):
                "the source text wouldn't load: \(error.description)"
            case .clipboardContentGone:
                "the copied content is no longer staged"
            }
        }
    }
}
