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
+62
View File
@@ -490,6 +490,40 @@ public final class BannerCenter {
postLoss(message)
}
/// One claimed board-root name whose squatter was moved aside what
/// `displacedClaimedNamesMessage(for:)` names.
///
/// Both names are carried because the notice owes **old and new** (01-storage-format.md
/// § Fractal layout Rules, ruled 2026-07-29: "with the relocation-style warning-tone notice
/// naming old and new"): the user needs to know which of their files moved *and* where to find
/// it, and a sentence naming only one of the two would be half an answer.
public struct Displacement: Sendable, Equatable {
/// The claimed name the app took back `.trash`, `CLAUDE.md`.
public let name: String
/// The Finder-ladder name the displaced node now has `.trash 2`.
public let movedTo: String
public init(name: String, movedTo: String) {
self.name = name
self.movedTo = movedTo
}
}
/// **The claimed-name displacement** (01-storage-format.md § Fractal layout Rules, ruled
/// 2026-07-29): a folder, file or symlink was sitting on a name the app owns, the app moved it
/// aside preserved verbatim, never destroyed and this is the row that says so.
///
/// **A loss row, on `postRelocatedLooseFiles`' exact reasoning**, which is also what the ruling
/// asks for by name ("the relocation-style warning-tone notice"): the app moved something of the
/// user's that they did not ask it to move, so it must be said out loud, must not evaporate
/// unread, and must not rank as an error, because nothing failed.
///
/// A displacement that displaced nothing posts nothing.
public func postDisplacedClaimedNames(_ displacements: [Displacement]) {
guard let message = Self.displacedClaimedNamesMessage(for: displacements) else { return }
postLoss(message)
}
/// Posts the skipped-folders loss row for a Finder drop that imported its files but refused its
/// folders (04-interactions.md Selection, drag & drop, "Folders are refused at hover"): "a
/// mixed drag proposes for its files only, and the drop imports the files while a one-shot
@@ -757,6 +791,13 @@ public final class BannerCenter {
// touched, and nothing is lost: the board works exactly as well without the guide, which
// is why every *refusal* to write it is a log line and only a real I/O failure gets here.
"Couldn't update the agent guide"
case let .displaceClaimedName(name):
// **The name, quoted, and what the app wanted with it** the failure's mirror of the
// success row ("Renamed '.trash' to '.trash 2' Lanework needs that name"). It names
// the *consequence* the user can act on rather than the mechanics: while the name is
// held, the feature that needs it does not work, and the fix is theirs (move or rename
// the thing sitting there) because the app has just demonstrated it cannot.
"Couldn't move '\(name)' aside — Lanework needs that name"
case let .toggleTask(title):
// The user's word for it, not the file's: they ticked a box. The card is named where
// the read that preceded the flip learned its title, so a body write that refused says
@@ -957,6 +998,27 @@ public final class BannerCenter {
return "\(clauses.joined(separator: " and "))\(tail)"
}
/// The claimed-name displacement's line the relocation's own voice (the act first, the subject
/// after an em dash), naming **old and new** as the ruling requires.
///
/// - **One name**: "Renamed '.trash' to '.trash 2' Lanework needs that name". The tail is the
/// whole explanation the row owes: the user did not rename anything, and without it the
/// sentence would read as an act they had somehow just taken. It says *needs the name* rather
/// than anything about what was there, because what was there is the user's business and
/// still exists, under the name the row just gave them.
/// - **Several**: folded to a count in the relocation's idiom "Renamed 2 items Lanework
/// needs those names". Two claimed names can be squatted at once (a board somebody unpacked
/// over an old one), and a two-clause sentence would be longer than the row.
///
/// `nil` when nothing moved a displacement that displaced nothing is not news.
public nonisolated static func displacedClaimedNamesMessage(for displacements: [Displacement]) -> String? {
guard let only = displacements.first else { return nil }
guard displacements.count == 1 else {
return "Renamed \(displacements.count) items — Lanework needs those names"
}
return "Renamed '\(only.name)' to '\(only.movedTo)' — Lanework needs that name"
}
/// A sole migrated item's name: its title in quotes, or the untitled rendering the relocation
/// line already uses ("an untitled card" / "an untitled lane" are one phrase here, because the
/// clause it sits in already says which level it is).