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
+16 -6
View File
@@ -60,7 +60,12 @@ private enum Child {
// `Ident` and `Item` live in `WriterTestSupport.swift` shared with `WriteFidelityTests.swift`.
/// The keys a move or a copy is allowed to have touched; every other line must be byte-identical.
private let rewrittenKeys = [FrontmatterKeys.order, FrontmatterKeys.modified, FrontmatterKeys.modifiedBy]
/// `kind` joins them as of 2026-07-29: a file without one gains it on the first app write that
/// rewrites it (the integrity service's on-touch heal), which is a key this write is allowed to have
/// touched exactly like the stamps.
private let rewrittenKeys = [
FrontmatterKeys.order, FrontmatterKeys.modified, FrontmatterKeys.modifiedBy, FrontmatterKeys.kind,
]
/// A folder's UUID-shaped children keyed by the `title` inside them. A copy remints every folder
/// it materializes, so the file's own content is the only way back to "which card is which".
@@ -567,7 +572,10 @@ struct BoardWriterCreateBoardTests {
#expect(abs(modified.timeIntervalSinceNow) < 60)
}
@Test func keyOrderIsSchemaTitleCreatedModified() throws {
/// **`kind` is written at creation of every object** (01-storage-format.md § Frontmatter,
/// re-ruled 2026-07-29) and goes last, where the common table puts it and where the on-touch
/// backfill appends one on an older file, so a created object and a healed one read the same.
@Test func keyOrderIsSchemaTitleCreatedModifiedKind() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let root = fixture.url("MyBoard.kanban")
@@ -575,7 +583,8 @@ struct BoardWriterCreateBoardTests {
try BoardWriter.createBoard(at: root, title: "My Board")
let document = try FrontmatterDocument.parse(fixture.indexText("MyBoard.kanban"))
#expect(document.keys == ["schema", "title", "created", "modified"])
#expect(document.keys == ["schema", "title", "created", "modified", "kind"])
#expect(document.kind == .valid("board"))
}
@Test func aNilTitleWritesNoTitleKeyAndTheLoaderReadsItMissing() throws {
@@ -586,7 +595,7 @@ struct BoardWriterCreateBoardTests {
try BoardWriter.createBoard(at: root, title: nil)
let document = try FrontmatterDocument.parse(fixture.indexText("Untitled.kanban"))
#expect(document.keys == ["schema", "created", "modified"])
#expect(document.keys == ["schema", "created", "modified", "kind"])
#expect(!document.contains(FrontmatterKeys.title))
let result = try BoardLoader.load(boardRoot: root)
@@ -656,14 +665,15 @@ struct BoardWriterCreateChildTests {
#expect(first != second)
}
@Test func laneKeyOrderIsSchemaTitleOrderCreatedModified() throws {
@Test func laneKeyOrderIsSchemaTitleOrderCreatedModifiedKind() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let laneID = try BoardWriter.createLane(inBoard: fixture.root, title: "Lane")
let document = try FrontmatterDocument.parse(fixture.indexText(laneID.rawValue))
#expect(document.keys == ["schema", "title", "order", "created", "modified"])
#expect(document.keys == ["schema", "title", "order", "created", "modified", "kind"])
#expect(document.kind == .valid("lane"))
#expect(document.schema == .valid(1))
#expect(document.modifiedBy == .missing)
}