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:
@@ -0,0 +1,265 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Kanban
|
||||
|
||||
/// **The scheduled-heal engine** (02-architecture.md ▸ Components ▸ HealScheduler;
|
||||
/// 01-storage-format.md § Validation and healing, settled 2026-07-29) — the six-step pattern the
|
||||
/// three healers used to re-derive one by one, pinned once.
|
||||
///
|
||||
/// Each healer's own end-to-end behavior stays where it always lived (`LooseFileRelocationTests`,
|
||||
/// `TrashWriteTests`, `AgentGuideTests`); what is here is the pattern itself, plus the two places the
|
||||
/// generalization *changed* behavior on purpose — the writability gate, which was only the guide's,
|
||||
/// and the explicit clear-on-success, which was only the guide's too.
|
||||
|
||||
// MARK: - Fixtures
|
||||
|
||||
/// A board whose one card has a loose file — the shape that gives the engine real work through the
|
||||
/// simplest healer.
|
||||
@MainActor
|
||||
private func makeLooseFileBoard() throws -> (fixture: WriterFixture, cardPath: String) {
|
||||
let fixture = try WriterFixture()
|
||||
try fixture.item("", Item.board)
|
||||
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
|
||||
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Fix login"))
|
||||
return (fixture, "\(Ident.lane1)/\(Ident.card1)")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class BracketLog {
|
||||
private(set) var begins = 0
|
||||
|
||||
func attach(to store: BoardStore) {
|
||||
store.watcherBrackets = (begin: { self.begins += 1 }, end: {})
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The gates
|
||||
|
||||
@MainActor
|
||||
@Suite("HealScheduler ▸ the gates")
|
||||
struct HealSchedulerGateTests {
|
||||
|
||||
/// **Step 1** — no work is not a failed attempt: an empty signature clears the memo, which is
|
||||
/// what makes a healthy board forget it ever tried.
|
||||
@Test("No work rests, and clears the memo")
|
||||
func noWorkRests() throws {
|
||||
let board = try makeLooseFileBoard()
|
||||
defer { board.fixture.tearDown() }
|
||||
try board.fixture.file("\(board.cardPath)/notes.txt", Data("notes".utf8))
|
||||
let store = try BoardStore(rootURL: board.fixture.root)
|
||||
|
||||
store.relocateLooseCardFiles()
|
||||
#expect(store.heals.memo(for: .looseCardFiles) == nil, "cleared on success")
|
||||
|
||||
// Nothing loose left in the snapshot's own reading — the resting path.
|
||||
store.relocateLooseCardFiles()
|
||||
#expect(store.heals.memo(for: .looseCardFiles) == nil)
|
||||
}
|
||||
|
||||
/// **Step 2, the lock half** — defer, never abandon, and remember nothing, so the reload that
|
||||
/// lifts the lock is the reload that heals.
|
||||
@Test("A read-only board defers and remembers nothing")
|
||||
func lockDefersWithoutArming() throws {
|
||||
let board = try makeLooseFileBoard()
|
||||
defer { board.fixture.tearDown() }
|
||||
try board.fixture.file("\(board.cardPath)/notes.txt", Data("notes".utf8))
|
||||
let store = try BoardStore(rootURL: board.fixture.root)
|
||||
let brackets = BracketLog()
|
||||
brackets.attach(to: store)
|
||||
store.enterUnwritableLock(.permissionDenied)
|
||||
|
||||
store.relocateLooseCardFiles()
|
||||
|
||||
#expect(brackets.begins == 0)
|
||||
#expect(store.heals.memo(for: .looseCardFiles) == nil, "a refused attempt is not a remembered one")
|
||||
#expect(board.fixture.exists("\(board.cardPath)/notes.txt"))
|
||||
}
|
||||
|
||||
/// **Step 2, the writability half — generalized 2026-07-29.** This `access(2)` check was the
|
||||
/// agent guide's private defense; it now covers every healer, because it is the only gate that
|
||||
/// covers the window *between* writability probes. Without it a heal on a root that went
|
||||
/// read-only mid-session reaches the Writer, fails, and banners about work the user never asked
|
||||
/// for.
|
||||
@Test("An unwritable board root is skipped silently, for every healer")
|
||||
func unwritableRootIsSkippedSilently() throws {
|
||||
let board = try makeLooseFileBoard()
|
||||
defer { board.fixture.tearDown() }
|
||||
try board.fixture.file("\(board.cardPath)/notes.txt", Data("notes".utf8))
|
||||
let store = try BoardStore(rootURL: board.fixture.root)
|
||||
let brackets = BracketLog()
|
||||
brackets.attach(to: store)
|
||||
// Read-only root, with no lock standing — the mid-session window the probe cannot see.
|
||||
try FileManager.default.setAttributes(
|
||||
[.posixPermissions: 0o500],
|
||||
ofItemAtPath: board.fixture.root.path
|
||||
)
|
||||
defer {
|
||||
try? FileManager.default.setAttributes(
|
||||
[.posixPermissions: 0o755],
|
||||
ofItemAtPath: board.fixture.root.path
|
||||
)
|
||||
}
|
||||
|
||||
store.runScheduledHeals()
|
||||
|
||||
#expect(brackets.begins == 0, "no write was attempted at all")
|
||||
#expect(store.banners.oneShots.isEmpty, "and nothing was said about it")
|
||||
#expect(store.banners.losses.isEmpty)
|
||||
#expect(store.heals.memo(for: .looseCardFiles) == nil)
|
||||
}
|
||||
|
||||
/// **Steps 3 and 4** — armed before the attempt, so a failure is remembered; one failure, one
|
||||
/// row, then silence until the picture on disk actually changes.
|
||||
@Test("A failure arms the memo and is not retried")
|
||||
func failureArmsTheMemo() throws {
|
||||
let board = try makeLooseFileBoard()
|
||||
defer { board.fixture.tearDown() }
|
||||
try board.fixture.file("\(board.cardPath)/notes.txt", Data("notes".utf8))
|
||||
let store = try BoardStore(rootURL: board.fixture.root)
|
||||
let brackets = BracketLog()
|
||||
brackets.attach(to: store)
|
||||
// The card folder is writable but its `attachments/` cannot be created: the relocation's
|
||||
// write half fails, with the defect still on disk afterwards.
|
||||
try FileManager.default.setAttributes(
|
||||
[.posixPermissions: 0o500],
|
||||
ofItemAtPath: board.fixture.url(board.cardPath).path
|
||||
)
|
||||
defer {
|
||||
try? FileManager.default.setAttributes(
|
||||
[.posixPermissions: 0o755],
|
||||
ofItemAtPath: board.fixture.url(board.cardPath).path
|
||||
)
|
||||
}
|
||||
|
||||
store.relocateLooseCardFiles()
|
||||
let armed = store.heals.memo(for: .looseCardFiles)
|
||||
store.relocateLooseCardFiles()
|
||||
|
||||
#expect(armed != nil, "armed before the attempt, so the throw leaves it set")
|
||||
#expect(store.heals.memo(for: .looseCardFiles) == armed)
|
||||
#expect(brackets.begins == 1, "the second call was refused by the memo, not attempted")
|
||||
#expect(store.banners.oneShots.count == 1, "one failure, one row")
|
||||
}
|
||||
|
||||
/// **Step 6, generalized 2026-07-29** — the memo clears *explicitly* on success, where before
|
||||
/// only the guide did this and the others merely happened to converge because the next walk
|
||||
/// found no work. It matters when the same defect comes back: a foreign undo restores the exact
|
||||
/// picture the heal just fixed, and a standing memo would make that the one thing the self-heal
|
||||
/// could not heal.
|
||||
@Test("The same picture heals again after a success")
|
||||
func theSamePictureHealsAgain() throws {
|
||||
let board = try makeLooseFileBoard()
|
||||
defer { board.fixture.tearDown() }
|
||||
try board.fixture.file("\(board.cardPath)/notes.txt", Data("notes".utf8))
|
||||
let store = try BoardStore(rootURL: board.fixture.root)
|
||||
|
||||
store.relocateLooseCardFiles()
|
||||
#expect(try board.fixture.data("\(board.cardPath)/attachments/notes.txt") == Data("notes".utf8))
|
||||
#expect(store.heals.memo(for: .looseCardFiles) == nil)
|
||||
|
||||
// Somebody puts it back, byte for byte — the same defect signature as before.
|
||||
try FileManager.default.removeItem(at: board.fixture.url("\(board.cardPath)/attachments"))
|
||||
try board.fixture.file("\(board.cardPath)/notes.txt", Data("notes".utf8))
|
||||
store.relocateLooseCardFiles()
|
||||
|
||||
#expect(try board.fixture.data("\(board.cardPath)/attachments/notes.txt") == Data("notes".utf8))
|
||||
}
|
||||
|
||||
/// The memos are per class, so one heal's failure never silences another's work.
|
||||
@Test("Memos are per defect class")
|
||||
func memosArePerClass() throws {
|
||||
let board = try makeLooseFileBoard()
|
||||
defer { board.fixture.tearDown() }
|
||||
try board.fixture.file("\(board.cardPath)/notes.txt", Data("notes".utf8))
|
||||
let store = try BoardStore(rootURL: board.fixture.root)
|
||||
|
||||
store.runScheduledHeals()
|
||||
|
||||
// The guide wrote (its class rests cleared), the relocation ran (so does its own), and
|
||||
// neither class's memo is holding the other's picture.
|
||||
#expect(store.heals.memo(for: .looseCardFiles) == nil)
|
||||
#expect(store.heals.memo(for: .staleAgentGuide) == nil)
|
||||
#expect(store.heals.memo(for: .legacyTombstone) == nil)
|
||||
#expect(try board.fixture.data("\(board.cardPath)/attachments/notes.txt") == Data("notes".utf8))
|
||||
#expect(board.fixture.exists(AgentGuide.filename))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The inline renumber-and-retry
|
||||
|
||||
@Suite("HealScheduler ▸ the renumber two-step")
|
||||
struct HealSchedulerRenumberTests {
|
||||
|
||||
/// A ladder with room answers on the **first** ask — no compaction, and the ladder comes back
|
||||
/// exactly as it went in.
|
||||
@Test("A usable ladder never renumbers")
|
||||
func usableLadderNeverRenumbers() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
try fixture.item("", Item.board)
|
||||
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
|
||||
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "A"))
|
||||
try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "B"))
|
||||
let before = try fixture.indexText("\(Ident.lane1)/\(Ident.card1)")
|
||||
|
||||
let placed = try #require(try HealScheduler.placingRanks(
|
||||
amongVisible: [1024, 2048],
|
||||
compacting: fixture.url(Ident.lane1),
|
||||
{ Ranks.insertionRank(amongVisible: $0, at: 1) }
|
||||
))
|
||||
|
||||
#expect(placed.placement == 1536)
|
||||
#expect(placed.ladder == [1024, 2048])
|
||||
#expect(!placed.renumbered)
|
||||
#expect(try fixture.indexText("\(Ident.lane1)/\(Ident.card1)") == before, "nothing was rewritten")
|
||||
}
|
||||
|
||||
/// Exhausted precision compacts the parent's visible children and asks again — **against the
|
||||
/// fresh ladder**, which is what the caller then has to place among.
|
||||
@Test("Exhausted precision compacts and answers against the fresh ladder")
|
||||
func exhaustedPrecisionCompacts() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
try fixture.item("", Item.board)
|
||||
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
|
||||
// Adjacent doubles: no representable midpoint between them.
|
||||
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1", title: "A"))
|
||||
try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "1.0000000000000002", title: "B"))
|
||||
|
||||
let orders = try [Ident.card1, Ident.card2].map {
|
||||
try #require(FrontmatterDocument.parse(fixture.indexText("\(Ident.lane1)/\($0)")).order.value)
|
||||
}
|
||||
let placed = try #require(try HealScheduler.placingRanks(
|
||||
amongVisible: orders,
|
||||
compacting: fixture.url(Ident.lane1),
|
||||
{ Ranks.insertionRank(amongVisible: $0, at: 1) }
|
||||
))
|
||||
|
||||
#expect(placed.renumbered)
|
||||
#expect(placed.ladder == [1024, 2048])
|
||||
#expect(placed.placement == 1536)
|
||||
// The compaction really landed on disk — the two cards now hold the fresh ladder.
|
||||
let after = try [Ident.card1, Ident.card2].map {
|
||||
try FrontmatterDocument.parse(fixture.indexText("\(Ident.lane1)/\($0)")).order
|
||||
}
|
||||
#expect(after == [.valid(1024), .valid(2048)])
|
||||
}
|
||||
|
||||
/// A compacted ladder that still has no answer is "write nothing" — every call site's own
|
||||
/// posture, spelled once.
|
||||
@Test("No answer after compacting is nil")
|
||||
func noAnswerAfterCompactingIsNil() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
try fixture.item("", Item.board)
|
||||
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
|
||||
|
||||
let placed = try HealScheduler.placingRanks(
|
||||
amongVisible: [],
|
||||
compacting: fixture.url(Ident.lane1),
|
||||
{ (_: [Double]) -> Double? in nil }
|
||||
)
|
||||
|
||||
#expect(placed == nil)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user