The decision surface — a refused open becomes a live repair, in place

Phase 3 of the decision surface, completing the card (01 ▸ Malformed
input, settled 2026-07-31). An attended open's fail-fast walk transforms
the loading window's content into one aggregated surface — never a
sheet, never a chain: defects grouped by class, each class stated once
with its files listed (Reveal in Finder + Open in Editor per row), a
class-level default preselected, per-item override behind a disclosure.
Only honest choices: YAML and malformed-schema get Editor + Re-check
(Skip below the root); newer-than-app gets Skip alone and blocks the
board at the root; the two root repairs — minted index, schema: 1 stamp
— are defaults. Repair and Open applies fixes in one store-less write
bracket and re-walks: clean proceeds, remainder re-aggregates into the
same surface. Cancel and ⌘W retire to welcome's row; restored opens
never see the surface at all (OpenOrigin rides the PendingOpen carrier).

Skips are per-open consent that rides the session — the store retains
the skip set and every reload passes it — and the opened board posts a
warning-tone notice naming what was left out, each item's Reveal riding
the banner strip's new reveal control. On Pro boards the repair bracket
binds its own EchoLedger, heal-marks everything, and the store adopts it
before the committer starts, so repairs land as one separate commit
authored Lanework Integrity — pinned end to end. Also fixed en route: a
retired loading window left its close interception installed and
returned false from windowShouldClose forever, blocking quit.

Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
This commit is contained in:
2026-08-01 10:52:02 -04:00
parent 0933ac1b01
commit 31fee00c73
17 changed files with 2448 additions and 107 deletions
+10
View File
@@ -34,6 +34,16 @@ A hand-made card file no longer needs an order or schema line — the card simpl
Every board carries a .gitignore naming which files count as noise, so system files like .DS_Store stay put instead of being gathered into a card's attachments. Every board carries a .gitignore naming which files count as noise, so system files like .DS_Store stay put instead of being gathered into a card's attachments.
Boards open into their own window right away, with a quiet spinner while a large one is read, and ⌘W cancels an open in progress.
A board that won't open now explains itself in that window, listing every problem file by file and grouped by what's wrong.
Each listed file offers Reveal in Finder and Open in Editor, so you can fix it yourself and press Re-check.
Repair and Open makes the fixes that are safe to make — a folder missing its board file, a board missing its format line — and opens the board.
You can skip a file Lanework can't fix and open the board without it; the board then names what was left out, and asks again next time.
Every card can carry a comment thread, shown beside or below the card's text in its window. Every card can carry a comment thread, shown beside or below the card's text in its window.
The comment composer keeps its draft inside the board itself, so a half-written comment is waiting whenever and wherever you reopen the card. The comment composer keeps its draft inside the board itself, so a half-written comment is waiting whenever and wherever you reopen the card.
+85 -15
View File
@@ -190,6 +190,32 @@ public final class ScopedAccess {
} }
} }
// 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 // MARK: - AppModel
/// The app's one piece of cross-window state: which boards are open, which card windows belong to /// The app's one piece of cross-window state: which boards are open, which card windows belong to
@@ -595,15 +621,31 @@ public final class AppModel {
// MARK: Pending opens // MARK: Pending opens
/// The security-scoped URL a board window is about to be built from, stashed between /// Everything `openBoard(at:origin:)` knows that a `BoardWindowRef` cannot carry.
/// `openBoard(at:)` and the host's first appearance.
/// ///
/// The handoff exists because a window value has to be `Codable` and a scoped URL is not a /// **One struct rather than two parallel dictionaries** (the shape this replaced was
/// string: by the time `BoardWindowHost` receives its `BoardWindowRef` the access token is gone /// `pendingAccess` alone): both facts are stashed by the same call, claimed by the same call, and
/// unless something carried it across. The host claims it on appear; an unclaimed entry (a window /// meaningless apart a window that found an origin but no access, or the reverse, would be a
/// that never opened) leaks one scope until quit, which is the cheapest failure available here. /// 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 @ObservationIgnored
private var pendingAccess: [BoardWindowRef: ScopedAccess] = [:] private var pendingOpens: [BoardWindowRef: PendingOpen] = [:]
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "app-model") private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "app-model")
@@ -677,8 +719,19 @@ public final class AppModel {
/// **Called before any scene has appeared, and that's fine.** A cold launch's Finder-open can /// **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 /// 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. /// method runs again for it once `captureWindowActions` has something to open it with.
public func openBoard(at url: URL) { ///
/// - 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 { 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) pendingOpenURLs.append(url)
return return
} }
@@ -689,11 +742,22 @@ public final class AppModel {
} }
let ref = BoardWindowRef(url: url) 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 // 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. // (an unopened window's ref is not reachable), but stopping the loser is free.
pendingAccess.removeValue(forKey: ref)?.stop() pendingOpens.removeValue(forKey: ref)?.access?.stop()
pendingAccess[ref] = ScopedAccess(url) pendingOpens[ref] = PendingOpen(access: ScopedAccess(url), origin: origin)
windowOpener(id: WindowID.board, value: ref)
} }
/// Shows or focuses the welcome window. Its own scene id, so this works with no windows at /// Shows or focuses the welcome window. Its own scene id, so this works with no windows at
@@ -748,10 +812,16 @@ public final class AppModel {
sessions[ref] sessions[ref]
} }
/// Claims the scoped URL `openBoard(at:)` stashed for this window, or `nil` if it opened by some /// Claims what `openBoard(at:origin:)` stashed for this window. Claiming removes it: the session
/// other route. Claiming removes it: the session owns the balance from here. /// owns the scope's balance from here.
func claimPendingAccess(for ref: BoardWindowRef) -> ScopedAccess? { ///
pendingAccess.removeValue(forKey: ref) /// 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. /// Starts a board's session the board window's host calls this once its load has succeeded.
+279 -16
View File
@@ -86,10 +86,39 @@ struct BoardWindowHost: View {
/// that precedes `start()`. /// that precedes `start()`.
@State private var recordID: UUID? @State private var recordID: UUID?
/// This open's security-scoped access, claimed in `start()` and held until the session takes it
/// over or the open ends.
///
/// `@State` rather than a local in `start()` because the decision surface outlives that call: a
/// repair *writes into the board*, and a scope released when `start()` returned would be released
/// exactly before the one write that needs it. Every exit balances it the session adopts it,
/// or Cancel and the failure path stop it.
@State private var access: ScopedAccess?
/// Whether a person asked for this board (`OpenOrigin`) claimed beside the access, and read by
/// exactly one branch: what a failed walk does.
@State private var origin: OpenOrigin = .attended
/// The board's URL as this open resolved it the scoped one where there is one. Held for the
/// surface's sake, which re-walks and repairs against it long after `start()` has returned.
@State private var boardURL: URL?
/// **The repair bracket's ledger**, held between Repair and Open's writes and the store that the
/// following walk builds (`BoardRepairRun`, `EchoLedger.adopt`).
///
/// It cannot live anywhere else: the repairs run before a store exists and the receipts have to
/// reach that store's ledger before `beginSession` composes Pro's committer, or the app's own
/// repair commits as `Lanework External`. Cleared once adopted.
@State private var repairLedger: EchoLedger?
@State private var phase: Phase = .opening @State private var phase: Phase = .opening
private enum Phase { private enum Phase {
case opening case opening
/// **The walk refused and a person is looking at it** the decision surface, in the loading
/// window's own content area (01-storage-format.md § Malformed input, settled 2026-07-31:
/// "the loading content transforms in place, never a sheet over a spinner").
case deciding(BoardDecisionSurfaceModel)
case open(BoardStore) case open(BoardStore)
/// The load failed; this window is on its way out and must not try again. /// The load failed; this window is on its way out and must not try again.
case failed case failed
@@ -122,6 +151,15 @@ struct BoardWindowHost: View {
// when `phase` becomes `.open` a snap, which is what assigning outside `withAnimation` // when `phase` becomes `.open` a snap, which is what assigning outside `withAnimation`
// means here. // means here.
BoardLoadingView(indicator: loading) BoardLoadingView(indicator: loading)
case let .deciding(model):
// **In place.** The same content area the spinner was in, with no transition of its own:
// 02's snap, read for the surface that arrives instead of a snapshot.
BoardDecisionSurface(
model: model,
onRepairAndOpen: { repairAndOpen(model) },
onRecheck: { recheck(model) },
onCancel: { cancelDecision(model, closingWindow: false) }
)
case .failed: case .failed:
// Nothing to render and nothing worth animating: this window is dismissing itself. // Nothing to render and nothing worth animating: this window is dismissing itself.
Color.clear Color.clear
@@ -246,9 +284,13 @@ struct BoardWindowHost: View {
private func start() async { private func start() async {
guard case .opening = phase else { return } guard case .opening = phase else { return }
// Claimed even on the failure path: an unclaimed stash is a scope nobody balances. // Claimed even on the failure path: an unclaimed stash is a scope nobody balances. The origin
let access = appModel.claimPendingAccess(for: ref) // rides along one claim, one dictionary (`AppModel.claimPendingOpen`).
let url = access?.url ?? ref.url let claimed = appModel.claimPendingOpen(for: ref)
access = claimed.access
origin = claimed.origin
let url = claimed.access?.url ?? ref.url
boardURL = url
// Record before load (settled, 02 § Per-board app state). `displayName` is omitted an // Record before load (settled, 02 § Per-board app state). `displayName` is omitted an
// existing record's cached title survives untouched, and a brand-new one takes the folder // existing record's cached title survives untouched, and a brand-new one takes the folder
@@ -263,9 +305,23 @@ struct BoardWindowHost: View {
configureLoadingWindow(recordID: recordID) configureLoadingWindow(recordID: recordID)
loading.begin() loading.begin()
await attemptOpen(url: url, recordID: recordID, skipping: [])
}
/// **One walk, and what it lands in** the loop Repair and Open and Re-check re-enter.
///
/// It is a method rather than the tail of `start()` because the surface's two buttons "re-run the
/// whole walk" (01-storage-format.md § Malformed input) and must land in exactly the places this
/// lands: a clean walk proceeds into the ordinary open, and a walk that still refuses
/// re-aggregates into the *same* surface. Sharing the body is what makes "never a chained second
/// dialog" structural rather than remembered.
///
/// - Parameter skipping: the surface's consented skips, empty on a first attempt. It reaches the
/// walk *and* the store, which retains it for the session (`BoardStore.skippedPaths`).
private func attemptOpen(url: URL, recordID: UUID, skipping: Set<String>) async {
let store: BoardStore let store: BoardStore
do throws(BoardLoadFailure) { do throws(BoardLoadFailure) {
guard let acquired = try await appModel.storeRegistry.acquireOffMain(url) else { guard let acquired = try await appModel.storeRegistry.acquireOffMain(url, skipping: skipping) else {
// W landed while the walk was running, and the walk has now finished into a result // W landed while the walk was running, and the walk has now finished into a result
// nobody wants (`acquireOffMain`, discard-on-completion). The window is already // nobody wants (`acquireOffMain`, discard-on-completion). The window is already
// closing and the registry kept nothing, so the only thing left to balance is this // closing and the registry kept nothing, so the only thing left to balance is this
@@ -273,27 +329,39 @@ struct BoardWindowHost: View {
// after the load, so a cancelled open never set one the very reason it lives there. // after the load, so a cancelled open never set one the very reason it lives there.
Self.logger.debug("board open cancelled during its walk") Self.logger.debug("board open cancelled during its walk")
loading.end() loading.end()
access?.stop() releaseAccess()
return return
} }
store = acquired store = acquired
} catch { } catch {
Self.logger.error("board failed to open: \(error.description, privacy: .public)") handleWalkFailure(error, url: url, recordID: recordID)
loading.end() return
access?.stop() }
phase = .failed
appModel.recordLaunchFailure(path: ref.path, message: error.description) // **The window retired while this walk was in flight** Cancel (or W) pressed during a
// The record above just changed the registry welcome, about to appear, must not // Re-check, whose walk then landed successfully. The board must not open behind a window that
// render the stale list `AppModel` cached before this open began, or the failure would // has already gone to welcome, and the reference `acquireOffMain` took has to go back or the
// fall through to the unmatched-failures list for want of a row that already exists. // registry would hold a watcher for a board nobody is showing.
appModel.refreshRecents() if case .failed = phase {
openWindow(id: WindowID.welcome) Self.logger.debug("a walk landed after the open was cancelled — releasing it")
dismissWindow(id: WindowID.board, value: ref) appModel.storeRegistry.release(store)
releaseAccess()
return return
} }
loading.end() loading.end()
// **The repair's receipts, into the board's own ledger before the session composes**
// (01: "On Pro boards the repairs drop heal-marked receipts and commit separately as one
// repair commit"). `beginSession` is where Pro's committer is built and started, and the
// committer harvests the ledger it is handed; receipts adopted after that line would be
// receipts the repair commit never sees, and the app's own repair would be authored
// `Lanework External`.
if let repairLedger {
store.echoes.adopt(repairLedger)
self.repairLedger = nil
}
// The load succeeded the frontmatter can be trusted now, so it replaces whatever // The load succeeded the frontmatter can be trusted now, so it replaces whatever
// provisional or stale name the record above was carrying. Through `syncDisplayState`, // provisional or stale name the record above was carrying. Through `syncDisplayState`,
// deliberately not a second `recordOpen`: this is a display-state refresh, not a second // deliberately not a second `recordOpen`: this is a display-state refresh, not a second
@@ -307,15 +375,210 @@ struct BoardWindowHost: View {
) )
appModel.boardRegistry.setOpenNow(id: recordID) appModel.boardRegistry.setOpenNow(id: recordID)
appModel.beginSession(ref: ref, store: store, recordID: recordID, access: access) appModel.beginSession(ref: ref, store: store, recordID: recordID, access: access)
// The session owns the balance from here (`AppModel.beginSession`), so this window must not
// stop it on any later path.
access = nil
phase = .open(store) phase = .open(store)
configureWindow(store: store, recordID: recordID) configureWindow(store: store, recordID: recordID)
postSkipNoticeIfNeeded(store: store, skipping: skipping)
// "Opening a board from welcome closes welcome" (02 § Launch and window lifecycle). Harmless // "Opening a board from welcome closes welcome" (02 § Launch and window lifecycle). Harmless
// when welcome is not open, which is the ordinary case. // when welcome is not open, which is the ordinary case.
dismissWindow(id: WindowID.welcome) dismissWindow(id: WindowID.welcome)
} }
/// **The attendance branch** (01-storage-format.md § Malformed input, settled 2026-07-31): the
/// surface "appears on attended opens only restoration failures keep the retire-to-welcome-row
/// landing".
///
/// Three outcomes, and the third is the new one:
///
/// - **A restored open retires**, exactly as it did before this milestone. Nobody is sitting in
/// front of a launch that reopened four boards, and "launch never chains dialogs".
/// - **An attended open whose failure is environmental retires too** a root that is gone, or a
/// root that is a file. There is nothing on disk to repair, so a surface would offer the user a
/// decision with no choices in it; welcome's row says the same thing in one line, which is
/// where it belonged already. (The carve-out the ruling implies rather than states Redesign
/// Gap 87cd782a.)
/// - **Anything else transforms into the surface**, in place, in this window.
///
/// A surface is *entered* rather than shown: it holds this window's access, its record, and its
/// URL for as long as the user is deciding, which is why none of the three is released here.
private func handleWalkFailure(_ error: BoardLoadFailure, url: URL, recordID: UUID) {
Self.logger.error("board failed to open: \(error.description, privacy: .public)")
// Retired while this walk ran (Cancel during a Re-check): the failure has already been
// recorded and the window is on its way out. A second `retire` would post the row twice.
if case .failed = phase { return }
guard Self.landing(for: error, origin: origin) == .decide else {
loading.end()
retire(message: error.description)
return
}
// Already deciding: this is a Re-check or a Repair and Open landing, and it re-aggregates in
// place the same model, the same window, no second dialog.
if case let .deciding(model) = phase {
model.reaggregate(error)
model.isWorking = false
return
}
loading.end()
phase = .deciding(BoardDecisionSurfaceModel(failure: error, boardRoot: url))
// W now means Cancel (01: the surface's own exit), replacing the loading half's
// cancel-the-walk closure a slot rather than a branch, `configureWindow`'s posture.
windowController.onCloseRequested = {
guard case let .deciding(model) = phase else { return }
cancelDecision(model, closingWindow: true)
}
}
/// Where a refused walk lands.
enum FailureLanding: Equatable {
/// Welcome, on the board's own recents row today's landing, unchanged.
case retire
/// The decision surface, in this window.
case decide
}
/// **The attendance branch as a pure rule** two facts in, one landing out, provable without a
/// window (`BoardDecisionSurfaceTests`).
///
/// It is a static rather than an `if` inside `handleWalkFailure` because it is the ruling's own
/// sentence and the one thing about this milestone that a regression would make silently wrong:
/// a launch that started showing surfaces would chain dialogs across four restored boards, and an
/// attended open that stopped showing one would look exactly like the app before this milestone.
static func landing(for failure: BoardLoadFailure, origin: OpenOrigin) -> FailureLanding {
guard origin == .attended else { return .retire }
return isEnvironmental(failure) ? .retire : .decide
}
/// **Nothing on disk to repair** the environmental carve-out's predicate.
///
/// A single defect, and that defect is a fact about the *root itself* rather than about a file
/// inside it: `BoardLoader` throws these immediately, before any walk, precisely because there is
/// nothing to walk. The single-defect check is stated rather than assumed the loader's
/// environmental throws are single by construction, and a future aggregate carrying one *among*
/// repairable defects should show the surface, because the rest of it is still actionable.
static func isEnvironmental(_ failure: BoardLoadFailure) -> Bool {
guard failure.defects.count == 1 else { return false }
switch failure.primary.reason {
case .unreadableRoot, .notADirectory:
return true
case .boardRootMissingIndex, .unparseableYAML, .missingSchema, .malformedSchema,
.schemaNewerThanApp, .missingOrder, .malformedOrder:
return false
}
}
// MARK: - The surface's three buttons
/// **Repair and Open**: apply every chosen fix in one write bracket, then re-run the whole walk
/// with the skip set (01-storage-format.md § Malformed input).
///
/// The repairs are store-less by necessity and heal-marked by rule (`BoardRepairRun`); the walk
/// that follows is the ordinary one, so a clean result proceeds into the ordinary open and a
/// dirty one re-aggregates here. A repair that failed does not abort anything the batch stops,
/// the failure shows on the surface, and the walk runs anyway, because "a partial repair simply
/// re-aggregates on the next walk".
private func repairAndOpen(_ model: BoardDecisionSurfaceModel) {
guard let url = boardURL, let recordID, !model.isWorking else { return }
model.isWorking = true
model.repairFailure = nil
let outcome = BoardRepairRun.apply(model.plannedRepairs, boardRoot: url)
model.repairFailure = outcome.failure
// Held for the store the walk below may build see `repairLedger`. Merged with anything an
// earlier pass left, so two rounds of repair both reach the commit.
if let existing = repairLedger {
existing.adopt(outcome.ledger)
} else {
repairLedger = outcome.ledger
}
let walk = Task { await attemptOpen(url: url, recordID: recordID, skipping: model.skipSet) }
openWalk.adopt(walk)
}
/// **Re-check**: re-run the whole walk with the current skip set, changing nothing on disk
/// (01: "a disk changed underneath re-aggregates into the *same* surface with the fresh defect
/// list a clean walk proceeds to the first snapshot").
private func recheck(_ model: BoardDecisionSurfaceModel) {
guard let url = boardURL, let recordID, !model.isWorking else { return }
model.isWorking = true
let walk = Task { await attemptOpen(url: url, recordID: recordID, skipping: model.skipSet) }
openWalk.adopt(walk)
}
/// **Cancel** and W, which means exactly this while the surface is up (01: "**Cancel** aborts
/// the open: the window retires and the board lands row-level on welcome, record-before-load
/// unchanged").
///
/// It is the failure path's own sequence, run against the aggregate the surface was showing: the
/// record was minted before the walk, so the row is already waiting for this message.
///
/// - Parameter closingWindow: true when AppKit asked (W), where the interception has to be
/// released for the close to complete. The button's own press dismisses through SwiftUI.
private func cancelDecision(_ model: BoardDecisionSurfaceModel, closingWindow: Bool) {
// A walk may still be in flight behind the surface (Cancel during a Re-check). Its landing is
// guarded by `phase`, which this sets to `.failed`; cancelling as well means the open is not
// merely ignored but abandoned.
openWalk.cancel()
retire(message: model.failure.description)
if closingWindow {
windowController.closeAfterFlush()
}
}
/// The retire-to-welcome landing, in one place: the sequence a failed restore has always had, and
/// the sequence Cancel now shares with it.
private func retire(message: String) {
releaseAccess()
phase = .failed
// **The interception has to go with the phase.** `windowShouldClose` returns `false` whenever
// a closure is installed that is how the close flush gets its turn so a retired window
// whose closure had nothing left to do would *refuse every close request for the rest of the
// app's life*, quit included. There is nothing to intercept once this window is on its way to
// welcome: no store, no session, no decision.
windowController.onCloseRequested = nil
appModel.recordLaunchFailure(path: ref.path, message: message)
// The record minted before the walk just changed the registry welcome, about to appear,
// must not render the stale list `AppModel` cached before this open began, or the failure
// would fall through to the unmatched-failures list for want of a row that already exists.
appModel.refreshRecents()
openWindow(id: WindowID.welcome)
dismissWindow(id: WindowID.board, value: ref)
}
private func releaseAccess() {
access?.stop()
access = nil
}
/// **The skip notice** (01: "the opened board carries a warning-tone notice naming the skipped
/// items, each with Reveal in Finder").
///
/// Written from the walk's own `LoadWarning.userSkipped` entries rather than from the surface's
/// skip set, deliberately: what the notice owes the user is what actually left the board, and a
/// skip for a defect that repaired itself between the decision and the walk names nothing.
///
/// **Only the open that carried the skips posts it.** A second window onto the same board
/// acquires the store that already exists, whose warnings still describe the first open and a
/// notice repeated per window would be the app reporting one decision twice. `skipping` is
/// non-empty for exactly the open that made the decision.
private func postSkipNoticeIfNeeded(store: BoardStore, skipping: Set<String>) {
guard !skipping.isEmpty else { return }
let root = store.rootURL
let items = store.loadWarnings.compactMap { warning -> RevealTarget? in
guard case let .userSkipped(path) = warning else { return nil }
return RevealTarget(path: path, url: root.appendingPathComponent(path))
}
store.banners.postSkippedOnOpen(items)
}
/// **The half of the wiring a window needs before it has a board** everything here is about /// **The half of the wiring a window needs before it has a board** everything here is about
/// the *window*, and nothing here mentions the store, which is exactly the split /// the *window*, and nothing here mentions the store, which is exactly the split
/// 02-architecture.md's loading state forces: this runs before the walk, and /// 02-architecture.md's loading state forces: this runs before the walk, and
+11 -4
View File
@@ -88,7 +88,12 @@ struct RestoreBootstrapView: View {
for board in appModel.boardRegistry.restorables() { for board in appModel.boardRegistry.restorables() {
switch board { switch board {
case let .available(_, url): case let .available(_, url):
appModel.openBoard(at: url) // **The one restored open in the app** (01-storage-format.md § Malformed input, the
// decision surface): a board that fails here keeps today's retire-to-welcome-row
// landing "launch never chains dialogs", and nobody is sitting in front of a
// restoration waiting to repair four boards at once. The row's retry click is the
// attended open that then shows the surface.
appModel.openBoard(at: url, origin: .restored)
attempted += 1 attempted += 1
case let .unavailable(record): case let .unavailable(record):
Self.logger.error("a flagged board could not be restored — its bookmark no longer resolves") Self.logger.error("a flagged board could not be restored — its bookmark no longer resolves")
@@ -109,9 +114,11 @@ struct RestoreBootstrapView: View {
/// ///
/// **Which board is the launch arguments' to say** (`UITestLaunch.variant`), and this method does /// **Which board is the launch arguments' to say** (`UITestLaunch.variant`), and this method does
/// not care: the malformed variant is built and opened exactly like the other two, and its /// not care: the malformed variant is built and opened exactly like the other two, and its
/// failure arrives one layer down as the *loader's* a board window that records fail-fast's own /// failure arrives one layer down as the *loader's*. It opens **attended**, like every other
/// sentence and dismisses itself (`BoardWindowHost.start`). Special-casing it here would replace /// board a person asks for, so its refusal transforms the loading window into the decision surface
/// the sentence under test with a sentence about the fixture. /// (`BoardWindowHost.handleWalkFailure`) rather than retiring which is precisely what the
/// fail-fast UI pass is there to see. Special-casing it here would replace the behaviour under
/// test with a behaviour about the fixture.
/// ///
/// **A failure to *build* lands on welcome as an ordinary launch failure**, with the fixture's own /// **A failure to *build* lands on welcome as an ordinary launch failure**, with the fixture's own
/// path on it. That is deliberate: a suite whose fixture failed to build would otherwise audit an /// path on it. That is deliberate: a suite whose fixture failed to build would otherwise audit an
+12
View File
@@ -300,6 +300,18 @@ public final class GitAutoCommitter {
/// which is not a shortcut but the doctrine: the ledger is empty because the app was not running, /// which is not a shortcut but the doctrine: the ledger is empty because the app was not running,
/// and "the app never vouches for changes it didn't witness". /// and "the app never vouches for changes it didn't witness".
public func start() { public func start() {
// **The ledger may already hold something, and exactly once it does.** An ordinary board's
// ledger is empty here the app has not written to it, which is the whole of the
// launch-catch-up doctrine so this harvest costs a dictionary copy of nothing.
//
// The exception is a board the **decision surface repaired** (01-storage-format.md
// § Malformed input): those writes happened before this board had a store at all, and their
// heal-marked receipts were adopted into the store's ledger a moment ago
// (`EchoLedger.adopt`, `BoardWindowHost`). Without this line the only harvest is at a write
// bracket's close, and no bracket has closed so the debounce this arms would find the
// repaired files unvouched-for and author the app's own repair `Lanework External`, which is
// the one misattribution the mechanism exists to prevent.
harvest()
arm() arm()
} }
+123 -7
View File
@@ -130,10 +130,45 @@ public struct LossBanner: Identifiable, Sendable, Equatable {
/// When the loss happened the sort key for "newest first within a class". /// When the loss happened the sort key for "newest first within a class".
public let occurredAt: Date public let occurredAt: Date
public init(id: UUID = UUID(), message: String, occurredAt: Date = Date()) { /// **What this row can show the user in Finder**, empty for every loss row that has nothing to
/// point at which is all of them but one.
///
/// It exists for the skip notice (01-storage-format.md § Malformed input, ruled 2026-07-31: "the
/// opened board carries a warning-tone notice naming the skipped items, **each with Reveal in
/// Finder**"). The affordance is per *item* while the row is one line, so the targets ride the
/// row's data and the strip renders one control over them (`BannerRowControl.reveal`) a button
/// for a sole item, a menu for several. Carrying them here rather than in a row case of their own
/// keeps the skip notice in the loss class the ruling puts it in.
public let reveals: [RevealTarget]
public init(
id: UUID = UUID(),
message: String,
occurredAt: Date = Date(),
reveals: [RevealTarget] = []
) {
self.id = id self.id = id
self.message = message self.message = message
self.occurredAt = occurredAt self.occurredAt = occurredAt
self.reveals = reveals
}
}
/// One file a banner row can reveal in Finder.
///
/// `path` is what the user reads the board-root-relative spelling `BoardLoadError.path` carries and
/// the decision surface's row already showed them and `url` is what Finder selects. The two are
/// carried together rather than derived from each other because only the producer holds the board
/// root, and a row that rebuilt a URL from a string would be a second answer to where the board is.
public struct RevealTarget: Identifiable, Sendable, Equatable {
public let path: String
public let url: URL
public var id: String { path }
public init(path: String, url: URL) {
self.path = path
self.url = url
} }
} }
@@ -327,7 +362,7 @@ public enum BannerRow: Identifiable, Sendable {
} }
} }
/// **This row's buttons, in the order Tab visits them** Cancel, then Dismiss. /// **This row's buttons, in the order Tab visits them** Cancel, then Reveal, then Dismiss.
/// ///
/// It exists because 10-accessibility.md Full Keyboard Access rules the banner's buttons in by /// It exists because 10-accessibility.md Full Keyboard Access rules the banner's buttons in by
/// name (2026-07-29): "'Every control' is literal and includes banner-row buttons a Dismiss or /// name (2026-07-29): "'Every control' is literal and includes banner-row buttons a Dismiss or
@@ -338,14 +373,18 @@ public enum BannerRow: Identifiable, Sendable {
/// posture the rest of this type already takes ("the per-kind affordances hang off the row's /// posture the rest of this type already takes ("the per-kind affordances hang off the row's
/// data, not off separate views"). /// data, not off separate views").
/// ///
/// No row has both today: the two conditions are disjoint by construction (only an in-progress /// Cancel and Dismiss are disjoint by construction (only an in-progress row cancels, and an
/// row cancels, and an in-progress row is never dismissable). The order is stated anyway, since /// in-progress row is never dismissable), so the pair that actually co-occurs is **Reveal then
/// it is the Tab order the moment one does. /// Dismiss** the skip notice's shape. Reveal comes first because it is the row's *content*
/// affordance and Dismiss is its lifecycle one: the same reason Cancel precedes Dismiss.
public var controls: [BannerRowControl] { public var controls: [BannerRowControl] {
var controls: [BannerRowControl] = [] var controls: [BannerRowControl] = []
if case let .inProgress(operation) = self, let cancel = operation.cancel { if case let .inProgress(operation) = self, let cancel = operation.cancel {
controls.append(.cancel(cancel)) controls.append(.cancel(cancel))
} }
if case let .loss(loss) = self, !loss.reveals.isEmpty {
controls.append(.reveal(loss.reveals))
}
if let dismissID { if let dismissID {
controls.append(.dismiss(dismissID)) controls.append(.dismiss(dismissID))
} }
@@ -370,10 +409,26 @@ public enum BannerRowControl: Identifiable, Sendable {
/// Clear this row, by the id `BannerCenter.dismiss(_:)` takes. /// Clear this row, by the id `BannerCenter.dismiss(_:)` takes.
case dismiss(UUID) case dismiss(UUID)
/// **Show the files this row is about in Finder** the skip notice's per-item affordance
/// (01-storage-format.md § Malformed input: "each with Reveal in Finder").
///
/// **One control over N targets, not N controls**, and the reason is the strip's own shape: a
/// banner row is one line, three collapsible rows are all the strip shows, and a notice that grew
/// a button per skipped item would push the rows below it behind "+N more" on the very board that
/// just told the user something went wrong. So the row stays one row, the control stays one Tab
/// stop (10-accessibility.md Full Keyboard Access), and the plurality lives *inside* it the
/// strip renders a plain button for a sole target and a menu naming each path for two or more.
///
/// Never empty: `BannerRow.controls` only produces it where there is something to reveal.
case reveal([RevealTarget])
public var label: String { public var label: String {
switch self { switch self {
case .cancel: "Cancel" case .cancel: "Cancel"
case .dismiss: "Dismiss" case .dismiss: "Dismiss"
// One label for both renderings it is the button's title *and* the menu's, and it is what
// 01 calls the affordance by name.
case .reveal: "Reveal in Finder"
} }
} }
@@ -478,8 +533,11 @@ public final class BannerCenter {
/// Posts a loss row content that didn't arrive though nothing failed (settled 2026-07-28, see /// Posts a loss row content that didn't arrive though nothing failed (settled 2026-07-28, see
/// `LossBanner`). Newest first, like the one-shots it shares a lifecycle with. /// `LossBanner`). Newest first, like the one-shots it shares a lifecycle with.
public func postLoss(_ message: String) { ///
losses.insert(LossBanner(message: message), at: 0) /// - Parameter reveals: the files this row can show in Finder, empty for every producer but the
/// skip notice (`LossBanner.reveals`).
public func postLoss(_ message: String, reveals: [RevealTarget] = []) {
losses.insert(LossBanner(message: message, reveals: reveals), at: 0)
} }
/// Posts a passive notice m6's remote-change signpost and whatever joins it. Newest first, /// Posts a passive notice m6's remote-change signpost and whatever joins it. Newest first,
@@ -655,6 +713,28 @@ public final class BannerCenter {
postLoss(message) postLoss(message)
} }
/// **The skip notice** (01-storage-format.md § Malformed input, ruled 2026-07-31): the decision
/// surface offered Skip on a defect the app has no honest repair for, the user consented, the
/// board opened without that item "the file stays on disk untouched, tolerated-invisible like
/// strays" and this is the row that says so.
///
/// > a skipped item loads the board without it and the opened board carries a warning-tone
/// > notice naming the skipped items, each with Reveal in Finder. Skips are per-open decisions,
/// > never persisted: the next open of a still-broken board presents the surface again the
/// > notice is the honest residue of this open, not a stored preference.
///
/// **A loss row, and the ruling names the tone**: the board on screen is not the whole board, and
/// that is exactly "content that didn't arrive though nothing failed". It must not evaporate
/// unread (the class's untimed lifecycle) and it must not rank as an error, because nothing
/// failed the user chose this.
///
/// An open that skipped nothing posts nothing: a notice about no skips is not news, and it is
/// what every ordinary open passes here.
public func postSkippedOnOpen(_ items: [RevealTarget]) {
guard let message = Self.skippedOnOpenMessage(for: items) else { return }
postLoss(message, reveals: items)
}
/// Posts the skipped-folders loss row for a Finder drop that imported its files but refused its /// Posts the skipped-folders loss row for a Finder drop that imported its files but refused its
/// folders (04-interactions.md Selection, drag & drop, "Folders are refused at hover"): "a /// folders (04-interactions.md Selection, drag & drop, "Folders are refused at hover"): "a
/// mixed drag proposes for its files only, and the drop imports the files while a one-shot /// mixed drag proposes for its files only, and the drop imports the files while a one-shot
@@ -1039,6 +1119,18 @@ public final class BannerCenter {
// this one *does* have a name users know from git, and "the ignore list" would be the // this one *does* have a name users know from git, and "the ignore list" would be the
// app inventing a word for something already called something. // app inventing a word for something already called something.
"Couldn't write this board's .gitignore" "Couldn't write this board's .gitignore"
case .mintBoardIndex:
// **Not "couldn't create the board"** the board is on screen behind the surface, with
// its lanes and its cards; what could not be written is the one file that says the
// folder is a board. It names `index.md` rather than a role because the decision surface
// the user is looking at has just named that file itself, twice: in the class's own
// sentence and on the row's Reveal.
"Couldn't create this board's index.md"
case .stampSchema:
// The repair in the user's own words the surface's choice reads "Stamp schema: 1", so
// its failure says the same thing negated. It names no file for `.mintBoardIndex`'s
// reason inverted: the file is right there and the surface just showed its path.
"Couldn't stamp this board's schema"
case let .displaceClaimedName(name): case let .displaceClaimedName(name):
// **The name, quoted, and what the app wanted with it** the failure's mirror of the // **The name, quoted, and what the app wanted with it** the failure's mirror of the
// success row ("Renamed '.trash' to '.trash 2' Lanework needs that name"). It names // success row ("Renamed '.trash' to '.trash 2' Lanework needs that name"). It names
@@ -1195,6 +1287,30 @@ public final class BannerCenter {
"Folders can't be attached — \(count) skipped" "Folders can't be attached — \(count) skipped"
} }
/// The skip notice's line, in the relocation family's voice the act first, the subject after an
/// em dash, plurals folded, a sole item named.
///
/// - **One**: "Opened without 'todo/index.md' you chose to skip it".
/// - **Several**: "Opened without 3 items you chose to skip them".
///
/// **The plural fold is safe here in a way it is not elsewhere**, and that is the whole reason
/// the count is allowed to stand in for the names: the row carries a Reveal target per item
/// (`LossBanner.reveals`), so "which ones" is one click away rather than lost which is what
/// 01's "each with Reveal in Finder" buys. The sole case still names its path, because it fits
/// and because a one-item row that said "1 item" would be the app declining to say what it knows.
///
/// **The tail names the cause**, the migration notice's rule: without it the sentence would read
/// as something that happened *to* the user, when it is the choice they just made on the surface.
///
/// `nil` when nothing was skipped the ordinary open, and not news.
public nonisolated static func skippedOnOpenMessage(for items: [RevealTarget]) -> String? {
guard let only = items.first else { return nil }
guard items.count == 1 else {
return "Opened without \(items.count) items — you chose to skip them"
}
return "Opened without '\(only.path)' — you chose to skip it"
}
/// The loose-file relocation's line 01-storage-format.md's own example sentence, "Moved /// The loose-file relocation's line 01-storage-format.md's own example sentence, "Moved
/// 'notes.txt' into attachments 'Fix login'", generalized over the two axes it varies on. /// 'notes.txt' into attachments 'Fix login'", generalized over the two axes it varies on.
/// ///
+33 -5
View File
@@ -244,6 +244,25 @@ public final class BoardStore: HealHost {
/// describe the tree currently on screen. /// describe the tree currently on screen.
public private(set) var loadWarnings: [LoadWarning] public private(set) var loadWarnings: [LoadWarning]
/// **The skips this open consented to** the decision surface's Skip set, riding the *session*
/// (01-storage-format.md § Malformed input, ruled 2026-07-31; the posture settled here rather
/// than left to each reload).
///
/// The ruling makes skips **per-open decisions, never persisted** "the next open of a
/// still-broken board presents the surface again" and this is what "per open" means once the
/// board is on screen: every reload of this session passes the same set, so the consent the user
/// gave when the window opened holds for as long as that window does. The alternative a reload
/// that dropped the set would blank the board on the first foreign filesystem event, because
/// the defect the user tolerated is still on disk and would fail the walk again.
///
/// Nothing writes it and nothing persists it: it arrives at `init` from the open that composed
/// it, and dies with the store, which is the ruling's own "not a stored preference".
///
/// Empty on every ordinary board no surface, no skips which is what makes the reload path
/// below byte-identical to what it was for every board that opens cleanly.
@ObservationIgnored
public let skippedPaths: Set<String>
/// **The pending work the load that produced `snapshot` found** the typed defect stream /// **The pending work the load that produced `snapshot` found** the typed defect stream
/// (`IntegrityRules.Defect`, settled 2026-07-29). Replaced with the snapshot, like /// (`IntegrityRules.Defect`, settled 2026-07-29). Replaced with the snapshot, like
/// `loadWarnings`, so it always describes the tree currently on screen. /// `loadWarnings`, so it always describes the tree currently on screen.
@@ -602,9 +621,9 @@ public final class BoardStore: HealHost {
/// with a reload behind it rather than a write into a board nothing is watching yet. A store /// with a reload behind it rather than a write into a board nothing is watching yet. A store
/// built directly (a test, a storeless consumer) heals when it is asked to, and on every reload /// built directly (a test, a storeless consumer) heals when it is asked to, and on every reload
/// thereafter. /// thereafter.
public convenience init(rootURL: URL) throws(BoardLoadFailure) { public convenience init(rootURL: URL, skipping: Set<String> = []) throws(BoardLoadFailure) {
let result = try BoardLoader.load(boardRoot: rootURL) let result = try BoardLoader.load(boardRoot: rootURL, skipping: skipping)
self.init(rootURL: rootURL, loaded: result) self.init(rootURL: rootURL, loaded: result, skipping: skipping)
} }
/// The same board, from a walk that already happened somewhere else. /// The same board, from a walk that already happened somewhere else.
@@ -618,11 +637,16 @@ public final class BoardStore: HealHost {
/// `rootURL` is passed rather than read off `result.model` for the reason the property's own doc /// `rootURL` is passed rather than read off `result.model` for the reason the property's own doc
/// comment gives the store's root follows an absorbed rename ahead of the snapshot that will /// comment gives the store's root follows an absorbed rename ahead of the snapshot that will
/// carry it. /// carry it.
public init(rootURL: URL, loaded result: LoadResult) { ///
/// - Parameter skipping: the skip set the walk was run with, retained for this session's reloads
/// (`skippedPaths`). Defaulted to none, which is every board that opened without a decision
/// surface.
public init(rootURL: URL, loaded result: LoadResult, skipping: Set<String> = []) {
self.rootURL = rootURL self.rootURL = rootURL
self.snapshot = result.model self.snapshot = result.model
self.loadWarnings = result.warnings self.loadWarnings = result.warnings
self.defects = result.defects self.defects = result.defects
self.skippedPaths = skipping
self.reloadFailure = nil self.reloadFailure = nil
self.readOnlyLock = nil self.readOnlyLock = nil
self.transient = TransientBoardState() self.transient = TransientBoardState()
@@ -693,6 +717,9 @@ public final class BoardStore: HealHost {
// `Sendable` value that touches libgit2 only if this walk finds a duplicate identity to // `Sendable` value that touches libgit2 only if this walk finds a duplicate identity to
// break a tie for. `nil` everywhere the app manages no git. // break a tie for. `nil` everywhere the app manages no git.
let historyRanker = makeIdentityHistoryRanker?() let historyRanker = makeIdentityHistoryRanker?()
// **This session's consented skips, on every walk it runs** (`skippedPaths`): the open's
// decision stands for the session, so a reload sees the board the user chose to open.
let skipping = skippedPaths
reloadInFlight = true reloadInFlight = true
Self.logger.debug("reload \(generation, privacy: .public) started (\(origin.rawValue, privacy: .public))") Self.logger.debug("reload \(generation, privacy: .public) started (\(origin.rawValue, privacy: .public))")
@@ -701,7 +728,8 @@ public final class BoardStore: HealHost {
// and the loader's typed failure is lost on the way into `Result`. // and the loader's typed failure is lost on the way into `Result`.
let outcome: Result<LoadResult, BoardLoadFailure> let outcome: Result<LoadResult, BoardLoadFailure>
do throws(BoardLoadFailure) { do throws(BoardLoadFailure) {
outcome = .success(try BoardLoader.load(boardRoot: root, historyRanker: historyRanker)) outcome = .success(try BoardLoader.load(
boardRoot: root, skipping: skipping, historyRanker: historyRanker))
} catch { } catch {
outcome = .failure(error) outcome = .failure(error)
} }
+26 -6
View File
@@ -197,21 +197,37 @@ public final class BoardStoreRegistry {
/// returns `nil` before constructing anything, so there is no store, no watcher, no entry and no /// returns `nil` before constructing anything, so there is no store, no watcher, no entry and no
/// reference and the caller's security-scoped access is its own to release. /// reference and the caller's security-scoped access is its own to release.
/// ///
/// - Parameter skipping: **the decision surface's consented skips** (01-storage-format.md
/// § Malformed input, ruled 2026-07-31), passed to the walk and then *retained by the store*
/// so every reload of the resulting session runs with the same set (`BoardStore.skippedPaths`).
/// Empty the default is every board that opens without a surface.
///
/// - Returns: the board's store, or `nil` if this acquire was cancelled before its walk landed. /// - Returns: the board's store, or `nil` if this acquire was cancelled before its walk landed.
/// `nil` is not a failure: nothing went wrong and nothing was opened. /// `nil` is not a failure: nothing went wrong and nothing was opened.
public func acquireOffMain(_ rootURL: URL) async throws(BoardLoadFailure) -> BoardStore? { public func acquireOffMain(
_ rootURL: URL,
skipping: Set<String> = []
) async throws(BoardLoadFailure) -> BoardStore? {
if let store = referenceExistingBoard(at: rootURL) { return store } if let store = referenceExistingBoard(at: rootURL) { return store }
// `nil` for a root that does not exist or whose volume will not answer there is nothing to // `nil` for a root that does not exist or whose volume will not answer there is nothing to
// coalesce on, so such an open walks alone and the loader produces the honest error for it. // coalesce on, so such an open walks alone and the loader produces the honest error for it.
let identity = FileIdentity(of: rootURL) //
// **A skip-carrying acquire also walks alone**, deliberately: the single flight exists so
// concurrent opens of one board share a walk, and two walks are only shareable when they
// would produce the same answer. A skip set changes what the walk *finds*, so joining one
// would hand a window a board someone else's consent composed and a Repair-and-Open's
// re-walk could be answered by the very walk that failed. The condition is exactly "an
// ordinary open", which is every open the coalescing was built for (restoration, a Finder
// open racing it, a card window arriving first) and none of the surface's.
let identity = skipping.isEmpty ? FileIdentity(of: rootURL) : nil
let walk: Task<Result<LoadResult, BoardLoadFailure>, Never> let walk: Task<Result<LoadResult, BoardLoadFailure>, Never>
if let identity, let joined = walksInFlight[identity] { if let identity, let joined = walksInFlight[identity] {
Self.logger.debug("acquire: joining the walk already running for this board") Self.logger.debug("acquire: joining the walk already running for this board")
walk = joined walk = joined
} else { } else {
walk = Self.walk(rootURL) walk = Self.walk(rootURL, skipping: skipping)
if let identity { walksInFlight[identity] = walk } if let identity { walksInFlight[identity] = walk }
} }
@@ -238,7 +254,8 @@ public final class BoardStoreRegistry {
case let .failure(failure): case let .failure(failure):
throw failure throw failure
case let .success(result): case let .success(result):
return try adopt(BoardStore(rootURL: rootURL, loaded: result), rootURL: rootURL) return try adopt(
BoardStore(rootURL: rootURL, loaded: result, skipping: skipping), rootURL: rootURL)
} }
} }
@@ -246,10 +263,13 @@ public final class BoardStoreRegistry {
/// `BoardStore.startReload`'s reason exactly: a task created inside a `@MainActor` method /// `BoardStore.startReload`'s reason exactly: a task created inside a `@MainActor` method
/// inherits that isolation and would run the walk on the main actor, which is the whole thing /// inherits that isolation and would run the walk on the main actor, which is the whole thing
/// this is avoiding. /// this is avoiding.
private static func walk(_ rootURL: URL) -> Task<Result<LoadResult, BoardLoadFailure>, Never> { private static func walk(
_ rootURL: URL,
skipping: Set<String>
) -> Task<Result<LoadResult, BoardLoadFailure>, Never> {
Task.detached(priority: .userInitiated) { Task.detached(priority: .userInitiated) {
do throws(BoardLoadFailure) { do throws(BoardLoadFailure) {
return .success(try BoardLoader.load(boardRoot: rootURL)) return .success(try BoardLoader.load(boardRoot: rootURL, skipping: skipping))
} catch { } catch {
return .failure(error) return .failure(error)
} }
+48
View File
@@ -246,6 +246,54 @@ public final class EchoLedger: Sendable {
} }
} }
/// **Marks everything this ledger holds as a heal** the whole-ledger form of `markHeal(at:)`,
/// for a ledger whose *every* receipt is a heal by construction.
///
/// Its one caller is the decision surface's repair bracket (01-storage-format.md § Malformed
/// input: "On Pro boards the repairs drop heal-marked receipts and commit separately as one
/// repair commit"). Repairs run **before** the board has a store there is no `BoardStore` yet,
/// so no `performWrite` to bind so the repair binds a ledger of its own for the duration of the
/// bracket. Everything that lands in it is a repair, which is exactly the condition that makes a
/// blanket mark honest here and would make it a lie on a session ledger.
///
/// Path-by-path marking would need the repair runner to enumerate the files each `BoardWriter`
/// call happened to touch `createBoard` writes `index.md` *and* seeds `.gitignore` which is
/// bookkeeping the Writer exists to keep call sites out of.
public func markAllAsHeal() {
receipts.withLock { store in
for path in store.keys {
store[path]?.isHeal = true
}
}
}
/// **Takes over another ledger's receipts, attributes and all** the repair bracket's ledger
/// handed to the board's own once the board finally has one.
///
/// The decision surface repairs a board that has no store, then re-walks it; the walk succeeds,
/// the store is built, and only *then* does a session ledger exist. Without this the repair's
/// receipts would die with the temporary ledger and Pro's committer would author the app's own
/// repair as `Lanework External` the one misattribution the whole mechanism exists to prevent.
///
/// **Safe because a receipt vouches against disk, not against a clock** (`Receipt.isSatisfied`):
/// the repaired files are on disk exactly as the repair left them, and the re-walk that just
/// succeeded read those very bytes. An adopted receipt is therefore satisfiable the moment it
/// arrives, which is the same standing a receipt dropped inside a write bracket has.
///
/// Plain overwrite, the supersession rule: the adopting ledger is brand new in the only case that
/// calls this, and a receipt it already held for the same path would be the newer of the two
/// which is the one case the ledger's own `recordWrite` also resolves by keeping what it has been
/// told last. Nothing is removed from `other`; it is discarded whole by its caller.
public func adopt(_ other: EchoLedger) {
let entries = other.receipts.withLock { $0 }
guard !entries.isEmpty else { return }
receipts.withLock { store in
for (path, entry) in entries {
store[path] = entry
}
}
}
private static func forget(_ store: inout [String: Entry], under path: String) { private static func forget(_ store: inout [String: Entry], under path: String) {
let prefix = path + "/" let prefix = path + "/"
for key in Array(store.keys) where key.hasPrefix(prefix) { for key in Array(store.keys) where key.hasPrefix(prefix) {
+47 -2
View File
@@ -314,8 +314,17 @@ public enum BoardWriter: Sendable {
/// `index.md`, after it, so the file that makes a folder a board is written first and a failure /// `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 /// 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. /// creation into a folder that somehow already carries one leaves it alone.
public static func createBoard(at rootURL: URL, title: String?) throws(BoardWriteError) { /// - Parameter operation: what the *caller* was doing, for the banner's sake `.createBoard` for
let operation = WriteOperation.createBoard /// 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) let indexURL = rootURL.appendingPathComponent(BoardLoader.indexFileName)
guard !FileManager.default.fileExists(atPath: indexURL.path) else { guard !FileManager.default.fileExists(atPath: indexURL.path) else {
@@ -2952,6 +2961,34 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
/// It never describes an *edit*: the app writes this file only when nothing holds the name. /// It never describes an *edit*: the app writes this file only when nothing holds the name.
case seedGitignore 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 /// 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 /// 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"). /// aside by a scheduled heal via the Finder-style rename ladder").
@@ -3074,8 +3111,13 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
// The comment family is identity for `.repairDuplicateID`'s reason, doubled: a comment's // 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*, // `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. // 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.
case .createBoard, .createLane, .createCard, .importAttachment, .listAttachments, case .createBoard, .createLane, .createCard, .importAttachment, .listAttachments,
.removeAttachment, .renumberChildren, .relocateLooseFile, .agentGuide, .seedGitignore, .removeAttachment, .renumberChildren, .relocateLooseFile, .agentGuide, .seedGitignore,
.mintBoardIndex, .stampSchema,
.displaceClaimedName, .repairDuplicateID, .saveCommentDraft, .postComment, .displaceClaimedName, .repairDuplicateID, .saveCommentDraft, .postComment,
.editComment, .deleteComment, .purgeCommentTrash: .editComment, .deleteComment, .purgeCommentTrash:
self self
@@ -3139,6 +3181,7 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
case .createBoard, .createLane, .createCard, .move, .copy, .delete, .purge, .migrateTombstone, case .createBoard, .createLane, .createCard, .move, .copy, .delete, .purge, .migrateTombstone,
.style, .resize, .rename, .duplicateBoard, .saveAsTemplate, .paste, .importAttachment, .style, .resize, .rename, .duplicateBoard, .saveAsTemplate, .paste, .importAttachment,
.listAttachments, .removeAttachment, .relocateLooseFile, .agentGuide, .seedGitignore, .listAttachments, .removeAttachment, .relocateLooseFile, .agentGuide, .seedGitignore,
.mintBoardIndex, .stampSchema,
.displaceClaimedName, .displaceClaimedName,
.repairDuplicateID, .toggleTask, .editBody, .rawSource, .saveCommentDraft, .postComment, .repairDuplicateID, .toggleTask, .editBody, .rawSource, .saveCommentDraft, .postComment,
.editComment, .deleteComment, .purgeCommentTrash: .editComment, .deleteComment, .purgeCommentTrash:
@@ -3176,6 +3219,8 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
case let .relocateLooseFile(filename): "relocate loose file '\(filename)'" case let .relocateLooseFile(filename): "relocate loose file '\(filename)'"
case .agentGuide: "update the agent guide" case .agentGuide: "update the agent guide"
case .seedGitignore: "seed the board's .gitignore" 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 .displaceClaimedName(name): "move a stray '\(name)' aside"
case let .repairDuplicateID(title): Self.phrase("repair the duplicate id of", title) 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 .toggleTask(title): Self.phrase("toggle a checkbox in", title)
+28
View File
@@ -43,6 +43,34 @@ enum AccessibilityPhrases {
/// would leave that window silent. /// would leave that window silent.
static let boardLoading = "Loading board" static let boardLoading = "Loading board"
/// **What the decision surface is called** (01-storage-format.md § Malformed input, settled
/// 2026-07-31; `BoardDecisionSurface`) its heading *and* the group label a VoiceOver user hears
/// on entering it, which are one string here for the banner row's reason exactly: the sentence
/// heard and the sentence read are one, or they are two descriptions of one thing waiting to
/// disagree.
///
/// It says the board did not open and stops there. What is wrong is the sections' to say the
/// surface exists precisely because there is usually more than one answer to that.
static let decisionSurfaceLabel = "Lanework couldn't open this board"
/// The surface's own subtitle: how much is wrong, and what to do about it. The count is the
/// number of affected *files*, which is what the rows below it are.
static func decisionSurfaceSummary(defects count: Int) -> String {
let subject = count == 1 ? "One file needs" : "\(count) files need"
return "\(subject) a decision before this board can open."
}
/// One affected file, as one utterance: **path then reason**, in that order, because the path is
/// what identifies the row and the reason is what the user is deciding about
/// (01: "lists the affected files (path + specifics )").
///
/// The reason is the loader's own sentence, unrewritten the same specifics the row shows for
/// `BannerCenter.causePhrase`'s reason: fail-fast's diagnostics are specific in a way a
/// re-phrasing would not be, and the alternative to showing one is a shrug.
static func decisionRowLabel(path: String, reason: String) -> String {
"\(path): \(reason)"
}
/// "3 cards", "1 card" the app's **one** plural folding for a card count, borrowed from /// "3 cards", "1 card" the app's **one** plural folding for a card count, borrowed from
/// `TrashModel.phrase` rather than restated so a lane's spoken count and the trash column's /// `TrashModel.phrase` rather than restated so a lane's spoken count and the trash column's
/// cannot drift apart. /// cannot drift apart.
+29
View File
@@ -1,3 +1,4 @@
import AppKit
import SwiftUI import SwiftUI
/// The banner strip: one window's standing conditions, unread failures, and work in flight, as a /// The banner strip: one window's standing conditions, unread failures, and work in flight, as a
@@ -218,6 +219,26 @@ private struct BannerRowView: View {
Button(control.label, action: cancel) Button(control.label, action: cancel)
.buttonStyle(.link) .buttonStyle(.link)
.font(.callout) .font(.callout)
case let .reveal(targets):
// **One control, two renderings** (`BannerRowControl.reveal`): a sole skipped item gets a
// plain button, several get a menu naming each path. The row stays one line and one Tab
// stop either way, which is what keeps the strip's three-row budget intact on the very
// board that just reported something wrong.
if targets.count == 1, let only = targets.first {
Button(control.label) { Self.reveal(only) }
.buttonStyle(.link)
.font(.callout)
} else {
Menu(control.label) {
ForEach(targets) { target in
Button(target.path) { Self.reveal(target) }
}
}
.menuStyle(.borderlessButton)
.fixedSize()
.font(.callout)
.accessibilityLabel(control.label)
}
case let .dismiss(id): case let .dismiss(id):
Button { Button {
onDismiss(id) onDismiss(id)
@@ -230,6 +251,14 @@ private struct BannerRowView: View {
.help(control.label) .help(control.label)
} }
} }
/// Finder, with the file selected `activateFileViewerSelecting` rather than `open`, so a
/// skipped `index.md` is shown *in its folder* rather than handed to whatever app claims `.md`.
/// The user's next act is looking at where the file sits; opening it is the surface's other
/// affordance, not this one.
private static func reveal(_ target: RevealTarget) {
NSWorkspace.shared.activateFileViewerSelecting([target.url])
}
} }
// MARK: - Tone rendering // MARK: - Tone rendering
+831
View File
@@ -0,0 +1,831 @@
import AppKit
import SwiftUI
// MARK: - The classes
/// **What kind of defect a row is** the grouping the decision surface is built on
/// (01-storage-format.md § Malformed input, settled 2026-07-31: "The surface groups defects **by
/// class**: each class section states the defect once, lists the affected files and carries one
/// class-level choice preselected to its default").
///
/// The class is what decides everything a section says and offers: its sentence, its choices, and its
/// default. A `BoardLoadError.Reason` is the *walk's* vocabulary one case per thing that can be
/// wrong with a file and this is the *surface's*: one case per honest answer the app has.
///
/// ### Two seatings worth stating
///
/// **`malformedSchema` sits with the YAML family** (Redesign Gap bcdd1942, filed): the ruling names
/// four classes and this reason is not one of them, but it is the same event to a user a key they
/// typed that Lanework cannot read and the same honest posture applies, because the app must not
/// guess what `schema: banana` was meant to be. So it takes the family's choices exactly: Open in
/// Editor + Re-check, plus Skip below the root.
///
/// **The retired `order` reasons sit there too.** Nothing throws them any more (01, re-ruled
/// 2026-07-31: below the root a missing or unusable `order` reads as append-at-end), and they survive
/// in the loader's vocabulary rather than being deleted. A surface that met one anyway would be
/// looking at a file only a person can fix, which is precisely what the family means.
public enum BoardDefectClass: String, Sendable, Hashable, CaseIterable {
/// *Unparseable YAML* "no app-minted rewrite (the app would be guessing at content)". Plus the
/// two seatings above.
case unreadableFrontmatter = "unreadable-frontmatter"
/// *`schema` newer than the app* "no honest fix (downgrading risks silent loss)".
case newerSchema = "newer-schema"
/// *Board root without `index.md`* the minted repair.
case missingBoardIndex = "missing-board-index"
/// *Board root missing `schema`* the stamp.
case missingRootSchema = "missing-root-schema"
/// The environmental failures a root that is gone, or that is a file. **Not normally reachable
/// here at all**: an attended open whose single defect is environmental retires to welcome like a
/// restored one, because there is nothing on disk to repair (`BoardWindowHost`). It is in the
/// vocabulary because a *re-walk* can meet one the surface is on screen, and the user (or their
/// agent) deletes the board folder underneath it and a class with no case would be a crash
/// where a blocked surface is the honest answer.
case unreachableRoot = "unreachable-root"
/// The class a walk's defect belongs to. Exhaustive with no `default`, the vocabulary's standing
/// rule: a reason added to the loader without a seat here fails to compile.
public init(_ reason: BoardLoadError.Reason) {
switch reason {
case .unparseableYAML, .malformedSchema, .missingOrder, .malformedOrder:
self = .unreadableFrontmatter
case .schemaNewerThanApp:
self = .newerSchema
case .boardRootMissingIndex:
self = .missingBoardIndex
case .missingSchema:
self = .missingRootSchema
case .notADirectory, .unreadableRoot:
self = .unreachableRoot
}
}
/// The section's heading what is wrong, said once for the whole class.
public var title: String {
switch self {
case .unreadableFrontmatter: "Frontmatter Lanework can't read"
case .newerSchema: "Made by a newer Lanework"
case .missingBoardIndex: "This folder has no board index"
case .missingRootSchema: "This board doesn't say which format it's in"
case .unreachableRoot: "This board can't be read"
}
}
/// The section's explanation why the choices below it are the only honest ones. Each is the
/// ruling's own reasoning said to a user.
public var explanation: String {
switch self {
case .unreadableFrontmatter:
"Lanework won't rewrite these files — it would be guessing at your content. Open one, fix it, then Re-check."
case .newerSchema:
"These were written by a newer Lanework. There's no honest way to read them here — update the app."
case .missingBoardIndex:
"Lanework can create one, titled after the folder."
case .missingRootSchema:
"Lanework can stamp it — reliably, because it just read this board as schema 1."
case .unreachableRoot:
"Nothing here can be repaired from inside Lanework."
}
}
/// **What this class offers for a defect at `path`**, in the order the picker lists them, first
/// being the default (01: "one class-level choice preselected to its default").
///
/// **The root restriction is the whole reason `path` is a parameter.** Skip is user-consented
/// *tolerance* the board loads without the item and there is no board without its root
/// (`BoardLoader.unskippablePaths`). So a root defect never gets Skip, whatever its class, and a
/// class whose only offer is Skip therefore offers nothing at the root: that is the ruling's "on
/// the board root it blocks the whole board (Cancel is the only exit)", falling out of the
/// restriction rather than being a rule of its own.
public func choices(atPath path: String) -> [BoardDefectChoice] {
let isRoot = BoardLoader.unskippablePaths.contains(path)
switch self {
case .unreadableFrontmatter:
return isRoot ? [.editAndRecheck] : [.editAndRecheck, .skip]
case .newerSchema:
return isRoot ? [] : [.skip]
case .missingBoardIndex:
return [.repair(.mintBoardIndex)]
case .missingRootSchema:
return [.repair(.stampSchema)]
case .unreachableRoot:
return []
}
}
}
// MARK: - The choices
/// **What the user decided about one defect** the surface's whole vocabulary of answers.
///
/// Two of the three are *resolutions*: they let the board open. `.editAndRecheck` is deliberately not
/// one, and that is the honest half of the ruling rather than a gap for unparseable frontmatter
/// there is no app-mediated outcome at all, so the row's answer is "a person will fix this", and the
/// way out of the surface is Re-check (or Cancel).
public enum BoardDefectChoice: Sendable, Equatable, Hashable {
/// Leave the file exactly as it is; the user opens it, fixes it, and presses Re-check.
case editAndRecheck
/// **User-consented tolerance** (01: "Skip is user-consented tolerance, loudly marked"): the item
/// leaves the board with its whole subtree, the file stays on disk untouched, and the opened
/// board carries a notice naming what left.
case skip
/// The app mints the fix, inside Repair and Open's one write bracket.
case repair(BoardRepair)
/// **Whether this answer can open the board** the enabling rule behind Repair and Open: "every
/// defect has an actionable resolution (a minted repair or a consented Skip)".
public var isActionable: Bool {
switch self {
case .editAndRecheck: false
case .skip, .repair: true
}
}
/// The picker's label the user's own words for the choice, in the ruling's vocabulary.
public var label: String {
switch self {
case .editAndRecheck: "Fix it myself"
case .skip: "Skip it"
case .repair(.mintBoardIndex): "Create a board index"
case .repair(.stampSchema): "Stamp schema: 1"
}
}
}
/// **A fix the app is willing to mint** the two the ruling grants, and no others.
///
/// Both are content-lossless, which is the whole test a repair has to pass here: one writes a file
/// that was not there, the other adds a key whose value the walk has already proved. Anything that
/// would guess at content is not a repair, it is the `.editAndRecheck` answer above.
public enum BoardRepair: String, Sendable, Equatable, Hashable {
/// *Board root without `index.md`* "create a board index (folder-name title, `schema: 1`) the
/// user just opened this folder as a board, and the mint is content-lossless".
case mintBoardIndex = "mint-board-index"
/// *Board root missing `schema`* "stamp `schema: 1`, the default: reliable exactly because the
/// walk just validated the file against schema 1".
case stampSchema = "stamp-schema"
}
// MARK: - Rows and sections
/// One affected file on the surface: the defect, where it is, and what the user has decided about it.
public struct BoardDefectRow: Identifiable, Sendable, Equatable {
/// The walk's own record `path` (root-relative) and `reason`, which is the specifics the row
/// shows verbatim rather than re-wording.
public let defect: BoardLoadError
/// What Reveal in Finder selects: the offending file where there is one, and the **folder**
/// where there is not (a root with no `index.md` Finder cannot select a file that does not
/// exist, and the folder is what the user needs to look at anyway).
public let revealURL: URL
/// The file itself, for Open in Editor `nil` where nothing is there to open. The missing-index
/// class is the only one that answers `nil`, and it is why the affordance is a row's fact rather
/// than a fixture of every row.
public let editURL: URL?
/// What this row may answer, first being the class default (`BoardDefectClass.choices(atPath:)`).
/// **Empty means blocked**: no repair, no tolerance, nothing the surface can do.
public let choices: [BoardDefectChoice]
/// The answer standing right now the class default until the user overrides it, `nil` for a
/// blocked row, which has no answer to give.
public var choice: BoardDefectChoice?
public var defectClass: BoardDefectClass { BoardDefectClass(defect.reason) }
/// The path is unique within one walk a file records at most one defect so it is the identity
/// a re-aggregation matches choices by (`BoardDecisionSurfaceModel.reaggregate`).
public var id: String { defect.path }
/// Whether this row is what blocks the whole board: it is at an unskippable path and its class
/// has nothing to offer there.
public var isBlocking: Bool { choices.isEmpty }
}
/// One class's section: the sentence, the files, and the class-level choice over them.
public struct BoardDefectSection: Identifiable, Sendable, Equatable {
public let defectClass: BoardDefectClass
public var rows: [BoardDefectRow]
public var id: String { defectClass.rawValue }
public var title: String { defectClass.title }
public var explanation: String { defectClass.explanation }
/// Every choice any row here can take, in canonical order what the class-level picker lists.
///
/// A union rather than one row's list, because a class can straddle the root: unparseable YAML in
/// the root's own `index.md` *and* in a lane's is one class with two offer sets. Applying a
/// class-level choice leaves any row that cannot take it alone (`choose(_:inClass:)`), which is
/// the honest behaviour a root row silently gaining Skip would be the one thing the restriction
/// exists to prevent.
public var choices: [BoardDefectChoice] {
var seen: [BoardDefectChoice] = []
for row in rows {
for choice in row.choices where !seen.contains(choice) {
seen.append(choice)
}
}
return seen
}
/// The class-level answer the one every row is giving, or `nil` where they disagree (the user
/// has overridden one) or where there is nothing to answer.
public var classChoice: BoardDefectChoice? {
let answers = Set(rows.compactMap(\.choice))
return answers.count == 1 ? answers.first : nil
}
/// Whether the per-item disclosure is worth showing: more than one file, and more than one thing
/// to say about them. One file's override *is* the class choice, and a class with a single offer
/// has no override to make.
public var offersPerItemOverride: Bool { rows.count > 1 && choices.count > 1 }
}
// MARK: - The model
/// **The decision surface's state and rules** everything about it that could go quietly wrong,
/// outside SwiftUI so it can be asked without a window (01-storage-format.md § Malformed input, the
/// decision surface, settled 2026-07-31).
///
/// ### What it is
///
/// One walk's `BoardLoadFailure`, grouped by class in walk order, each row preselected to its class
/// default, plus the three rules the buttons turn on: which paths a Skip set names, which repairs
/// Repair and Open would apply, and whether Repair and Open may be pressed at all.
///
/// ### What it deliberately is not
///
/// It runs nothing. It does not walk, does not write, and does not know what a window is: the host
/// owns the loop apply the repairs, re-run the walk, re-aggregate or open because that loop is
/// about a *window*, and this is about a decision. That split is what lets the whole vocabulary be
/// pinned from a fixture (`BoardDecisionSurfaceTests`) and what keeps "never a second dialog"
/// structural: there is one model per open, and Re-check refreshes it in place
/// (`reaggregate(_:)`).
@MainActor
@Observable
public final class BoardDecisionSurfaceModel {
/// The board this is about what every row's URL is resolved against, and what the repairs
/// write into.
public let boardRoot: URL
/// The walk's whole aggregate, as it stands. Replaced by each re-aggregation, and it is what
/// Cancel's launch-failure message is written from.
public private(set) var failure: BoardLoadFailure
/// The sections, in **walk order**: classes ordered by where each first appeared in the walk, and
/// rows within a class in walk order too. The walk goes root, then lanes in folder-name order
/// with their cards inside them, then `.trash/` so a grouped surface still reads top-down like
/// the tree does, which is the whole reason `BoardLoadFailure` keeps its order.
public private(set) var sections: [BoardDefectSection]
/// Whether a walk or a repair is running right now the buttons' disabled state. The surface
/// stays on screen while it works, because the alternative (back to a spinner, then a surface
/// again) would be the chained dialog the ruling forbids, spelled as a flicker.
public var isWorking = false
/// **The last repair that didn't happen**, shown inline on the surface, or `nil`.
///
/// It has nowhere else to go: the banner strip belongs to a board window that has a store, and a
/// board being repaired has neither. A failed repair is not fatal "interrupted batches are
/// accepted per the renumber precedent, every intermediate state valid, and a partial repair
/// simply re-aggregates on the next walk" so this is a line, not an exit.
public var repairFailure: BoardWriteError?
public init(failure: BoardLoadFailure, boardRoot: URL) {
self.boardRoot = boardRoot
self.failure = failure
self.sections = Self.group(failure, boardRoot: boardRoot, keeping: [:])
}
// MARK: Re-aggregation
/// **A fresh walk's defects, into the same surface** (01: "a disk changed underneath re-aggregates
/// into the *same* surface with the fresh defect list, never a chained second dialog").
///
/// Choices survive by path: a defect that is still there keeps the answer the user gave it, and
/// anything new takes its class default. That is what makes Repair and Open's partial-failure
/// story bearable the user's other decisions are still standing when the surface comes back.
public func reaggregate(_ failure: BoardLoadFailure) {
var kept: [String: BoardDefectChoice] = [:]
for section in sections {
for row in section.rows where row.choice != nil {
kept[row.id] = row.choice
}
}
self.failure = failure
self.sections = Self.group(failure, boardRoot: boardRoot, keeping: kept)
}
// MARK: Choosing
/// Sets a whole class's answer the class-level control. Rows that cannot take the choice (a
/// root row offered Skip) are left exactly as they were.
public func choose(_ choice: BoardDefectChoice, inClass defectClass: BoardDefectClass) {
guard let index = sections.firstIndex(where: { $0.defectClass == defectClass }) else { return }
for rowIndex in sections[index].rows.indices
where sections[index].rows[rowIndex].choices.contains(choice) {
sections[index].rows[rowIndex].choice = choice
}
}
/// Sets one file's answer the per-item override behind the disclosure. A choice the row does
/// not offer is ignored rather than trusted.
public func choose(_ choice: BoardDefectChoice, forPath path: String) {
for sectionIndex in sections.indices {
guard let rowIndex = sections[sectionIndex].rows.firstIndex(where: { $0.id == path }) else { continue }
guard sections[sectionIndex].rows[rowIndex].choices.contains(choice) else { return }
sections[sectionIndex].rows[rowIndex].choice = choice
return
}
}
// MARK: The rules the buttons turn on
/// The paths the user consented to skip what the next walk is run with
/// (`BoardLoader.load(boardRoot:skipping:)`), and what the opened board's notice is written from.
public var skipSet: Set<String> {
var paths: Set<String> = []
for section in sections {
for row in section.rows where row.choice == .skip {
paths.insert(row.id)
}
}
return paths
}
/// The repairs Repair and Open would apply, **in walk order** which is the order they run in,
/// so a board whose root needs both a minted index and well, one of the two: the two repairs are
/// mutually exclusive today (a root with no `index.md` has no `schema` to be missing), and the
/// order is stated for the day a third joins them.
public var plannedRepairs: [PlannedRepair] {
var planned: [PlannedRepair] = []
for section in sections {
for row in section.rows {
guard case let .repair(repair) = row.choice else { continue }
planned.append(PlannedRepair(path: row.id, repair: repair))
}
}
return planned
}
/// One repair, bound to the file it fixes.
public struct PlannedRepair: Sendable, Equatable {
/// Root-relative, the defect's own path.
public let path: String
public let repair: BoardRepair
}
/// **Whether a defect blocks the whole board** a root defect its class cannot answer for
/// (01: "on the board root it blocks the whole board (Cancel is the only exit)").
///
/// The surface still shows everything: a user whose root was written by a newer Lanework is owed
/// the whole picture, not a single terse row, because what they do next is decided by how much
/// else is wrong.
public var isRootBlocked: Bool {
sections.contains { $0.rows.contains(where: \.isBlocking) }
}
/// **Repair and Open's enabling rule**: every defect has an actionable resolution a minted
/// repair or a consented Skip and nothing blocks the root.
///
/// An `.editAndRecheck` row disables it by construction: that answer is "a person will fix this",
/// and pressing Repair and Open would either write something the app promised not to write or
/// open a board that still refuses to load.
public var canRepairAndOpen: Bool {
guard !isRootBlocked else { return false }
return sections.allSatisfy { section in
section.rows.allSatisfy { $0.choice?.isActionable == true }
}
}
/// Every row on the surface, flattened the count the header states, and what the suites walk.
public var rows: [BoardDefectRow] { sections.flatMap(\.rows) }
// MARK: Grouping
/// The grouping rule: **by class, in walk order** (`sections`).
///
/// `keeping` carries the answers a previous aggregation had, by path empty on a first build.
private static func group(
_ failure: BoardLoadFailure,
boardRoot: URL,
keeping kept: [String: BoardDefectChoice]
) -> [BoardDefectSection] {
var sections: [BoardDefectSection] = []
for defect in failure.defects {
let defectClass = BoardDefectClass(defect.reason)
let choices = defectClass.choices(atPath: defect.path)
// A kept answer only survives if the row still offers it: a repaired root that came back
// as a *different* defect is a different question.
let choice = kept[defect.path].flatMap { choices.contains($0) ? $0 : nil } ?? choices.first
let row = BoardDefectRow(
defect: defect,
revealURL: revealURL(for: defect, boardRoot: boardRoot),
editURL: editURL(for: defect, boardRoot: boardRoot),
choices: choices,
choice: choice
)
if let index = sections.firstIndex(where: { $0.defectClass == defectClass }) {
sections[index].rows.append(row)
} else {
sections.append(BoardDefectSection(defectClass: defectClass, rows: [row]))
}
}
return sections
}
/// The folder a defect's path names the board root for a root defect, the item's folder
/// otherwise. `BoardLoadError.path` names the `index.md` (`"<lane>/index.md"`), and `"."` is the
/// environmental failures' spelling of the root itself.
public static func folder(forDefectAt path: String, under boardRoot: URL) -> URL {
guard path != "." else { return boardRoot }
let relative = (path as NSString).deletingLastPathComponent
guard !relative.isEmpty else { return boardRoot }
return boardRoot.appendingPathComponent(relative, isDirectory: true)
}
/// The file a defect names, whether or not it is there.
private static func fileURL(for defect: BoardLoadError, boardRoot: URL) -> URL? {
guard defect.path != "." else { return nil }
return boardRoot.appendingPathComponent(defect.path)
}
/// Finder selects the file where there is one, and the folder where there is not.
private static func revealURL(for defect: BoardLoadError, boardRoot: URL) -> URL {
guard let file = fileURL(for: defect, boardRoot: boardRoot),
FileManager.default.fileExists(atPath: file.path)
else {
return folder(forDefectAt: defect.path, under: boardRoot)
}
return file
}
/// Open in Editor needs a file that exists a missing `index.md` has nothing to open, and
/// handing its URL to `NSWorkspace` would produce a system error instead of an explanation.
private static func editURL(for defect: BoardLoadError, boardRoot: URL) -> URL? {
guard let file = fileURL(for: defect, boardRoot: boardRoot),
FileManager.default.fileExists(atPath: file.path)
else {
return nil
}
return file
}
}
// MARK: - Applying the repairs
/// **Repair and Open's write half** every chosen fix, in one bracket, store-less
/// (01-storage-format.md § Malformed input: "**Repair and Open** applies every chosen fix in one
/// write bracket each repaired `index.md` is an ordinary app write (stamps `modified`, clears
/// `modified-by`)").
///
/// ### Why it is store-less, and what that costs
///
/// There is no `BoardStore` yet that is the whole situation the surface exists in so there is no
/// `performWrite` to run inside, and with it none of the machinery a write usually gets: no watcher
/// bracket (nothing is watching a board that never opened), no banner strip (the window is showing
/// this surface), no undo registration (there is no session and no stack), and no read-only lock to
/// consult (the probe runs at acquire, which has not happened). Each of those absences is correct
/// here rather than merely tolerable.
///
/// What it does keep is the **ledger**, bound by hand for the bracket's duration and marked
/// wholesale afterwards (`EchoLedger.markAllAsHeal`), because 01 asks for exactly that: "On Pro
/// boards the repairs drop heal-marked receipts and commit separately as one repair commit, never
/// folded into anyone else's work." The ledger travels to the store that the following walk builds
/// (`EchoLedger.adopt`), which is the only reason it can outlive the bracket.
///
/// ### Interrupted batches are accepted
///
/// "Interrupted batches are accepted per the renumber precedent, every intermediate state valid, and
/// a partial repair simply re-aggregates on the next walk." So a failure stops the batch and is
/// *returned* rather than thrown away or thrown up: the caller re-walks regardless, the surface comes
/// back with whatever is still wrong, and the receipts of what did land come back too.
///
/// `@MainActor` because that is where every app write in this app already happens `performWrite` is
/// synchronous on a gesture's own path and because a repair is one or two small file writes on a
/// board the user is looking at. There is nothing here worth an actor hop that the ordinary write
/// path does not already do without one.
@MainActor
public enum BoardRepairRun {
/// What one repair pass produced: the heal-marked receipts, and the failure that stopped it.
public struct Outcome: Sendable {
/// The repair bracket's own ledger every receipt in it heal-marked, ready for the store's
/// ledger to adopt once the walk builds one.
public let ledger: EchoLedger
/// The repair that did not happen, or `nil`. Everything before it did.
public let failure: BoardWriteError?
}
@discardableResult
public static func apply(
_ repairs: [BoardDecisionSurfaceModel.PlannedRepair],
boardRoot: URL
) -> Outcome {
let ledger = EchoLedger()
var failure: BoardWriteError?
EchoLedger.$current.withValue(ledger) {
for planned in repairs {
let folder = BoardDecisionSurfaceModel.folder(forDefectAt: planned.path, under: boardRoot)
do throws(BoardWriteError) {
switch planned.repair {
case .mintBoardIndex:
// **Folder-name title, `schema: 1`** the ruling's own parenthesis, and
// `createBoard` is already exactly that write. It refuses only when an
// `index.md` is already there, which is precisely the defect's negation; and
// it seeds `.gitignore`, which is the every-board heal ruled 2026-07-31 and
// therefore wanted here too (a board being repaired is a board being brought
// up to today's shape).
try BoardWriter.createBoard(
at: folder,
title: AppModel.folderDisplayName(of: folder),
operation: .mintBoardIndex
)
case .stampSchema:
// **An ordinary app write** which is the ruling's word for it, and what
// `updateIndex` means by default: `modified` stamped, `modified-by` cleared,
// the round trip preserving every other byte. `kind: .board` is named rather
// than derived because a board root's folder name is a Finder document name,
// not an identity, so position has no answer to give.
try BoardWriter.updateIndex(
inItemFolder: folder,
kind: .board,
operation: .stampSchema
) { document in
document.set(FrontmatterKeys.schema, to: .int(BoardLoader.supportedSchema))
}
}
} catch {
failure = error
break
}
}
}
// After the bracket, over whatever landed: every receipt this ledger holds came from a repair,
// which is what makes the blanket mark honest (`EchoLedger.markAllAsHeal`). A partial batch is
// marked too the files that did land are still the app's own heal work.
ledger.markAllAsHeal()
return Outcome(ledger: ledger, failure: failure)
}
}
// MARK: - The surface
/// **The decision surface** (01-storage-format.md § Malformed input, settled 2026-07-31):
///
/// > Blocking means one aggregated surface hosted by the pre-snapshot loading window
/// > (02-architecture.md the loading state) the loading content transforms in place, never a
/// > sheet over a spinner.
///
/// So this is a plain view in the board window's content area, replacing `BoardLoadingView` where it
/// stood. No sheet, no alert, no second window: the window the user opened is the window that
/// explains itself, and W means Cancel because there is nothing else it could mean.
///
/// Everything it renders is `BoardDecisionSurfaceModel`'s; everything it *does* is the host's, as
/// three closures. That split is the file's whole shape see the model.
struct BoardDecisionSurface: View {
let model: BoardDecisionSurfaceModel
/// Applies the chosen repairs and re-runs the walk with the skip set.
let onRepairAndOpen: () -> Void
/// Re-runs the walk with the current skip set, changing nothing on disk.
let onRecheck: () -> Void
/// Aborts the open the window retires and the board lands row-level on welcome.
let onCancel: () -> Void
/// The identifier a UI suite finds the surface by **on the heading, not on the container**.
///
/// That placement is load-bearing rather than incidental: SwiftUI's `accessibilityIdentifier`
/// propagates *down*, and an outer one overwrites every identifier set inside it. A container
/// carrying this string would therefore stamp it over the sections, the rows and the three
/// buttons leaving a surface with one identifier repeated a dozen times and nothing a test could
/// press. Every identifier in this file is on a leaf for that reason.
static let accessibilityIdentifier = "decision-surface"
@MainActor private static var pointSize: CGFloat { BoardMetrics.bodyPointSize }
@MainActor private static var gutter: CGFloat { BoardMetrics.em(1.4, bodyPointSize: pointSize) }
@MainActor private static var stack: CGFloat { BoardMetrics.em(0.8, bodyPointSize: pointSize) }
var body: some View {
VStack(alignment: .leading, spacing: 0) {
header
Divider()
ScrollView {
VStack(alignment: .leading, spacing: Self.gutter) {
ForEach(model.sections) { section in
sectionView(section)
}
}
.padding(Self.gutter)
.frame(maxWidth: .infinity, alignment: .leading)
}
Divider()
footer
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
// `.contain` rather than `.combine`, the banner strip's rule for its reason: the sections and
// their rows are each their own element, and fusing a whole repair decision into one
// utterance would bury the row a VoiceOver user has to act on.
.accessibilityElement(children: .contain)
.accessibilityLabel(AccessibilityPhrases.decisionSurfaceLabel)
}
// MARK: Header and footer
private var header: some View {
VStack(alignment: .leading, spacing: Self.stack / 2) {
Text(AccessibilityPhrases.decisionSurfaceLabel)
.font(.headline)
.accessibilityIdentifier(Self.accessibilityIdentifier)
Text(AccessibilityPhrases.decisionSurfaceSummary(defects: model.rows.count))
.font(.callout)
.foregroundStyle(.secondary)
if let failure = model.repairFailure {
// The pre-store failure's only surface see `BoardDecisionSurfaceModel.repairFailure`.
// Phrased by `BannerCenter`, like every other write failure in the app.
Label(BannerCenter.headline(for: failure), systemImage: "exclamationmark.triangle.fill")
.font(.callout)
.foregroundStyle(.red)
.accessibilityIdentifier("decision-repair-failure")
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(Self.gutter)
}
private var footer: some View {
HStack(spacing: Self.stack) {
if model.isWorking {
ProgressView()
.controlSize(.small)
.accessibilityLabel(AccessibilityPhrases.boardLoading)
}
Spacer(minLength: 0)
Button("Cancel", role: .cancel, action: onCancel)
.keyboardShortcut(.cancelAction)
.accessibilityIdentifier("decision-cancel")
Button("Re-check", action: onRecheck)
.accessibilityIdentifier("decision-re-check")
Button("Repair and Open", action: onRepairAndOpen)
.keyboardShortcut(.defaultAction)
.disabled(!model.canRepairAndOpen)
.accessibilityIdentifier("decision-repair-and-open")
}
.disabled(model.isWorking)
.padding(Self.gutter)
}
// MARK: One class
@ViewBuilder
private func sectionView(_ section: BoardDefectSection) -> some View {
VStack(alignment: .leading, spacing: Self.stack) {
VStack(alignment: .leading, spacing: Self.stack / 3) {
Text(section.title)
.font(.headline)
// On the heading rather than on the section see `accessibilityIdentifier`.
.accessibilityIdentifier("decision-section-\(section.id)")
Text(section.explanation)
.font(.callout)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
if section.choices.count > 1 {
Picker("What to do", selection: classChoiceBinding(section)) {
ForEach(section.choices, id: \.self) { choice in
Text(choice.label).tag(Optional(choice))
}
// **Only while the class actually is mixed.** A per-item override leaves the
// class with no single answer, and a picker with no matching tag would silently
// show the first choice instead which would say the override had not happened.
// Offering it the rest of the time would be a fourth radio button that does
// nothing.
if section.classChoice == nil {
Text("Mixed").tag(Optional<BoardDefectChoice>.none)
}
}
.pickerStyle(.radioGroup)
.accessibilityIdentifier("decision-class-choice-\(section.id)")
} else if let only = section.choices.first {
Text(only.label)
.font(.callout)
}
VStack(alignment: .leading, spacing: Self.stack / 2) {
ForEach(section.rows) { row in
rowView(row)
}
}
if section.offersPerItemOverride {
DisclosureGroup("Choose for each file") {
VStack(alignment: .leading, spacing: Self.stack / 2) {
ForEach(section.rows) { row in
overrideView(row)
}
}
.padding(.top, Self.stack / 2)
}
.font(.callout)
.accessibilityIdentifier("decision-overrides-\(section.id)")
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.accessibilityElement(children: .contain)
.accessibilityLabel(section.title)
}
// MARK: One file
private func rowView(_ row: BoardDefectRow) -> some View {
HStack(alignment: .firstTextBaseline, spacing: Self.stack) {
VStack(alignment: .leading, spacing: 0) {
Text(row.defect.path)
.font(.callout.monospaced())
Text(row.defect.reason.description)
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
.frame(maxWidth: .infinity, alignment: .leading)
Button("Reveal in Finder") {
NSWorkspace.shared.activateFileViewerSelecting([row.revealURL])
}
.buttonStyle(.link)
.font(.callout)
if let editURL = row.editURL {
Button("Open in Editor") {
NSWorkspace.shared.open(editURL)
}
.buttonStyle(.link)
.font(.callout)
}
}
.accessibilityIdentifier("decision-row-\(row.id)")
// One element per file, path and reason together, so a VoiceOver user hears *which* file and
// *what is wrong with it* as one sentence rather than as two neighbouring labels. The two
// buttons stay inside it as custom actions and are Tab stops besides the banner row's rule.
.accessibilityElement(children: .combine)
.accessibilityLabel(
AccessibilityPhrases.decisionRowLabel(path: row.defect.path, reason: row.defect.reason.description)
)
}
private func overrideView(_ row: BoardDefectRow) -> some View {
HStack(alignment: .firstTextBaseline, spacing: Self.stack) {
Text(row.defect.path)
.font(.caption.monospaced())
.frame(maxWidth: .infinity, alignment: .leading)
Picker("What to do", selection: rowChoiceBinding(row)) {
ForEach(row.choices, id: \.self) { choice in
Text(choice.label).tag(Optional(choice))
}
}
.labelsHidden()
.fixedSize()
.disabled(row.choices.count < 2)
}
.accessibilityIdentifier("decision-override-\(row.id)")
}
// MARK: Bindings
private func classChoiceBinding(_ section: BoardDefectSection) -> Binding<BoardDefectChoice?> {
Binding(
get: { section.classChoice },
set: { choice in
guard let choice else { return }
model.choose(choice, inClass: section.defectClass)
}
)
}
private func rowChoiceBinding(_ row: BoardDefectRow) -> Binding<BoardDefectChoice?> {
Binding(
get: { row.choice },
set: { choice in
guard let choice else { return }
model.choose(choice, forPath: row.id)
}
)
}
}
+769
View File
@@ -0,0 +1,769 @@
import Foundation
import SwiftGitX
import Testing
@testable import Kanban
/// **The decision surface** (01-storage-format.md § Malformed input, settled 2026-07-31) the
/// grouping, the vocabulary, the two minted repairs, the skip channel's ride through the session, and
/// the attendance branch that decides whether any of it is reached at all.
///
/// Everything here is asked of values. The surface's rules live in `BoardDecisionSurfaceModel` and
/// its landing rule in a static on the host precisely so a suite with no window can hold the whole
/// decision in its hand: what a class offers, what it defaults to, when Repair and Open may be
/// pressed, and what a repair actually puts on disk.
// MARK: - Fixtures
/// A board whose root has no `schema` and one lane that will not parse the minimal two-class
/// aggregate, and the shape most of these cases need: one minted repair, one hand-edit.
@MainActor
private func makeTwoClassBoard() throws -> WriterFixture {
let fixture = try WriterFixture()
// No `schema` at the root the this-really-is-a-board gate, defect #1.
try fixture.item("", "---\ntitle: Two Classes\n---\nA board with two kinds of problem.\n")
try fixture.item(Ident.lane1, "---\nschema: 1\ntitle: Intact\norder: 1024\n---\n")
try fixture.item("\(Ident.lane1)/\(Ident.card1)", "---\nschema: 1\ntitle: Card\norder: 1024\n---\n")
// Unparseable YAML an unterminated flow sequence, defect #2.
try fixture.item(Ident.lane2, "---\nschema: 1\ntitle: Broken\norder: 2048\nlabels: [a, b\n---\n")
return fixture
}
/// A board whose only defects are **below the root** the skip channel's golden case.
@MainActor
private func makeSkippableBoard() throws -> WriterFixture {
let fixture = try WriterFixture()
try fixture.item("", "---\nschema: 1\ntitle: Skippable\n---\n")
try fixture.item(Ident.lane1, "---\nschema: 1\ntitle: Intact\norder: 1024\n---\n")
try fixture.item("\(Ident.lane1)/\(Ident.card1)", "---\nschema: 1\ntitle: Card\norder: 1024\n---\n")
// A card from a newer Lanework: unfixable, so Skip is the only offer.
try fixture.item("\(Ident.lane1)/\(Ident.card2)", "---\nschema: 2\ntitle: Newer\norder: 2048\n---\n")
// A lane that will not parse: Open in Editor + Re-check, or Skip.
try fixture.item(Ident.lane2, "---\nschema: 1\ntitle: Broken\norder: 2048\nlabels: [a, b\n---\n")
return fixture
}
/// The failure a board refuses with the input every model here is built from.
@MainActor
private func failure(of fixture: WriterFixture, skipping: Set<String> = []) throws -> BoardLoadFailure {
do throws(BoardLoadFailure) {
_ = try BoardLoader.load(boardRoot: fixture.root, skipping: skipping)
Issue.record("the fixture board loaded — it is supposed to refuse")
throw BoardLoadFailure(BoardLoadError(path: ".", reason: .notADirectory))
} catch {
return error
}
}
@MainActor
private func makeModel(_ fixture: WriterFixture) throws -> BoardDecisionSurfaceModel {
BoardDecisionSurfaceModel(failure: try failure(of: fixture), boardRoot: fixture.root)
}
// MARK: - Grouping and defaults
@MainActor
@Suite("Decision surface ▸ grouping and defaults")
struct BoardDecisionSurfaceGroupingTests {
@Test("Defects group by class, and the classes come in walk order")
func groupingIsByClassInWalkOrder() throws {
let fixture = try makeSkippableBoard()
defer { fixture.tearDown() }
let model = try makeModel(fixture)
// The walk is root, then lanes in folder-name order with their cards inside them so the
// newer-schema card (under lane 1) is met before the unparseable lane 2.
#expect(model.sections.map(\.defectClass) == [.newerSchema, .unreadableFrontmatter])
#expect(model.sections.allSatisfy { $0.rows.count == 1 })
}
@Test("One class with several files is one section, in walk order")
func oneClassIsOneSection() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", "---\nschema: 1\ntitle: Board\n---\n")
try fixture.item(Ident.lane1, "---\nschema: 1\ntitle: A\norder: 1024\nlabels: [a\n---\n")
try fixture.item(Ident.lane2, "---\nschema: 1\ntitle: B\norder: 2048\nlabels: [b\n---\n")
let model = try makeModel(fixture)
// "each class section states the defect once, lists the affected files".
#expect(model.sections.count == 1)
let section = try #require(model.sections.first)
#expect(section.defectClass == .unreadableFrontmatter)
#expect(section.rows.map(\.id) == ["\(Ident.lane1)/index.md", "\(Ident.lane2)/index.md"])
}
@Test("Every class preselects its ruled default")
func classDefaultsAreTheRuledOnes() throws {
// The minted repairs, at the root where they occur.
#expect(BoardDefectClass.missingBoardIndex.choices(atPath: "index.md").first == .repair(.mintBoardIndex))
#expect(BoardDefectClass.missingRootSchema.choices(atPath: "index.md").first == .repair(.stampSchema))
// "Open in Editor + Re-check, **or** Skip" the posture leads, the tolerance follows.
#expect(
BoardDefectClass.unreadableFrontmatter.choices(atPath: "lane/index.md")
== [.editAndRecheck, .skip]
)
// "an unfixable row offering only Skip".
#expect(BoardDefectClass.newerSchema.choices(atPath: "lane/index.md") == [.skip])
}
@Test("malformedSchema is seated with the YAML family, root restriction and all")
func malformedSchemaJoinsTheYAMLFamily() {
// Redesign Gap bcdd1942: not one of the ruled four, and the same honest posture the app
// must not guess what `schema: banana` was meant to be.
#expect(BoardDefectClass(.malformedSchema(raw: "banana")) == .unreadableFrontmatter)
#expect(BoardDefectClass(.unparseableYAML(message: "x", line: 2)) == .unreadableFrontmatter)
// The root restriction it inherits: Editor + Re-check at the root, plus Skip below it.
#expect(BoardDefectClass.unreadableFrontmatter.choices(atPath: "index.md") == [.editAndRecheck])
}
@Test("A root defect is never offered Skip")
func theRootIsNeverSkippable() throws {
let fixture = try makeTwoClassBoard()
defer { fixture.tearDown() }
let model = try makeModel(fixture)
let rootRow = try #require(model.rows.first { $0.id == BoardLoader.indexFileName })
#expect(!rootRow.choices.contains(.skip), "there is no board without its root")
#expect(rootRow.choice == .repair(.stampSchema))
}
@Test("Every row states its path and the walk's own reason")
func rowsCarryPathAndSpecifics() throws {
let fixture = try makeTwoClassBoard()
defer { fixture.tearDown() }
let model = try makeModel(fixture)
let broken = try #require(model.rows.first { $0.id == "\(Ident.lane2)/index.md" })
#expect(broken.defect.reason.description.contains("unparseable YAML"))
// Reveal points at the file that is actually there; Open in Editor too.
#expect(broken.revealURL.lastPathComponent == BoardLoader.indexFileName)
#expect(broken.editURL != nil)
}
@Test("A root with no index.md has nothing to open, and reveals its folder")
func aMissingIndexRevealsTheFolder() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item(Ident.lane1, "---\nschema: 1\ntitle: Orphan\norder: 1024\n---\n")
let model = try makeModel(fixture)
let row = try #require(model.rows.first)
#expect(row.defectClass == .missingBoardIndex)
#expect(row.editURL == nil, "Finder cannot open a file that is not there")
#expect(row.revealURL == fixture.root, "the folder is what the user needs to look at")
}
}
// MARK: - The choices, the override, and the enabling rule
@MainActor
@Suite("Decision surface ▸ choices and the enabling rule")
struct BoardDecisionSurfaceChoiceTests {
@Test("A class-level choice sets every row in the class")
func theClassChoiceSetsTheClass() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", "---\nschema: 1\ntitle: Board\n---\n")
try fixture.item(Ident.lane1, "---\nschema: 1\ntitle: A\norder: 1024\nlabels: [a\n---\n")
try fixture.item(Ident.lane2, "---\nschema: 1\ntitle: B\norder: 2048\nlabels: [b\n---\n")
let model = try makeModel(fixture)
#expect(model.sections.first?.classChoice == .editAndRecheck)
model.choose(.skip, inClass: .unreadableFrontmatter)
#expect(model.sections.first?.classChoice == .skip)
#expect(model.skipSet == ["\(Ident.lane1)/index.md", "\(Ident.lane2)/index.md"])
}
@Test("A per-item override moves one row and leaves the class mixed")
func perItemOverrideLeavesTheClassMixed() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", "---\nschema: 1\ntitle: Board\n---\n")
try fixture.item(Ident.lane1, "---\nschema: 1\ntitle: A\norder: 1024\nlabels: [a\n---\n")
try fixture.item(Ident.lane2, "---\nschema: 1\ntitle: B\norder: 2048\nlabels: [b\n---\n")
let model = try makeModel(fixture)
model.choose(.skip, forPath: "\(Ident.lane1)/index.md")
#expect(model.skipSet == ["\(Ident.lane1)/index.md"], "only the overridden row moved")
#expect(model.sections.first?.classChoice == nil, "the class has no single answer any more")
#expect(model.sections.first?.offersPerItemOverride == true)
}
@Test("A class-level choice a row cannot take leaves that row alone")
func aRootRowIgnoresAClassLevelSkip() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
// Unparseable YAML at the root *and* below it: one class, two offer sets.
try fixture.item("", "---\nschema: 1\ntitle: Board\nlabels: [a\n---\n")
try fixture.item(Ident.lane1, "---\nschema: 1\ntitle: A\norder: 1024\nlabels: [b\n---\n")
let model = try makeModel(fixture)
model.choose(.skip, inClass: .unreadableFrontmatter)
#expect(model.skipSet == ["\(Ident.lane1)/index.md"], "the root never joins a skip set")
let rootRow = try #require(model.rows.first { $0.id == BoardLoader.indexFileName })
#expect(rootRow.choice == .editAndRecheck)
}
@Test("Repair and Open enables only when every defect has an actionable resolution")
func repairAndOpenNeedsEveryDefectResolved() throws {
let fixture = try makeSkippableBoard()
defer { fixture.tearDown() }
let model = try makeModel(fixture)
// Out of the box the unparseable lane defaults to "fix it myself", which is not a resolution.
#expect(!model.canRepairAndOpen)
model.choose(.skip, inClass: .unreadableFrontmatter)
#expect(model.canRepairAndOpen, "a consented Skip is an actionable resolution")
#expect(model.skipSet.count == 2, "the newer-schema card defaults to Skip, its only offer")
}
@Test("Repair and Open enables on a board whose only defect has a minted repair")
func aMintedRepairAloneEnablesIt() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", "---\ntitle: No Schema\n---\n")
let model = try makeModel(fixture)
#expect(model.canRepairAndOpen)
#expect(model.plannedRepairs == [
BoardDecisionSurfaceModel.PlannedRepair(path: "index.md", repair: .stampSchema)
])
}
@Test("A root blocked by a newer schema disables Repair and Open outright")
func aRootBlockerDisablesRepairAndOpen() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
// "on the board root it blocks the whole board (Cancel is the only exit)".
try fixture.item("", "---\nschema: 99\ntitle: From The Future\n---\n")
try fixture.item(Ident.lane1, "---\nschema: 1\ntitle: Fine\norder: 1024\n---\n")
let model = try makeModel(fixture)
let row = try #require(model.rows.first)
#expect(row.isBlocking, "no repair, and no tolerance at the root")
#expect(row.choice == nil)
#expect(model.isRootBlocked)
#expect(!model.canRepairAndOpen)
}
@Test("The surface still shows everything a blocked board has wrong")
func aBlockedSurfaceStillShowsEverything() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", "---\nschema: 99\ntitle: From The Future\n---\n")
try fixture.item(Ident.lane1, "---\nschema: 1\ntitle: A\norder: 1024\nlabels: [a\n---\n")
let model = try makeModel(fixture)
// The user whose root was written by a newer Lanework is owed the whole picture what they
// do next depends on how much else is wrong.
#expect(model.sections.map(\.defectClass) == [.newerSchema, .unreadableFrontmatter])
#expect(model.rows.count == 2)
}
}
// MARK: - Re-aggregation
@MainActor
@Suite("Decision surface ▸ re-aggregation")
struct BoardDecisionSurfaceReaggregationTests {
@Test("A fresh walk refreshes the same surface and keeps the answers still standing")
func reaggregationKeepsStandingAnswers() throws {
let fixture = try makeTwoClassBoard()
defer { fixture.tearDown() }
let model = try makeModel(fixture)
// The user overrides the broken lane to Skip, then repairs the root by hand outside the app.
model.choose(.skip, inClass: .unreadableFrontmatter)
try fixture.item("", "---\nschema: 1\ntitle: Two Classes\n---\n")
model.reaggregate(try failure(of: fixture))
#expect(model.sections.map(\.defectClass) == [.unreadableFrontmatter], "the root's defect is gone")
#expect(model.skipSet == ["\(Ident.lane2)/index.md"], "the standing consent survived the re-walk")
#expect(model.canRepairAndOpen)
}
@Test("A defect the walk newly reveals arrives at its class default")
func newlyRevealedDefectsTakeTheirDefault() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", "---\nschema: 1\ntitle: Board\n---\n")
try fixture.item(Ident.lane1, "---\nschema: 1\ntitle: A\norder: 1024\nlabels: [a\n---\n")
// Hidden behind the broken lane: a broken lane takes its subtree with it.
try fixture.item("\(Ident.lane1)/\(Ident.card1)", "---\nschema: 2\ntitle: Newer\norder: 1024\n---\n")
let model = try makeModel(fixture)
#expect(model.rows.count == 1, "the card under the broken lane is never enumerated")
// The lane is fixed by hand; Re-check reveals what it was hiding in the *same* surface.
model.choose(.skip, forPath: "\(Ident.lane1)/index.md")
try fixture.item(Ident.lane1, "---\nschema: 1\ntitle: A\norder: 1024\n---\n")
model.reaggregate(try failure(of: fixture))
#expect(model.rows.count == 1)
let revealed = try #require(model.rows.first)
#expect(revealed.id == "\(Ident.lane1)/\(Ident.card1)/index.md")
#expect(revealed.choice == .skip, "the newer-schema class's only offer, preselected")
}
}
// MARK: - The repairs
@MainActor
@Suite("Decision surface ▸ the minted repairs")
struct BoardDecisionSurfaceRepairTests {
@Test("Stamping schema writes schema: 1, stamps modified, and clears modified-by")
func stampingSchemaIsAnOrdinaryAppWrite() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", """
---
title: No Schema
created: 2026-01-01T09:00:00Z
modified: 2026-01-01T09:00:00Z
modified-by: claude
project: lanework
---
Body text.
""")
let model = try makeModel(fixture)
let outcome = BoardRepairRun.apply(model.plannedRepairs, boardRoot: fixture.root)
#expect(outcome.failure == nil)
let text = try fixture.indexText("")
#expect(text.contains("schema: 1"))
// "each repaired `index.md` is an ordinary app write (stamps `modified`, clears
// `modified-by`)".
#expect(!text.contains("modified-by"))
#expect(!text.contains("modified: 2026-01-01"))
// And the round trip is the Writer's own: unknown keys and the body survive.
#expect(text.contains("project: lanework"))
#expect(text.contains("Body text."))
// The board loads now, which is the whole test of a repair.
#expect(throws: Never.self) { try BoardLoader.load(boardRoot: fixture.root) }
}
@Test("Minting a board index creates one titled after the folder, at schema 1")
func mintingABoardIndexUsesTheFolderName() throws {
let outer = FileManager.default.temporaryDirectory
.appendingPathComponent("DecisionMint-\(UUID().uuidString)", isDirectory: true)
let root = outer.appendingPathComponent("Roadmap.kanban", isDirectory: true)
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: outer) }
try Data("---\nschema: 1\ntitle: Todo\norder: 1024\n---\n".utf8)
.write(to: {
let lane = root.appendingPathComponent(Ident.lane1, isDirectory: true)
try? FileManager.default.createDirectory(at: lane, withIntermediateDirectories: true)
return lane.appendingPathComponent(BoardLoader.indexFileName)
}())
let aggregate: BoardLoadFailure
do throws(BoardLoadFailure) {
_ = try BoardLoader.load(boardRoot: root)
Issue.record("a folder with no index.md is not a board")
return
} catch {
aggregate = error
}
let model = BoardDecisionSurfaceModel(failure: aggregate, boardRoot: root)
#expect(model.plannedRepairs.map(\.repair) == [.mintBoardIndex])
let outcome = BoardRepairRun.apply(model.plannedRepairs, boardRoot: root)
#expect(outcome.failure == nil)
// "(folder-name title, `schema: 1`)" and the extension is not part of the name
// (01 § Board naming).
let loaded = try BoardLoader.load(boardRoot: root)
#expect(loaded.model.title.value == "Roadmap")
#expect(loaded.model.schema == 1)
#expect(loaded.model.lanes.count == 1, "the board that was already there is still there")
}
@Test("The repair bracket's receipts are all heal-marked")
func repairReceiptsAreHealMarked() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", "---\ntitle: No Schema\n---\n")
let model = try makeModel(fixture)
let outcome = BoardRepairRun.apply(model.plannedRepairs, boardRoot: fixture.root)
let index = fixture.root.appendingPathComponent(BoardLoader.indexFileName)
#expect(outcome.ledger.receipt(at: index) != nil, "the write dropped a receipt")
#expect(
outcome.ledger.isHeal(at: index),
"01: repairs drop heal-marked receipts and commit separately as one repair commit"
)
}
@Test("A repair pass with nothing to repair writes nothing and fails nothing")
func anEmptyRepairPassIsANoOp() throws {
let fixture = try makeSkippableBoard()
defer { fixture.tearDown() }
let model = try makeModel(fixture)
model.choose(.skip, inClass: .unreadableFrontmatter)
// Every resolution here is a Skip: Repair and Open is a re-walk and nothing else.
#expect(model.plannedRepairs.isEmpty)
let outcome = BoardRepairRun.apply(model.plannedRepairs, boardRoot: fixture.root)
#expect(outcome.failure == nil)
#expect(outcome.ledger.outstandingReceipts == 0)
}
@Test("A repaired board walks clean, which is the loop Repair and Open runs")
func repairThenWalkIsTheLoop() throws {
let fixture = try makeTwoClassBoard()
defer { fixture.tearDown() }
let model = try makeModel(fixture)
model.choose(.skip, inClass: .unreadableFrontmatter)
BoardRepairRun.apply(model.plannedRepairs, boardRoot: fixture.root)
let result = try BoardLoader.load(boardRoot: fixture.root, skipping: model.skipSet)
#expect(result.model.schema == 1)
// The skipped lane left with its whole subtree; the intact one stayed.
#expect(result.model.lanes.map { $0.id.rawValue } == [Ident.lane1])
#expect(result.warnings.contains(.userSkipped(path: "\(Ident.lane2)/index.md")))
}
@Test("An interrupted batch is accepted: what landed stays, and the rest re-aggregates")
func aPartialRepairReAggregates() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", "---\ntitle: No Schema\n---\n")
// A repair aimed at a file that is not there the shape of a disk that moved underneath.
let planned = [
BoardDecisionSurfaceModel.PlannedRepair(path: "index.md", repair: .stampSchema),
BoardDecisionSurfaceModel.PlannedRepair(
path: "\(Ident.lane1)/index.md", repair: .stampSchema)
]
let outcome = BoardRepairRun.apply(planned, boardRoot: fixture.root)
#expect(outcome.failure != nil, "the second repair could not run")
#expect(try fixture.indexText("").contains("schema: 1"), "the first one still landed")
// "every intermediate state valid, and a partial repair simply re-aggregates on the next walk".
#expect(throws: Never.self) { try BoardLoader.load(boardRoot: fixture.root) }
}
}
// MARK: - The skip channel through the session
@MainActor
@Suite("Decision surface ▸ the skip set rides the session")
struct BoardDecisionSurfaceSkipTests {
@Test("A skip-carrying open builds a store that retains the set")
func theStoreRetainsTheSkipSet() async throws {
let fixture = try makeSkippableBoard()
defer { fixture.tearDown() }
let registry = BoardStoreRegistry()
let skips: Set<String> = ["\(Ident.lane2)/index.md", "\(Ident.lane1)/\(Ident.card2)/index.md"]
let store = try #require(try await registry.acquireOffMain(fixture.root, skipping: skips))
defer { registry.release(store) }
#expect(store.skippedPaths == skips)
#expect(store.snapshot.lanes.map { $0.id.rawValue } == [Ident.lane1])
#expect(store.snapshot.lanes.first?.cards.count == 1, "the newer-schema card left too")
}
@Test("Every reload of that session passes the same set")
func reloadsCarryTheSkipSet() async throws {
let fixture = try makeSkippableBoard()
defer { fixture.tearDown() }
let registry = BoardStoreRegistry()
let skips: Set<String> = ["\(Ident.lane2)/index.md", "\(Ident.lane1)/\(Ident.card2)/index.md"]
let store = try #require(try await registry.acquireOffMain(fixture.root, skipping: skips))
defer { registry.release(store) }
// A foreign filesystem event. Without the retained set this reload walks the still-broken
// board, fails, and the window carries a breakage banner over the board the user just chose
// to open.
store.handleWatcherEvent(.treeChanged(.foreign))
await store.awaitQuiescence()
#expect(store.reloadFailure == nil, "the open's consent stands for the session")
#expect(store.snapshot.lanes.map { $0.id.rawValue } == [Ident.lane1])
}
@Test("The walk marks every skip loudly, and the notice is written from those marks")
func skipsAreLoudlyMarked() throws {
let fixture = try makeSkippableBoard()
defer { fixture.tearDown() }
// Every defect on this board has to be answered before it opens at all a skip omits the item
// it names, never its siblings' defects.
let skips: Set<String> = ["\(Ident.lane1)/\(Ident.card2)/index.md", "\(Ident.lane2)/index.md"]
let result = try BoardLoader.load(boardRoot: fixture.root, skipping: skips)
let skipped = result.warnings.compactMap { warning -> String? in
if case let .userSkipped(path) = warning { path } else { nil }
}
// Walk order, like every other list the aggregate produces.
#expect(skipped == ["\(Ident.lane1)/\(Ident.card2)/index.md", "\(Ident.lane2)/index.md"])
// The row the opened board carries, values and all.
let items = skipped.map { RevealTarget(path: $0, url: fixture.root.appendingPathComponent($0)) }
let center = BannerCenter()
center.postSkippedOnOpen(items)
let loss = try #require(center.losses.first)
#expect(loss.message == "Opened without 2 items — you chose to skip them")
#expect(loss.reveals.map(\.path) == skipped)
// Each reveal points at the file the surface's row named, still on disk and untouched.
#expect(loss.reveals.allSatisfy { FileManager.default.fileExists(atPath: $0.url.path) })
// "each with Reveal in Finder", as a control the strip can render and Tab can reach.
let row = BannerRow.loss(loss)
#expect(row.tone == .warning, "a warning-tone loss row — nothing failed")
#expect(row.controls.map(\.label) == ["Reveal in Finder", "Dismiss"])
}
@Test("A sole skip is named rather than counted")
func aSoleSkipIsNamed() throws {
let center = BannerCenter()
center.postSkippedOnOpen([
RevealTarget(path: "lane/index.md", url: URL(fileURLWithPath: "/b/lane/index.md"))
])
#expect(center.losses.first?.message == "Opened without 'lane/index.md' — you chose to skip it")
}
@Test("Several skips fold to a count, with a reveal for each")
func severalSkipsFold() throws {
let items = [
RevealTarget(path: "a/index.md", url: URL(fileURLWithPath: "/b/a/index.md")),
RevealTarget(path: "b/index.md", url: URL(fileURLWithPath: "/b/b/index.md")),
RevealTarget(path: "c/index.md", url: URL(fileURLWithPath: "/b/c/index.md"))
]
let center = BannerCenter()
center.postSkippedOnOpen(items)
let loss = try #require(center.losses.first)
#expect(loss.message == "Opened without 3 items — you chose to skip them")
// The fold is only safe because "which ones" survives on the row.
#expect(loss.reveals.count == 3)
}
@Test("An open that skipped nothing says nothing")
func noSkipsNoNotice() {
let center = BannerCenter()
center.postSkippedOnOpen([])
#expect(center.losses.isEmpty)
}
@Test("An ordinary loss row still carries no reveal control")
func ordinaryLossRowsAreUnchanged() throws {
let center = BannerCenter()
center.postSkippedFolders(count: 2)
let loss = try #require(center.losses.first)
#expect(loss.reveals.isEmpty)
#expect(BannerRow.loss(loss).controls.map(\.label) == ["Dismiss"])
}
}
// MARK: - The attendance branch
@MainActor
@Suite("Decision surface ▸ the attendance branch")
struct BoardDecisionSurfaceAttendanceTests {
private func aggregate(_ reason: BoardLoadError.Reason, path: String = "index.md") -> BoardLoadFailure {
BoardLoadFailure(BoardLoadError(path: path, reason: reason))
}
@Test("A restored open never reaches the surface, whatever is wrong")
func restoredAlwaysRetires() {
// "restoration failures keep the retire-to-welcome-row landing launch never chains dialogs".
let repairable = aggregate(.missingSchema)
let handEdit = aggregate(.unparseableYAML(message: "x", line: 1), path: "lane/index.md")
#expect(BoardWindowHost.landing(for: repairable, origin: .restored) == .retire)
#expect(BoardWindowHost.landing(for: handEdit, origin: .restored) == .retire)
}
@Test("An attended open reaches the surface for anything a repair could touch")
func attendedDecides() {
#expect(BoardWindowHost.landing(for: aggregate(.missingSchema), origin: .attended) == .decide)
#expect(BoardWindowHost.landing(for: aggregate(.boardRootMissingIndex), origin: .attended) == .decide)
#expect(BoardWindowHost.landing(for: aggregate(.schemaNewerThanApp(found: 9)), origin: .attended) == .decide)
}
@Test("An environmental failure retires even when attended")
func theEnvironmentalCarveOut() {
// Redesign Gap 87cd782a: there is nothing on disk to repair, so a surface would offer a
// decision with no choices in it. Welcome's row says the same thing in one line.
let gone = BoardLoadFailure(
BoardLoadError(path: ".", reason: .unreadableRoot(message: "no such file or directory")))
let file = BoardLoadFailure(BoardLoadError(path: ".", reason: .notADirectory))
#expect(BoardWindowHost.landing(for: gone, origin: .attended) == .retire)
#expect(BoardWindowHost.landing(for: file, origin: .attended) == .retire)
}
@Test("An environmental defect among repairable ones still shows the surface")
func aMixedAggregateStillDecides() {
// The carve-out is "nothing to repair", not "one of these is environmental": the rest of the
// aggregate is still actionable.
let mixed = BoardLoadFailure([
BoardLoadError(path: "index.md", reason: .missingSchema),
BoardLoadError(path: ".", reason: .notADirectory)
])
#expect(BoardWindowHost.landing(for: mixed, origin: .attended) == .decide)
}
@Test("The origin rides the pending open, attended by default")
func theOriginRidesThePendingOpen() throws {
let folder = FileManager.default.temporaryDirectory
.appendingPathComponent("DecisionOrigin-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: folder) }
let model = AppModel(
registryStorageURL: folder.appendingPathComponent("board-registry.json"),
clipboardStagingRoot: folder.appendingPathComponent("Clipboard", isDirectory: true)
)
let attended = folder.appendingPathComponent("Attended.kanban", isDirectory: true)
let restored = folder.appendingPathComponent("Restored.kanban", isDirectory: true)
// The stash `openBoard(at:origin:)` makes reached directly, because that method needs a
// SwiftUI `OpenWindowAction` no test can build (`AppModel.stashPendingOpen`).
model.stashPendingOpen(for: BoardWindowRef(url: attended), url: attended, origin: .attended)
model.stashPendingOpen(for: BoardWindowRef(url: restored), url: restored, origin: .restored)
let attendedClaim = model.claimPendingOpen(for: BoardWindowRef(url: attended))
#expect(attendedClaim.origin == .attended)
#expect(attendedClaim.access != nil, "the scope rides the same stash")
#expect(model.claimPendingOpen(for: BoardWindowRef(url: restored)).origin == .restored)
// Claiming removes it, and a window that arrived by some other route reads attended with no
// scope the safe direction (the worst it can do is offer a repair nobody asked for).
let second = model.claimPendingOpen(for: BoardWindowRef(url: attended))
#expect(second.origin == .attended)
#expect(second.access == nil)
}
}
// MARK: - The Pro repair commit
/// HEAD's first-parent ancestry, newest first read through SwiftGitX, never through the committer
/// that made the commits (`AutoCommitTests`' rule, kept: nothing here shells out to `git`).
private func repairHistory(at boardRoot: URL, limit: Int = 8) throws -> [(subject: String, authorName: String, authorEmail: String, committerName: String)] {
let repository = try Repository.open(at: boardRoot)
guard !repository.isHEADUnborn, let tip = try repository.HEAD.target as? Commit else { return [] }
var records: [(String, String, String, String)] = []
var current: Commit? = tip
while let commit = current, records.count < limit {
records.append((commit.summary, commit.author.name, commit.author.email, commit.committer.name))
current = (try? commit.parents)?.first
}
return records
}
@MainActor
@Suite("Decision surface ▸ the Pro repair commit")
struct BoardDecisionSurfaceRepairCommitTests {
/// An `AppModel` whose app-side state lives in temp rather than in the app's real Application
/// Support home, and which reads as Pro `AutoCommitCompositionRootTests`' fixture.
private func makeModel() throws -> (model: AppModel, tearDown: () -> Void) {
let folder = FileManager.default.temporaryDirectory
.appendingPathComponent("DecisionRepairCommit-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
let model = AppModel(
registryStorageURL: folder.appendingPathComponent("board-registry.json"),
clipboardStagingRoot: folder.appendingPathComponent("Clipboard", isDirectory: true)
)
model.currentTier = { .pro }
return (model, { try? FileManager.default.removeItem(at: folder) })
}
/// **A repaired Pro board's first flush is one heal commit, authored by the integrity identity**
/// (01-storage-format.md § Malformed input: "On Pro boards the repairs drop heal-marked receipts
/// and commit separately as one repair commit, never folded into anyone else's work";
/// 06-history-undo.md Commit messages Healing mutations commit separately).
///
/// The whole chain is exercised end to end, because every link in it can fail silently and the
/// symptom is identical each time a commit blaming the outside world for the app's own repair:
///
/// 1. The surface's default resolution is the minted repair.
/// 2. `BoardRepairRun` writes it store-lessly and marks every receipt in its own ledger.
/// 3. The store built by the following walk **adopts** that ledger (`EchoLedger.adopt`)
/// before `beginSession`, which is where Pro's committer is composed and started.
/// 4. `GitAutoCommitter.start()` harvests, so the debounce it arms can see receipts that were
/// dropped before any write bracket of this session existed.
/// 5. `CommitAttribution.split` sorts the repaired path into the heal class, and the heal class
/// is authored `Lanework Integrity <integrity@lanework.invalid>` with the user as committer.
@Test("A repaired board under Pro + git produces a separate heal commit")
func aRepairCommitsAsTheIntegrityIdentity() async throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
// A board whose root is missing `schema` the minted stamp's own case. The lane is here so
// the repository has an ordinary tree around the file being repaired.
try fixture.item("", "---\ntitle: Needs A Stamp\ncreated: 2026-01-01T09:00:00Z\n---\nBoard.\n")
try fixture.item(Ident.lane1, "---\nschema: 1\ntitle: Todo\norder: 1024\n---\n")
let (model, tearDown) = try makeModel()
defer { tearDown() }
// The repository, with everything as it stands committed including the broken root, which is
// what makes the repair a real change rather than a fresh file.
let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro, ledger: EchoLedger()))
#expect(await git.addGit())
let commitsBefore = try repairHistory(at: fixture.root).count
git.stopAutoCommit()
// The surface, exactly as the window builds it.
let surface = BoardDecisionSurfaceModel(failure: try failure(of: fixture), boardRoot: fixture.root)
#expect(surface.canRepairAndOpen, "a lone missing root schema is a minted repair, preselected")
// Repair and Open's write half.
let outcome = BoardRepairRun.apply(surface.plannedRepairs, boardRoot: fixture.root)
#expect(outcome.failure == nil)
// The walk that follows, and the store it builds. Built directly rather than through the
// registry so this case is about the repair's commit and not about the open's *other* heals
// (the agent guide, the `.gitignore` seed), which the registry's acquire also fires.
let result = try BoardLoader.load(boardRoot: fixture.root, skipping: surface.skipSet)
let store = BoardStore(rootURL: fixture.root, loaded: result, skipping: surface.skipSet)
// The adoption, before the session composes the committer.
store.echoes.adopt(outcome.ledger)
let ref = BoardWindowRef(url: fixture.root)
let recordID = model.boardRegistry.recordOpen(of: fixture.root)
model.beginSession(ref: ref, store: store, recordID: recordID, access: nil)
let committer = try #require(model.session(for: ref)?.git?.committer)
// The debounce `start()` armed is not what this asserts; the explicit flush is.
committer.stop()
committer.debounceInterval = .seconds(30)
committer.coveringSnapshotDeadline = .milliseconds(50)
committer.coveringSnapshotPollInterval = .milliseconds(5)
await committer.flushNow()
let log = try repairHistory(at: fixture.root)
#expect(log.count == commitsBefore + 1, "one repair commit, never folded and never split further")
let head = try #require(log.first)
#expect(head.authorName == CommitAttribution.integrityAuthorName)
#expect(head.authorEmail == CommitAttribution.integrityAuthorEmail)
#expect(
head.authorEmail != CommitAttribution.externalAuthorEmail,
"the app's own repair must never be blamed on the outside world"
)
// "the committer stays the user (the recorded-by convention)".
#expect(head.committerName == GitCommitOperation.userIdentity(at: fixture.root).name)
#expect(GitCommitOperation.changedPaths(at: fixture.root).isEmpty, "the flush leaves nothing dirty")
}
}
+109 -47
View File
@@ -3,29 +3,35 @@ import XCTest
/// **A board that will not load, from the outside** (01-storage-format.md § Malformed input; /// **A board that will not load, from the outside** (01-storage-format.md § Malformed input;
/// 02-architecture.md § Launch and window lifecycle). /// 02-architecture.md § Launch and window lifecycle).
/// ///
/// ### The claim /// ### The claims, as they stand after the decision surface
///
/// > A restored board that fails surfaces on welcome, row-level: its window doesn't open; welcome
/// > appears alongside whatever did restore, the failed board's recents row carrying fail-fast's
/// > specifics (load error) never a silent drop.
/// ///
/// The `malformed` fixture is a well-formed board with exactly one unparseable card `index.md` /// The `malformed` fixture is a well-formed board with exactly one unparseable card `index.md`
/// (`UITestLaunch.malformedIndexText` a frontmatter flow sequence that is never closed). Building /// (`UITestLaunch.malformedIndexText` a frontmatter flow sequence that is never closed). Building
/// it succeeds; loading it must not, and *how* it fails is the whole of this file: /// it succeeds; loading it must not, and *how* it fails is the whole of this file:
/// ///
/// 1. **No board window.** Not an empty one, not one with the good lanes in it fail-fast is /// 1. **The window stays, and explains itself.** The fixture opens through `AppModel.openBoard`, which
/// all-or-nothing, so a partial board on screen would be the worse failure. /// is an **attended** open and 01 (settled 2026-07-31) makes an attended refusal the decision
/// 2. **Welcome, loudly.** The recents row for that board wears the loader's own sentence, which /// surface's case: "one aggregated surface hosted by the pre-snapshot loading window the loading
/// names the offending file. A row that fell back to "Unavailable", or to a count, would be the /// content transforms in place, never a sheet over a spinner". So there *is* a window, it is the
/// app declining to say what it found. /// one that was opening, and what it shows names the offending file.
/// 3. **Nothing repaired.** The bytes on disk are the bytes the fixture wrote. The loader is a pure /// 2. **No board behind it.** Fail-fast is all-or-nothing: the surface is a decision, not a partial
/// function of the tree and writes nothing, ever the Repair precedent so a board it refused /// board, so the good lanes are not on screen underneath it.
/// must still be refusable, byte for byte. /// 3. **Cancel is the old landing, on demand.** "**Cancel** aborts the open: the window retires and
/// the board lands row-level on welcome, record-before-load unchanged" which is the sequence
/// this file used to assert *unconditionally*, now reached by the user's own choice.
/// 4. **Nothing repaired.** The bytes on disk are the bytes the fixture wrote. The loader is a pure
/// function of the tree and writes nothing, ever the Repair precedent and the surface writes
/// only what the user chooses, which here is nothing.
///
/// **What changed and why**: claims 1 and 2 used to read "no board window, welcome instead". That was
/// the whole landing for *every* refusal before this milestone; it is now the **restored** landing
/// (`RestoreBootstrapView`) and the environmental one. The fixture launch is neither it opens a
/// board the way a double-click does.
/// ///
/// ### Where each claim is checked /// ### Where each claim is checked
/// ///
/// The first two are here, because they are about *windows* and a window is what a unit test does not /// The first three are here, because they are about *windows* and a window is what a unit test does
/// have. The third is checked **both** here and in `KanbanTests` unconditionally there /// not have. The fourth is checked **both** here and in `KanbanTests` unconditionally there
/// (`UITestMalformedFixtureBoardTests`, which builds the fixture and re-reads the tree), and /// (`UITestMalformedFixtureBoardTests`, which builds the fixture and re-reads the tree), and
/// opportunistically here, because reaching the app's container from the runner depends on how the /// opportunistically here, because reaching the app's container from the runner depends on how the
/// app under test was signed and installed. Where the container is not reachable this file says so /// app under test was signed and installed. Where the container is not reachable this file says so
@@ -37,47 +43,107 @@ final class FailFastLaunchTests: XCTestCase {
continueAfterFailure = false continueAfterFailure = false
} }
/// The whole of claims 1 and 2, in one launch: no board window, welcome instead, and the row /// Claims 1 and 2: the surface appears in the board's own window, names the file, and shows no
/// carrying the loader's specifics. /// board.
@MainActor @MainActor
func testMalformedBoardFailsLoudlyAndOpensNoWindow() throws { func testMalformedBoardShowsTheDecisionSurface() throws {
let app = XCUIApplication.launched(with: .malformed) let app = XCUIApplication.launched(with: .malformed)
// Welcome is where a failed open lands (`BoardWindowHost.start`: record the failure, refresh // The surface, by the identifier it carries for exactly this
// the recents, open welcome, dismiss the board window). // (`BoardDecisionSurface.accessibilityIdentifier`).
let surface = app.descendants(matching: .any)["decision-surface"]
XCTAssertTrue( XCTAssertTrue(
app.windows["Welcome to Lanework"].waitForExistence(timeout: XCUIApplication.uiTimeout), surface.waitForExistence(timeout: XCUIApplication.uiTimeout),
"the welcome window did not appear after a failed open" "an attended open of a refusing board did not transform into the decision surface"
) )
// Claim 1. Checked *after* welcome has appeared, so this is "the board window never came", // It is the *opening window* that hosts it the loading content transformed in place, so the
// not "the board window has not come yet". // window is still called what the loading state called it.
XCTAssertFalse( XCTAssertTrue(
app.windows[FixtureBoard.malformed.windowTitle].exists, app.windows[FixtureBoard.malformed.windowTitle].exists,
"a board window opened for a board the loader rejected" "the surface is not in the board's own window"
)
XCTAssertFalse(
app.windows["Welcome to Lanework"].exists,
"an attended refusal must not retire — that is the restored landing"
) )
// Claim 2. The row is one combined accessibility element name, location, caption and the // The class section for unparseable frontmatter, and the specifics on its row. The UUIDs in
// caption is `BoardLoadError.description`: "lane/card/index.md: unparseable YAML at line // the path are minted at launch and unknowable here, so the assertion is on the parts that are
// N: ". The UUIDs in that path are minted at launch and unknowable here, so the assertion is // the *app's* to keep stable: the offending file is named, and the reason is stated.
// on the parts that are the *app's* to keep stable: the offending file is named, and the
// reason is stated.
XCTAssertTrue( XCTAssertTrue(
app.element(labelContaining: "index.md").waitForExistence(timeout: XCUIApplication.uiTimeout), app.descendants(matching: .any)["decision-section-unreadable-frontmatter"].exists,
"no welcome row named the offending index.md — fail-fast's specifics did not reach the surface" "the defect was not grouped into its class section"
)
XCTAssertTrue(
app.element(labelContaining: "index.md").exists,
"no row named the offending index.md — fail-fast's specifics did not reach the surface"
) )
XCTAssertTrue( XCTAssertTrue(
app.element(labelContaining: "unparseable YAML").exists, app.element(labelContaining: "unparseable YAML").exists,
"the welcome row did not say why the board was refused" "the row did not say what is wrong with the file"
) )
// And it is the malformed board's own row that says it.
// Claim 2: a decision, not a partial board. The fixture's intact lane is not on screen.
XCTAssertFalse(
app.element(labelContaining: "A good card").exists,
"a partial board rendered behind the surface — fail-fast is all-or-nothing"
)
}
/// Claim 3: Cancel is the old landing, reached by the user's own choice the window retires and
/// the board lands row-level on welcome with fail-fast's specifics on it.
@MainActor
func testCancelRetiresToWelcomeWithTheSpecifics() throws {
let app = XCUIApplication.launched(with: .malformed)
// Queried across every element type rather than as `app.buttons[...]`: the surface's footer
// sits inside a `.contain` accessibility group, and which AX type a SwiftUI `Button` lands on
// there is not something a test should be asserting about in passing.
let cancel = app.descendants(matching: .any)["decision-cancel"]
XCTAssertTrue( XCTAssertTrue(
app.element(labelContaining: FixtureBoard.malformed.windowTitle).exists, cancel.waitForExistence(timeout: XCUIApplication.uiTimeout),
"the surface did not appear, so Cancel could not be pressed"
)
cancel.click()
XCTAssertTrue(
app.windows["Welcome to Lanework"].waitForExistence(timeout: XCUIApplication.uiTimeout),
"Cancel did not land on welcome"
)
XCTAssertFalse(
app.windows[FixtureBoard.malformed.windowTitle].exists,
"the window did not retire"
)
// The row is one combined accessibility element name, location, caption and the caption is
// `BoardLoadFailure.description`. Matched on *value* as well as label: welcome's rows are
// list cells whose combined text lands in the AX value, not in a label (see
// `Self.element(textContaining:in:)`).
XCTAssertTrue(
Self.element(textContaining: "unparseable YAML", in: app)
.waitForExistence(timeout: XCUIApplication.uiTimeout),
"the welcome row did not carry fail-fast's specifics"
)
XCTAssertTrue(
Self.element(textContaining: FixtureBoard.malformed.windowTitle, in: app).exists,
"the failure did not land on the failed board's row" "the failure did not land on the failed board's row"
) )
} }
/// Claim 3, twice over: the malformed bytes survive the refusal, and a second launch is refused /// The first element whose **label or value** contains `fragment`.
///
/// `XCUIApplication.element(labelContaining:)` matches the label alone, which is the right question
/// for the decision surface's own rows (each is one combined element with an explicit
/// accessibility label) and the wrong one for welcome's recents rows: those are list cells whose
/// name-location-caption text arrives as the cell's AX *value*.
@MainActor
private static func element(textContaining fragment: String, in app: XCUIApplication) -> XCUIElement {
app.descendants(matching: .any)
.matching(NSPredicate(format: "label CONTAINS %@ OR value CONTAINS %@", fragment, fragment))
.firstMatch
}
/// Claim 4, twice over: the malformed bytes survive the refusal, and a second launch is refused
/// the same way rather than opening a board the app quietly fixed. /// the same way rather than opening a board the app quietly fixed.
/// ///
/// The relaunch is not redundant with the byte check it is what the byte check *means* from the /// The relaunch is not redundant with the byte check it is what the byte check *means* from the
@@ -86,8 +152,8 @@ final class FailFastLaunchTests: XCTestCase {
func testMalformedBoardIsNeverRepaired() throws { func testMalformedBoardIsNeverRepaired() throws {
let app = XCUIApplication.launched(with: .malformed) let app = XCUIApplication.launched(with: .malformed)
XCTAssertTrue( XCTAssertTrue(
app.windows["Welcome to Lanework"].waitForExistence(timeout: XCUIApplication.uiTimeout), app.descendants(matching: .any)["decision-surface"].waitForExistence(timeout: XCUIApplication.uiTimeout),
"the welcome window did not appear after a failed open" "the surface did not appear"
) )
// The bytes, where the runner can reach them. `NSTemporaryDirectory()` inside the sandboxed // The bytes, where the runner can reach them. `NSTemporaryDirectory()` inside the sandboxed
@@ -101,7 +167,7 @@ final class FailFastLaunchTests: XCTestCase {
) )
XCTAssertTrue( XCTAssertTrue(
malformed.contains("order: [1024"), malformed.contains("order: [1024"),
"the malformed frontmatter was repaired — fail-fast must not write" "the malformed frontmatter was repaired — nothing here mints a rewrite of unparseable YAML"
) )
} else { } else {
// Not a failure, and not silence either: the run says which half of the claim it made. // Not a failure, and not silence either: the run says which half of the claim it made.
@@ -120,16 +186,12 @@ final class FailFastLaunchTests: XCTestCase {
app.terminate() app.terminate()
let second = XCUIApplication.launched(with: .malformed) let second = XCUIApplication.launched(with: .malformed)
XCTAssertTrue( XCTAssertTrue(
second.windows["Welcome to Lanework"].waitForExistence(timeout: XCUIApplication.uiTimeout), second.descendants(matching: .any)["decision-surface"].waitForExistence(timeout: XCUIApplication.uiTimeout),
"the second launch did not reach welcome"
)
XCTAssertFalse(
second.windows[FixtureBoard.malformed.windowTitle].exists,
"the second launch opened the board the first one refused" "the second launch opened the board the first one refused"
) )
XCTAssertTrue( XCTAssertTrue(
second.element(labelContaining: "index.md").waitForExistence(timeout: XCUIApplication.uiTimeout), second.element(labelContaining: "unparseable YAML").exists,
"the second launch did not name the offending file" "the second launch did not name the same defect"
) )
} }
+7 -4
View File
@@ -22,8 +22,9 @@ enum FixtureBoard {
/// lot of it. /// lot of it.
case large case large
/// A well-formed board with exactly one unparseable card `index.md`, for the fail-fast pass. It /// A well-formed board with exactly one unparseable card `index.md`, for the fail-fast pass. Its
/// is the only variant whose board window is *expected* never to appear. /// window is the only one that never holds a board: the open is attended, so the refusal
/// transforms the loading content into the decision surface (`FailFastLaunchTests`).
case malformed case malformed
/// `UITestLaunch.fixtureFlag` plus this variant's own flag. Both, always: the first is what "this /// `UITestLaunch.fixtureFlag` plus this variant's own flag. Both, always: the first is what "this
@@ -149,8 +150,10 @@ extension XCUIApplication {
/// Launches the app on `board` the audit fixture unless told otherwise and waits for its /// Launches the app on `board` the audit fixture unless told otherwise and waits for its
/// window. /// window.
/// ///
/// **Not for `.malformed`**, which has no window to wait for: that variant is launched with /// **Not for `.malformed`**, whose window never holds a board: since the decision surface (01
/// `launched(with:)` and the caller waits for welcome instead. /// § Malformed input, settled 2026-07-31) that variant's window does appear carrying the
/// surface rather than lanes so the wait below would pass while saying nothing. That variant is
/// launched with `launched(with:)` and the caller waits for the surface instead.
@MainActor @MainActor
static func launchedWithFixtureBoard(_ board: FixtureBoard = .standard) -> XCUIApplication { static func launchedWithFixtureBoard(_ board: FixtureBoard = .standard) -> XCUIApplication {
let app = launched(with: board) let app = launched(with: board)
+1 -1
View File
@@ -12,7 +12,7 @@ Lanework is in early development. This list tracks what has actually shipped and
- **Storage contract, read side** — frontmatter engine with a byte-perfect round-trip guarantee (unknown keys, comments, and formatting survive every rewrite; duplicate keys read last-wins; wrong-type scalars coerce read-side), gapped fractional ordering (Ranks), and a fail-fast board loader with UUID-gated level detection, warning-collecting skips, and a reserved `.trash/` container read by the very same card parse the lanes use — below the board root `order` and `schema` are optional (order-less items read append-at-end until a write stamps them), and the board-root `.gitignore`, seeded on every board, is the one noise gate the loose-file relocation heal obeys. One walk reports **every** fail-fast defect at once rather than stopping at the first — the root's own, then each lane and card in walk order, a broken lane's subtree left for the repair's re-check — so a refusal is one aggregate instead of a chain of them, and a per-open set of skipped paths (never persisted, never accepted at the root) loads the board without exactly the items the user chose to leave out. Pinned by a golden fixture suite of 17 on-disk boards. - **Storage contract, read side** — frontmatter engine with a byte-perfect round-trip guarantee (unknown keys, comments, and formatting survive every rewrite; duplicate keys read last-wins; wrong-type scalars coerce read-side), gapped fractional ordering (Ranks), and a fail-fast board loader with UUID-gated level detection, warning-collecting skips, and a reserved `.trash/` container read by the very same card parse the lanes use — below the board root `order` and `schema` are optional (order-less items read append-at-end until a write stamps them), and the board-root `.gitignore`, seeded on every board, is the one noise gate the loose-file relocation heal obeys. One walk reports **every** fail-fast defect at once rather than stopping at the first — the root's own, then each lane and card in walk order, a broken lane's subtree left for the repair's re-check — so a refusal is one aggregate instead of a chain of them, and a per-open set of skipped paths (never persisted, never accepted at the root) loads the board without exactly the items the user chose to leave out. Pinned by a golden fixture suite of 17 on-disk boards.
- **Storage contract, write side** — BoardWriter turns every mutation into an atomic temp-file+rename over exactly the files it touches: creates mint lowercase-UUIDv4 identities and `.kanban` packages; moves keep the UUID (with per-folder collision repair at the cross-board import boundary); copies mint fresh identities at every level; deletes move a card's folder into the board's reserved `.trash/` at a caller-minted top rank, restore is the ordinary move back out, and purge is physical; attachment imports never overwrite and never refuse (Finder-style renames). Every app write stamps `modified`, clears `modified-by`, and preserves everything it didn't change byte-for-byte; readable-but-uneditable frontmatter shapes refuse loudly instead of corrupting. Strays are preserved verbatim everywhere with exactly one carve-out: a loose *file* dropped beside a card's `index.md` belongs in that card's `attachments/`, so the app moves it there — Finder-renamed on collision, byte-faithfully, without touching `index.md` — and says so in a dismissable warning row naming the card and the files. Detection stays read-only in the loader; the move is an ordinary bracketed write that waits out the read-only lock and never retries a failure in a loop. Stray *folders*, symlinks, and everything at board or lane level keep the verbatim posture untouched, and a paste normalizes at the import boundary so a pasted card lands already tidy. The one other scope on that promise is the handful of board-root names the app claims: a file or symlink squatting `.trash/`, a folder squatting `CLAUDE.md`, is moved aside by the same Finder-style ladder rather than deleted or worked around, since deletion is broken while it stands. - **Storage contract, write side** — BoardWriter turns every mutation into an atomic temp-file+rename over exactly the files it touches: creates mint lowercase-UUIDv4 identities and `.kanban` packages; moves keep the UUID (with per-folder collision repair at the cross-board import boundary); copies mint fresh identities at every level; deletes move a card's folder into the board's reserved `.trash/` at a caller-minted top rank, restore is the ordinary move back out, and purge is physical; attachment imports never overwrite and never refuse (Finder-style renames). Every app write stamps `modified`, clears `modified-by`, and preserves everything it didn't change byte-for-byte; readable-but-uneditable frontmatter shapes refuse loudly instead of corrupting. Strays are preserved verbatim everywhere with exactly one carve-out: a loose *file* dropped beside a card's `index.md` belongs in that card's `attachments/`, so the app moves it there — Finder-renamed on collision, byte-faithfully, without touching `index.md` — and says so in a dismissable warning row naming the card and the files. Detection stays read-only in the loader; the move is an ordinary bracketed write that waits out the read-only lock and never retries a failure in a loop. Stray *folders*, symlinks, and everything at board or lane level keep the verbatim posture untouched, and a paste normalizes at the import boundary so a pasted card lands already tidy. The one other scope on that promise is the handful of board-root names the app claims: a file or symlink squatting `.trash/`, a folder squatting `CLAUDE.md`, is moved aside by the same Finder-style ladder rather than deleted or worked around, since deletion is broken while it stands.
- **Live store** — every open board is one shared, watched, in-memory snapshot: an FSEvents folder watcher (debounced, `.git`-filtered, origin-reconciling) drives whole-tree reloads with a generation guard and single-flight coalescing; write brackets suppress self-echo, and a per-board **write-provenance ledger** — in-memory, dying with the session — records a content hash, an absence marker or an old→new pair for every file the app writes, so a landing reload can tell its own echo from an outside edit file by file (final content decides: byte-identical is the app's, one byte different is somebody else's); a file-identity-keyed store registry refcounts stores and watchers across windows and absorbs root renames via bookmark re-resolution (a vanished root locks the board and watches for its return); plus the board registry (recents, bookmarks, cached counts), the banner center's single precedence order, the dirty-buffer guard, and transient UI state. - **Live store** — every open board is one shared, watched, in-memory snapshot: an FSEvents folder watcher (debounced, `.git`-filtered, origin-reconciling) drives whole-tree reloads with a generation guard and single-flight coalescing; write brackets suppress self-echo, and a per-board **write-provenance ledger** — in-memory, dying with the session — records a content hash, an absence marker or an old→new pair for every file the app writes, so a landing reload can tell its own echo from an outside edit file by file (final content decides: byte-identical is the app's, one byte different is somebody else's); a file-identity-keyed store registry refcounts stores and watchers across windows and absorbs root renames via bookmark re-resolution (a vanished root locks the board and watches for its return); plus the board registry (recents, bookmarks, cached counts), the banner center's single precedence order, the dirty-buffer guard, and transient UI state.
- **Window architecture** — the three window types and their lifecycle: a welcome window (below), one board window per root (per-board frame memory, repositioned onto a live screen), and at-most-one card window per card (last-used size, cascaded, then per-card frame memory once you've placed one; follows its card across lanes; dismisses the moment its card leaves the board — into the trash, with its deleted lane, purged, or moved to another board). Closing a board window or quitting runs one strict close flush — card sessions end, pending work drains, the registry is stamped — before the store tears down; launch restores the boards whose open-now flags survived quit (or crash), a preference gating only whether the flags are consulted. - **Window architecture** — the three window types and their lifecycle: a welcome window (below), one board window per root (per-board frame memory, repositioned onto a live screen), and at-most-one card window per card (last-used size, cascaded, then per-card frame memory once you've placed one; follows its card across lanes; dismisses the moment its card leaves the board — into the trash, with its deleted lane, purged, or moved to another board). Closing a board window or quitting runs one strict close flush — card sessions end, pending work drains, the registry is stamped — before the store tears down; launch restores the boards whose open-now flags survived quit (or crash), a preference gating only whether the flags are consulted. Every open passes through a real loading window — it appears immediately at its saved frame under the registry's cached name, its content a centered spinner behind a ~200 ms grace, while the tree walk runs off the main actor (one walk per board however many windows ask at once), and ⌘W during the walk genuinely cancels it. When an **attended** open refuses, that same content area transforms in place into the **decision surface** — never a sheet, never a chained dialog: the walk's defects grouped by class, each class stating the problem once and listing the affected files with Reveal in Finder and Open in Editor, one class-level choice preselected to its default and a per-file override behind a disclosure. Only honest choices are offered — unparseable frontmatter gets Open in Editor and Re-check (or Skip below the root), a file from a newer Lanework gets Skip alone (and blocks the whole board at the root), a board root with no `index.md` gets a minted index, a root with no `schema` gets a `schema: 1` stamp. Repair and Open applies every chosen fix in one write bracket — ordinary app writes, and on Pro boards a separate heal-authored repair commit — then re-runs the whole walk, opening on a clean result and re-aggregating into the *same* surface otherwise; Re-check re-walks without writing; Cancel (and ⌘W) retires to welcome's row. Skips are per-open consent that rides the session and is never persisted, and the opened board carries a warning-tone notice naming what was left out, each with its own Reveal in Finder. Restoration failures keep the retire-to-welcome-row landing — repair is an attended act, and launch never chains dialogs.
- **The board** — every lane always on screen, the window's width dividing across the lanes' width units with no horizontal scroll: cards flow into as many interior masonry columns as a lane is wide, a right-edge drag resizes between whole units by growing the *window* (snapping at the gap with release hysteresis, hard-stopping at the screen with rubber-band feedback), and ⌥⌘→/⌥⌘← re-divide the existing width instead. Lane chrome is a per-lane SF Symbol (unknown names fall back leniently), title or untitled placeholder, a card-count badge that counts exactly what's rendered, and a new-card button — the whole title bar doubling as the drag surface, a plain click selecting the lane and movement carrying it away. - **The board** — every lane always on screen, the window's width dividing across the lanes' width units with no horizontal scroll: cards flow into as many interior masonry columns as a lane is wide, a right-edge drag resizes between whole units by growing the *window* (snapping at the gap with release hysteresis, hard-stopping at the screen with rubber-band feedback), and ⌥⌘→/⌥⌘← re-divide the existing width instead. Lane chrome is a per-lane SF Symbol (unknown names fall back leniently), title or untitled placeholder, a card-count badge that counts exactly what's rendered, and a new-card button — the whole title bar doubling as the drag surface, a plain click selecting the lane and movement carrying it away.
- **Card faces** — a card reads as a leading SF Symbol, its title (or a quiet untitled placeholder), and a quiet paperclip when it has attachments — title-only by design, no body excerpt. Colour is an edge accent rather than a fill: `background` paints a stripe down the card's left edge and `iconColor` tints the symbol, both written as a kebab-case palette name (12 icon tints, 12 backgrounds) or a `#RRGGBB[AA]` hex. Everything degrades rather than complains — an unreadable colour simply doesn't paint, and the value stays on disk exactly as written. Each card's snapshot carries its attachment names, listed flat and in Finder order (top-level files only; subfolders, hidden files, and symlinks are preserved but never surfaced). A card has one presentation: selection changes only its styling, never its geometry, so the masonry never reflows on a click — the paperclip chip is the face's whole attachment story, and viewing the files themselves is the card window's job. - **Card faces** — a card reads as a leading SF Symbol, its title (or a quiet untitled placeholder), and a quiet paperclip when it has attachments — title-only by design, no body excerpt. Colour is an edge accent rather than a fill: `background` paints a stripe down the card's left edge and `iconColor` tints the symbol, both written as a kebab-case palette name (12 icon tints, 12 backgrounds) or a `#RRGGBB[AA]` hex. Everything degrades rather than complains — an unreadable colour simply doesn't paint, and the value stays on disk exactly as written. Each card's snapshot carries its attachment names, listed flat and in Finder order (top-level files only; subfolders, hidden files, and symlinks are preserved but never surfaced). A card has one presentation: selection changes only its styling, never its geometry, so the masonry never reflows on a click — the paperclip chip is the face's whole attachment story, and viewing the files themselves is the card window's job.
- **Creating and renaming** — New Card (⌘N) files into the selected card's lane immediately after it, a selected lane's bottom, or the last-active lane, opening a focused pseudo-card that exists nowhere on disk until its title commits (Return commits and re-selects the lane, ⌘↩ also opens the card window, Escape or clicking away discards, and a failed create discards rather than waiting for a card that can't arrive). Inline rename — Return on a card, Board ▸ Rename for either kind — tracks its target by UUID, so a foreign move mid-edit is invisible and a target that is trashed or deleted discards the edit silently; committing empty removes the `title` key rather than writing a blank one. New Lane is ⇧⌘N. Every mutating command disables while an editor holds the keyboard and under the read-only lock. - **Creating and renaming** — New Card (⌘N) files into the selected card's lane immediately after it, a selected lane's bottom, or the last-active lane, opening a focused pseudo-card that exists nowhere on disk until its title commits (Return commits and re-selects the lane, ⌘↩ also opens the card window, Escape or clicking away discards, and a failed create discards rather than waiting for a card that can't arrive). Inline rename — Return on a card, Board ▸ Rename for either kind — tracks its target by UUID, so a foreign move mid-edit is invisible and a target that is trashed or deleted discards the edit silently; committing empty removes the `title` key rather than writing a blank one. New Lane is ⇧⌘N. Every mutating command disables while an editor holds the keyboard and under the read-only lock.