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:
@@ -840,3 +840,105 @@ struct TrashIdentityTests {
|
||||
#expect(try fixture.indexText("destination/.trash/\(Ident.card1)").contains("title: Trashed twin"))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The trash's kind discriminator
|
||||
|
||||
/// **`kind:` discriminates inside `.trash/`** (01-storage-format.md § Deletion, re-ruled
|
||||
/// 2026-07-29): depth defines meaning on the live board, but the trash is flat, and an empty lane
|
||||
/// folder is shape-identical to a card folder. The reader **trusts the value**, and only an
|
||||
/// unrecognized value or no key at all falls through to shape.
|
||||
///
|
||||
/// The verdict rides `LoadResult.trashKinds` — a *reading*, not a rendering: every entry still
|
||||
/// parses through the one card parse (a trashed card is "an ordinary card in a special place"), and
|
||||
/// nothing is hidden or dropped on account of its kind.
|
||||
@Suite("BoardLoader ▸ the trash's kind discriminator")
|
||||
struct TrashKindDiscriminatorTests {
|
||||
|
||||
@Test("kind: card is honored even against the shape")
|
||||
func cardValueBeatsShape() throws {
|
||||
let fixture = try TrashFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let entry = uuidName()
|
||||
let child = uuidName()
|
||||
|
||||
try fixture.index("", "schema: 1\n")
|
||||
// Lane-shaped on disk — a UUID-named child with its own index.md — and yet it says card.
|
||||
try fixture.index(".trash/\(entry)", "schema: 1\norder: 1024\nkind: card\n")
|
||||
try fixture.index(".trash/\(entry)/\(child)", "schema: 1\norder: 1024\n")
|
||||
|
||||
let result = try BoardLoader.load(boardRoot: fixture.root)
|
||||
|
||||
#expect(result.trashKinds[ItemID(rawValue: entry)] == .card)
|
||||
// Trusting the value is not policing it: the entry still loads, and its child is still not a
|
||||
// level (the walk stops at a trash entry exactly as it stops at a card).
|
||||
#expect(result.model.trash.map(\.id.rawValue) == [entry])
|
||||
}
|
||||
|
||||
@Test("kind: lane is honored even against the shape")
|
||||
func laneValueBeatsShape() throws {
|
||||
let fixture = try TrashFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let entry = uuidName()
|
||||
|
||||
try fixture.index("", "schema: 1\n")
|
||||
// Card-shaped on disk — no children at all — and yet it says lane. An external writer's
|
||||
// `kind: lane` is honored, never policed.
|
||||
try fixture.index(".trash/\(entry)", "schema: 1\norder: 1024\nkind: lane\n")
|
||||
|
||||
let result = try BoardLoader.load(boardRoot: fixture.root)
|
||||
#expect(result.trashKinds[ItemID(rawValue: entry)] == .lane)
|
||||
}
|
||||
|
||||
/// No key, or a value outside the schema's three, falls through to shape — UUID-shaped children
|
||||
/// with their own `index.md` → lane, else card.
|
||||
@Test("An unrecognized value or no key falls through to shape")
|
||||
func unrecognizedFallsToShape() throws {
|
||||
let fixture = try TrashFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let bare = uuidName()
|
||||
let laneShaped = uuidName()
|
||||
let child = uuidName()
|
||||
let odd = uuidName()
|
||||
|
||||
try fixture.index("", "schema: 1\n")
|
||||
try fixture.index(".trash/\(bare)", "schema: 1\norder: 1024\n")
|
||||
try fixture.index(".trash/\(laneShaped)", "schema: 1\norder: 2048\n")
|
||||
try fixture.index(".trash/\(laneShaped)/\(child)", "schema: 1\norder: 1024\n")
|
||||
try fixture.index(".trash/\(odd)", "schema: 1\norder: 3072\nkind: widget\n")
|
||||
|
||||
let result = try BoardLoader.load(boardRoot: fixture.root)
|
||||
|
||||
#expect(result.trashKinds[ItemID(rawValue: bare)] == .card)
|
||||
#expect(result.trashKinds[ItemID(rawValue: laneShaped)] == .lane)
|
||||
#expect(result.trashKinds[ItemID(rawValue: odd)] == .card, "unrecognized, and card-shaped")
|
||||
// The unrecognized value is preserved verbatim — never corrected, never stripped.
|
||||
#expect(result.model.trash.first { $0.id.rawValue == odd }?.document.kind == .valid("widget"))
|
||||
}
|
||||
|
||||
/// A folder whose `kind` is *shape-derived* today keeps that reading only until it is touched —
|
||||
/// at which point the backfill writes the same answer down. The two rules are one function, so
|
||||
/// the read and the write can never disagree.
|
||||
@Test("The reading a shape produces is the value the backfill writes")
|
||||
func shapeReadingMatchesTheBackfill() throws {
|
||||
let fixture = try TrashFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let entry = uuidName()
|
||||
let child = uuidName()
|
||||
|
||||
try fixture.index("", "schema: 1\n")
|
||||
try fixture.index(".trash/\(entry)", "schema: 1\norder: 1024\n")
|
||||
try fixture.index(".trash/\(entry)/\(child)", "schema: 1\norder: 1024\n")
|
||||
|
||||
let before = try BoardLoader.load(boardRoot: fixture.root)
|
||||
#expect(before.trashKinds[ItemID(rawValue: entry)] == .lane)
|
||||
|
||||
try BoardWriter.updateIndex(
|
||||
inItemFolder: fixture.root.appendingPathComponent(".trash/\(entry)"),
|
||||
operation: .reorder(title: nil)
|
||||
) { $0.set(FrontmatterKeys.order, to: .double(4096)) }
|
||||
|
||||
let after = try BoardLoader.load(boardRoot: fixture.root)
|
||||
#expect(after.model.trash.first?.document.kind == .valid("lane"))
|
||||
#expect(after.trashKinds[ItemID(rawValue: entry)] == .lane)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user