import AppKit
import Observation
import SwiftUI
import os

// MARK: - Scene ids

/// The scene identifiers, in one place because they are matched by string in three unrelated
/// spots — the scene declaration, `openWindow(id:)`, and `dismissWindow(id:)` — and a typo in any
/// one of them fails silently at runtime.
public enum WindowID {
    public static let welcome = "welcome"
    public static let restoreBootstrap = "restore-bootstrap"
    /// The template chooser (09-templates.md; File ▸ New Board… ⌥⌘N). Its own window rather than a
    /// sheet on welcome because ⌥⌘N is available *everywhere* (11-command-nexus.md) — including from
    /// a board window, and including when welcome is not open at all, which a sheet would have to
    /// conjure a host for.
    public static let templateChooser = "template-chooser"
    public static let board = "board"
    public static let card = "card"
}

// MARK: - App-wide preferences

/// The `UserDefaults` half of "App-wide state has the same home" (02-architecture.md § Per-board app
/// state): the app-scoped values that are scalars, kept out of the board registry because no board
/// owns them.
///
/// The keys are declared here rather than spelled at each `@AppStorage`, for the same reason
/// `WindowID` exists.
///
/// The domain is `UserDefaults.standard`, which the sandbox already scopes to this one app — the
/// same reason `AppStateHome` needs no bundle-id subfolder. A `@AppStorage` left to its own devices
/// reads exactly this domain, so nothing here has to be named at a binding site.
public enum AppPreferences {

    /// "Restore open boards at launch" (Settings, ⌘, — 11-command-nexus.md). **Default on.**
    public static let restoreOpenBoardsAtLaunchKey = "restoreOpenBoardsAtLaunch"

    /// Read outside a view, where `@AppStorage` is not available — the launch flow needs it before
    /// any scene exists. `object(forKey:)` rather than `bool(forKey:)` because the latter cannot
    /// tell "off" from "never set", and this preference defaults to *on*.
    public static var restoreOpenBoardsAtLaunch: Bool {
        UserDefaults.standard.object(forKey: restoreOpenBoardsAtLaunchKey) as? Bool ?? true
    }

    /// The last-used card-window size (05-card-window.md; 02 files it as app-wide, not per-board —
    /// "the last-used card-window size" is named there explicitly). Stored as a string because
    /// `NSSize` is not a property-list type and two more keys would be worse.
    public static let lastCardWindowSizeKey = "lastCardWindowSize"

    public static var lastCardWindowSize: CGSize? {
        guard let text = UserDefaults.standard.string(forKey: lastCardWindowSizeKey) else { return nil }
        let size = NSSizeFromString(text)
        guard size.width > 0, size.height > 0 else { return nil }
        return size
    }

    public static func setLastCardWindowSize(_ size: CGSize) {
        UserDefaults.standard.set(NSStringFromSize(size), forKey: lastCardWindowSizeKey)
    }

    // MARK: The comments pane's three bits

    /// **View ▸ Show Comments** — "a checkmark toggle à la Show Trash, and its choice is **app-wide
    /// and persisted across restarts**" (05-card-window.md ▸ The comments column, re-ruled
    /// 2026-07-29; 11-command-nexus.md).
    ///
    /// **One bit, and no content-derived auto-show**: checked, every card window carries the pane —
    /// a comment-less card shows the empty thread and the composer, because the invitation is the
    /// point; unchecked, threads and drafts are out of sight until the user says otherwise. The
    /// checkmark reads exactly this value, so the menu never lies, and deleting the last comment
    /// never closes the pane because nothing but this bit does.
    ///
    /// **Default on.** 05 does not spell a default, and the two candidate readings pull in opposite
    /// directions — the Show Trash bargain (a secondary surface, default off) against "the invitation
    /// is the point" (a pane whose empty state is its whole argument). The invitation wins: a
    /// comments feature nobody sees until they find a View-menu row is a feature that is not there,
    /// and the user who does not want it turns it off once, forever, which is what the persistence is
    /// for.
    public static let showCommentsKey = "showComments"

    public static var showComments: Bool {
        UserDefaults.standard.object(forKey: showCommentsKey) as? Bool ?? true
    }

    /// **View ▸ Comments Beside Body** — "checked = side-by-side (default), unchecked = body over
    /// comments; app-wide, persisted" (11-command-nexus.md; 05 ▸ Composition).
    ///
    /// Default **on**, which 05 does state: "side-by-side is the default".
    public static let commentsBesideBodyKey = "commentsBesideBody"

    public static var commentsBesideBody: Bool {
        UserDefaults.standard.object(forKey: commentsBesideBodyKey) as? Bool ?? true
    }

    /// The comments header's **sort-direction control** — "chronological ascending by default,
    /// flippable to newest-first (app-wide, persisted)" (05 ▸ The comments column; 11 files it under
    /// Configuration controls).
    ///
    /// Stored as "newest first" rather than as a direction so the default is `false` and the plain
    /// `bool(forKey:)` reading is the right one — the one preference here that does not need to tell
    /// "off" from "never set".
    public static let commentsNewestFirstKey = "commentsNewestFirst"

    public static var commentsNewestFirst: Bool {
        UserDefaults.standard.bool(forKey: commentsNewestFirstKey)
    }

    // MARK: The comments column's user width

    /// **The comments column's width, once the beside-mount divider has been dragged** — the drag's
    /// memory (05-card-window.md ▸ The comments column, extended 2026-08-09: "the divider between
    /// the body and the comments column is user-resizable … app-wide, persisted, the same home as
    /// the pane's other two bits").
    ///
    /// App-wide for the neighbours' reason exactly: every open card window's divider answers to one
    /// figure, so a window that took it as a parameter would need something above it keeping every
    /// window in step with a value that has exactly one instance — which is also what makes
    /// `@AppStorage` the right binding for it, as it is for `commentsBesideBody`.
    ///
    /// Stored as a `Double` in points — what a drag actually produces — rather than in characters:
    /// re-deriving characters from it on every launch would round-trip through the body font for a
    /// number that is no longer a character count once someone has dragged it away from the default.
    /// **`0` reads as "never dragged"**: `CardWindowMetrics.clampedCommentsColumnWidth`'s floor is
    /// always positive, so a real drag can never land on zero, which makes the plain
    /// `double(forKey:)` reading safe here in a way `boardZoomLevel`'s note warns it usually is not.
    public static let commentsColumnWidthKey = "commentsColumnWidth"

    // MARK: The attributes sidebar's visibility

    /// **View ▸ Show Sidebar** — the card window's trailing attributes sidebar (05-card-window.md ▸
    /// Composition), `showComments`'s shape and reasons exactly: one bit, app-wide, persisted across
    /// restarts, and the checkmark (and the toolbar toggle that mirrors it, `CardToolbar`) reads
    /// exactly this value so neither ever lies.
    ///
    /// **Default on.** The sidebar has been part of every card window since m6; an unset key must
    /// read as the window everyone already knows rather than as a surprise collapse the first time
    /// this preference exists to read.
    public static let showCardSidebarKey = "showCardSidebar"

    /// Read outside a view — `CardToolbar`'s toggle item, which is AppKit rather than SwiftUI and so
    /// has no `@AppStorage` of its own (`lastCardWindowSize`'s reason).
    public static var showCardSidebar: Bool {
        UserDefaults.standard.object(forKey: showCardSidebarKey) as? Bool ?? true
    }

