Build the integrity service - IntegrityRules and the HealScheduler

The 2026-07-29 integrity design pass, consolidated (DESIGN/01 -
Validation and healing; DESIGN/02 - Components): IntegrityRules
(Storage, pure) is the one home for the identity predicate and
canonical form (BoardWriter.canonicalIdentity deleted, ItemID and the
loader forward to it), the per-field rulebook, uneditable shapes,
per-kind index validation, the reserved-name tables, and the trash
kind discriminator (values trusted - kind: lane/card explicit,
unrecognized falls to shape). LoadResult's ad-hoc channels fold into
one typed Defect stream (looseCardFiles / legacyTombstone /
claimedNameSquatted, per-defect heal signatures); the old accessors
survive as computed views.

HealScheduler (LiveStore) states the six-step heal pattern once -
resting-clear, lock gate, isWritableFile gate (now covering all four
heals), signature memo armed-before-attempt with explicit
clear-on-success, disk re-verify in each write half, one banner-posture
table (BannerCenter keeps all phrasing). The three hand-rolled healers
run on it with behavior preserved - including the
relocation-notice-despite-partial-failure quirk, deliberately. Heals
run at the reload tail AND at registry acquire, closing the
migration-never-fires-at-open asymmetry. Displacement runs first: a
squatted .trash would otherwise fail the migration and arm its memo
against an unchanged picture.

