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
+60 -16
View File
@@ -11,8 +11,10 @@ import Testing
/// 2. **The decision is pure** write, leave alone, displace, or skip, from what the two claimed
/// board-root names look like on disk and nothing else.
/// 3. **Nothing the user owns is ever destroyed** a markerless `CLAUDE.md` is rescued, a taken
/// `CLAUDE.user.md` cancels the write outright, a symlink or a folder is not touched at all, and
/// a current guide is not even opened for writing.
/// `CLAUDE.user.md` cancels the write outright, a symlink or a folder wearing the name is
/// *displaced* rather than clobbered (ruled 2026-07-29 the claimed-name rule; it replaced an
/// untouchable-skip, and displacement-never-destruction is what survives), and a current guide is
/// not even opened for writing.
///
/// Every on-disk claim is read back as **raw bytes**, never through a snapshot: the promises are
/// about the files. `WriterFixture`, `Ident` and `Item` come from `WriterTestSupport.swift`.
@@ -176,10 +178,18 @@ struct AgentGuideDecisionTests {
#expect(AgentGuide.decide(state(.missing, userFileFree: false)) == .write)
}
@Test("A symlink or a folder is never touched, free name or not")
func untouchableIsSkipped() {
#expect(AgentGuide.decide(state(.untouchable)) == .skipUntouchable)
#expect(AgentGuide.decide(state(.untouchable, userFileFree: false)) == .skipUntouchable)
/// **Updated 2026-07-29** the claimed-name squatter ruling (01-storage-format.md § Fractal
/// layout Rules) upgraded this case from a skip to a displacement: Lanework owns the board, so
/// a folder or symlink on a name the app claims is an invalid artifact, not a resident. It is
/// moved aside by the Finder ladder and never destroyed.
///
/// **Free name or not is still irrelevant here**, but for a new reason: `CLAUDE.user.md` is the
/// *rescue* destination for user content, and a squatter is not rescued to it it goes to
/// `CLAUDE.md 2`, so the other name's state has no bearing on the decision.
@Test("A symlink or a folder is displaced, free name or not")
func squatterIsDisplaced() {
#expect(AgentGuide.decide(state(.squatted)) == .displaceSquatterThenWrite)
#expect(AgentGuide.decide(state(.squatted, userFileFree: false)) == .displaceSquatterThenWrite)
}
}
@@ -338,10 +348,11 @@ struct AgentGuideStoreTests {
#expect(store.banners.oneShots.isEmpty, "a skip is a log line, not a banner")
}
/// Symlinks are never followed or touched anywhere in this app (01-storage-format.md § Fractal
/// layout Rules) including one wearing the guide's name.
@Test("A symlinked CLAUDE.md is left as a symlink, and its target is untouched")
func symlinkedGuideIsSkipped() throws {
/// **Updated 2026-07-29** the claimed-name squatter ruling. A symlink wearing the guide's name
/// is still never *followed*: it is moved aside **as a link** (`lstat` semantics all the way
/// down), its target is never opened, and the guide is written on the freed name.
@Test("A symlinked CLAUDE.md is displaced as a link, and its target is untouched")
func symlinkedGuideIsDisplaced() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let target = try fixture.file("elsewhere.md", Data("# somewhere else\n".utf8))
@@ -350,10 +361,20 @@ struct AgentGuideStoreTests {
store.refreshAgentGuide()
let destination = try FileManager.default.destinationOfSymbolicLink(atPath: guideURL(in: fixture).path)
#expect(destination == "elsewhere.md", "the link is still a link")
#expect(try Data(contentsOf: target) == Data("# somewhere else\n".utf8), "and it was not written through")
// The link moved, still a link, still pointing where it pointed never resolved, never
// written through.
// Finder's own splitting: `CLAUDE.md` `CLAUDE 2.md` (the ladder splits after the last
// dot), exactly as `.trash` `.trash 2` for an extension-less name.
let moved = fixture.root.appendingPathComponent("CLAUDE 2.md")
#expect(try FileManager.default.destinationOfSymbolicLink(atPath: moved.path) == "elsewhere.md")
#expect(try Data(contentsOf: target) == Data("# somewhere else\n".utf8))
// And the freed name now carries the guide.
#expect(try fixture.data(AgentGuide.filename) == Data(AgentGuide.content.utf8))
// A squatter's displacement is announced the relocation-style warning-tone notice, naming
// old and new. The rescue to CLAUDE.user.md is silent; this is not that.
#expect(!fixture.exists(AgentGuide.userFilename))
#expect(store.banners.losses.count == 1)
#expect(store.banners.losses.first?.message == "Renamed 'CLAUDE.md' to 'CLAUDE 2.md' — Lanework needs that name")
}
/// The rescue name is checked with `lstat` semantics, so a **broken** symlink counts as taken:
@@ -391,8 +412,11 @@ struct AgentGuideStoreTests {
#expect(try fixture.data(AgentGuide.userFilename) == Data("# secret\n".utf8))
}
@Test("A folder named CLAUDE.md is left alone")
func directoryGuideIsSkipped() throws {
/// **Updated 2026-07-29** the claimed-name squatter ruling: a folder on the guide's name is an
/// invalid artifact, not a resident. It moves aside whole, **contents preserved verbatim**, and
/// the guide takes the freed name.
@Test("A folder named CLAUDE.md is displaced whole, contents intact")
func directoryGuideIsDisplaced() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try fixture.file("\(AgentGuide.filename)/inside.txt", Data("inside".utf8))
@@ -400,10 +424,30 @@ struct AgentGuideStoreTests {
store.refreshAgentGuide()
#expect(try fixture.data("\(AgentGuide.filename)/inside.txt") == Data("inside".utf8))
#expect(try fixture.data("CLAUDE 2.md/inside.txt") == Data("inside".utf8))
#expect(try fixture.data(AgentGuide.filename) == Data(AgentGuide.content.utf8))
// Displacement, never a rescue: `CLAUDE.user.md` is where *user content* goes, and a folder
// on a file's name is not that.
#expect(!fixture.exists(AgentGuide.userFilename))
}
/// The ladder climbs rather than overwriting: a board that already has a `CLAUDE.md 2` gets a
/// `CLAUDE.md 3`, Finder-style, one collision at a time.
@Test("The displacement climbs the Finder ladder past a taken name")
func displacementClimbsTheLadder() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try fixture.file("\(AgentGuide.filename)/inside.txt", Data("inside".utf8))
try writeRoot("CLAUDE 2.md", Data("someone else's\n".utf8), in: fixture)
let store = try BoardStore(rootURL: fixture.root)
store.refreshAgentGuide()
#expect(try fixture.data("CLAUDE 2.md") == Data("someone else's\n".utf8), "untouched")
#expect(try fixture.data("CLAUDE 3.md/inside.txt") == Data("inside".utf8))
#expect(try fixture.data(AgentGuide.filename) == Data(AgentGuide.content.utf8))
}
/// 02-architecture.md § Write-failure surfacing: "The open-time agent-guide write is
/// skipped-with-log, the `CLAUDE.user.md`-taken precedent." A board on a read-only volume must
/// not spend a banner on a courtesy file.