    /// Show/Hide Sidebar's whole behaviour — **the one write path both of its faces share**
    /// (`BoardStore.setTrashVisible`'s precedent: "a toolbar item is a menu command with a different
    /// face, never a second implementation of it"), so `ShowSidebarCommand`'s Toggle and
    /// `CardToolbar`'s item call this rather than writing `UserDefaults` each in their own words.
    ///
    /// **A user-initiated structural change**, so it animates in the structural voice and goes
    /// instant under Reduce Motion — the Show Trash re-divide's own reasoning, applied to a pane
    /// leaving a window instead of a lane leaving a strip. Reduce Motion is read from AppKit rather
    /// than from `@Environment` for the same reason `setTrashVisible` reads it that way: a toolbar
    /// item's action and a menu row's both run outside any rendered hierarchy.
    @MainActor
    public static func setShowCardSidebar(_ shown: Bool) {
        withAnimation(Motion.structural(reduced: Motion.prefersReducedMotion)) {
            UserDefaults.standard.set(shown, forKey: showCardSidebarKey)
        }
    }

    /// The quick-style row's recently-used backgrounds — an array of palette names / hex strings,
    /// most-recent-first (03-board-ui.md § Styling ▸ Controls: "Recents are app-wide and persist
    /// app-side (user preference, never board data)"; 11-command-nexus.md files it under the
    /// preferences that "need no UI"). Read and written by `StyleRecents`, which owns the list rule;
    /// the key is declared here with its neighbours for `WindowID`'s reason.
    public static let quickStyleBackgroundsKey = "quickStyleBackgrounds"

    /// **The recently applied labels** — the tie-break half of the card menu's "most frequently and
    /// recently used" twelve (`LabelRecents`, which owns the list rule; `LabelRanking`;
    /// `FrontmatterKeys.labels`, activated 2026-08-09). `quickStyleBackgroundsKey`'s neighbour in every
    /// respect: an array of strings, most-recent-first, app-wide, a user preference and never board
    /// data — the labels a board *has* are derived from its own cards (`LabelIndex`), and this is only
    /// a memory of what this user reached for.
    public static let labelRecentsKey = "labelRecents"

    /// **The board's zoom level** — "app-wide and persisted across restarts" (11-command-nexus.md
    /// ▸ View ▸ Actual Size; 03-board-ui.md ▸ Layout — zoom). A rung on `BoardZoom.levels`, stored as
    /// the multiplier itself. Read and written by `BoardZoomStore`, which owns the ladder's rules; the
    /// key is declared here with its neighbours for `WindowID`'s reason.
    ///
    /// **Every read goes through `BoardZoom.normalize`**, and this one cannot use the
    /// `object(forKey:) as? Double ?? default` idiom its neighbours use to tell "off" from "never set"
    /// — the trap here is worse than an ambiguous default. `double(forKey:)` answers 0 for an unset
    /// key, and 0 is not merely a wrong level: it drives every `BoardMetrics.em` multiple to its 1pt
    /// floor and draws a board of hairlines. Normalising is what makes an unset, hand-edited or
    /// stale-from-a-future-build value indistinguishable from a legal one downstream.
    public static let boardZoomLevelKey = "boardZoomLevel"

    // MARK: The print profiles

    /// **The named print profiles** (11-command-nexus.md ▸ File ▸ Print…; the printing card's closing
    /// line, "these configurations probably good to persist (as named print profiles) and reused").
    /// Read and written by `PrintProfileStore`, which owns their rules; the key is declared here with
    /// its neighbours for `WindowID`'s reason.
    ///
    /// The value is JSON `Data`, not a plist tree — see that type's persistence note. It is app-side
    /// and never board data: a print profile is how this user likes to read on paper, which no
    /// collaborator and no agent has any business round-tripping.
    public static let printProfilesKey = "printProfiles"

    /// **The reserved "Last Used" pseudo-profile's content** — the options the last print ran with, so
    /// ⌘P opens on what the user did last. Absent until the first print, which reads as the factory
    /// defaults (`PrintProfileStore.lastUsed`).
    public static let printLastUsedKey = "printLastUsed"

    // MARK: The appearance override

    /// **View ▸ Appearance** (11-command-nexus.md) — Auto / Light / Dark, app-wide and persisted
    /// across restarts (03-board-ui.md ▸ Toolbar). Read and written by `AppearanceStore`, which owns
    /// the override's rules; the key is declared here with its neighbours for `WindowID`'s reason.
    ///
    /// **Absent key = Auto.** Setting Auto removes the key rather than writing a third spelling of it
    /// (the remove-at-default family — a default lane width and an empty rename both do the same), and
    /// a stored string that is neither "light" nor "dark" — a hand edit, a future build's value read by
    /// an older one — degrades to Auto rather than refusing to resolve.
    public static let appearanceKey = "appearance"

    /// The stored override, read the same lenient way `AppearanceStore.init` does. Not itself on that
    /// type's read path — it takes its own injectable `defaults` rather than always reading
    /// `.standard` — but declared here with a reader for the shape every other preference in this enum
    /// keeps (`showComments`'s).
    public static var appearance: AppAppearance? {
        UserDefaults.standard.string(forKey: appearanceKey).flatMap(AppAppearance.init(rawValue:))
    }
}

// MARK: - Launch failures

/// A board that could not be restored or opened, as the welcome window renders it.
///
/// **A struct rather than the obvious tuple** only because SwiftUI needs identity to list these and
/// two failures can share a path (a board that failed, was retried, and failed again).
///
/// The join onto a recents row is `WelcomeRow.derive(recents:failures:)` — 02 § Launch and window
/// lifecycle wants the failure *on the board's row*, carrying fail-fast's specifics or the
/// unavailable state, and a failure naming no row (a first open of a folder that was never a board)
/// falls back to a list of its own. `path` is what the join matches on, which is why it is stored
/// rather than derived from the message.
public struct LaunchFailure: Identifiable, Sendable, Equatable {
    public let id = UUID()
    public let path: String
    public let message: String

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

    /// What the row shows for a name: the folder, not the whole path. The path is the subtitle.
    public var displayName: String {
        URL(fileURLWithPath: path).deletingPathExtension().lastPathComponent
    }
}

// MARK: - Security-scoped access

/// One board's security-scoped access, held for the **whole session**.
///
/// `BoardRegistry.withScopedAccess(to:_:)` is the scoped-per-call form and is right for what it does
/// — resolving identities during a recents listing, where holding a scope open would be a leak. It is
/// exactly wrong for an open board: the store, the watcher, and every Writer call need access for
/// minutes or hours, and re-entering the scope per call would be both slower and racy against a
/// watcher thread that is already inside the folder.
///
/// So the pairing is explicit and its balance is the session's job: started when the board's window
/// opens, stopped in the close flush's teardown step. A class rather than a struct so the balance
/// cannot be duplicated by a copy.
///
/// **The URL matters, not the path.** A security-scoped URL is a token, not a string: a `URL`
/// rebuilt from `ref.path` grants nothing, which is why `AppModel.openBoard(at:)` stashes the
/// resolved URL for the host that is about to appear instead of letting it reconstruct one.
public final class ScopedAccess {

    public let url: URL
    private var started: Bool

    public init(_ url: URL) {
        self.url = url
        // `false` for a URL that is not security-scoped — a plain bookmark's, one the open panel
        // already blessed for the app's lifetime, anything inside the container. There is then
        // nothing to stop, and the pairing stays balanced either way.
        started = url.startAccessingSecurityScopedResource()
    }

    public func stop() {
        guard started else { return }
        started = false
        url.stopAccessingSecurityScopedResource()
    }
}

// MARK: - OpenOrigin

