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:
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user