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:
@@ -0,0 +1,289 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Kanban
|
||||
|
||||
/// **Claimed-name squatters heal by displacement** (01-storage-format.md § Fractal layout ▸ Rules,
|
||||
/// ruled 2026-07-29 — "Lanework owns the board, so an invalid artifact on a claimed name is a defect,
|
||||
/// not a resident").
|
||||
///
|
||||
/// A regular file or symlink squatting `.trash` — a directory name — is moved aside by the
|
||||
/// Finder-style rename ladder (`.trash` → `.trash 2`), **preserved verbatim, never destroyed**, with
|
||||
/// the relocation-style warning-tone notice naming old and new. The freed name then serves the app:
|
||||
/// the *next delete* mints the real `.trash/`, exactly as it does on a board that never had one.
|
||||
///
|
||||
/// `CLAUDE.md`'s squatter is the same ruling through the guide's own heal — `AgentGuideTests` owns
|
||||
/// that half.
|
||||
|
||||
// MARK: - Fixture
|
||||
|
||||
@MainActor
|
||||
private func makeBoard() throws -> WriterFixture {
|
||||
let fixture = try WriterFixture()
|
||||
try fixture.item("", Item.board)
|
||||
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
|
||||
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Fix login"))
|
||||
return fixture
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class BracketLog {
|
||||
private(set) var begins = 0
|
||||
|
||||
func attach(to store: BoardStore) {
|
||||
store.watcherBrackets = (begin: { self.begins += 1 }, end: {})
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Detection
|
||||
|
||||
@Suite("Claimed names ▸ detection")
|
||||
struct ClaimedNameDetectionTests {
|
||||
|
||||
/// Detection is **read-only in the loader**, the Repair precedent: the walk reports, the store
|
||||
/// acts.
|
||||
@Test("A file on .trash is reported as a defect, and nothing is moved by the load")
|
||||
func fileOnTrashIsADefect() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
try fixture.item("", Item.board)
|
||||
try fixture.file(".trash", Data("not a folder".utf8))
|
||||
|
||||
let result = try BoardLoader.load(boardRoot: fixture.root)
|
||||
|
||||
#expect(result.claimedNameSquatters == [
|
||||
ClaimedNameSquatter(name: ".trash", found: .file, expected: .directory),
|
||||
])
|
||||
// Until the heal lands: the empty-trash read, and the squatter exactly where it was.
|
||||
#expect(result.model.trash.isEmpty)
|
||||
#expect(try fixture.data(".trash") == Data("not a folder".utf8))
|
||||
// A claimed name is not a stray, so it never earns the stray-tolerance vocabulary.
|
||||
#expect(result.warnings.isEmpty)
|
||||
}
|
||||
|
||||
/// **Symlinks are nodes that are there** — `lstat`, never `stat`, so a dangling one counts too.
|
||||
@Test("A symlink on .trash is a defect, dangling or not")
|
||||
func symlinkOnTrashIsADefect() throws {
|
||||
for destination in ["nowhere", "elsewhere"] {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
try fixture.item("", Item.board)
|
||||
try FileManager.default.createDirectory(
|
||||
at: fixture.root.appendingPathComponent("elsewhere"),
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
try FileManager.default.createSymbolicLink(
|
||||
atPath: fixture.root.appendingPathComponent(".trash").path,
|
||||
withDestinationPath: destination
|
||||
)
|
||||
|
||||
let result = try BoardLoader.load(boardRoot: fixture.root)
|
||||
#expect(result.claimedNameSquatters.map(\.found) == [.symlink])
|
||||
}
|
||||
}
|
||||
|
||||
@Test("A real .trash directory, or none at all, is no defect")
|
||||
func healthyTrashIsNoDefect() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
try fixture.item("", Item.board)
|
||||
|
||||
#expect(try BoardLoader.load(boardRoot: fixture.root).defects.isEmpty)
|
||||
|
||||
try FileManager.default.createDirectory(
|
||||
at: fixture.root.appendingPathComponent(".trash"),
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
#expect(try BoardLoader.load(boardRoot: fixture.root).defects.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The displacement
|
||||
|
||||
@MainActor
|
||||
@Suite("Claimed names ▸ the displacement")
|
||||
struct ClaimedNameDisplacementTests {
|
||||
|
||||
@Test("A file on .trash is moved aside, preserved verbatim, and announced")
|
||||
func fileIsDisplacedAndAnnounced() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
try fixture.file(".trash", Data("somebody's notes".utf8))
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let brackets = BracketLog()
|
||||
brackets.attach(to: store)
|
||||
|
||||
store.displaceClaimedNames()
|
||||
|
||||
// Preserved verbatim under the ladder's name — displacement, never destruction.
|
||||
#expect(try fixture.data(".trash 2") == Data("somebody's notes".utf8))
|
||||
// The freed name is left *empty*: the next delete mints the real container, exactly as on a
|
||||
// board that never had one.
|
||||
#expect(!fixture.exists(".trash"))
|
||||
#expect(store.banners.losses.map(\.message) == [
|
||||
"Renamed '.trash' to '.trash 2' — Lanework needs that name",
|
||||
])
|
||||
#expect(store.banners.oneShots.isEmpty, "nothing failed")
|
||||
#expect(brackets.begins == 1, "one bracket — one app-mediated reload, one commit")
|
||||
}
|
||||
|
||||
/// A link is displaced **as a link**, never followed: its target is not read, not moved, and not
|
||||
/// written through.
|
||||
@Test("A symlink is displaced as a link, its target untouched")
|
||||
func symlinkIsDisplacedAsALink() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let target = try fixture.file("elsewhere/keep.txt", Data("keep".utf8))
|
||||
try FileManager.default.createSymbolicLink(
|
||||
atPath: fixture.root.appendingPathComponent(".trash").path,
|
||||
withDestinationPath: "elsewhere"
|
||||
)
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
|
||||
store.displaceClaimedNames()
|
||||
|
||||
let moved = fixture.root.appendingPathComponent(".trash 2")
|
||||
#expect(try FileManager.default.destinationOfSymbolicLink(atPath: moved.path) == "elsewhere")
|
||||
#expect(try Data(contentsOf: target) == Data("keep".utf8))
|
||||
#expect(!fixture.exists(".trash"))
|
||||
}
|
||||
|
||||
/// The ladder climbs rather than overwriting — Finder's rule, and the same helper the attachment
|
||||
/// import uses.
|
||||
@Test("The ladder climbs past a taken name")
|
||||
func ladderClimbs() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
try fixture.file(".trash", Data("squatter".utf8))
|
||||
try fixture.file(".trash 2", Data("already here".utf8))
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
|
||||
store.displaceClaimedNames()
|
||||
|
||||
#expect(try fixture.data(".trash 2") == Data("already here".utf8), "untouched")
|
||||
#expect(try fixture.data(".trash 3") == Data("squatter".utf8))
|
||||
}
|
||||
|
||||
/// **The re-verify** (§ Validation and healing): losing the race to a foreign fix is success,
|
||||
/// never an error — nothing is moved, nothing is said, nothing fails.
|
||||
@Test("A defect that healed itself under the write is a silent no-op")
|
||||
func vanishedDefectIsASilentNoOp() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
try fixture.file(".trash", Data("squatter".utf8))
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
// The store's snapshot still carries the defect; disk no longer does.
|
||||
#expect(store.claimedNameSquatters.count == 1)
|
||||
try FileManager.default.removeItem(at: fixture.root.appendingPathComponent(".trash"))
|
||||
|
||||
store.displaceClaimedNames()
|
||||
|
||||
#expect(!fixture.exists(".trash 2"), "nothing was moved")
|
||||
#expect(store.banners.losses.isEmpty, "and nothing was claimed to have been")
|
||||
#expect(store.banners.oneShots.isEmpty)
|
||||
}
|
||||
|
||||
/// The whole point of the timing being *scheduled*: deletion is broken while the squatter
|
||||
/// stands, and works again on the far side of one heal.
|
||||
@Test("Deleting works again once the name is freed")
|
||||
func deleteWorksAfterTheHeal() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
try fixture.file(".trash", Data("squatter".utf8))
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
|
||||
store.displaceClaimedNames()
|
||||
try BoardWriter.deleteCardToTrash(
|
||||
at: fixture.url("\(Ident.lane1)/\(Ident.card1)"),
|
||||
inBoard: fixture.root,
|
||||
order: 1024
|
||||
)
|
||||
|
||||
#expect(fixture.exists(".trash/\(Ident.card1)"))
|
||||
#expect(try fixture.data(".trash 2") == Data("squatter".utf8))
|
||||
}
|
||||
|
||||
/// It rides the engine like every other scheduled heal: deferred under a lock, remembered on a
|
||||
/// failure, and fired by the reload tail.
|
||||
@Test("A read-only board defers it")
|
||||
func lockDefersIt() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
try fixture.file(".trash", Data("squatter".utf8))
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
store.enterUnwritableLock(.permissionDenied)
|
||||
|
||||
store.displaceClaimedNames()
|
||||
|
||||
#expect(try fixture.data(".trash") == Data("squatter".utf8))
|
||||
#expect(!fixture.exists(".trash 2"))
|
||||
#expect(store.heals.memo(for: .claimedNameSquatted) == nil, "deferred, not remembered")
|
||||
}
|
||||
|
||||
@Test("A reload fires it")
|
||||
func aReloadFiresIt() async throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
try fixture.file(".trash", Data("squatter".utf8))
|
||||
|
||||
store.handleWatcherEvent(.treeChanged(.foreign))
|
||||
await store.awaitQuiescence()
|
||||
|
||||
#expect(try fixture.data(".trash 2") == Data("squatter".utf8))
|
||||
#expect(store.banners.losses.count == 1)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Phrasing
|
||||
|
||||
@Suite("Claimed names ▸ phrasing")
|
||||
struct ClaimedNamePhrasingTests {
|
||||
|
||||
/// The notice owes **old and new** — which file moved, and where to find it.
|
||||
@Test("One displacement names both names")
|
||||
func oneDisplacementNamesBoth() {
|
||||
let message = BannerCenter.displacedClaimedNamesMessage(for: [
|
||||
BannerCenter.Displacement(name: ".trash", movedTo: ".trash 2"),
|
||||
])
|
||||
#expect(message == "Renamed '.trash' to '.trash 2' — Lanework needs that name")
|
||||
}
|
||||
|
||||
@Test("Several fold to a count, the relocation's idiom")
|
||||
func severalFold() {
|
||||
let message = BannerCenter.displacedClaimedNamesMessage(for: [
|
||||
BannerCenter.Displacement(name: ".trash", movedTo: ".trash 2"),
|
||||
BannerCenter.Displacement(name: "CLAUDE.md", movedTo: "CLAUDE 2.md"),
|
||||
])
|
||||
#expect(message == "Renamed 2 items — Lanework needs those names")
|
||||
}
|
||||
|
||||
@Test("Nothing displaced says nothing")
|
||||
func nothingSaysNothing() {
|
||||
#expect(BannerCenter.displacedClaimedNamesMessage(for: []) == nil)
|
||||
}
|
||||
|
||||
/// It ranks as a **loss row**: warning tone, user-dismissed, never expiring — the relocation's
|
||||
/// class, because it is the same kind of event (the app moved something of the user's).
|
||||
@Test("It rides the loss-row class")
|
||||
@MainActor
|
||||
func ridesTheLossClass() {
|
||||
let banners = BannerCenter()
|
||||
banners.postDisplacedClaimedNames([BannerCenter.Displacement(name: ".trash", movedTo: ".trash 2")])
|
||||
#expect(banners.losses.count == 1)
|
||||
#expect(banners.oneShots.isEmpty)
|
||||
#expect(banners.signposts.isEmpty)
|
||||
}
|
||||
|
||||
/// The failure's mirror, in the one-shot vocabulary the Writer's errors reach the strip through.
|
||||
@Test("A failed displacement says so")
|
||||
func failureSaysSo() {
|
||||
let error = BoardWriteError(
|
||||
operation: .displaceClaimedName(name: ".trash"),
|
||||
path: "/b/.trash",
|
||||
reason: .io(message: "permission denied")
|
||||
)
|
||||
// The reason rides as the tail, like every other one-shot's.
|
||||
#expect(BannerCenter.headline(for: error)
|
||||
== "Couldn't move '.trash' aside — Lanework needs that name — permission denied")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user