/// **Whether a person asked for this board right now** (01-storage-format.md § Malformed input, the
/// decision surface, settled 2026-07-31):
///
/// > It appears on **attended opens only** (welcome click, File ▸ Open…, Finder): restoration
/// > failures keep the retire-to-welcome-row landing, and the row's retry click is the attended open
/// > that then shows the surface — repair is an attended act, and launch never chains dialogs.
///
/// So this is not a description of *where* an open came from — it is the one bit that decides what a
/// failed one does. A closed two-case vocabulary rather than a `Bool` because the sentence a reader
/// needs at the branch is "restored boards retire", not "`isAttended` is false".
///
/// **Attended is the default everywhere**, and that is load-bearing: welcome's rows, File ▸ Open…,
/// the Finder open, Duplicate's follow-on open and the template chooser's are all somebody clicking
/// something. Exactly one caller says otherwise — launch restoration (`RestoreBootstrapView`) — which
/// makes "did a person ask for this" a question one place answers rather than a flag every call site
/// has to get right.
public enum OpenOrigin: Sendable, Equatable {
    /// A person just asked for this board.
    case attended
    /// Launch restoration reopening what was open last time. Nobody is waiting on it, and a failure
    /// lands on welcome's row rather than in a surface.
    case restored
}

// MARK: - AppModel

/// The app's one piece of cross-window state: which boards are open, which card windows belong to
/// which board, and the two window actions AppKit-side code needs but cannot reach.
///
/// ### What lives here, and why it is not a singleton
///
/// The two registries (02-architecture.md § Layering ▸ Components and § Per-board app state) are
/// owned here because they are app-scoped and because "the app holds one instance, so a test can
/// hold its own without the two colliding" — `BoardStoreRegistry`'s own note. Everything else here is
/// window bookkeeping that has no other home: a `BoardStore` knows nothing about windows by design,
/// and a SwiftUI scene is a value that cannot hold state across a window's life.
///
/// ### Sessions are the join
///
/// A `BoardSession` is what makes the two halves of the app meet: the store the windows share, the
/// registry record they stamp, the card windows the close flush has to close first, and the
/// security-scoped access the whole thing runs inside. Its lifetime is exactly the board window's —
/// created when the host's load succeeds, removed by the close flush's last step. A card window with
/// no session is a card window with no board, which 02's ownership rule says cannot exist; the card
/// host reads that as "dismiss".
@MainActor
@Observable
public final class AppModel {

    // MARK: Registries

    public let storeRegistry = BoardStoreRegistry()
    public let boardRegistry: BoardRegistry

    /// The quick-style row's app-wide recents (03-board-ui.md § Styling ▸ Controls). Owned here for
    /// the registries' reason — app-scoped, and a test holds its own rather than colliding with the
    /// app's — and reached by the context menus through the environment, since a `BoardStore` is
    /// board-scoped and this list deliberately is not.
    public let styleRecents: StyleRecents

    /// The recently applied labels, app-wide (`LabelRecents`; `FrontmatterKeys.labels`). Owned here
    /// for `styleRecents`' reason exactly — app-scoped, persisted beside it, and reached by the card
    /// context menu and the card window's sidebar through the environment, since a `BoardStore` is
    /// board-scoped and this list deliberately is not.
    public let labelRecents: LabelRecents

    /// The board's app-wide zoom level (03-board-ui.md ▸ Layout — zoom). Owned here for
    /// `styleRecents`' reason exactly: app-scoped, persisted beside it, and reached by every board
    /// window through the environment — while the menu rows and the toolbar buttons, which live
    /// outside every scene's environment, reach it through this object.
    public let zoom: BoardZoomStore

    /// The app-wide appearance override (11-command-nexus.md ▸ View ▸ Appearance; 03-board-ui.md ▸
    /// Toolbar). Owned here for `zoom`'s reason exactly: app-scoped, persisted beside it, and reached
    /// by the View-menu picker and the board-toolbar item alike — both live outside a board's own
    /// environment (the menu bar entirely, the toolbar through `WindowToolbarController`), so an
    /// `@Observable` object both can hold is the only thing keeping them from becoming two answers to
    /// one question.
    public let appearance: AppearanceStore

    /// The app-wide named print profiles (11-command-nexus.md ▸ File ▸ Print…). Owned here for
    /// `appearance`'s reason exactly: app-scoped, persisted beside its neighbours, and reached by File ▸
    /// Print… — a menu row, which lives outside every scene's environment and therefore receives this
    /// object rather than looking a store up.
    public let printProfiles: PrintProfileStore

    /// The app's one drag session (DRAG-REORDER.md; 04-interactions.md ▸ Drag and drop).
    ///
    /// App-wide for the reason cross-board drags exist at all: **a drag crosses windows**, so the
    /// source board hides the dragged items while any other open board's drop delegates propose a
    /// landing spot for them. It lives here rather than as a global for `styleRecents`' reason — a
    /// test holds its own rather than colliding with the app's — and every board window reaches it
    /// through the environment.
    /// Internal rather than `public`, unlike its neighbours: the drag is entirely a UI-layer
    /// concern, and nothing outside this module has any business reaching into a gesture in flight.
    let dragSession = DragSession()

    /// The app's one clipboard (04-interactions.md ▸ Clipboard).
    ///
    /// App-wide for the drag session's reason turned up a level: a cut/copy **outlives the board it
    /// came from** — the pasteboard and the staged snapshot survive the source window closing, and
    /// survive the app quitting — so nothing board-scoped could own it. It lives here rather than as
    /// a singleton for `styleRecents`' reason (a test holds its own rather than colliding with the
    /// app's, which for this one also means staying off the machine's real pasteboard), and every
    /// board window reaches it through the environment.
    ///
    /// Building it here is also the **launch sweep** (04: "a sweep at launch"): the store's `init`
    /// reads the pasteboard once and collects every staged tree it no longer names.
    public let clipboard: ClipboardStore

    // MARK: The provider seam

    /// **The composition root for `HistoryProviding`** (12-editions.md ▸ The provider seam): what a
    /// board session's undo stack is built by, called once per board as its session begins.
    ///
    /// **Every board gets the native stack, and the seam has one answer**
    /// (13-native-undo.md's header; `strategy/01-git-excision.md`, ruled 2026-08-08 — the app-managed
    /// git substrate is excised, so `Kanban/History/` is the only one there is). Nothing about a board
    /// decides this any more: not what the user paid (12-editions.md ▸ PIVOT 2026-08-08 — one
    /// version, everything free, no edition axis left to consult), not whether it sits inside
    /// somebody's repository, not what is on disk beside it. A board nobody has done anything
    /// special to and a board living in a user's git repo bind the same stack, which was already
    /// true before this ruling and is now true by construction.
    ///
    /// ### Why it is still a seam
    ///
    /// Deliberately kept, and the excision plan says so in as many words (▸ Reversibility: "the seams
    /// the git stack plugged into … are all nil-safe/default-native and are **kept**, so a future
    /// provider — journal, ops service, or even git again — re-binds without re-plumbing"). Two things
    /// it buys today: a test binds a fake substrate — or a substrate-less board — without a second
    /// `AppModel` initializer, and the day a second provider exists it arrives as a different default
    /// here rather than as a branch threaded through the session.
    ///
    /// It takes the store because that is what a provider is a history *of*: the native stack's steps
    /// are computed from that store's snapshots, and a provider that needed the board root would find
    /// it there too. `@ObservationIgnored` because nothing renders from it.
    ///
    /// **The production closure never answers `nil`**, and the optionality is the seam's rather than a
    /// board's: `nil` means "no undo at all", which no board the app composes is
    /// (`BoardUndoManager.history` answers the empty way over an absent substrate, which is what makes
    /// a test able to bind one).
    ///
    /// ### Consumers
    ///
    /// `beginSession`, once per board. Nothing re-binds a live session's substrate: the one event that
    /// used to — add-git's commanded mid-session mode flip — went with the git stack.
    @ObservationIgnored
    public var makeHistoryProvider: (BoardStore) -> (any HistoryProviding)? = { _ in
        NativeHistoryProvider()
    }

