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
222 lines
12 KiB
Swift
222 lines
12 KiB
Swift
import Foundation
|
|
import os
|
|
|
|
// MARK: - The host
|
|
|
|
/// What a scheduled heal needs from the board it is healing — the seam that keeps the engine
|
|
/// independent of `BoardStore` (which is the only conformer, and a test's fake is the second).
|
|
///
|
|
/// Deliberately four members and no more: the engine decides *whether and when*, never *what*. The
|
|
/// work itself is a closure the store hands over, because only the store knows which Writer call a
|
|
/// defect class maps to.
|
|
@MainActor
|
|
public protocol HealHost: AnyObject {
|
|
/// Where the board is **now** — re-read per heal, because a rename absorbed mid-session moves it.
|
|
var rootURL: URL { get }
|
|
|
|
/// The standing read-only condition, `nil` when the board accepts writes.
|
|
var readOnlyLock: ReadOnlyLockReason? { get }
|
|
|
|
/// The board window's banner strip — the engine posts through the posture table below, and
|
|
/// `BannerCenter` keeps every word.
|
|
var banners: BannerCenter { get }
|
|
|
|
/// The write gate: one watcher bracket, one app-mediated reload, the failure already banner-ed.
|
|
@discardableResult
|
|
func performWrite<T>(_ operation: () throws(BoardWriteError) -> T) throws -> T
|
|
}
|
|
|
|
// MARK: - HealScheduler
|
|
|
|
/// **The scheduled-heal engine** — the six-step pattern expressed once (02-architecture.md ▸
|
|
/// Components ▸ HealScheduler; 01-storage-format.md § Validation and healing, settled 2026-07-29).
|
|
///
|
|
/// Before this existed, three healers in `BoardStore` re-derived the same pattern by hand — the
|
|
/// loose-file relocation, the legacy-tombstone migration, and the agent-guide refresh — and the
|
|
/// copies had already drifted: only the guide defended itself against an unwritable location, only
|
|
/// the guide cleared its memo on success, and only two of the three fired at board open. The engine
|
|
/// is the pattern with the drift taken out.
|
|
///
|
|
/// ### The six steps, in order
|
|
///
|
|
/// 1. **Compute the work from the latest defects**, and **rest** when there is none: an empty
|
|
/// signature clears the memo, which is what makes a board with nothing to heal forget it ever
|
|
/// tried.
|
|
/// 2. **The lock-and-writability gate.** A read-only board **defers, never abandons** — it returns
|
|
/// having written nothing *and having remembered nothing*, so the reload that lifts the lock is
|
|
/// the reload that heals. The `access(2)` check beside it was the guide's private defense and is
|
|
/// now everyone's: it covers the window *between* writability probes, where a volume remounted
|
|
/// read-only would otherwise let a heal reach the Writer, fail, and banner about work the user
|
|
/// never asked for.
|
|
/// 3. **Signature compare.** An attempt is made only when the defect picture **differs from the one
|
|
/// last acted on** — one failure, one banner row, then silence until something on disk actually
|
|
/// changes.
|
|
/// 4. **Arm the memo *before* attempting**, so a write that throws leaves it set: the memo's whole
|
|
/// job is to remember pictures this store has already failed on.
|
|
/// 5. **One write bracket**, whose write half re-verifies each defect against disk and no-ops when
|
|
/// it is gone — losing the race to a foreign fix is success, never an error.
|
|
/// 6. **Post per the banner-posture table**, then **clear the memo explicitly on success** — the
|
|
/// guide's rule, generalized. Clearing is load-bearing rather than tidy: the memo keys on the
|
|
/// picture that provoked the write, and a foreign undo restores that exact picture, which a
|
|
/// standing memo would make the one thing the self-heal could not heal.
|
|
///
|
|
/// ### Where it runs
|
|
///
|
|
/// **At the reload tail and at registry acquire, uniformly** — closing the asymmetry where the
|
|
/// tombstone migration never fired at open. Both seams call `BoardStore.runScheduledHeals()`.
|
|
///
|
|
/// ### What it deliberately is not
|
|
///
|
|
/// Not the home of *inline* heals, which stay gesture-scoped (`placingRanks` below is a helper for
|
|
/// one of them, not a scheduling of it), and not the home of *on-touch* heals, which live at the
|
|
/// Writer's `updateIndex` seam. The three timings are different by design (01-storage-format.md
|
|
/// § Validation and healing), and only this one needs a scheduler.
|
|
@MainActor
|
|
public final class HealScheduler {
|
|
|
|
/// The picture each defect class was last *acted on* against — armed before an attempt, cleared
|
|
/// on success, and reset to nothing when the class has no work at all.
|
|
///
|
|
/// **In-memory, per-store, dies with the session**, like every other loop guard here: closing
|
|
/// the board is the reset.
|
|
private var memos: [IntegrityRules.Defect.Class: Set<String>] = [:]
|
|
|
|
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "heal")
|
|
|
|
public init() {}
|
|
|
|
/// The picture a class was last acted on, for tests and diagnostics. `nil` means "nothing has
|
|
/// been acted on against the current picture" — both the opening state and the resting one.
|
|
public func memo(for healClass: IntegrityRules.Defect.Class) -> Set<String>? {
|
|
memos[healClass]
|
|
}
|
|
|
|
// MARK: - The engine
|
|
|
|
/// Runs one scheduled heal against `host`, per the six steps above.
|
|
///
|
|
/// - Parameters:
|
|
/// - healClass: the memo key and the banner-posture row.
|
|
/// - signature: the defect picture, as comparable values. **Empty means resting** — nothing to
|
|
/// heal — and is the one input that clears the memo without writing.
|
|
/// - work: the write half. Runs inside the host's write bracket, and re-verifies each defect
|
|
/// against disk itself (the Writer's heal operations do it per call).
|
|
/// - notice: what to say afterwards, read **whether or not `work` threw** — a batch that moved
|
|
/// three files and failed on the fourth still names the three, which is what the relocation
|
|
/// has always done and what a partial heal owes the user.
|
|
public func run(
|
|
_ healClass: IntegrityRules.Defect.Class,
|
|
signature: Set<String>,
|
|
on host: some HealHost,
|
|
performing work: () throws(BoardWriteError) -> Void,
|
|
posting notice: () -> HealNotice = { .none }
|
|
) {
|
|
// 1. Resting-clear.
|
|
guard !signature.isEmpty else {
|
|
memos[healClass] = nil
|
|
return
|
|
}
|
|
// 2. The lock-and-writability gate — deferral, deliberately *before* the memo is written, so
|
|
// the attempt a lock refused is not the attempt the guard below remembers.
|
|
guard host.readOnlyLock == nil else {
|
|
Self.logger.debug("\(String(describing: healClass), privacy: .public) heal deferred — the board is read-only")
|
|
return
|
|
}
|
|
guard FileManager.default.isWritableFile(atPath: host.rootURL.path) else {
|
|
Self.logger.debug("\(String(describing: healClass), privacy: .public) heal skipped — the board's location is not writable")
|
|
return
|
|
}
|
|
// 3. Signature compare, and 4. arm before attempting.
|
|
guard memos[healClass] != signature else { return }
|
|
memos[healClass] = signature
|
|
|
|
// 5. One bracket. `performWrite` posts every `BoardWriteError` before it rethrows, and the
|
|
// rethrow is swallowed here like every other app-initiated write with nothing else to do
|
|
// about a failure — the armed memo is what keeps it from being posted again every reload.
|
|
let landed: Void? = try? host.performWrite(work)
|
|
// 6. Post, then clear on success.
|
|
Self.post(notice(), to: host.banners)
|
|
if landed != nil {
|
|
memos[healClass] = nil
|
|
}
|
|
}
|
|
|
|
// MARK: - The banner-posture table
|
|
|
|
/// What a heal has to say for itself — **one row per defect class, declared once**
|
|
/// (02-architecture.md ▸ Components: "post per one banner-posture table … BannerCenter still
|
|
/// owns all phrasing").
|
|
///
|
|
/// Every case here is a *loss row* or silence; there is no failure row, because failures are
|
|
/// `performWrite`'s and reach the strip as one-shots without anyone here deciding. The payloads
|
|
/// are values, never strings: the phrasing lives in `BannerCenter` and nowhere else, so a
|
|
/// sentence cannot be spelled two ways.
|
|
public enum HealNotice: Sendable, Equatable {
|
|
/// Silent — nothing was healed, or the heal is one the user has no business hearing about
|
|
/// (the guide's ordinary refresh: a courtesy file the user did not create and may not know
|
|
/// exists).
|
|
case none
|
|
/// Loss row, warning tone: files were moved into a card's `attachments/`.
|
|
case relocatedLooseFiles([BannerCenter.Relocation])
|
|
/// Loss row, warning tone: legacy `deleted:` keys were resolved.
|
|
case migratedTombstones(cards: [String?], lanes: [String?])
|
|
/// Loss row, warning tone: a claimed name's squatter was moved aside, named old and new.
|
|
case displacedClaimedNames([BannerCenter.Displacement])
|
|
}
|
|
|
|
private static func post(_ notice: HealNotice, to banners: BannerCenter) {
|
|
switch notice {
|
|
case .none:
|
|
break
|
|
case let .relocatedLooseFiles(relocations):
|
|
banners.postRelocatedLooseFiles(relocations)
|
|
case let .migratedTombstones(cards, lanes):
|
|
banners.postMigratedTombstones(cards: cards, lanes: lanes)
|
|
case let .displacedClaimedNames(displacements):
|
|
banners.postDisplacedClaimedNames(displacements)
|
|
}
|
|
}
|
|
|
|
// MARK: - The inline renumber-and-retry
|
|
|
|
/// **The midpoint-exhaustion two-step, once** — ask, renumber, ask again (01-storage-format.md
|
|
/// § Ordering; 02-architecture.md ▸ Components: "the renumber's ask-renumber-ask-again two-step
|
|
/// as one shared helper instead of today's nine hand-rolled copies").
|
|
///
|
|
/// An **inline** heal, and it stays gesture-scoped: this runs inside the caller's own
|
|
/// `performWrite` bracket, inside the gesture that triggered it, and composes no event of its
|
|
/// own (the renumber is bookkeeping — 06-history-undo.md ▸ Commit messages). It is a helper here
|
|
/// rather than a method on the engine because inline heals are not scheduled; what they share
|
|
/// with the scheduled ones is the vocabulary, not the machinery.
|
|
///
|
|
/// - Parameters:
|
|
/// - ladder: the ranks to place among, in display order.
|
|
/// - parentFolder: whose visible children compact when the first ask fails — a lane
|
|
/// (renumbering its cards) or the board root (renumbering its lanes).
|
|
/// - ask: computes the placement from a ladder. **`nil` means the gap is exhausted, not that
|
|
/// the insertion is illegal** (`Ranks`' own convention).
|
|
/// - Returns: what `ask` answered, plus the ladder it answered against — the *renumbered* one
|
|
/// when a compaction happened, which callers need because a renumber moves ranks they had
|
|
/// already read (a dragged item's own prior rank, the undo step's inverse). `nil` when even
|
|
/// the compacted ladder had no answer, which every call site treats as "write nothing".
|
|
///
|
|
/// `nonisolated` because it is not the engine's state, only its vocabulary: an inline heal has
|
|
/// no memo, no gate and no bracket of its own, and tying it to the main actor would claim
|
|
/// otherwise.
|
|
public nonisolated static func placingRanks<Placement>(
|
|
amongVisible ladder: [Double],
|
|
compacting parentFolder: URL,
|
|
_ ask: ([Double]) -> Placement?
|
|
) throws(BoardWriteError) -> (placement: Placement, ladder: [Double], renumbered: Bool)? {
|
|
if let placement = ask(ladder) {
|
|
return (placement: placement, ladder: ladder, renumbered: false)
|
|
}
|
|
// Compact, then place against the fresh ranks. The renumber assigns in display order, so the
|
|
// compacted ladder lines up one-for-one with the one that was passed in.
|
|
try BoardWriter.renumberVisibleChildren(of: parentFolder)
|
|
let compacted = Ranks.renumbered(count: ladder.count)
|
|
guard let placement = ask(compacted) else { return nil }
|
|
return (placement: placement, ladder: compacted, renumbered: true)
|
|
}
|
|
}
|