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