    // MARK: Sessions

    /// One open board window and everything hanging off it.
    public struct BoardSession {

        /// The shared store — the same object every one of this board's windows renders.
        public let store: BoardStore

        /// Which registry record this board is, so the close flush can stamp counts and clear the
        /// open-now flag without matching by identity a second time.
        public let recordID: UUID

        /// This board's undo/redo substrate — **the board half of 13-native-undo.md ▸ Rules' two
        /// levels** (re-ruled 2026-07-31): one stack per board session, carrying board-surface
        /// gestures and the one coarse step each card window's close registers. A card window's own
        /// fine-grained stack is not here and never was the session's (`CardWindowUndo`, held by the
        /// window). It lives here for the store's reason exactly: the session is what every window
        /// over this board shares, and "undo is board-local".
        ///
        /// Which implementation it is comes from one place — see `AppModel.makeHistoryProvider`,
        /// which since the 2026-08-08 git excision has exactly one answer for every board.
        ///
        /// **`nil` is a board with no undo at all, and no board the app composes is one**. What keeps
        /// the optionality is the seam rather than a board: a test binds a substrate-less session
        /// through `makeHistoryProvider`, and a store with no session at all registers nothing
        /// (`BoardStore.registerStep`). The command surface disables through `undoManager`, which
        /// answers the empty way over an absent substrate.
        ///
        /// A `var` rather than a `let`, and now for no event at all: the one sanctioned mid-session
        /// substrate swap was add-git's commanded mode flip, which went with the git stack. It stays
        /// a `var` because a second provider is a live possibility (`strategy/01-git-excision.md` ▸
        /// Reversibility) and because nothing is bought by tightening it.
        public var history: (any HistoryProviding)?

        /// The same stack, wearing the face AppKit needs (`BoardUndoManager`): what this board's
        /// windows hand back from `windowWillReturnUndoManager`, so the Edit menu's Undo/Redo rows
        /// and the toolbar's pair resolve to *this* board through the ordinary responder chain.
        ///
        /// Built once with the session rather than per window, because a second adapter would be a
        /// second answer to "what is this board's undo" — and card windows share this one.
        let undoManager: BoardUndoManager

        /// This board's open card windows. The close flush's step 1 reads it; the card hosts
        /// maintain it. Empty is the common case.
        public var cardRefs: Set<CardWindowRef> = []

        /// The scope the board is being read and written inside, released at teardown. `nil` when
        /// the board was opened from a URL that needed none.
        var access: ScopedAccess?
    }

    /// Keyed by board window, because that is the thing whose lifetime a session shares.
    ///
    /// Observed: a card window watches for its board's session disappearing and dismisses itself when
    /// it does — the safety net behind "card windows never outlive the board window".
    public private(set) var sessions: [BoardWindowRef: BoardSession] = [:]

    /// The end-session hooks, keyed the same way the card windows are.
    ///
    /// Beside `BoardSession.cardRefs` rather than inside it: the set is *membership* (what the close
    /// flush drains and what the safety net checks), this is the *seam table* (what it calls). They
    /// are only ever written together, by the two register/unregister methods below, which is what
    /// keeps them from becoming two answers to one question.
    @ObservationIgnored
    private var cardSessions: [CardWindowRef: any CardSessionFlushing] = [:]

    /// Boards whose close flush is already running — the re-entrancy guard.
    ///
    /// Needed because a board window can be told to close twice in quick succession: the
    /// `windowShouldClose` interception starts the flush, and the host's own disappear runs a second
    /// attempt as its safety net. The second must not re-enter a sequence that is mid-await.
    @ObservationIgnored
    private var closingBoards: Set<BoardWindowRef> = []

    // MARK: Window actions

    /// SwiftUI's window-opening action, captured from whatever scene view is alive.
    ///
    /// It exists because the two things that most need to open a window are not views:
    /// `AppDelegate.applicationShouldHandleReopen` (a Dock click with no windows must show welcome)
    /// and the close-flush coordinator (which dismisses card windows). Neither can read
    /// `@Environment`. The action stays valid after the view that supplied it is gone — it is a value
    /// addressed to the app, not to a window — which is precisely the windowless case it is for.
    ///
    /// `@ObservationIgnored` on both: nothing renders from them, and an assignment on every scene's
    /// appear would otherwise invalidate every observer for no reason.
    @ObservationIgnored
    public var windowOpener: OpenWindowAction?

    @ObservationIgnored
    public var windowDismisser: DismissWindowAction?

    /// URLs handed to `openBoard(at:)` before `windowOpener` existed to open them — a cold launch's
    /// Finder-open (`AppDelegate.application(_:open:)`) can arrive ahead of the first scene's
    /// `onAppear`. Held in order, replayed the moment `captureWindowActions` gives the app somewhere
    /// to open them, then discarded — the buffer is a doorway, not a second registry of intent.
    @ObservationIgnored
    private var pendingOpenURLs: [URL] = []

    /// What `CaptureOpenWindow` calls. A method rather than two assignments so the launch flow, which
    /// needs the actions before any `onAppear` has run, has one thing to call.
    ///
    /// Returns how many buffered Finder-open URLs it replayed — the restore bootstrap's input: a
    /// launch that already opened a document's board must not put welcome up beside it, and only this
    /// method knows the buffer wasn't empty.
    @discardableResult
    func captureWindowActions(open: OpenWindowAction, dismiss: DismissWindowAction) -> Int {
        windowOpener = open
        windowDismisser = dismiss

        guard !pendingOpenURLs.isEmpty else { return 0 }
        let urls = pendingOpenURLs
        pendingOpenURLs.removeAll()
        for url in urls {
            openBoard(at: url)
        }
        return urls.count
    }

    // MARK: Recents

    /// The recents list, cached: what the welcome window renders and what File ▸ Open Recent lists
    /// (02-architecture.md § Per-board app state — "The recents list *is* this registry sorted by
    /// last-opened").
    ///
    /// **Cached rather than read through on demand, and both halves of that are deliberate.**
    /// `BoardRegistry` is not `@Observable`, so a view reading it directly would never learn that a
    /// row was forgotten; and `recents()` resolves every record's bookmark, which is filesystem work
    /// no SwiftUI body should be doing on every evaluation — the File menu's command graph is
    /// rebuilt far more often than this list changes. So the list lives here as observable state and
    /// every path that can change the registry refreshes it explicitly (`refreshRecents()`).
    ///
    /// The honest residual: a registry mutated behind this object's back would show stale until the
    /// next refresh. There is no such path today — every writer goes through this type or through a
    /// session it owns — and welcome refreshes on appearance as the cheap belt-and-braces.
    public private(set) var recents: [RecentBoard] = []

    /// Re-reads the registry into `recents`. Called wherever the registry changes: a board opening,
    /// a board closing (the counts are stamped there), Forget, Clear Menu, and welcome appearing.
    public func refreshRecents() {
        recents = boardRegistry.recents()
    }

    /// The welcome row's Forget (11-command-nexus.md ▸ Welcome recent) — the record, plus any launch
    /// failure that row was carrying, plus the refresh, in one call so no caller can do one without
    /// the others.
    ///
    /// **Forgetting the board forgets the failure too.** The row *is* the failure's surface (02
    /// § Launch and window lifecycle); dropping the row while keeping the failure would relocate its
    /// message into the unmatched-failures list, which reads as the app declining to forget.
    public func forget(boardID: UUID) {
        clearLaunchFailures(naming: knownPaths(ofBoard: boardID))
        boardRegistry.forget(id: boardID)
        refreshRecents()
    }

