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
+113 -29
View File
@@ -25,6 +25,14 @@ import Foundation
/// written, read, upgraded or validated by this app that one rescue is its only creation
/// (08 `CLAUDE.user.md`).
///
/// **A folder or symlink squatting `CLAUDE.md` is displaced too** (ruled 2026-07-29 the
/// claimed-names rule, 01-storage-format.md § Fractal layout Rules): Lanework owns the board, so
/// an invalid artifact on a name the app claims is a defect rather than a resident. It moves aside
/// by the Finder-style rename ladder (`CLAUDE.md` `CLAUDE.md 2`) preserved verbatim, a symlink
/// moved as a link and never followed and the guide is written on the freed name, with a
/// warning-tone notice naming old and new. This replaced an untouchable-skip; what survives from it
/// is displacement-never-destruction.
///
/// **Nothing here is a user-facing event.** Every refusal below is a log line and nothing more; the
/// only thing that can reach a banner is a genuine I/O failure of the write itself, because
/// `BoardStore.performWrite` posts every `BoardWriteError` it sees. The scheduling when this is
@@ -82,11 +90,17 @@ enum AgentGuide {
/// the app cannot read is a file whose marker it cannot honestly claim to have checked.
case file(text: String?)
/// A symlink, a directory, or any other node that is not a regular file. **Symlinks are
/// never followed or touched anywhere in this app** (01-storage-format.md § Fractal layout
/// Rules), and a folder named `CLAUDE.md` is somebody's deliberate arrangement; neither
/// is displaced or overwritten to make room for a courtesy file.
case untouchable
/// A symlink, a directory, or any other node that is not a regular file **a squatter on a
/// claimed name**, and since 2026-07-29 not a resident (01-storage-format.md § Fractal
/// layout Rules: "Lanework owns the board, so an invalid artifact on a claimed name is a
/// defect, not a resident").
///
/// It is moved aside by the Finder-style rename ladder (`CLAUDE.md` `CLAUDE.md 2`),
/// **preserved verbatim, never destroyed** a symlink moved as a link, never followed
/// and the guide is then written on the freed name. This case used to mean "skipped"; the
/// ruling upgraded the skip to a displacement, and the invariant that survives is
/// displacement-never-destruction.
case squatted
}
/// The board root's two claimed names, read once the input to `decide(_:)`.
@@ -97,6 +111,20 @@ enum AgentGuide {
/// rescue move re-checks this atomically anyway (`FileManager.moveItem` fails rather than
/// overwrite), so this is the decision's input, not its safety.
var userFilenameIsFree: Bool
/// This picture as the heal engine's comparable value (`HealScheduler`'s memo unit).
///
/// The file's *text* is hashed rather than carried: two states are the same picture exactly
/// when the same bytes are on the same name, and a memo holding a whole guide's prose for the
/// life of a session would be the one place in this store that grows with a file's size.
var signature: String {
let existing = switch existing {
case .missing: "missing"
case .squatted: "squatted"
case let .file(text): "file:\(text.map(EchoLedger.hash(of:)) ?? "undecodable")"
}
return "guide:\(existing):\(userFilenameIsFree)"
}
}
/// The four outcomes, and the only four.
@@ -112,10 +140,16 @@ enum AgentGuide {
/// A markerless `CLAUDE.md` with `CLAUDE.user.md` already taken the ruling's
/// skipped-with-a-log case. Two files the user owns, both left alone.
///
/// **The one standing exception to the squatter displacement, and it stands** (ruled
/// 2026-07-29): this displacement has a designated *destination*, and freeing a destination
/// by a second displacement would cascade renames.
case skipUserFilenameTaken
/// `CLAUDE.md` is a symlink, a folder, or some other non-file. Skipped with a log.
case skipUntouchable
/// `CLAUDE.md` is a symlink, a folder, or some other non-file: move it aside by the
/// Finder-style rename ladder, then write the guide on the freed name (ruled 2026-07-29
/// the claimed-name squatter rule; it replaced a skip).
case displaceSquatterThenWrite
}
/// The whole rule, as a pure function of `state` so "never downgrade", "never clobber" and
@@ -128,8 +162,8 @@ enum AgentGuide {
switch state.existing {
case .missing:
.write
case .untouchable:
.skipUntouchable
case .squatted:
.displaceSquatterThenWrite
case let .file(text):
if let text, let installed = installedVersion(of: text) {
installed >= version ? .leaveAlone : .write
@@ -145,28 +179,20 @@ enum AgentGuide {
/// cheap enough (one `lstat`, plus a small file read only when there is a file to read) to run
/// on every reload.
///
/// **`attributesOfItem` throughout, never `fileExists`** `lstat` semantics rather than `stat`:
/// a **dangling** symlink is a node that is *there* (the rescue move would fail on it, and this
/// app does not touch symlinks anyway), while `fileExists` follows the link, finds nothing, and
/// would call the name free.
/// **`lstat` semantics throughout, never `fileExists`** (`IntegrityRules.node(at:)`): a
/// **dangling** symlink is a node that is *there* it holds the name, and it is displaced as a
/// link rather than followed while `fileExists` follows the link, finds nothing, and would
/// call the name free.
static func inspect(atBoardRoot root: URL) -> State {
State(
existing: existingNode(at: root.appendingPathComponent(filename)),
userFilenameIsFree: !nodeExists(at: root.appendingPathComponent(userFilename))
userFilenameIsFree: IntegrityRules.node(at: root.appendingPathComponent(userFilename)) == nil
)
}
private static func nodeExists(at url: URL) -> Bool {
(try? FileManager.default.attributesOfItem(atPath: url.path)) != nil
}
private static func existingNode(at url: URL) -> Existing {
guard let type = (try? FileManager.default.attributesOfItem(atPath: url.path))?[.type]
as? FileAttributeType
else {
return .missing
}
guard type == .typeRegular else { return .untouchable }
guard let node = IntegrityRules.node(at: url) else { return .missing }
guard node == .file else { return .squatted }
// A regular file whose *contents* cannot be read reads as undecodable rather than as
// missing, and the difference is the whole promise: `.missing` would overwrite it, while
// undecodable displaces it and the rescue move needs no read permission on the file to
@@ -190,12 +216,30 @@ enum AgentGuide {
/// `CLAUDE.user.md` appeared between the decision and this call, which is what makes the
/// "user content is never destroyed" promise hold against a race rather than merely against a
/// stale read.
static func install(
atBoardRoot root: URL,
displacingUserContent displace: Bool
) throws(BoardWriteError) {
///
/// **It re-verifies against disk** (01-storage-format.md § Validation and healing: "every
/// scheduled heal re-verifies its defect against disk at write time and no-ops when it is
/// gone"): the board root is re-inspected here, and a guide that has become current since the
/// decision an agent wrote it, another window healed it first returns `nil` rather than
/// rewriting a file that no longer needs it. Losing the race to a foreign fix is success.
///
/// - Returns: what this call displaced, or `nil` when it wrote nothing at all.
@discardableResult
static func install(atBoardRoot root: URL) throws(BoardWriteError) -> Displacement? {
let guideURL = root.appendingPathComponent(filename)
if displace {
let decision = decide(inspect(atBoardRoot: root))
var displaced: Displacement?
switch decision {
case .leaveAlone, .skipUserFilenameTaken:
// Nothing to write: either the defect healed itself under us, or the standing exception
// applies and both of the user's files stay exactly where they are.
return nil
case .write:
break
case .displaceThenWrite:
// The rescue, not a squatter: a markerless `CLAUDE.md` is user *content*, and it has a
// designated destination (08-agent-integration.md Ownership).
do {
try FileManager.default.moveItem(at: guideURL, to: root.appendingPathComponent(userFilename))
} catch {
@@ -205,8 +249,48 @@ enum AgentGuide {
reason: .io(message: "could not move the existing \(filename) aside to \(userFilename): \(error.localizedDescription)")
)
}
EchoLedger.current?.recordMove(from: guideURL, to: root.appendingPathComponent(userFilename))
displaced = Displacement(name: filename, movedTo: userFilename, wasUserContent: true)
case .displaceSquatterThenWrite:
// The claimed-name displacement (ruled 2026-07-29): a folder or symlink on the app's own
// name, moved aside by the Finder ladder and never destroyed.
guard let freed = try BoardWriter.displaceClaimedName(
ClaimedNameSquatter(
name: filename,
found: IntegrityRules.node(at: guideURL) ?? .directory,
expected: .file
),
atBoardRoot: root
) else {
// Gone under us re-decide rather than write blind, which the next reload does
// anyway. Nothing displaced, nothing written.
return nil
}
displaced = Displacement(name: filename, movedTo: freed, wasUserContent: false)
}
try BoardWriter.atomicReplace(text: content, at: guideURL, operation: .agentGuide)
// Heal-marked: the guide's refresh is app-initiated work, and its commit is its own
// ("Update agent guide (vN)" already commits alone 06-history-undo.md Commit messages).
EchoLedger.current?.markHeal(at: guideURL)
return displaced
}
/// What an install moved out of the way, for the notice that names old and new.
///
/// Two shapes ride one type because the *user-facing* fact is the same in both a file the user
/// owns is now under a different name and only the tone differs: the `CLAUDE.user.md` rescue
/// is the settled, silent ownership rule (08-agent-integration.md), while a squatter's
/// displacement gets the relocation-style warning-tone notice (01-storage-format.md § Fractal
/// layout Rules, ruled 2026-07-29).
struct Displacement: Sendable, Equatable {
/// The claimed name that was freed.
let name: String
/// The name the displaced node now has.
let movedTo: String
/// Whether this was the markerless-`CLAUDE.md` rescue (silent) rather than a squatter's
/// displacement (announced).
let wasUserContent: Bool
}
// MARK: - The guide itself