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
+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)
}
}