    /// File ▸ Open Recent ▸ Clear Menu (11-command-nexus.md).
    ///
    /// **Finder clears the *menu*; here the registry is the menu**, so clearing removes every record
    /// — there is no second list to clear, and a "menu" that still knew about the boards it had
    /// stopped listing would be a distinction with no surface. What that costs is per-board settings
    /// (window frames, push-on-commit) for boards the user reopens later, which is exactly what
    /// Forget costs one row at a time and what 02's "its settings are conveniences" already accepts.
    ///
    /// It is Forget applied wholesale, so it clears failures the same way — the ones naming records,
    /// leaving a failure that named no row (and therefore no menu entry) standing in its own list.
    ///
    /// A board that is open right now keeps working: its session holds a record id that no longer
    /// resolves, and `BoardRegistry.update` treats an unknown id as a no-op for precisely this case.
    public func clearRecents() {
        clearLaunchFailures(naming: Set(recents.flatMap { recent in
            [recent.record.lastKnownPath, recent.url?.path].compactMap { $0 }
        }))
        boardRegistry.forgetAll()
        refreshRecents()
    }

    /// Every path a given record is known by — the one it was last seen at and, when its bookmark
    /// still resolves, where it lives now. The two can differ (a bookmark follows a move), and a
    /// failure recorded before the move names the older one.
    private func knownPaths(ofBoard id: UUID) -> Set<String> {
        var paths: Set<String> = []
        if let record = boardRegistry.record(id: id) {
            paths.insert(record.lastKnownPath)
        }
        if let url = recents.first(where: { $0.record.id == id })?.url {
            paths.insert(url.path)
        }
        return paths
    }

    // MARK: Launch failures

    /// Boards that failed to restore or open, newest last. Rendered on their own recents rows where
    /// one exists, and in a fallback list where none does — `WelcomeRow.derive(recents:failures:)`.
    public private(set) var launchFailures: [LaunchFailure] = []

    // MARK: Card-window placement

    /// Where the next card window cascades from (05-card-window.md, "New windows open at the
    /// last-used card-window size, cascaded").
    ///
    /// `NSWindow.cascadeTopLeft(from:)` is the whole mechanism: passing `.zero` places the window at
    /// its natural position and returns the point for the next one, so this is a running cursor
    /// rather than a computed grid. App-wide, not per-board: two boards' card windows cascade past
    /// each other rather than landing on top of one another.
    @ObservationIgnored
    var cardCascadePoint: NSPoint = .zero

    // MARK: Pending opens

    /// Everything `openBoard(at:origin:)` knows that a `BoardWindowRef` cannot carry.
    ///
    /// **One struct rather than two parallel dictionaries** (the shape this replaced was
    /// `pendingAccess` alone): both facts are stashed by the same call, claimed by the same call, and
    /// meaningless apart — a window that found an origin but no access, or the reverse, would be a
    /// bug with no honest reading. Keeping them in one value makes "they are always in step" true by
    /// construction instead of by two `removeValue`s that must not drift.
    private struct PendingOpen {
        /// The security-scoped URL the board will be built from, or `nil` where the open needed no
        /// scope. See `ScopedAccess`: a `URL` rebuilt from `ref.path` grants nothing.
        let access: ScopedAccess?
        /// Whether a person asked for this board — what a failed open branches on (`OpenOrigin`).
        let origin: OpenOrigin
    }

    /// What a board window is about to be built from, stashed between `openBoard(at:origin:)` and the
    /// host's first appearance.
    ///
    /// The handoff exists because a window value has to be `Codable` and neither of these is a
    /// string: by the time `BoardWindowHost` receives its `BoardWindowRef` the access token is gone,
    /// and the origin was never in the ref at all. The host claims both on appear; an unclaimed entry
    /// (a window that never opened) leaks one scope until quit, which is the cheapest failure
    /// available here.
    @ObservationIgnored
    private var pendingOpens: [BoardWindowRef: PendingOpen] = [:]

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

    /// The app builds one of these with the real state home; a test passes its own for the reason
    /// `BoardRegistry` takes a storage URL at all — "injecting it is how a test stays out of the real
    /// Application Support directory" (`AppStateHome`). A suite that swept the real staging root
    /// would be sweeping the developer's own clipboard.
    ///
    /// `clipboardStagingRoot` is a separate parameter rather than derived from `registryStorageURL`'s
    /// folder because the two are injected for different reasons and by different callers: the UI-test
    /// fixture launch redirects both into one scratch root (`UITestLaunch`), a unit test usually wants
    /// only one of them, and deriving would silently move a test's staging directory the day it moved
    /// its registry file.
    public init(
        registryStorageURL: URL = BoardRegistry.defaultStorageURL,
        clipboardStagingRoot: URL = ClipboardStore.defaultStagingRoot,
        preferences: UserDefaults = .standard
    ) {
        boardRegistry = BoardRegistry(storageURL: registryStorageURL)
        styleRecents = StyleRecents(defaults: preferences)
        labelRecents = LabelRecents(defaults: preferences)
        zoom = BoardZoomStore(defaults: preferences)
        appearance = AppearanceStore(defaults: preferences)
        printProfiles = PrintProfileStore(defaults: preferences)
        clipboard = ClipboardStore(stagingRoot: clipboardStagingRoot)
        // Read once here rather than lazily, so File ▸ Open Recent is populated from the app's first
        // menu pass — a launch that restores boards never shows welcome, and a submenu that filled
        // in only after the first close would look broken. It costs one bookmark-resolution sweep at
        // launch, next to the one `restorables()` already runs.
        refreshRecents()
    }

    // MARK: - Launch restoration

    /// The launch-restoration gate, as a pure function (02-architecture.md § Launch and window
    /// lifecycle: "the preference gates only whether the flagged set is consulted; the flags are
    /// maintained regardless").
    ///
    /// `KanbanApp.init()` is where this actually runs — read once, before any scene exists, into a
    /// `let` rather than a computed property, because `restorables()` costs a bookmark resolution per
    /// known board and nothing should pay that on every scene-graph evaluation. An `App`'s `init` is
    /// not itself reachable from a test, so the decision is pulled out to here: two `Bool`s in, one
    /// out, provable without a real `UserDefaults` domain or a live registry.
    ///
    /// `nonisolated` because it is exactly as pure as that sentence claims — it touches no stored
    /// state, and `LaunchPlan.decide` (which is not main-actor-bound either, for the same reason)
    /// composes it into the three-way launch decision.
    public nonisolated static func shouldRestoreAtLaunch(preference: Bool, hasRestorables: Bool) -> Bool {
        preference && hasRestorables
    }

    // MARK: - Opening

    public var hasOpenBoards: Bool { !sessions.isEmpty }

