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:
@@ -516,3 +516,171 @@ struct EchoLedgerStoreTests {
|
||||
#expect(log.lines == ["Board changed: 1 lane edited"])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Heal-marked receipts
|
||||
|
||||
/// **The Writer's heal operations drop heal-marked receipts** (06-history-undo.md ▸ Commit messages,
|
||||
/// ruled 2026-07-29: "attribution machinery like the author split, never message tagging").
|
||||
///
|
||||
/// **Inert in base beyond the ledger itself**: nothing here reads the flag and nothing renders it —
|
||||
/// it is what pro-m1's committer will read to split a heal's paths into their own commit, and these
|
||||
/// tests pin the seam it will read from, not a committer that does not exist yet.
|
||||
@Suite("EchoLedger — heal-marked receipts")
|
||||
struct EchoLedgerHealMarkTests {
|
||||
|
||||
/// An ordinary gesture is not a heal, and says so by default.
|
||||
@Test("An ordinary write is not heal-marked")
|
||||
func ordinaryWritesAreNotHeals() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let ledger = EchoLedger()
|
||||
let folder = fixture.url(lane1)
|
||||
|
||||
try EchoLedger.$current.withValue(ledger) {
|
||||
try BoardWriter.updateIndex(inItemFolder: folder, operation: .style(title: nil)) { document in
|
||||
document.set(FrontmatterKeys.width, to: .int(3))
|
||||
}
|
||||
}
|
||||
|
||||
#expect(!ledger.isHeal(at: folder.appendingPathComponent("index.md")))
|
||||
}
|
||||
|
||||
/// The loose-file relocation — an app-initiated heal, so its landed file's receipt is marked.
|
||||
@Test("The loose-file relocation marks what it moved")
|
||||
func relocationMarksItsMoves() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let ledger = EchoLedger()
|
||||
let cardFolder = fixture.url("\(lane1)/\(card1)")
|
||||
try fixture.file("\(lane1)/\(card1)/notes.txt", Data("notes".utf8))
|
||||
|
||||
try EchoLedger.$current.withValue(ledger) {
|
||||
_ = try BoardWriter.relocateLooseFiles(["notes.txt"], inCard: cardFolder)
|
||||
}
|
||||
|
||||
#expect(ledger.isHeal(at: cardFolder.appendingPathComponent("attachments/notes.txt")))
|
||||
}
|
||||
|
||||
/// **The import-boundary normalization is not marked**, and the distinction is the design's:
|
||||
/// an *inline* heal batches with the gesture that triggered it, so its paths belong in that
|
||||
/// gesture's commit rather than in a heal's own.
|
||||
@Test("The paste boundary's normalization is not heal-marked")
|
||||
func inlineNormalizationIsNotAHeal() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let ledger = EchoLedger()
|
||||
let cardFolder = fixture.url("\(lane1)/\(card1)")
|
||||
try fixture.file("\(lane1)/\(card1)/notes.txt", Data("notes".utf8))
|
||||
|
||||
try EchoLedger.$current.withValue(ledger) {
|
||||
_ = try BoardWriter.normalizeLooseFiles(inCard: cardFolder)
|
||||
}
|
||||
|
||||
#expect(!ledger.isHeal(at: cardFolder.appendingPathComponent("attachments/notes.txt")))
|
||||
}
|
||||
|
||||
/// The legacy-tombstone migration marks both halves of what it wrote — and the ordinary delete
|
||||
/// it shares a body with does not, because that one is a gesture.
|
||||
@Test("The tombstone migration marks, the delete beside it does not")
|
||||
func migrationMarksButDeleteDoesNot() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let ledger = EchoLedger()
|
||||
try fixture.item(
|
||||
"\(lane1)/\(Ident.card3)",
|
||||
"---\nschema: 1\norder: 4096\ntitle: Tombstoned\ndeleted: 2026-01-01T00:00:00Z\n---\nbody\n"
|
||||
)
|
||||
|
||||
try EchoLedger.$current.withValue(ledger) {
|
||||
_ = try BoardWriter.migrateTombstonedCard(
|
||||
at: fixture.url("\(lane1)/\(Ident.card3)"),
|
||||
inBoard: fixture.root,
|
||||
order: 1024
|
||||
)
|
||||
_ = try BoardWriter.deleteCardToTrash(
|
||||
at: fixture.url("\(lane1)/\(card1)"),
|
||||
inBoard: fixture.root,
|
||||
order: 2048
|
||||
)
|
||||
}
|
||||
|
||||
let migrated = fixture.url(".trash/\(Ident.card3)")
|
||||
#expect(ledger.isHeal(at: migrated))
|
||||
#expect(ledger.isHeal(at: migrated.appendingPathComponent("index.md")))
|
||||
let deleted = fixture.url(".trash/\(card1)")
|
||||
#expect(!ledger.isHeal(at: deleted))
|
||||
#expect(!ledger.isHeal(at: deleted.appendingPathComponent("index.md")))
|
||||
}
|
||||
|
||||
/// The agent guide's write, and the claimed-name displacement — both app-initiated, both marked.
|
||||
@Test("The guide write and a displacement are heals")
|
||||
func guideAndDisplacementAreHeals() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let ledger = EchoLedger()
|
||||
try Data("squatter".utf8).write(to: fixture.root.appendingPathComponent(".trash"))
|
||||
|
||||
try EchoLedger.$current.withValue(ledger) {
|
||||
_ = try BoardWriter.displaceClaimedName(
|
||||
ClaimedNameSquatter(name: ".trash", found: .file, expected: .directory),
|
||||
atBoardRoot: fixture.root
|
||||
)
|
||||
_ = try AgentGuide.install(atBoardRoot: fixture.root)
|
||||
}
|
||||
|
||||
#expect(ledger.isHeal(at: fixture.root.appendingPathComponent(".trash 2")))
|
||||
#expect(ledger.isHeal(at: fixture.root.appendingPathComponent(AgentGuide.filename)))
|
||||
}
|
||||
|
||||
/// **Supersession drops the mark with the receipt it belonged to**: a later ordinary write to a
|
||||
/// healed path is exactly the case where the path stops being the heal's alone.
|
||||
@Test("An ordinary write over a healed path clears the mark")
|
||||
func supersessionClearsTheMark() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let ledger = EchoLedger()
|
||||
let index = fixture.url(lane1).appendingPathComponent("index.md")
|
||||
|
||||
ledger.recordWrite(at: index, text: "one")
|
||||
ledger.markHeal(at: index)
|
||||
#expect(ledger.isHeal(at: index))
|
||||
|
||||
ledger.recordWrite(at: index, text: "two")
|
||||
#expect(!ledger.isHeal(at: index))
|
||||
#expect(ledger.receipt(at: index) == .content(hash: EchoLedger.hash(of: "two")))
|
||||
}
|
||||
|
||||
/// A mark with no receipt to attach to describes nothing, so it creates nothing.
|
||||
@Test("Marking an unknown path is a no-op")
|
||||
func markingAnUnknownPathIsANoOp() {
|
||||
let ledger = EchoLedger()
|
||||
ledger.markHeal(atPath: "/nowhere/index.md")
|
||||
#expect(ledger.receipt(atPath: "/nowhere/index.md") == nil)
|
||||
#expect(!ledger.isHeal(atPath: "/nowhere/index.md"))
|
||||
#expect(ledger.outstandingReceipts == 0)
|
||||
}
|
||||
|
||||
/// A move is one fact under two keys, so marking either end marks both — the same rule that
|
||||
/// retires both ends when one is consumed.
|
||||
@Test("Marking one end of a move marks both")
|
||||
func markingAMoveMarksBothEnds() {
|
||||
let ledger = EchoLedger()
|
||||
ledger.recordMove(fromPath: "/b/lane/card", toPath: "/b/.trash/card")
|
||||
ledger.markHeal(atPath: "/b/.trash/card")
|
||||
|
||||
#expect(ledger.isHeal(atPath: "/b/.trash/card"))
|
||||
#expect(ledger.isHeal(atPath: "/b/lane/card"))
|
||||
}
|
||||
|
||||
/// The flag changes **nothing** about classification: a heal is an app write like any other, and
|
||||
/// the render path never consults the ledger at all.
|
||||
@Test("A heal mark does not change provenance")
|
||||
func markDoesNotChangeClassification() {
|
||||
let ledger = EchoLedger()
|
||||
let index = "/b/lane/index.md"
|
||||
ledger.recordWrite(atPath: index, hash: EchoLedger.hash(of: "one"))
|
||||
ledger.markHeal(atPath: index)
|
||||
|
||||
#expect(ledger.classify([index: .content(hash: EchoLedger.hash(of: "one"))]) == .appMediated)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user