Files
lanework/KanbanTests/HealSchedulerTests.swift
rzen 3a9db2e78b 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
2026-07-29 15:45:48 -04:00

266 lines
12 KiB
Swift

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