    /// Opens a board window for `url`, or focuses the one this board already has.
    ///
    /// **The already-open check is by file identity, not by path** — `liveStore(for:)` resolves it —
    /// so a board reached through a resolved bookmark and the same board reached through the open
    /// panel land on one window even when the two URLs are spelled differently. Only when nothing is
    /// open for it does a ref get minted, and `openWindow(value:)` with an equal ref focuses rather
    /// than duplicates, which is the second half of "one board window per root".
    ///
    /// Security-scoped access starts here, *before* the window exists, because the host's very first
    /// act is a tree walk: a scope started after the load would be too late.
    ///
    /// **Called before any scene has appeared, and that's fine.** A cold launch's Finder-open can
    /// reach here before `windowOpener` is captured; the URL joins `pendingOpenURLs` and this same
    /// method runs again for it once `captureWindowActions` has something to open it with.
    ///
    /// - Parameter origin: whether a person asked for this board right now (`OpenOrigin`) — the one
    ///   bit a *failed* open branches on (01-storage-format.md § Malformed input: the decision
    ///   surface "appears on attended opens only"). `.attended` by default, which is every caller but
    ///   launch restoration: welcome's rows, File ▸ Open…, the Finder open, the template chooser's
    ///   follow-on, Duplicate's. The default is the rule stated once rather than repeated five times.
    public func openBoard(at url: URL, origin: OpenOrigin = .attended) {
        guard let windowOpener else {
            // **The queue carries no origin, and needs none**: it is reachable only *before any scene
            // exists*, which is the cold-launch Finder/URL open and nothing else — restoration
            // captures the window actions as its first act (`RestoreBootstrapView.restore`) and so can
            // never queue. Everything in here is therefore attended, which is what the replay's
            // default gives it.
            pendingOpenURLs.append(url)
            return
        }

        if storeRegistry.liveStore(for: url) != nil, let existing = boardRef(forBoardAt: url) {
            windowOpener(id: WindowID.board, value: existing)
            return
        }

        let ref = BoardWindowRef(url: url)
        stashPendingOpen(for: ref, url: url, origin: origin)
        windowOpener(id: WindowID.board, value: ref)
    }

    /// Stashes what the window about to appear will claim — the handoff's write half.
    ///
    /// A method of its own rather than two lines inside `openBoard(at:origin:)` because that method
    /// cannot run without SwiftUI's `OpenWindowAction`, which is not a thing a test can construct: the
    /// carrier would otherwise be the one part of the attendance plumbing with no headless proof, and
    /// an origin that quietly stopped travelling would look exactly like the app before this
    /// milestone.
    func stashPendingOpen(for ref: BoardWindowRef, url: URL, origin: OpenOrigin) {
        // Replacing a stash for the same ref would strand the old scope; there is no such case today
        // (an unopened window's ref is not reachable), but stopping the loser is free.
        pendingOpens.removeValue(forKey: ref)?.access?.stop()
        pendingOpens[ref] = PendingOpen(access: ScopedAccess(url), origin: origin)
    }

    /// Shows — or focuses — the welcome window. Its own scene id, so this works with no windows at
    /// all, which is the Dock-reactivation case (02: "Reactivation (Dock click) with no windows shows
    /// welcome").
    public func showWelcome() {
        windowOpener?(id: WindowID.welcome)
    }

    /// File ▸ New Board… (⌥⌘N) — shows, or focuses, the template chooser (09-templates.md).
    ///
    /// The command opens a *chooser*, never a board: the location is the save panel's question and
    /// the panel is the chooser's, so this method's whole job is the window.
    public func showTemplateChooser() {
        windowOpener?(id: WindowID.templateChooser)
    }

    /// The standard open panel behind File ▸ Open… ⌘O (11-command-nexus.md).
    ///
    /// **Validation is the open attempt itself** — there is no pre-flight check that a folder is a
    /// board. Fail-fast owns that verdict (01-storage-format.md § Malformed input) and it is the same
    /// verdict a restored board gets, so a folder that is not a board produces one error in one
    /// vocabulary rather than two near-identical rejections in two.
    ///
    /// `treatsFilePackagesAsDirectories` is what lets a `.kanban` package be *chosen* while
    /// `canChooseFiles` stays off: a package is a file to the panel otherwise, and boards are both
    /// packages and plain folders (01 § Board naming). The cost is that double-clicking a package
    /// navigates into it, which the welcome window's own open affordances will make moot.
    public func presentOpenPanel() {
        let panel = NSOpenPanel()
        panel.canChooseDirectories = true
        panel.canChooseFiles = false
        panel.treatsFilePackagesAsDirectories = true
        panel.allowsMultipleSelection = false
        panel.prompt = "Open"
        panel.message = "Choose a board folder."

        guard panel.runModal() == .OK, let url = panel.url else { return }
        openBoard(at: url)
    }

    /// The ref of the window already showing the board at `url`, if any — matched through the store,
    /// which is identity-keyed, rather than through the path.
    private func boardRef(forBoardAt url: URL) -> BoardWindowRef? {
        guard let store = storeRegistry.liveStore(for: url) else { return nil }
        return sessions.first { $0.value.store === store }?.key
    }

    // MARK: - Sessions

    public func session(for ref: BoardWindowRef) -> BoardSession? {
        sessions[ref]
    }

    /// Claims what `openBoard(at:origin:)` stashed for this window. Claiming removes it: the session
    /// owns the scope's balance from here.
    ///
    /// A window that opened by some other route — a route that never went through `openBoard` — gets
    /// no scope and reads as **attended**, which is the safe direction: the worst an attended reading
    /// can do to a failed open is offer the user a repair they did not ask for, where the reverse
    /// would silently retire a board somebody just double-clicked.
    func claimPendingOpen(for ref: BoardWindowRef) -> (access: ScopedAccess?, origin: OpenOrigin) {
        guard let pending = pendingOpens.removeValue(forKey: ref) else { return (nil, .attended) }
        return (pending.access, pending.origin)
    }

    /// Starts a board's session — the board window's host calls this once its load has succeeded.
    ///
    /// Two bookkeeping consequences of "this board is now open" ride along. The recents list is
    /// re-read, because `recordOpen` just moved this board to the top of it. And any launch failure
    /// naming this board is dropped: the board demonstrably opens, so a row still captioned with the
    /// old error would be reporting a condition that has stopped being true. That is not the silent
    /// drop 02 forbids — it forbids a failure that was never surfaced disappearing, not one the user
    /// has since fixed.
    func beginSession(ref: BoardWindowRef, store: BoardStore, recordID: UUID, access: ScopedAccess?) {
        // The board's stack is born here, with the session that owns it, and dies in `tearDown`
        // below — the whole of 13-native-undo.md's session-only persistence: "the stack lives with
        // the board session and dies at close/quit ... standard macOS behavior".
        let history = makeHistoryProvider(store)
        // **The binding 13-native-undo.md ▸ Rules' "registration at the Writer boundary" needs**: the
        // store is that boundary — every app-mediated mutation goes out through one of its write
        // methods — so it is the store that computes each inverse and registers it. What it cannot
        // know is *which* stack, because a stack belongs to a session and a store knows nothing about
        // windows; this line is where the session tells it. Weak on the store's side, so the loop
        // this closes (provider → step closures → store) is not a retain cycle.
        store.history = history
        sessions[ref] = BoardSession(
            store: store,
            recordID: recordID,
            history: history,
            // The lock's enablement half (13-native-undo.md ▸ Rules): Undo and Redo disable with the
            // other mutating commands while the board refuses writes, and the stack survives to
            // resume when it clears. Weak, so the adapter is never the reason a closed board's store
            // stays alive; a store that has gone answers "writable", which is moot — its stack went
            // with it.
            undoManager: BoardUndoManager(history: history, isReadOnly: { [weak store] in
                store?.isReadOnly ?? false
            }),
            cardRefs: [],
            access: access
        )
        clearLaunchFailures(naming: [ref.path, store.rootURL.path])
        refreshRecents()
    }

