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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user