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
+62
View File
@@ -490,6 +490,40 @@ public final class BannerCenter {
postLoss(message)
}
/// One claimed board-root name whose squatter was moved aside what
/// `displacedClaimedNamesMessage(for:)` names.
///
/// Both names are carried because the notice owes **old and new** (01-storage-format.md
/// § Fractal layout Rules, ruled 2026-07-29: "with the relocation-style warning-tone notice
/// naming old and new"): the user needs to know which of their files moved *and* where to find
/// it, and a sentence naming only one of the two would be half an answer.
public struct Displacement: Sendable, Equatable {
/// The claimed name the app took back `.trash`, `CLAUDE.md`.
public let name: String
/// The Finder-ladder name the displaced node now has `.trash 2`.
public let movedTo: String
public init(name: String, movedTo: String) {
self.name = name
self.movedTo = movedTo
}
}
/// **The claimed-name displacement** (01-storage-format.md § Fractal layout Rules, ruled
/// 2026-07-29): a folder, file or symlink was sitting on a name the app owns, the app moved it
/// aside preserved verbatim, never destroyed and this is the row that says so.
///
/// **A loss row, on `postRelocatedLooseFiles`' exact reasoning**, which is also what the ruling
/// asks for by name ("the relocation-style warning-tone notice"): the app moved something of the
/// user's that they did not ask it to move, so it must be said out loud, must not evaporate
/// unread, and must not rank as an error, because nothing failed.
///
/// A displacement that displaced nothing posts nothing.
public func postDisplacedClaimedNames(_ displacements: [Displacement]) {
guard let message = Self.displacedClaimedNamesMessage(for: displacements) else { return }
postLoss(message)
}
/// 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
/// mixed drag proposes for its files only, and the drop imports the files while a one-shot
@@ -757,6 +791,13 @@ public final class BannerCenter {
// touched, and nothing is lost: the board works exactly as well without the guide, which
// is why every *refusal* to write it is a log line and only a real I/O failure gets here.
"Couldn't update the agent guide"
case let .displaceClaimedName(name):
// **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
// the *consequence* the user can act on rather than the mechanics: while the name is
// held, the feature that needs it does not work, and the fix is theirs (move or rename
// the thing sitting there) because the app has just demonstrated it cannot.
"Couldn't move '\(name)' aside — Lanework needs that name"
case let .toggleTask(title):
// The user's word for it, not the file's: they ticked a box. The card is named where
// the read that preceded the flip learned its title, so a body write that refused says
@@ -957,6 +998,27 @@ public final class BannerCenter {
return "\(clauses.joined(separator: " and "))\(tail)"
}
/// The claimed-name displacement's line the relocation's own voice (the act first, the subject
/// after an em dash), naming **old and new** as the ruling requires.
///
/// - **One name**: "Renamed '.trash' to '.trash 2' Lanework needs that name". The tail is the
/// whole explanation the row owes: the user did not rename anything, and without it the
/// sentence would read as an act they had somehow just taken. It says *needs the name* rather
/// than anything about what was there, because what was there is the user's business and
/// still exists, under the name the row just gave them.
/// - **Several**: folded to a count in the relocation's idiom "Renamed 2 items Lanework
/// needs those names". Two claimed names can be squatted at once (a board somebody unpacked
/// over an old one), and a two-clause sentence would be longer than the row.
///
/// `nil` when nothing moved a displacement that displaced nothing is not news.
public nonisolated static func displacedClaimedNamesMessage(for displacements: [Displacement]) -> String? {
guard let only = displacements.first else { return nil }
guard displacements.count == 1 else {
return "Renamed \(displacements.count) items — Lanework needs those names"
}
return "Renamed '\(only.name)' to '\(only.movedTo)' — Lanework needs that name"
}
/// A sole migrated item's name: its title in quotes, or the untitled rendering the relocation
/// line already uses ("an untitled card" / "an untitled lane" are one phrase here, because the
/// clause it sits in already says which level it is).
+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)
+16 -15
View File
@@ -221,24 +221,25 @@ public final class BoardStoreRegistry {
lastKnownRoot: rootURL
)
// The loose-file carve-out's first firing (01-storage-format.md § Fractal layout Rules,
// settled 2026-07-28): files an agent or a hand-editor left beside a card's `index.md`
// while this board was closed are relocated into `attachments/` now, with the notice.
// **The heal engine's open seam** (02-architecture.md Components HealScheduler: "fires
// uniformly at the reload tail and at registry acquire"). Everything an agent or a
// hand-editor left in this board while it was closed is healed now: loose files relocated
// into `attachments/`, legacy `deleted:` keys migrated, a squatter moved off a claimed name,
// the agent guide brought up to version each with its own notice where it has one.
//
// **Here rather than in `BoardStore.init`**, and last rather than first: the store's own
// init is one tree walk and no writes, and a relocation written before the brackets and the
// init is one tree walk and no writes, and a heal written before the brackets and the
// watcher exist would be a write nothing is watching landing on disk with the snapshot
// above it left one reload stale. By this line the pair is wired, so it is an ordinary
// bracketed app write whose echo reload refreshes the board like any other. Every reload
// thereafter re-fires it from `BoardStore.land`; this call is only the one the opening walk
// would otherwise have no reload behind.
store.relocateLooseCardFiles()
// The agent guide's first firing (08-agent-integration.md The agent guide), here for the
// same reason and with the same timing as the relocation above: a board opened with no
// `CLAUDE.md`, or with one an older version wrote, gets the current guide now as a
// bracketed app write with a reload behind it, never as a write into a board nothing is
// watching. Every reload thereafter re-checks it from `BoardStore.land`.
store.refreshAgentGuide()
// above it left one reload stale. By this line the pair is wired, so each heal is an
// ordinary bracketed app write whose echo reload refreshes the board like any other. Every
// reload thereafter re-fires them from `BoardStore.land`; this call is only the one the
// opening walk would otherwise have no reload behind.
//
// **One call, all four** before 2026-07-29 this seam named two of the three healers by
// hand, which is how the legacy-tombstone migration came to be the one heal that never fired
// at open. A board opened, migrated nothing, and waited for an unrelated filesystem event to
// do what opening should have done.
store.runScheduledHeals()
return store
}
+65 -7
View File
@@ -104,7 +104,23 @@ public final class EchoLedger: Sendable {
/// is what those paths want: there is no session whose echoes they are.
@TaskLocal public static var current: EchoLedger?
private let receipts = Mutex<[String: Receipt]>([:])
/// One receipt plus its attributes. Split from `Receipt` so the public vocabulary stays the
/// three outcomes it always was and an attribute can be added without every reader learning a
/// new shape.
private struct Entry: Sendable, Equatable {
var receipt: Receipt
/// **Whether the write that dropped this receipt was a heal** (06-history-undo.md Commit
/// messages, ruled 2026-07-29: "the Writer's heal operations drop heal-marked receipts in
/// the EchoLedger attribution machinery like the author split, never message tagging").
///
/// **Inert in base beyond the ledger itself.** Nothing here reads it and nothing renders it;
/// it is the flag pro-m1's committer reads to split a heal's paths into their own commit,
/// and it is stored rather than derived because by commit time the only thing that still
/// knows a path was healed is the receipt.
var isHeal: Bool = false
}
private let receipts = Mutex<[String: Entry]>([:])
public init() {}
@@ -145,7 +161,10 @@ public final class EchoLedger: Sendable {
/// The path-and-hash form **supersession** lives here, as a plain overwrite: "a newer app
/// write to the same path supersedes the receipt", because only the final content decides.
public func recordWrite(atPath path: String, hash: String) {
receipts.withLock { $0[path] = .content(hash: hash) }
// The heal mark is **not** carried over: supersession replaces the whole receipt, and a
// later ordinary write to the same path is exactly the case where the path stops being the
// heal's alone. `markHeal(at:)` is called after the write it describes, never before.
receipts.withLock { $0[path] = Entry(receipt: .content(hash: hash)) }
}
/// An attachment import "attachment imports hash during the copy (the bytes stream through
@@ -174,7 +193,7 @@ public final class EchoLedger: Sendable {
public func recordDeletion(atPath path: String) {
receipts.withLock { store in
Self.forget(&store, under: path)
store[path] = .absence
store[path] = Entry(receipt: .absence)
}
}
@@ -192,13 +211,42 @@ public final class EchoLedger: Sendable {
for path in Array(store.keys) where path.hasPrefix(source + "/") {
store[destination + path.dropFirst(source.count)] = store.removeValue(forKey: path)
}
let pair = Receipt.move(from: source, to: destination)
let pair = Entry(receipt: .move(from: source, to: destination))
store[source] = pair
store[destination] = pair
}
}
private static func forget(_ store: inout [String: Receipt], under path: String) {
/// **Marks the receipt at `path` as a heal** the Writer's heal operations call this on the
/// paths they touched, after the bytes land (06-history-undo.md Commit messages, ruled
/// 2026-07-29).
///
/// A no-op where there is no receipt (a Writer call outside any bracket, a test): a mark with no
/// receipt to attach to would describe nothing. It never *creates* a receipt for the same reason
/// the receipt is the record of a completed write, and this only ever adds an attribute to
/// one that already exists.
///
/// Both ends of a move pair are one fact filed under two keys, so marking either marks both.
public func markHeal(at url: URL) {
markHeal(atPath: Self.key(url))
}
public func markHeal(atPath path: String) {
receipts.withLock { store in
guard var entry = store[path] else { return }
entry.isHeal = true
store[path] = entry
if case let .move(from, to) = entry.receipt {
let other = path == from ? to : from
if var twin = store[other] {
twin.isHeal = true
store[other] = twin
}
}
}
}
private static func forget(_ store: inout [String: Entry], under path: String) {
let prefix = path + "/"
for key in Array(store.keys) where key.hasPrefix(prefix) {
store.removeValue(forKey: key)
@@ -217,7 +265,17 @@ public final class EchoLedger: Sendable {
}
public func receipt(atPath path: String) -> Receipt? {
receipts.withLock { $0[path] }
receipts.withLock { $0[path]?.receipt }
}
/// Whether the receipt at this path is heal-marked `false` for a path with no receipt at all,
/// which is the same shrug every other read here gives an unknown path.
public func isHeal(at url: URL) -> Bool {
isHeal(atPath: Self.key(url))
}
public func isHeal(atPath path: String) -> Bool {
receipts.withLock { $0[path]?.isHeal ?? false }
}
/// Every path the ledger holds a receipt for strictly *below* `folder`.
@@ -247,7 +305,7 @@ public final class EchoLedger: Sendable {
/// byte under an item would make every copy foreign.
public func classify(_ observations: [String: Observation]) -> Provenance {
let held: [(path: String, receipt: Receipt)] = receipts.withLock { store in
observations.keys.compactMap { path in store[path].map { (path, $0) } }
observations.keys.compactMap { path in store[path].map { (path, $0.receipt) } }
}
guard !held.isEmpty else { return .foreign }
for entry in held {
+221
View File
@@ -0,0 +1,221 @@
import Foundation
import os
// MARK: - The host
/// What a scheduled heal needs from the board it is healing the seam that keeps the engine
/// independent of `BoardStore` (which is the only conformer, and a test's fake is the second).
///
/// Deliberately four members and no more: the engine decides *whether and when*, never *what*. The
/// work itself is a closure the store hands over, because only the store knows which Writer call a
/// defect class maps to.
@MainActor
public protocol HealHost: AnyObject {
/// Where the board is **now** re-read per heal, because a rename absorbed mid-session moves it.
var rootURL: URL { get }
/// The standing read-only condition, `nil` when the board accepts writes.
var readOnlyLock: ReadOnlyLockReason? { get }
/// The board window's banner strip the engine posts through the posture table below, and
/// `BannerCenter` keeps every word.
var banners: BannerCenter { get }
/// The write gate: one watcher bracket, one app-mediated reload, the failure already banner-ed.
@discardableResult
func performWrite<T>(_ operation: () throws(BoardWriteError) -> T) throws -> T
}
// MARK: - HealScheduler
/// **The scheduled-heal engine** the six-step pattern expressed once (02-architecture.md
/// Components HealScheduler; 01-storage-format.md § Validation and healing, settled 2026-07-29).
///
/// Before this existed, three healers in `BoardStore` re-derived the same pattern by hand the
/// loose-file relocation, the legacy-tombstone migration, and the agent-guide refresh and the
/// copies had already drifted: only the guide defended itself against an unwritable location, only
/// the guide cleared its memo on success, and only two of the three fired at board open. The engine
/// is the pattern with the drift taken out.
///
/// ### The six steps, in order
///
/// 1. **Compute the work from the latest defects**, and **rest** when there is none: an empty
/// signature clears the memo, which is what makes a board with nothing to heal forget it ever
/// tried.
/// 2. **The lock-and-writability gate.** A read-only board **defers, never abandons** it returns
/// having written nothing *and having remembered nothing*, so the reload that lifts the lock is
/// the reload that heals. The `access(2)` check beside it was the guide's private defense and is
/// now everyone's: it covers the window *between* writability probes, where a volume remounted
/// read-only would otherwise let a heal reach the Writer, fail, and banner about work the user
/// never asked for.
/// 3. **Signature compare.** An attempt is made only when the defect picture **differs from the one
/// last acted on** one failure, one banner row, then silence until something on disk actually
/// changes.
/// 4. **Arm the memo *before* attempting**, so a write that throws leaves it set: the memo's whole
/// job is to remember pictures this store has already failed on.
/// 5. **One write bracket**, whose write half re-verifies each defect against disk and no-ops when
/// it is gone losing the race to a foreign fix is success, never an error.
/// 6. **Post per the banner-posture table**, then **clear the memo explicitly on success** the
/// guide's rule, generalized. Clearing is load-bearing rather than tidy: the memo keys on the
/// picture that provoked the write, and a foreign undo restores that exact picture, which a
/// standing memo would make the one thing the self-heal could not heal.
///
/// ### Where it runs
///
/// **At the reload tail and at registry acquire, uniformly** closing the asymmetry where the
/// tombstone migration never fired at open. Both seams call `BoardStore.runScheduledHeals()`.
///
/// ### What it deliberately is not
///
/// Not the home of *inline* heals, which stay gesture-scoped (`placingRanks` below is a helper for
/// one of them, not a scheduling of it), and not the home of *on-touch* heals, which live at the
/// Writer's `updateIndex` seam. The three timings are different by design (01-storage-format.md
/// § Validation and healing), and only this one needs a scheduler.
@MainActor
public final class HealScheduler {
/// The picture each defect class was last *acted on* against armed before an attempt, cleared
/// on success, and reset to nothing when the class has no work at all.
///
/// **In-memory, per-store, dies with the session**, like every other loop guard here: closing
/// the board is the reset.
private var memos: [IntegrityRules.Defect.Class: Set<String>] = [:]
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "heal")
public init() {}
/// The picture a class was last acted on, for tests and diagnostics. `nil` means "nothing has
/// been acted on against the current picture" both the opening state and the resting one.
public func memo(for healClass: IntegrityRules.Defect.Class) -> Set<String>? {
memos[healClass]
}
// MARK: - The engine
/// Runs one scheduled heal against `host`, per the six steps above.
///
/// - Parameters:
/// - healClass: the memo key and the banner-posture row.
/// - signature: the defect picture, as comparable values. **Empty means resting** nothing to
/// heal and is the one input that clears the memo without writing.
/// - work: the write half. Runs inside the host's write bracket, and re-verifies each defect
/// against disk itself (the Writer's heal operations do it per call).
/// - notice: what to say afterwards, read **whether or not `work` threw** a batch that moved
/// three files and failed on the fourth still names the three, which is what the relocation
/// has always done and what a partial heal owes the user.
public func run(
_ healClass: IntegrityRules.Defect.Class,
signature: Set<String>,
on host: some HealHost,
performing work: () throws(BoardWriteError) -> Void,
posting notice: () -> HealNotice = { .none }
) {
// 1. Resting-clear.
guard !signature.isEmpty else {
memos[healClass] = nil
return
}
// 2. The lock-and-writability gate deferral, deliberately *before* the memo is written, so
// the attempt a lock refused is not the attempt the guard below remembers.
guard host.readOnlyLock == nil else {
Self.logger.debug("\(String(describing: healClass), privacy: .public) heal deferred — the board is read-only")
return
}
guard FileManager.default.isWritableFile(atPath: host.rootURL.path) else {
Self.logger.debug("\(String(describing: healClass), privacy: .public) heal skipped — the board's location is not writable")
return
}
// 3. Signature compare, and 4. arm before attempting.
guard memos[healClass] != signature else { return }
memos[healClass] = signature
// 5. One bracket. `performWrite` posts every `BoardWriteError` before it rethrows, and the
// rethrow is swallowed here like every other app-initiated write with nothing else to do
// about a failure the armed memo is what keeps it from being posted again every reload.
let landed: Void? = try? host.performWrite(work)
// 6. Post, then clear on success.
Self.post(notice(), to: host.banners)
if landed != nil {
memos[healClass] = nil
}
}
// MARK: - The banner-posture table
/// What a heal has to say for itself **one row per defect class, declared once**
/// (02-architecture.md Components: "post per one banner-posture table BannerCenter still
/// owns all phrasing").
///
/// Every case here is a *loss row* or silence; there is no failure row, because failures are
/// `performWrite`'s and reach the strip as one-shots without anyone here deciding. The payloads
/// are values, never strings: the phrasing lives in `BannerCenter` and nowhere else, so a
/// sentence cannot be spelled two ways.
public enum HealNotice: Sendable, Equatable {
/// Silent nothing was healed, or the heal is one the user has no business hearing about
/// (the guide's ordinary refresh: a courtesy file the user did not create and may not know
/// exists).
case none
/// Loss row, warning tone: files were moved into a card's `attachments/`.
case relocatedLooseFiles([BannerCenter.Relocation])
/// Loss row, warning tone: legacy `deleted:` keys were resolved.
case migratedTombstones(cards: [String?], lanes: [String?])
/// Loss row, warning tone: a claimed name's squatter was moved aside, named old and new.
case displacedClaimedNames([BannerCenter.Displacement])
}
private static func post(_ notice: HealNotice, to banners: BannerCenter) {
switch notice {
case .none:
break
case let .relocatedLooseFiles(relocations):
banners.postRelocatedLooseFiles(relocations)
case let .migratedTombstones(cards, lanes):
banners.postMigratedTombstones(cards: cards, lanes: lanes)
case let .displacedClaimedNames(displacements):
banners.postDisplacedClaimedNames(displacements)
}
}
// MARK: - The inline renumber-and-retry
/// **The midpoint-exhaustion two-step, once** ask, renumber, ask again (01-storage-format.md
/// § Ordering; 02-architecture.md Components: "the renumber's ask-renumber-ask-again two-step
/// as one shared helper instead of today's nine hand-rolled copies").
///
/// An **inline** heal, and it stays gesture-scoped: this runs inside the caller's own
/// `performWrite` bracket, inside the gesture that triggered it, and composes no event of its
/// own (the renumber is bookkeeping 06-history-undo.md Commit messages). It is a helper here
/// rather than a method on the engine because inline heals are not scheduled; what they share
/// with the scheduled ones is the vocabulary, not the machinery.
///
/// - Parameters:
/// - ladder: the ranks to place among, in display order.
/// - parentFolder: whose visible children compact when the first ask fails a lane
/// (renumbering its cards) or the board root (renumbering its lanes).
/// - ask: computes the placement from a ladder. **`nil` means the gap is exhausted, not that
/// the insertion is illegal** (`Ranks`' own convention).
/// - Returns: what `ask` answered, plus the ladder it answered against the *renumbered* one
/// when a compaction happened, which callers need because a renumber moves ranks they had
/// already read (a dragged item's own prior rank, the undo step's inverse). `nil` when even
/// the compacted ladder had no answer, which every call site treats as "write nothing".
///
/// `nonisolated` because it is not the engine's state, only its vocabulary: an inline heal has
/// no memo, no gate and no bracket of its own, and tying it to the main actor would claim
/// otherwise.
public nonisolated static func placingRanks<Placement>(
amongVisible ladder: [Double],
compacting parentFolder: URL,
_ ask: ([Double]) -> Placement?
) throws(BoardWriteError) -> (placement: Placement, ladder: [Double], renumbered: Bool)? {
if let placement = ask(ladder) {
return (placement: placement, ladder: ladder, renumbered: false)
}
// Compact, then place against the fresh ranks. The renumber assigns in display order, so the
// compacted ladder lines up one-for-one with the one that was passed in.
try BoardWriter.renumberVisibleChildren(of: parentFolder)
let compacted = Ranks.renumbered(count: ladder.count)
guard let placement = ask(compacted) else { return nil }
return (placement: placement, ladder: compacted, renumbered: true)
}
}