    /// **The three buttons, as a seam** — `SessionSettleStep.ask(message:)` in production.
    ///
    /// `SessionSettleGate` already keeps the presentation behind a closure for its own reason
    /// ("presenting three buttons is AppKit's job and cannot be asserted without a display … the
    /// presentation is a seam and the decision is testable"), and every gate this model builds pointed
    /// that closure straight at the alert — so the *composition* around the gate, which is what
    /// `discardCardWindowUndoStacks(for:)` hangs off, could only be exercised by a board with nothing
    /// to settle. Lifting the ask one level up is what lets a test answer Save All, Discard and Cancel
    /// over real card windows without a modal on screen.
    ///
    /// `@ObservationIgnored` because nothing renders from it, and internal because it is a test seam
    /// rather than API: production never assigns it.
    @ObservationIgnored
    var settleAsk: @MainActor (String) async -> SessionSettleChoice = {
        await SessionSettleStep.ask(message: $0)
    }

    /// **The save-or-discard step for one board**, built from its open card windows
    /// (`SessionSettleGate`).
    ///
    /// Built per ask rather than stored, because its whole content is "which card windows are open
    /// right now" — a set that changes under any operation slow enough to need the step at all.
    ///
    /// **No production caller today** — the git restore and branch switch were the two, and both went
    /// with the git stack (`strategy/01-git-excision.md`). The composition is kept beside the gate it
    /// composes, for the gate's own reason: it is what the next wholesale operation binds to.
    ///
    /// - Parameters:
    ///   - message: what the step says it is about. Different operations describe different
    ///     consequences, and a step that described the wrong one would be a worse modal than none.
    ///   - didDiscard: told each card folder the Discard branch abandoned, so the operation behind the
    ///     gate can reconcile that folder's already-written bytes its own way.
    func settleGate(
        for ref: BoardWindowRef,
        message: String = SessionSettleStep.message,
        didDiscard: @escaping (String) -> Void = { _ in }
    ) -> SessionSettleGate {
        SessionSettleGate(
            sessions: { [weak self] in
                guard let self, let session = self.sessions[ref] else { return [] }
                return session.cardRefs.compactMap { cardRef in
                    guard let flushing = self.cardSessions[cardRef],
                          let settlement = flushing.settlement else { return nil }
                    return SettleableSession(
                        id: cardRef.cardID,
                        cardFolderName: cardRef.cardID,
                        needsSettling: settlement.needsSettling,
                        saveAll: settlement.saveAll,
                        discard: {
                            settlement.discard()
                            didDiscard(cardRef.cardID)
                        }
                    )
                }
            },
            ask: { [weak self] in
                guard let self else { return await SessionSettleStep.ask(message: message) }
                return await self.settleAsk(message)
            },
            focus: { [weak self] id in
                guard let self, let session = self.sessions[ref] else { return }
                guard let cardRef = session.cardRefs.first(where: { $0.cardID == id }) else { return }
                // Opening a window that is already open is how SwiftUI's value-addressed groups say
                // "bring that one forward" — the same call `BoardWindowHost` makes to open a card, and
                // the reason reopening a live card focuses its window rather than making a second one.
                self.windowOpener?(id: WindowID.card, value: cardRef)
            }
        )
    }

    /// Registers a card window with its board's session, so the close flush can find it.
    ///
    /// A card window whose board has no session is a card window with no board — the ownership rule
    /// says that cannot exist, and the host's own check dismisses it before reaching this. Recording
    /// the seam anyway would leave an entry nothing ever drains.
    func registerCardWindow(_ ref: CardWindowRef, session: any CardSessionFlushing) {
        guard sessions[ref.board] != nil else {
            Self.logger.debug("card window registered against a board with no session — ignored")
            return
        }
        sessions[ref.board]?.cardRefs.insert(ref)
        cardSessions[ref] = session
    }

    func unregisterCardWindow(_ ref: CardWindowRef) {
        sessions[ref.board]?.cardRefs.remove(ref)
        cardSessions[ref] = nil
    }

    /// **A wholesale operation's settle empties every open card window's fine undo stack.**
    ///
    /// The rule the branch switch established, kept as a rule about *any* operation that replaces the
    /// tree under an open window: pre-operation steps describe a state that has gone, so Save All and
    /// Discard alike end with every window's stack empty — the board-stack discard-and-reseed
    /// precedent one level down; the windows stay open with fresh stacks. Cancel clears nothing, which
    /// is why this hangs off a `.proceed` at the call site rather than off the ask.
    ///
    /// **The board stack is not touched here**, and it is not an omission: an operation that replaces
    /// the tree reseeds the board's stack itself, after the write that decides what to reseed *from*.
    /// Doing it here would be the same discard at the wrong moment.
    ///
    /// **A diff-shaped operation deliberately does not call this.** One that materializes only a diff
    /// leaves a window's steps describing a state that is still mostly there; what protects them is
    /// 13-native-undo.md's field-level staleness predicate, a per-step question rather than a
    /// wholesale one.
    ///
    /// **No production caller today** — it went with the branch switch (`strategy/01-git-excision.md`)
    /// — and it is kept beside `settleGate(for:message:didDiscard:)` for that gate's reason.
    ///
    /// The downcast is the honest shape rather than a shortcut: `CardSessionFlushing` is the *close
    /// flush's* seam — end the session, say whether it holds unsaved content, offer the settle's two
    /// writes — and a fine undo stack is none of those things. The one type that has one is the card
    /// window's own session, which is what every registration passes.
    func discardCardWindowUndoStacks(for ref: BoardWindowRef) {
        for cardRef in sessions[ref]?.cardRefs ?? [] {
            (cardSessions[cardRef] as? CardWindowSession)?.undo.discardSteps()
        }
    }

    // MARK: - Launch failures

    /// Records a board that could not be opened. Deliberately additive and never cleared on success:
    /// welcome is showing *because* something failed, and a list that emptied itself as other boards
    /// arrived would be the silent drop 02 rules out.
    public func recordLaunchFailure(path: String, message: String) {
        launchFailures.append(LaunchFailure(path: path, message: message))
    }

    /// Forgets the failures — the welcome window's dismissal of a list the user has read.
    public func clearLaunchFailures() {
        launchFailures.removeAll()
    }

    /// Forgets exactly the named failures — what the unmatched-failures list's Clear dismisses, so
    /// that pressing it never also erases a message still standing on a recents row the user has
    /// not looked at.
    public func clearLaunchFailures(ids: Set<UUID>) {
        launchFailures.removeAll { ids.contains($0.id) }
    }

    /// Drops every failure naming one of `paths` — the resolution path, used when a board opens
    /// successfully and when its record is forgotten. Paths are compared the way the welcome row's
    /// join compares them, so "this row's failure" means the same thing in both places.
    private func clearLaunchFailures(naming paths: Set<String>) {
        guard !paths.isEmpty else { return }
        let keys = Set(paths.map(WelcomeRow.pathKey))
        launchFailures.removeAll { keys.contains(WelcomeRow.pathKey($0.path)) }
    }

    // MARK: - Counts

    /// The lane and card counts stamped into the registry at close — **live items only** (02
    /// § Per-board app state, settled).
    ///
    /// > deleted lanes and cards don't count; the row advertises the board's working size, and the
    /// > trash is an errand, not inventory.
    ///
    /// **`.trash/` is excluded by construction** (02-architecture.md § Per-board app state,
    /// re-grounded 2026-07-28 for the materialized trash): this walks `snapshot.lanes`, and the
    /// trash is `snapshot.trash` — a sibling container, never a lane — so no filter is needed and
    /// none could be forgotten. The tombstone era's ancestor walk over `deleted:` flags is gone with
    /// the flag; a board an older version wrote counts its unmigrated cards until the migration moves
    /// them, which is the safe direction and lasts exactly one write.
    ///
    /// Static and pure: it is a fact about a snapshot, and the close flush is the wrong place to
    /// discover a counting bug.
    public static func liveCounts(of snapshot: BoardModel) -> (lanes: Int, cards: Int) {
        var lanes = 0
        var cards = 0
        for lane in snapshot.lanes {
            lanes += 1
            cards += lane.cards.count
        }
        return (lanes, cards)
    }