Claimed-name squatters (ruled today, 62c47a2) displace by the shared
Finder-style rename ladder - preserved verbatim, symlinks moved as
links, nothing stamped; AgentGuide's untouchable-skip upgrades to
displace-then-write, the CLAUDE.user.md-taken skip stands. kind stamps
on every create and backfills on any index rewrite via the on-touch
seam (placement resolver stamps nothing when the parent is unknown -
a guessed kind is worse than an absent one; board-root writers declare
theirs). Heal writes mark their EchoLedger receipts (inert in base;
pro-m1's committer will split them into their own commits). The
renumber ask-renumber-ask-again two-step is one shared helper, adopted
at all nine call sites.

69 tests added. 1738 green on both schemes.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-29 15:45:48 -04:00
parent 0d846c634e
commit 3a9db2e78b
27 changed files with 3226 additions and 617 deletions
+341 -338
View File
@@ -222,7 +222,7 @@ public enum TransferOperation: Sendable, Equatable {
/// is simply rendered on top of whatever the latest reload produced.
@MainActor
@Observable
public final class BoardStore {
public final class BoardStore: HealHost {
// MARK: Read-side state
@@ -244,23 +244,30 @@ public final class BoardStore {
/// describe the tree currently on screen.
public private(set) var loadWarnings: [LoadWarning]
/// The cards the load that produced `snapshot` found holding loose files, exactly as the loader
/// reported them the loose-file carve-out's detection channel (01-storage-format.md § Fractal
/// layout Rules, settled 2026-07-28). Replaced with the snapshot, like `loadWarnings`, so it
/// always describes the tree currently on screen.
/// **The pending work the load that produced `snapshot` found** the typed defect stream
/// (`IntegrityRules.Defect`, settled 2026-07-29). Replaced with the snapshot, like
/// `loadWarnings`, so it always describes the tree currently on screen.
///
/// **Nothing renders it.** A loose file is not content it reaches no view, and the card it
/// sits in draws exactly as it would without it. Its one consumer is
/// `relocateLooseCardFiles()`, immediately below the reload that produced it.
public private(set) var looseCardFiles: [LooseCardFiles]
/// **Nothing renders it.** A defect is not content it reaches no view, and the board draws
/// exactly as it would without it. Its one consumer is `runScheduledHeals()`, immediately below
/// the reload that produced it.
public private(set) var defects: [IntegrityRules.Defect]
/// The legacy `deleted:` keys the load that produced `snapshot` found the retired tombstone
/// model's migration input (01-storage-format.md § Deletion, resettled 2026-07-28), in the
/// loose-file channel's idiom and replaced with the snapshot exactly as it is.
///
/// **Nothing renders it either.** Its one consumer is `migrateLegacyTombstones()`, immediately
/// below the reload that produced it.
public private(set) var legacyTombstones: [LegacyTombstone]
/// The cards the last load found holding loose files a view over `defects`, under the name it
/// has always had.
public var looseCardFiles: [LooseCardFiles] {
defects.compactMap { if case let .looseCardFiles(work) = $0 { work } else { nil } }
}
/// The legacy `deleted:` keys the last load found a view over `defects`.
public var legacyTombstones: [LegacyTombstone] {
defects.compactMap { if case let .legacyTombstone(work) = $0 { work } else { nil } }
}
/// The claimed board-root names the last load found squatted a view over `defects`.
public var claimedNameSquatters: [ClaimedNameSquatter] {
defects.compactMap { if case let .claimedNameSquatted(work) = $0 { work } else { nil } }
}
/// The standing read-side condition: the error from the last reload that failed, `nil` when the
/// board is healthy. `BoardLoadError` already carries fail-fast's specifics the offending path
@@ -471,24 +478,14 @@ public final class BoardStore {
@ObservationIgnored
private var quiescenceWaiters: [CheckedContinuation<Void, Never>] = []
/// The loose-file set the last relocation attempt was made against the loop guard
/// `relocateLooseCardFiles()` documents. Empty means "nothing has been attempted against the
/// current picture", which is both the opening state and what a clean board resets it to.
/// **The scheduled-heal engine** (02-architecture.md Components HealScheduler): the six-step
/// pattern the three healers below used to re-derive one by one, plus the memo each of them
/// used to keep on its own.
///
/// A `let` beside `transient`, `banners` and `echoes`, and for their reason: the memos are
/// per-open state, and closing the board is the reset.
@ObservationIgnored
private var attemptedRelocation: Set<String> = []
/// The tombstone set the last migration attempt was made against `attemptedRelocation`'s twin,
/// documented at `migrateLegacyTombstones()`.
@ObservationIgnored
private var attemptedTombstoneMigration: Set<String> = []
/// The board root's agent-guide picture the last refresh acted on the third of the same loop
/// guard, documented at `refreshAgentGuide()`. `nil` means "nothing has been acted on against
/// the current picture", which is both the opening state and what a board whose guide is already
/// current resets it to (so the state it holds is never a guide's own text for longer than the
/// one reload that wrote it).
@ObservationIgnored
private var attemptedGuideRefresh: AgentGuide.State?
let heals = HealScheduler()
/// Awaited off the main actor **after** a tree walk finishes and **before** its result is
/// applied the one seam this type keeps, `nil` in production.
@@ -534,19 +531,18 @@ public final class BoardStore {
/// The walk is synchronous because the caller has nothing to render until it lands; the
/// asynchronous, off-main pipeline starts with the first reload.
///
/// **It writes nothing, the opened board's loose files included.** `looseCardFiles` is recorded
/// here and acted on by whoever wired this store up `BoardStoreRegistry.acquire` calls
/// `relocateLooseCardFiles()` once the watcher and the brackets exist, so the relocation is a
/// bracketed write 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) relocates when it is asked to, and
/// on every reload thereafter.
/// **It writes nothing, the opened board's defects included.** `defects` is recorded here and
/// acted on by whoever wired this store up `BoardStoreRegistry.acquire` calls
/// `runScheduledHeals()` once the watcher and the brackets exist, so a heal is a bracketed write
/// 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
/// thereafter.
public init(rootURL: URL) throws(BoardLoadError) {
let result = try BoardLoader.load(boardRoot: rootURL)
self.rootURL = rootURL
self.snapshot = result.model
self.loadWarnings = result.warnings
self.looseCardFiles = result.looseCardFiles
self.legacyTombstones = result.legacyTombstones
self.defects = result.defects
self.reloadFailure = nil
self.readOnlyLock = nil
self.transient = TransientBoardState()
@@ -765,8 +761,7 @@ public final class BoardStore {
// Breakage always heals on a success it *is* the claim "the last reload failed", and
// this one did not.
reloadFailure = nil
looseCardFiles = result.looseCardFiles
legacyTombstones = result.legacyTombstones
defects = result.defects
reconcileLock(after: origin)
// The registry write-through, for the same "not board structure" reason the lock
// clearing sits out here: whether this board's row needs a new title, icon, or
@@ -774,29 +769,14 @@ public final class BoardStore {
// guard), not a decision this store makes by comparing against its own prior
// snapshot.
displayStateDelegate?()
// Last, and after `reconcileLock` deliberately: this is the seam the two deferred
// app-initiated writes are armed on. A board that was locked read-only tolerated its
// loose files and its legacy tombstones for exactly as long as the lock stood, and the
// reload that clears the lock is the reload that lets them move see
// `relocateLooseCardFiles()` and `migrateLegacyTombstones()`. The ordering cuts the
// other way too now that the probe is symmetric: a reconciling reload that *raises* the
// lock raises it before these three run, so none of them writes into a location the
// same reload just learned is read-only.
//
// The migration goes second only because the relocation is the older rule; they touch
// disjoint files (loose files beside an `index.md` vs the `deleted:` key inside one) and
// share one bracket-per-call posture, so neither can see the other's work half-done
// each opens its own bracket and each is re-armed by the reload the other's write
// produces.
//
// The agent guide joins them last, and is the one of the three that runs on *every*
// board rather than only on one an older version or an outside writer left work in: it
// re-checks a single board-root file and writes only when the version marker says to
// (08-agent-integration.md The agent guide). Running it here rather than at open alone
// is what makes it self-healing see `refreshAgentGuide()`.
relocateLooseCardFiles()
migrateLegacyTombstones()
refreshAgentGuide()
// **The reload tail** one of the two seams the heal engine runs at (the other is
// `BoardStoreRegistry.acquire`), and after `reconcileLock` deliberately: this is where
// the deferred app-initiated writes are armed. A board that was locked read-only
// tolerated its defects for exactly as long as the lock stood, and the reload that
// clears the lock is the reload that heals them. The ordering cuts the other way too:
// a reconciling reload that *raises* the unwritable-location lock raises it before this
// runs, so no heal writes into a location the same reload just learned is read-only.
runScheduledHeals()
case let .failure(error):
// `snapshot`, `loadWarnings` and the transient state are untouched: a failed reload
@@ -1398,7 +1378,13 @@ public final class BoardStore {
for edit in edits {
// `.style(title: nil)`: `updateIndex` enriches it off the document it reads, so a
// failure names the item by the title it still has (see `WriteOperation.style`).
try BoardWriter.updateIndex(inItemFolder: edit.folder, operation: .style(title: nil)) { document in
// `kind: .board` for the one subject with no identity the board root, whose
// position nothing can infer (`BoardWriter.updateIndex`'s on-touch backfill).
try BoardWriter.updateIndex(
inItemFolder: edit.folder,
kind: edit.id == nil ? .board : nil,
operation: .style(title: nil)
) { document in
Self.apply(edit.background, to: FrontmatterKeys.background, in: &document)
Self.apply(edit.icon, to: FrontmatterKeys.icon, in: &document)
}
@@ -1437,14 +1423,22 @@ public final class BoardStore {
}
) { _ in
for edit in edits {
try BoardWriter.updateIndex(inItemFolder: edit.folder, operation: .style(title: nil)) { document in
try BoardWriter.updateIndex(
inItemFolder: edit.folder,
kind: edit.id == nil ? .board : nil,
operation: .style(title: nil)
) { document in
Self.restore(edit.priorBackground, to: FrontmatterKeys.background, in: &document)
Self.restore(edit.priorIcon, to: FrontmatterKeys.icon, in: &document)
}
}
} redo: { _ in
for edit in edits {
try BoardWriter.updateIndex(inItemFolder: edit.folder, operation: .style(title: nil)) { document in
try BoardWriter.updateIndex(
inItemFolder: edit.folder,
kind: edit.id == nil ? .board : nil,
operation: .style(title: nil)
) { document in
Self.apply(edit.background, to: FrontmatterKeys.background, in: &document)
Self.apply(edit.icon, to: FrontmatterKeys.icon, in: &document)
}
@@ -1551,16 +1545,16 @@ public final class BoardStore {
// degenerate reorder "a move whose destination is the item's current parent degrades
// to a plain reorder" inside the *same* `performWrite`, so the pair rounds back as
// one app-mediated reload rather than showing the card at the bottom for a frame.
var rank = Ranks.insertionRank(amongVisible: visible.map(\.order), at: position)
if rank == nil {
// Midpoint precision exhausted between the anchor and its neighbour
// (01-storage-format.md § Ordering). Compact, then place against the fresh ranks:
// the new card is not among the renumbered siblings it was appended past them
// so the compacted ladder lines up one-for-one with `visible`.
try BoardWriter.renumberVisibleChildren(of: laneFolder)
rank = Ranks.insertionRank(amongVisible: Ranks.renumbered(count: visible.count), at: position)
}
guard let rank else { return id }
// Ask, and on exhausted midpoint precision (01-storage-format.md § Ordering) compact
// and ask again the shared two-step. The new card is not among the renumbered
// siblings (it was appended past them), so the compacted ladder lines up one-for-one
// with `visible` and nothing captured needs refreshing here.
guard let placed = try HealScheduler.placingRanks(
amongVisible: visible.map(\.order),
compacting: laneFolder,
{ Ranks.insertionRank(amongVisible: $0, at: position) }
) else { return id }
let rank = placed.placement
_ = try BoardWriter.moveItem(
at: laneFolder.appendingPathComponent(id.rawValue),
@@ -1670,8 +1664,16 @@ public final class BoardStore {
/// The title write every rename shares the item-level one and the board's spelled once so
/// the empty-title rule (a missing key, never `title: ""`) cannot differ between a gesture and
/// its own undo.
private static func setTitle(_ title: String?, at folder: URL) throws(BoardWriteError) {
try BoardWriter.updateIndex(inItemFolder: folder, operation: .rename(title: nil)) { document in
///
/// - Parameter kind: `.board` from the board rename, `nil` from an item's the on-touch `kind`
/// backfill's one declared case, since a board root's folder name is a Finder document name
/// and position cannot answer for it (`BoardWriter.updateIndex`).
private static func setTitle(
_ title: String?,
at folder: URL,
kind: IntegrityRules.ObjectKind? = nil
) throws(BoardWriteError) {
try BoardWriter.updateIndex(inItemFolder: folder, kind: kind, operation: .rename(title: nil)) { document in
if let title {
document.set(FrontmatterKeys.title, to: .string(title))
} else {
@@ -1988,7 +1990,7 @@ public final class BoardStore {
let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
// `.rename(title: nil)`: `updateIndex` enriches it off the document it reads, so a
// refusal names the board by the title it still has (see `WriteOperation.rename`).
try Self.setTitle(newTitle, at: folder)
try Self.setTitle(newTitle, at: folder, kind: .board)
}
guard landed != nil else { return }
@@ -2001,9 +2003,9 @@ public final class BoardStore {
undoExpects: [.present(folder, .title(newTitle))],
redoExpects: [.present(folder, .title(priorTitle))]
) { _ in
try Self.setTitle(priorTitle, at: folder)
try Self.setTitle(priorTitle, at: folder, kind: .board)
} redo: { _ in
try Self.setTitle(newTitle, at: folder)
try Self.setTitle(newTitle, at: folder, kind: .board)
}
}
@@ -2040,19 +2042,21 @@ public final class BoardStore {
var priorOrder = lanes[from].order
var newOrder: Double?
let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
var rank = Ranks.insertionRank(amongVisible: remaining.map(\.order), at: target)
if rank == nil {
// Compact and place again. Unlike the card case the dragged lane *is* among the
// renumbered children it is a real folder on disk so its fresh rank is dropped
// from the ladder before the neighbours are consulted.
try BoardWriter.renumberVisibleChildren(of: root)
let renumbered = Ranks.renumbered(count: lanes.count)
priorOrder = renumbered[from]
var compacted = renumbered
compacted.remove(at: from)
rank = Ranks.insertionRank(amongVisible: compacted, at: target)
}
guard let rank else { return }
// The shared two-step, over the *whole* strip: unlike the card-create case the dragged
// lane **is** among the renumbered children it is a real folder on disk so the ask
// drops its own rung before consulting the neighbours, and a compaction refreshes the
// prior rank the inverse has to restore.
guard let placed = try HealScheduler.placingRanks(
amongVisible: lanes.map(\.order),
compacting: root,
{ ladder in
var compacted = ladder
compacted.remove(at: from)
return Ranks.insertionRank(amongVisible: compacted, at: target)
}
) else { return }
if placed.renumbered { priorOrder = placed.ladder[from] }
let rank = placed.placement
newOrder = rank
_ = try BoardWriter.moveItem(
@@ -2119,18 +2123,25 @@ public final class BoardStore {
var priorOrders = members.map(\.order)
var rewrites: [(folder: URL, order: Double)] = []
let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
var ranks = Ranks.insertionRanks(amongVisible: remaining.map(\.order), at: target, count: members.count)
if ranks == nil {
// Compact and place again. The dragged lanes *are* among the renumbered children
// they are real folders on disk so their fresh rungs are dropped from the ladder
// before the neighbours are consulted, exactly as `moveLane` drops its one.
try BoardWriter.renumberVisibleChildren(of: root)
let renumbered = Array(zip(lanes, Ranks.renumbered(count: lanes.count)))
priorOrders = renumbered.filter { ids.contains($0.0.id) }.map(\.1)
let compacted = renumbered.filter { !ids.contains($0.0.id) }.map(\.1)
ranks = Ranks.insertionRanks(amongVisible: compacted, at: target, count: members.count)
// `moveLane`'s two-step, plural: the dragged lanes *are* among the renumbered children,
// so the ask drops their rungs before consulting the neighbours, and a compaction
// refreshes the prior ranks the inverse restores.
guard let placed = try HealScheduler.placingRanks(
amongVisible: lanes.map(\.order),
compacting: root,
{ ladder in
let rungs = Array(zip(lanes, ladder))
return Ranks.insertionRanks(
amongVisible: rungs.filter { !ids.contains($0.0.id) }.map(\.1),
at: target,
count: members.count
)
}
) else { return }
if placed.renumbered {
priorOrders = Array(zip(lanes, placed.ladder)).filter { ids.contains($0.0.id) }.map(\.1)
}
guard let ranks else { return }
let ranks = placed.placement
for (member, rank) in zip(members, ranks) {
let folder = root.appendingPathComponent(member.id.rawValue, isDirectory: true)
@@ -2291,21 +2302,28 @@ public final class BoardStore {
var origins = Dictionary(uniqueKeysWithValues: members.map { ($0.id, (path: $0.path, order: $0.order)) })
var arrivals: [(id: ItemID, order: Double)] = []
let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
var ranks = Ranks.insertionRanks(amongVisible: remaining.map(\.order), at: target, count: members.count)
if ranks == nil {
// Compact and place again. The renumber assigns in display order over the lane's
// cards, so the compacted ladder lines up one-for-one with `rendered`; the members
// already in this lane are dropped from it before the neighbours are consulted,
// exactly as `moveLane` drops the dragged lane's own rung.
try BoardWriter.renumberVisibleChildren(of: laneFolder)
let renumbered = Array(zip(rendered, Ranks.renumbered(count: rendered.count)))
for (card, rank) in renumbered where ids.contains(card.id) {
// The shared two-step. The renumber assigns in display order over the lane's cards, so
// the compacted ladder lines up one-for-one with `rendered`; members already in this
// lane are dropped from it before the neighbours are consulted, exactly as `moveLane`
// drops the dragged lane's own rung and a compaction refreshes their captured origins.
guard let placed = try HealScheduler.placingRanks(
amongVisible: rendered.map(\.order),
compacting: laneFolder,
{ ladder in
let rungs = Array(zip(rendered, ladder))
return Ranks.insertionRanks(
amongVisible: rungs.filter { !ids.contains($0.0.id) }.map(\.1),
at: target,
count: members.count
)
}
) else { return }
if placed.renumbered {
for (card, rank) in zip(rendered, placed.ladder) where ids.contains(card.id) {
origins[card.id] = (path: .card(lane: laneID, id: card.id), order: rank)
}
let compacted = renumbered.filter { !ids.contains($0.0.id) }.map(\.1)
ranks = Ranks.insertionRanks(amongVisible: compacted, at: target, count: members.count)
}
guard let ranks else { return }
let ranks = placed.placement
for (member, rank) in zip(members, ranks) {
arrivals.append((id: member.id, order: rank))
@@ -2412,16 +2430,15 @@ public final class BoardStore {
let laneFolder = ItemPath.lane(laneID).folder(under: root)
try? performWrite { () throws(BoardWriteError) -> Void in
var ranks = Ranks.insertionRanks(amongVisible: rendered.map(\.order), at: placement, count: members.count)
if ranks == nil {
try BoardWriter.renumberVisibleChildren(of: laneFolder)
ranks = Ranks.insertionRanks(
amongVisible: Ranks.renumbered(count: rendered.count),
at: placement,
count: members.count
)
}
guard let ranks else { return }
// The shared two-step. The copies are not among the renumbered children they do not
// exist yet so the compacted ladder lines up one-for-one with `rendered` and nothing
// captured needs refreshing.
guard let placed = try HealScheduler.placingRanks(
amongVisible: rendered.map(\.order),
compacting: laneFolder,
{ Ranks.insertionRanks(amongVisible: $0, at: placement, count: members.count) }
) else { return }
let ranks = placed.placement
for (member, rank) in zip(members, ranks) {
_ = try BoardWriter.copyItem(
@@ -2562,16 +2579,13 @@ public final class BoardStore {
let laneFolder = ItemPath.lane(laneID).folder(under: root)
try? performWrite { () throws(BoardWriteError) -> Void in
var ranks = Ranks.insertionRanks(amongVisible: rendered.map(\.order), at: target, count: sources.count)
if ranks == nil {
try BoardWriter.renumberVisibleChildren(of: laneFolder)
ranks = Ranks.insertionRanks(
amongVisible: Ranks.renumbered(count: rendered.count),
at: target,
count: sources.count
)
}
guard let ranks else { return }
// The shared two-step; the arrivals are not among the renumbered children.
guard let placed = try HealScheduler.placingRanks(
amongVisible: rendered.map(\.order),
compacting: laneFolder,
{ Ranks.insertionRanks(amongVisible: $0, at: target, count: sources.count) }
) else { return }
let ranks = placed.placement
for (source, rank) in zip(sources, ranks) {
guard let arrived = try Self.materialize(
@@ -2676,16 +2690,13 @@ public final class BoardStore {
let target = min(max(0, stripIndex), rendered.count)
try? performWrite { () throws(BoardWriteError) -> Void in
var ranks = Ranks.insertionRanks(amongVisible: rendered.map(\.order), at: target, count: sources.count)
if ranks == nil {
try BoardWriter.renumberVisibleChildren(of: root)
ranks = Ranks.insertionRanks(
amongVisible: Ranks.renumbered(count: rendered.count),
at: target,
count: sources.count
)
}
guard let ranks else { return }
// The shared two-step; the arriving lanes are not among the renumbered children.
guard let placed = try HealScheduler.placingRanks(
amongVisible: rendered.map(\.order),
compacting: root,
{ Ranks.insertionRanks(amongVisible: $0, at: target, count: sources.count) }
) else { return }
let ranks = placed.placement
for (source, rank) in zip(sources, ranks) {
guard let arrived = try Self.materialize(
@@ -2792,7 +2803,12 @@ public final class BoardStore {
/// Moves every loose file the last applied snapshot found beside a card's `index.md` into that
/// card's `attachments/`, and posts one notice naming what moved the **act** half of
/// 01-storage-format.md's loose-file carve-out (§ Fractal layout Rules, settled 2026-07-28,
/// "Lanework-owns-the-board"; the loader's `looseCardFiles` is the notice half).
/// "Lanework-owns-the-board"; the loader's loose-file defect is the notice half).
///
/// **Scheduling is the engine's** (`HealScheduler`): the resting-clear, the lock-and-writability
/// gate, the signature compare, the armed-before-attempt memo, the one bracket, the banner
/// posture and the clear-on-success are all its six steps, and this method is now only what is
/// genuinely this heal's which Writer call the defect maps to, and what the notice names.
///
/// **It registers no undo step**, and unlike its neighbours that is not a deferral: nobody asked
/// for it. The relocation is the app tidying its own house on a reload, not a gesture there is
@@ -2800,58 +2816,23 @@ public final class BoardStore {
/// the user never did. (It is `renumberVisibleChildren`'s posture: bookkeeping composes no event,
/// 06-history-undo.md Commit messages.)
///
/// **It is an ordinary app write and nothing more.** One `performWrite` bracket over the whole
/// board's worth of relocation, so the churn rounds back as a single app-mediated reload and (on
/// git boards) a single commit the style batch's rule, applied to a batch the app started
/// itself. The snapshot is not touched here any more than it is anywhere else: the files move,
/// the watcher notices, the reload lands.
/// **One bracket over the whole board's worth of relocation**, so the churn rounds back as a
/// single app-mediated reload and (on git boards) a single commit the style batch's rule,
/// applied to a batch the app started itself. The snapshot is not touched here any more than it
/// is anywhere else: the files move, the watcher notices, the reload lands.
///
/// ### The read-only lock defers it, it does not cancel it
///
/// "The relocation waits out any read-only lock strays stay tolerated until it clears." A
/// locked board returns here having written nothing **and having remembered nothing**, so the
/// next attempt is a fresh one. The arming seam is `land(_:generation:origin:)`: every lock
/// clears on a successful reload and nowhere else, and this runs at the end of every successful
/// reload, after `reconcileLock(after:)` so the reload that lifts the lock is the reload that
/// performs the relocation, with no timer, no queue, and no second state to keep in step. The
/// same ordering covers the other direction, now that the probe is symmetric: a reconciling
/// reload that *raises* the unwritable-location lock raises it before this runs.
///
/// ### It cannot hot-loop
///
/// The relocation's own reload re-walks the tree, which is the loop the guard exists for. After
/// a success the walk finds nothing loose, `looseCardFiles` empties, and the memo below is
/// cleared the ordinary resting state. After a *failure* the walk finds the same files again,
/// and an unguarded call would fail again, forever, at the speed of a directory walk. So an
/// attempt is made only when the loose-file set **differs from the last one attempted**: one
/// failure, one banner row, then silence until the picture on disk actually changes (a file
/// added, removed, or partially moved by the failed attempt itself each of which is a
/// different set and so a fresh attempt).
///
/// The failure is the banner's already: `performWrite` posts every `BoardWriteError` before it
/// rethrows, and the rethrow is swallowed here like every other gesture with nothing else to do
/// about it. Files moved before the failure stay moved, and the notice names exactly those.
/// **The write half re-verifies against disk**: `BoardWriter.relocateLooseFiles` re-reads each
/// name at write time (`isRelocatable`) and skips what has gone, so a card whose loose files
/// vanished under the write contributes no line to the notice.
public func relocateLooseCardFiles() {
let work = looseCardFiles
guard !work.isEmpty else {
// The resting state, and the memo's reset: a board with nothing loose has nothing to
// remember having tried.
attemptedRelocation = []
return
}
// Deferred, not abandoned and deliberately *before* the memo is written, so the attempt
// this lock refused is not the attempt the guard below remembers.
guard readOnlyLock == nil else {
Self.logger.debug("loose-file relocation deferred — the board is read-only")
return
}
let signature = Self.relocationSignature(of: work)
guard signature != attemptedRelocation else { return }
attemptedRelocation = signature
let root = rootURL
var relocated: [BannerCenter.Relocation] = []
try? performWrite { () throws(BoardWriteError) -> Void in
heals.run(
.looseCardFiles,
signature: Self.signature(of: work.map(IntegrityRules.Defect.looseCardFiles)),
on: self
) { () throws(BoardWriteError) -> Void in
for card in work {
let folder = root
.appendingPathComponent(card.laneID.rawValue, isDirectory: true)
@@ -2865,23 +2846,18 @@ public final class BoardStore {
fileNames: moved.map { $0.sourceURL.lastPathComponent }
))
}
} posting: {
.relocatedLooseFiles(relocated)
}
banners.postRelocatedLooseFiles(relocated)
}
/// The loose-file picture as a comparable value: one entry per file, keyed by where it sits.
/// A defect list as the engine's comparable picture every defect's own signature, flattened.
///
/// A `Set` rather than the array itself because the *identity* of the work is what matters, not
/// the order the walk happened to meet it in and because two loads of an unchanged tree must
/// compare equal even if a lane's folder-name ordering shifted underneath them.
nonisolated static func relocationSignature(of work: [LooseCardFiles]) -> Set<String> {
var signature: Set<String> = []
for card in work {
for name in card.fileNames {
signature.insert("\(card.laneID.rawValue)/\(card.cardID.rawValue)/\(name)")
}
}
return signature
nonisolated static func signature(of defects: [IntegrityRules.Defect]) -> Set<String> {
Set(defects.flatMap(\.signatures))
}
/// Creates one card per file at `index` in `laneID`, each titled with its filename minus the
@@ -2933,14 +2909,13 @@ public final class BoardStore {
var created: [(folder: URL, source: URL)] = []
try? performWrite { () throws(BoardWriteError) -> Void in
var ranks = Ranks.insertionRanks(
amongVisible: rendered.map(\.order), at: target, count: urls.count)
if ranks == nil {
try BoardWriter.renumberVisibleChildren(of: laneFolder)
ranks = Ranks.insertionRanks(
amongVisible: Ranks.renumbered(count: rendered.count), at: target, count: urls.count)
}
guard let ranks else { return }
// The shared two-step; the created cards are not among the renumbered children.
guard let placed = try HealScheduler.placingRanks(
amongVisible: rendered.map(\.order),
compacting: laneFolder,
{ Ranks.insertionRanks(amongVisible: $0, at: target, count: urls.count) }
) else { return }
let ranks = placed.placement
for (url, rank) in zip(urls, ranks) {
// Create then place, `commitPlaceholder`'s pair: the Writer's create appends after the
@@ -3050,13 +3025,17 @@ public final class BoardStore {
// what its own rewrite overwrites and therefore what an undo has to put back.
var rewrites: [(folder: URL, from: Double, to: Double)] = []
let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
var ladder = orders
if !Self.isStrictlyAscending(orders) {
try BoardWriter.renumberVisibleChildren(of: laneFolder)
// The renumber assigns in display order, so the compacted ladder lines up one-for-one
// with `rendered` the same alignment `commitPlaceholder` relies on.
ladder = Ranks.renumbered(count: rendered.count)
}
// The shared two-step, with the *ask* being "are these ranks usable at all?": a
// permutation can only rewrite ranks that already separate the cards, so a ladder with
// ties is exhausted in exactly the sense the helper means, and the compacted one which
// is strictly ascending by construction always answers. It lines up one-for-one with
// `rendered`, the same alignment `commitPlaceholder` relies on.
guard let placed = try HealScheduler.placingRanks(
amongVisible: orders,
compacting: laneFolder,
{ Self.isStrictlyAscending($0) ? $0 : nil }
) else { return }
let ladder = placed.placement
for (destination, id) in plan.ordering.enumerated() {
guard let origin = positions[id], origin != destination else { continue }
let rank = ladder[destination]
@@ -3500,12 +3479,13 @@ public final class BoardStore {
/// Migrates every legacy `deleted:` key the last applied snapshot found, and posts one notice
/// the **act** half of 01-storage-format.md § Deletion's migration rule ("Legacy `deleted:` keys
/// migrate on load-and-write, never destroy"; the loader's `legacyTombstones` is the notice half).
/// migrate on load-and-write, never destroy"; the loader's legacy-tombstone defect is the notice
/// half).
///
/// **`relocateLooseCardFiles()`'s twin in every mechanical respect**, deliberately: same tail hook
/// on a successful reload, same lock deferral, same attempted-set loop guard, same one bracket
/// for the whole board, same warning-tone loss row. Two migrations arriving in one release with
/// two different schedulings would be two things to keep honest.
/// **`relocateLooseCardFiles()`'s twin in every mechanical respect**, and since 2026-07-29 that
/// is true by construction rather than by two methods agreeing: both run on `HealScheduler`, so
/// the tail hook, the lock deferral, the writability gate, the memo and the clear-on-success are
/// one implementation. What is this heal's own is below.
///
/// ### The two acts
///
@@ -3524,36 +3504,17 @@ public final class BoardStore {
/// the work. A card whose stamp is missing or unparseable sorts as **oldest** (the retired sort's
/// own rule: "a corrupt stamp must not outrank fresh deletions"), and ties fall to the loader's
/// walk order lane `order`, then card `order` which is the deterministic tie-break the whole
/// corpus already uses. `legacyTombstones` carries no timestamp of its own, so the stamps are
/// read out of the snapshot the same walk produced.
/// corpus already uses.
///
/// ### The read-only lock defers it, it does not cancel it
/// ### The write half re-verifies against disk
///
/// Exactly the relocation's posture, and stated there: a locked board returns having written
/// nothing **and having remembered nothing**, so the reload that lifts the lock is the reload
/// that performs the migration.
///
/// ### It cannot hot-loop
///
/// The migration's own write triggers a reload, which re-walks the tree the loop the guard
/// exists for. After a success the walk finds no keys, `legacyTombstones` empties, and the memo
/// clears. After a *failure* it finds the same keys again, and an unguarded call would fail
/// forever at the speed of a directory walk; so an attempt is made only when the tombstone set
/// **differs from the last one attempted**.
/// Each item's `deleted:` key is re-read at write time (`stillTombstoned(at:)`) and a key that
/// has gone an agent removed it, another window migrated first skips silently: "losing the
/// race to a foreign fix is success, never an error" (§ Validation and healing, generalized from
/// the Repair-races-a-vanished-duplicate precedent). Without it a lane whose key vanished under
/// the write would be rewritten for nothing, stamping `modified` on a file with no defect left.
public func migrateLegacyTombstones() {
let work = legacyTombstones
guard !work.isEmpty else {
attemptedTombstoneMigration = []
return
}
guard readOnlyLock == nil else {
Self.logger.debug("legacy tombstone migration deferred — the board is read-only")
return
}
let signature = Self.migrationSignature(of: work)
guard signature != attemptedTombstoneMigration else { return }
attemptedTombstoneMigration = signature
let root = rootURL
let cards = Self.migrationOrder(of: work, in: snapshot)
let lanes = work.filter { $0.kind == .lane }
@@ -3563,30 +3524,45 @@ public final class BoardStore {
// The ranks are minted exactly as a delete's are head of the trash, threaded forward so a
// migrated card is indistinguishable on disk from one the user deletes today.
var ladder = snapshot.trash.map(\.order)
try? performWrite { () throws(BoardWriteError) -> Void in
heals.run(
.legacyTombstone,
signature: Self.signature(of: work.map(IntegrityRules.Defect.legacyTombstone)),
on: self
) { () throws(BoardWriteError) -> Void in
for card in cards {
guard let cardID = card.cardID else { continue }
let folder = ItemPath.card(lane: card.laneID, id: cardID).folder(under: root)
guard Self.stillTombstoned(at: folder) else { continue }
let rank = Ranks.insertAtHead(ofVisible: ladder)
try BoardWriter.migrateTombstonedCard(
at: ItemPath.card(lane: card.laneID, id: cardID).folder(under: root),
inBoard: root,
order: rank
)
try BoardWriter.migrateTombstonedCard(at: folder, inBoard: root, order: rank)
ladder.insert(rank, at: 0)
movedCards.append(card.title)
}
for lane in lanes {
try BoardWriter.migrateTombstonedLane(at: ItemPath.lane(lane.laneID).folder(under: root))
let folder = ItemPath.lane(lane.laneID).folder(under: root)
guard Self.stillTombstoned(at: folder) else { continue }
try BoardWriter.migrateTombstonedLane(at: folder)
returnedLanes.append(lane.title)
}
} posting: {
.migratedTombstones(cards: movedCards, lanes: returnedLanes)
}
banners.postMigratedTombstones(cards: movedCards, lanes: returnedLanes)
}
/// The tombstone picture as a comparable value `relocationSignature`'s shape, for its reason:
/// the *identity* of the work is what matters, not the order the walk happened to meet it in.
nonisolated static func migrationSignature(of work: [LegacyTombstone]) -> Set<String> {
Set(work.map { "\($0.laneID.rawValue)/\($0.cardID?.rawValue ?? "")" })
/// Whether the item at `folder` still carries a `deleted:` key the migration's disk re-verify.
///
/// **Presence, not validity**, exactly as `Lane`/`Card.isDeleted` reads it: a malformed timestamp
/// still tombstones, and an explicit `deleted: null` is absence to both. A file that cannot be
/// read or parsed answers `false` the conservative direction, since a migration is a *move* and
/// the one thing it must never do is move something on a guess.
nonisolated static func stillTombstoned(at folder: URL) -> Bool {
guard let data = try? Data(contentsOf: folder.appendingPathComponent(BoardLoader.indexFileName)),
let text = String(validating: data, as: UTF8.self),
let document = try? FrontmatterDocument.parse(text)
else {
return false
}
return !document.deleted.isMissing
}
/// The `.card` tombstones in the order they should be filed into the trash oldest `deleted:`
@@ -3616,17 +3592,52 @@ public final class BoardStore {
.map(\.element)
}
// MARK: - The claimed-name displacement
/// Moves a squatter off a board-root name the app claims, and posts the notice naming old and
/// new the **act** half of the claimed-names ruling (01-storage-format.md § Fractal layout
/// Rules, ruled 2026-07-29: "Lanework owns the board, so an invalid artifact on a claimed name is
/// a defect, not a resident").
///
/// Today that is `.trash` and only `.trash`: a file or symlink sitting on the name the trash
/// needs, which breaks deletion for as long as it stands which is exactly why the timing is
/// *scheduled* rather than on-touch (§ Validation and healing: "proactive when the defect is
/// load-bearing now"). `CLAUDE.md`'s squatter is displaced by the guide's own heal, which owns
/// that file end to end.
///
/// **Displacement, never destruction**, and never a mint: the freed name is left empty and the
/// *next delete* creates the real `.trash/`, exactly as it does on a board that never had one.
public func displaceClaimedNames() {
let work = claimedNameSquatters
let root = rootURL
var displaced: [BannerCenter.Displacement] = []
heals.run(
.claimedNameSquatted,
signature: Self.signature(of: work.map(IntegrityRules.Defect.claimedNameSquatted)),
on: self
) { () throws(BoardWriteError) -> Void in
for squatter in work {
// The Writer re-verifies and answers `nil` when the name freed itself under us.
guard let freed = try BoardWriter.displaceClaimedName(squatter, atBoardRoot: root) else {
continue
}
displaced.append(BannerCenter.Displacement(name: squatter.name, movedTo: freed))
}
} posting: {
.displacedClaimedNames(displaced)
}
}
// MARK: - The agent guide
/// Brings the board root's `CLAUDE.md` up to the current guide version, or leaves it exactly as
/// it is the whole of 08-agent-integration.md The agent guide's scheduling. The rule itself
/// is `AgentGuide.decide(_:)`, a pure function; this method is the I/O and the policy around it.
///
/// **Run on every successful reload**, beside the relocation and the tombstone migration, and
/// once more at open (`BoardStoreRegistry.acquire`, which fires it after the watcher and the
/// brackets exist, for the reason stated there). That makes the guide *self-healing* rather than
/// merely written-once: a foreign deletion, a downgrade to an older guide, a board restored from
/// a template carrying a stale one each heals on the next reload, without a single new signal.
/// **Run on every successful reload**, beside the other scheduled heals, and once more at open
/// (`BoardStoreRegistry.acquire`). That makes the guide *self-healing* rather than merely
/// written-once: a foreign deletion, a downgrade to an older guide, a board restored from a
/// template carrying a stale one each heals on the next reload, without a single new signal.
/// It also pre-wires the Pro-era bounce 06-history-undo.md acknowledges by name, where undoing
/// an "Update agent guide (vN)" commit restores an older guide that the app immediately
/// re-upgrades.
@@ -3635,85 +3646,77 @@ public final class BoardStore {
/// first-line parse and no write at all. Nothing here touches the snapshot: the bytes land, the
/// watcher notices, the reload applies, exactly like every other app write.
///
/// ### The read-only board is skipped, never banner-ed
/// ### The two skips, and the one displacement
///
/// Two gates, because two different things can be true. `performWrite` would refuse under the
/// read-only lock on its own, but that refusal is a thrown error and this is not a gesture so
/// the lock is checked first, the relocation's own deferral idiom. That gate is now the one that
/// actually fires on an unwritable board: `BoardStoreRegistry.acquire` probes writability
/// *before* it calls this method, so 02-architecture.md's "the open-time agent-guide write is
/// skipped-with-log, the `CLAUDE.user.md`-taken precedent" is honored by the lock being up
/// rather than by this method noticing on its own.
/// `.skipUserFilenameTaken` is the standing exception (a rescue destination is not itself freed
/// by a second displacement); `.displaceSquatterThenWrite` is the 2026-07-29 upgrade of the old
/// untouchable-skip, and it *does* post a node of the user's moved aside owes the same
/// warning-tone notice `.trash`'s displacement does. Both decisions are re-made inside the write
/// half, which is this heal's disk re-verify.
///
/// The `access(2)` check below stays anyway, as the second line of defense: it is the only gate
/// covering the window *between* probes a volume remounted read-only mid-session raises the
/// lock at the next reconciling reload, and a reload landing in that window would otherwise
/// reach the Writer, fail, and post a banner about a file the user never asked for. Both skips
/// are logged and neither is ever surfaced.
/// ### The signature is the board root's picture
///
/// ### It cannot hot-loop
///
/// `performWrite`'s bracket schedules a reload whether or not the write succeeded, and this runs
/// on every reload so a *failing* guide write would retry forever at the speed of the debounce,
/// posting a banner row each time. The guard is the relocation's exactly: an attempt is made only
/// when the board root's picture **differs from the one last acted on**. One failure, one row,
/// then silence until something on disk actually changes. The same memo is what keeps the two
/// skip cases from repeating their log line on every reload of an unchanged board.
/// One failure, one row, then silence until something on disk actually changes and the memo
/// clears on success, which is what lets a foreign deletion of the guide be healed again
/// immediately (the picture "missing" is restored, and a standing memo would make that deletion
/// the one thing this could not heal).
public func refreshAgentGuide() {
let state = AgentGuide.inspect(atBoardRoot: rootURL)
let root = rootURL
let state = AgentGuide.inspect(atBoardRoot: root)
let decision = AgentGuide.decide(state)
guard decision != .leaveAlone else {
// The resting state, and the memo's reset: a board whose guide is current has nothing to
// remember having tried.
attemptedGuideRefresh = nil
return
}
// Deferred, not abandoned and deliberately *before* the memo is written, so the refresh a
// lock refused is not the one the guard below remembers.
guard readOnlyLock == nil else {
Self.logger.debug("agent-guide refresh deferred — the board is read-only")
return
}
guard FileManager.default.isWritableFile(atPath: rootURL.path) else {
Self.logger.debug("agent-guide refresh skipped — the board's location is not writable")
return
}
guard state != attemptedGuideRefresh else { return }
// Armed before anything is attempted, so a write that throws leaves it set the memo's
// whole job is to remember pictures this store has already failed or refused to act on.
attemptedGuideRefresh = state
// **A decision that writes nothing is no work at all**, and says so with an empty signature:
// the engine's resting-clear then costs no bracket, which matters because a bracket schedules
// a reload whether or not anything was written a skip that opened one would tick forever.
let signature: Set<String>
switch decision {
case .leaveAlone:
break // Ruled out above; the switch stays exhaustive so a new decision is a compile error.
case .skipUntouchable:
Self.logger.debug("agent-guide refresh skipped — CLAUDE.md is not an ordinary file")
signature = []
case .skipUserFilenameTaken:
// The ruling's own outcome (08 Ownership): a user-authored CLAUDE.md that cannot be
// rescued keeps its name, and the guide simply does not exist on this board.
Self.logger.debug("agent-guide refresh skipped — CLAUDE.md is not the app's and CLAUDE.user.md is taken")
case .write, .displaceThenWrite:
let root = rootURL
let displace = decision == .displaceThenWrite
do {
// One bracket over the rescue move *and* the write: two files change, one
// app-mediated reload lands, and (in the Pro edition) one honestly-attributed commit
// records it.
try performWrite { () throws(BoardWriteError) -> Void in
try AgentGuide.install(atBoardRoot: root, displacingUserContent: displace)
}
// **Cleared on success, and this is load-bearing rather than tidy**: the memo keys on
// the picture that provoked the write, and a foreign deletion restores that exact
// picture ("missing"). A memo left standing would make the deletion the one thing the
// self-heal could not heal.
attemptedGuideRefresh = nil
} catch {
// Already the banner's `performWrite` posts every `BoardWriteError` before it
// rethrows and there is nothing else a courtesy write can do about a failure. The
// memo, left armed above, is what keeps it from being posted again every reload.
Self.logger.error("agent-guide write failed: \(error.localizedDescription, privacy: .public)")
}
signature = []
case .write, .displaceThenWrite, .displaceSquatterThenWrite:
signature = [state.signature]
}
var displaced: [BannerCenter.Displacement] = []
heals.run(
.staleAgentGuide,
signature: signature,
on: self
) { () throws(BoardWriteError) -> Void in
// One bracket over the displacement *and* the write: two files change, one app-mediated
// reload lands, and (in the Pro edition) one honestly-attributed commit records it.
guard let moved = try AgentGuide.install(atBoardRoot: root) else { return }
// The `CLAUDE.user.md` rescue is the settled, silent ownership rule; a squatter's
// displacement is announced.
guard !moved.wasUserContent else { return }
displaced.append(BannerCenter.Displacement(name: moved.name, movedTo: moved.movedTo))
} posting: {
.displacedClaimedNames(displaced)
}
}
/// **Every scheduled heal, in order** the engine's two seams call exactly this
/// (02-architecture.md Components HealScheduler: "fires uniformly at the reload tail and at
/// registry acquire, closing today's asymmetry where tombstone migration never fires at open").
///
/// **The claimed-name displacement goes first, and that ordering is load-bearing**: a card's
/// migration mints `<root>/.trash/`, which cannot be created while a file or symlink holds that
/// name so a migration attempted ahead of the displacement fails *and arms its memo against an
/// unchanged tombstone picture*, which would leave the board unmigrated until something else on
/// disk changed. Running the displacement first closes the ruling's one-reload window inside a
/// single pass, because a heal's write lands synchronously even though its reload does not.
///
/// The rest of the order is immaterial: they touch disjoint files (a card's loose files, a
/// `deleted:` key inside an `index.md`, `CLAUDE.md`), each opens its own bracket, and each is
/// re-armed by the reload the others' writes produce, so none can see another's work half-done.
public func runScheduledHeals() {
displaceClaimedNames()
relocateLooseCardFiles()
migrateLegacyTombstones()
refreshAgentGuide()
}
// MARK: - Selection (delegated)