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:
@@ -224,6 +224,41 @@ struct BoardStoreRegistryTests {
|
||||
registry.release(store)
|
||||
}
|
||||
|
||||
/// **Every scheduled heal fires at open** (02-architecture.md ▸ Components ▸ HealScheduler,
|
||||
/// settled 2026-07-29: "fires uniformly at the reload tail and at registry acquire, closing
|
||||
/// today's asymmetry where tombstone migration never fires at open").
|
||||
///
|
||||
/// Before the engine 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. The three defects below are healed by `acquire` alone — no watcher event, no reload
|
||||
/// beyond the one each heal's own write produces.
|
||||
@Test("Opening a board runs every scheduled heal, migration included")
|
||||
func acquireRunsEveryScheduledHeal() async throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
// One of each: a legacy tombstone, a loose card file, and a squatter on a claimed name.
|
||||
try fixture.item(
|
||||
"\(Ident.lane2)/\(Ident.card3)",
|
||||
"---\nschema: 1\norder: 1024\ntitle: Tombstoned\ndeleted: 2026-01-01T00:00:00Z\n---\nbody\n"
|
||||
)
|
||||
try fixture.file("\(Ident.lane1)/\(Ident.card1)/notes.txt", Data("notes".utf8))
|
||||
try fixture.file(".trash", Data("squatter".utf8))
|
||||
let registry = BoardStoreRegistry()
|
||||
|
||||
let store = try registry.acquire(fixture.root)
|
||||
defer { registry.release(store) }
|
||||
|
||||
// The migration — the one that used to wait for an unrelated event.
|
||||
await waitUntil { fixture.exists(".trash/\(Ident.card3)") }
|
||||
#expect(fixture.exists(".trash/\(Ident.card3)"))
|
||||
#expect(try !fixture.indexText(".trash/\(Ident.card3)").contains("deleted:"))
|
||||
// The relocation, the displacement, and the guide — the three that already did.
|
||||
#expect(try fixture.data("\(Ident.lane1)/\(Ident.card1)/attachments/notes.txt") == Data("notes".utf8))
|
||||
#expect(try fixture.data(".trash 2") == Data("squatter".utf8))
|
||||
#expect(fixture.exists(AgentGuide.filename))
|
||||
}
|
||||
|
||||
@Test("Acquiring a root that does not exist throws the loader's own error")
|
||||
func acquireOfAMissingRootThrows() async throws {
|
||||
let registry = BoardStoreRegistry()
|
||||
|
||||
Reference in New Issue
Block a user