    /// The folder name, extension stripped (01-storage-format.md § Board naming) — `displayName`'s
    /// own fallback, and (02-architecture.md § Per-board app state) the registry record's
    /// *provisional* display name for a board recorded before its load has run: "fail-fast means the
    /// frontmatter can't be trusted, and the folder name is the Finder document name the user just
    /// picked". A first successful load replaces it with the cached title through the ordinary
    /// `displayName(of:)` path — there is no separate provisional vocabulary, just this one fallback
    /// used a moment earlier than usual.
    public static func folderDisplayName(of url: URL) -> String {
        url.deletingPathExtension().lastPathComponent
    }

    /// A board's display name: its `title`, falling back to the folder name sans extension
    /// (01-storage-format.md § Board naming).
    ///
    /// Read from `store.rootURL` rather than `snapshot.rootURL` so the fallback follows a rename the
    /// moment it is absorbed, instead of lagging by one reload (see `BoardStore.rootURL`).
    public static func displayName(of store: BoardStore) -> String {
        if let title = store.snapshot.title.value, !title.isEmpty {
            return title
        }
        return folderDisplayName(of: store.rootURL)
    }

    // MARK: - Closing

    /// Runs the close flush for one board and tears its session down.
    ///
    /// Idempotent by two guards: a board with no session has already closed, and a board already
    /// mid-flush is not started again. Both matter — the window's close interception and the host's
    /// disappear both call this, by design, because neither one alone fires on every path a window
    /// can leave by.
    public func closeBoard(ref: BoardWindowRef, cause: BoardCloseCause) async {
        guard sessions[ref] != nil, !closingBoards.contains(ref) else { return }
        closingBoards.insert(ref)
        defer { closingBoards.remove(ref) }

        await coordinator(for: ref).run(cause: cause)
        // The flush stamped this board's counts and (on a user close) cleared its open-now flag, so
        // the cached list is now one close out of date — and welcome is often the very next thing on
        // screen.
        refreshRecents()
    }

    /// The close flush's **pending-work step, without the teardown** — what File ▸ Duplicate runs
    /// before it copies (03-board-ui.md § Welcome screen & templates: "The copy is preceded by the
    /// close flush ... so neither the tree nor the copied history misses pending work").
    ///
    /// **Not `closeBoard`**, and the design says so itself: 09-templates.md ▸ Save as Template states
    /// the rule together with its exception — "with the pull-style mechanical exception committing an
    /// open Edit session's on-disk saves as-is, **sessions staying open**". A duplicate leaves the
    /// original on screen (03: "the original stays open too"), so what it needs is pending work
    /// *landed on disk*, not a session ended: no card window is dismissed, no record is stamped
    /// closed, nothing is torn down, and the board the user is looking at never blinks.
    ///
    /// It goes through `CloseFlushCoordinator` rather than calling the store directly so that the
    /// order of the three flushes — store pipeline, then editor saves, then the pending auto-commit
    /// (02's own order) — keeps having exactly one definition.
    public func flushPendingWork(for ref: BoardWindowRef) async {
        guard sessions[ref] != nil else { return }
        await coordinator(for: ref).flushPendingWork()
    }

    /// Whether any of this board's card windows is holding content the files do not have — a dirty
    /// Edit buffer or a typed-in raw-source outlet (`CardSessionFlushing.holdsUnsavedContent`).
    ///
    /// **One caller, one rule**: File ▸ Save as Template's carve-out from the read-only lock. Under
    /// the unwritable-location lock the item stays live — "reads the board, writes Application
    /// Support" — but only while no such session exists, because the lock has suspended exactly the
    /// saves that would flush one and 09-templates.md's never-misses-keystrokes guarantee outranks
    /// the item's availability (02-architecture.md ▸ Live-reload resilience, settled scoping).
    ///
    /// It asks the sessions rather than the store: the content in question is in *memory*, in the
    /// card windows, which is the whole reason the flush cannot reach it.
    public func hasUnsavedCardContent(for ref: BoardWindowRef) -> Bool {
        guard let session = sessions[ref] else { return false }
        return session.cardRefs.contains { cardSessions[$0]?.holdsUnsavedContent == true }
    }

    /// Quit: the same sequence, once per open board, **sequentially**.
    ///
    /// Sequential rather than concurrent so each board's ordering is the one 02 fixes rather than
    /// three interleavings of it, and in a stable board order so a quit is reproducible. Nothing here
    /// clears an open-now flag — that is what `.quit` means, and it is what makes the next launch
    /// restore this set (§ Launch and window lifecycle).
    public func flushAllBoardsForQuit() async {
        for ref in sessions.keys.sorted(by: { $0.path < $1.path }) {
            await closeBoard(ref: ref, cause: .quit)
        }
    }

    /// Wires a session into `CloseFlushCoordinator`'s seams. The ordering lives over there; this is
    /// only which real object each step touches.
    private func coordinator(for ref: BoardWindowRef) -> CloseFlushCoordinator {
        CloseFlushCoordinator(
            openCardRefs: { [weak self] in
                // Sorted so a board with several card windows commits and closes them in a stable
                // order rather than a `Set`'s.
                (self?.sessions[ref]?.cardRefs).map { $0.sorted { $0.cardID < $1.cardID } } ?? []
            },
            endCardSession: { [weak self] cardRef in
                await self?.cardSessions[cardRef]?.endSession()
            },
            dismissCardWindow: { [weak self] cardRef in
                self?.windowDismisser?(value: cardRef)
            },
            storeFlush: { [weak self] in
                await self?.sessions[ref]?.store.awaitQuiescence()
            },
            // `editorFlush` stays nil, and now deliberately rather than for want of an editor: the
            // card windows' debounced body saves flush in **step 1**, inside each window's
            // `endSession()` (`CardWindowSession`), which is both earlier than this slot and where
            // 02-architecture.md puts them ("each open Edit session ends with its normal session
            // commit", then pending work). The slot stays for a board-level editor with no card
            // window of its own — the raw-source buffer is the candidate — so that the order
            // relative to `committerFlush` is already decided when one arrives.
            //
            // **`committerFlush` stays nil too**, and now for want of a committer rather than by
            // policy: the auto-committer it drove went with the git stack
            // (`strategy/01-git-excision.md`). The slot is 02's ordering statement — pending editor
            // saves, then whatever records history — and it keeps that place for the successor.
            recordClose: { [weak self] in
                guard let self, let session = sessions[ref] else { return }
                let counts = Self.liveCounts(of: session.store.snapshot)
                boardRegistry.recordClose(
                    id: session.recordID,
                    displayName: Self.displayName(of: session.store),
                    laneCount: counts.lanes,
                    cardCount: counts.cards,
                    icon: session.store.snapshot.icon.value,
                    iconColor: session.store.snapshot.iconColor.value
                )
            },
            clearOpenNow: { [weak self] in
                guard let self, let session = sessions[ref] else { return }
                boardRegistry.clearOpenNow(id: session.recordID)
            },
            tearDown: { [weak self] in
                guard let self, let session = sessions.removeValue(forKey: ref) else { return }
                // Session-only persistence, the other half of `beginSession` (13-native-undo.md
                // ▸ Rules): "the stack ... dies at close/quit", so reopening the board starts empty.
                // Cleared rather than merely dropped because the steps hold closures over the store
                // this line is about to release, and a stack that outlived its board would be a
                // retain cycle wearing an undo stack's clothes.
                session.history?.clear()
                storeRegistry.release(session.store)
                session.access?.stop()
            }
        )
    }
}
