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:
@@ -104,7 +104,23 @@ public final class EchoLedger: Sendable {
|
||||
/// is what those paths want: there is no session whose echoes they are.
|
||||
@TaskLocal public static var current: EchoLedger?
|
||||
|
||||
private let receipts = Mutex<[String: Receipt]>([:])
|
||||
/// One receipt plus its attributes. Split from `Receipt` so the public vocabulary stays the
|
||||
/// three outcomes it always was and an attribute can be added without every reader learning a
|
||||
/// new shape.
|
||||
private struct Entry: Sendable, Equatable {
|
||||
var receipt: Receipt
|
||||
/// **Whether the write that dropped this receipt was a heal** (06-history-undo.md ▸ Commit
|
||||
/// messages, ruled 2026-07-29: "the Writer's heal operations drop heal-marked receipts in
|
||||
/// the EchoLedger — attribution machinery like the author split, never message tagging").
|
||||
///
|
||||
/// **Inert in base beyond the ledger itself.** Nothing here reads it and nothing renders it;
|
||||
/// it is the flag pro-m1's committer reads to split a heal's paths into their own commit,
|
||||
/// and it is stored rather than derived because by commit time the only thing that still
|
||||
/// knows a path was healed is the receipt.
|
||||
var isHeal: Bool = false
|
||||
}
|
||||
|
||||
private let receipts = Mutex<[String: Entry]>([:])
|
||||
|
||||
public init() {}
|
||||
|
||||
@@ -145,7 +161,10 @@ public final class EchoLedger: Sendable {
|
||||
/// The path-and-hash form — **supersession** lives here, as a plain overwrite: "a newer app
|
||||
/// write to the same path supersedes the receipt", because only the final content decides.
|
||||
public func recordWrite(atPath path: String, hash: String) {
|
||||
receipts.withLock { $0[path] = .content(hash: hash) }
|
||||
// The heal mark is **not** carried over: supersession replaces the whole receipt, and a
|
||||
// later ordinary write to the same path is exactly the case where the path stops being the
|
||||
// heal's alone. `markHeal(at:)` is called after the write it describes, never before.
|
||||
receipts.withLock { $0[path] = Entry(receipt: .content(hash: hash)) }
|
||||
}
|
||||
|
||||
/// An attachment import — "attachment imports hash during the copy (the bytes stream through
|
||||
@@ -174,7 +193,7 @@ public final class EchoLedger: Sendable {
|
||||
public func recordDeletion(atPath path: String) {
|
||||
receipts.withLock { store in
|
||||
Self.forget(&store, under: path)
|
||||
store[path] = .absence
|
||||
store[path] = Entry(receipt: .absence)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,13 +211,42 @@ public final class EchoLedger: Sendable {
|
||||
for path in Array(store.keys) where path.hasPrefix(source + "/") {
|
||||
store[destination + path.dropFirst(source.count)] = store.removeValue(forKey: path)
|
||||
}
|
||||
let pair = Receipt.move(from: source, to: destination)
|
||||
let pair = Entry(receipt: .move(from: source, to: destination))
|
||||
store[source] = pair
|
||||
store[destination] = pair
|
||||
}
|
||||
}
|
||||
|
||||
private static func forget(_ store: inout [String: Receipt], under path: String) {
|
||||
/// **Marks the receipt at `path` as a heal** — the Writer's heal operations call this on the
|
||||
/// paths they touched, after the bytes land (06-history-undo.md ▸ Commit messages, ruled
|
||||
/// 2026-07-29).
|
||||
///
|
||||
/// A no-op where there is no receipt (a Writer call outside any bracket, a test): a mark with no
|
||||
/// receipt to attach to would describe nothing. It never *creates* a receipt for the same reason
|
||||
/// — the receipt is the record of a completed write, and this only ever adds an attribute to
|
||||
/// one that already exists.
|
||||
///
|
||||
/// Both ends of a move pair are one fact filed under two keys, so marking either marks both.
|
||||
public func markHeal(at url: URL) {
|
||||
markHeal(atPath: Self.key(url))
|
||||
}
|
||||
|
||||
public func markHeal(atPath path: String) {
|
||||
receipts.withLock { store in
|
||||
guard var entry = store[path] else { return }
|
||||
entry.isHeal = true
|
||||
store[path] = entry
|
||||
if case let .move(from, to) = entry.receipt {
|
||||
let other = path == from ? to : from
|
||||
if var twin = store[other] {
|
||||
twin.isHeal = true
|
||||
store[other] = twin
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func forget(_ store: inout [String: Entry], under path: String) {
|
||||
let prefix = path + "/"
|
||||
for key in Array(store.keys) where key.hasPrefix(prefix) {
|
||||
store.removeValue(forKey: key)
|
||||
@@ -217,7 +265,17 @@ public final class EchoLedger: Sendable {
|
||||
}
|
||||
|
||||
public func receipt(atPath path: String) -> Receipt? {
|
||||
receipts.withLock { $0[path] }
|
||||
receipts.withLock { $0[path]?.receipt }
|
||||
}
|
||||
|
||||
/// Whether the receipt at this path is heal-marked — `false` for a path with no receipt at all,
|
||||
/// which is the same shrug every other read here gives an unknown path.
|
||||
public func isHeal(at url: URL) -> Bool {
|
||||
isHeal(atPath: Self.key(url))
|
||||
}
|
||||
|
||||
public func isHeal(atPath path: String) -> Bool {
|
||||
receipts.withLock { $0[path]?.isHeal ?? false }
|
||||
}
|
||||
|
||||
/// Every path the ledger holds a receipt for strictly *below* `folder`.
|
||||
@@ -247,7 +305,7 @@ public final class EchoLedger: Sendable {
|
||||
/// byte under an item would make every copy foreign.
|
||||
public func classify(_ observations: [String: Observation]) -> Provenance {
|
||||
let held: [(path: String, receipt: Receipt)] = receipts.withLock { store in
|
||||
observations.keys.compactMap { path in store[path].map { (path, $0) } }
|
||||
observations.keys.compactMap { path in store[path].map { (path, $0.receipt) } }
|
||||
}
|
||||
guard !held.isEmpty else { return .foreign }
|
||||
for entry in held {
|
||||
|
||||
Reference in New Issue
Block a user