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:
@@ -394,7 +394,10 @@ enum TemplateEngine {
|
|||||||
// One `Date` for the whole tree, so the board and every item in it are born at the same
|
// One `Date` for the whole tree, so the board and every item in it are born at the same
|
||||||
// instant rather than merely close (`BoardWriter.newDocumentText`'s convention).
|
// instant rather than merely close (`BoardWriter.newDocumentText`'s convention).
|
||||||
let now = Date()
|
let now = Date()
|
||||||
try BoardWriter.updateIndex(inItemFolder: root, operation: operation) { document in
|
// `kind: .board` — an instantiated board's root is the one file whose kind position
|
||||||
|
// cannot answer, and this write is where a template's kind-less root gains it
|
||||||
|
// (`BoardWriter.updateIndex`'s on-touch backfill; no template migration, by design).
|
||||||
|
try BoardWriter.updateIndex(inItemFolder: root, kind: .board, operation: operation) { document in
|
||||||
document.set(FrontmatterKeys.created, to: .date(now))
|
document.set(FrontmatterKeys.created, to: .date(now))
|
||||||
document.set(FrontmatterKeys.title, to: .string(title))
|
document.set(FrontmatterKeys.title, to: .string(title))
|
||||||
}
|
}
|
||||||
@@ -591,7 +594,7 @@ enum TemplateEngine {
|
|||||||
operation: WriteOperation
|
operation: WriteOperation
|
||||||
) throws(Failure) {
|
) throws(Failure) {
|
||||||
do throws(BoardWriteError) {
|
do throws(BoardWriteError) {
|
||||||
try BoardWriter.updateIndex(inItemFolder: root, operation: operation) { document in
|
try BoardWriter.updateIndex(inItemFolder: root, kind: .board, operation: operation) { document in
|
||||||
document.set(BoardLoader.templateKey, to: .raw("{order: \(orderText(order))}"))
|
document.set(BoardLoader.templateKey, to: .raw("{order: \(orderText(order))}"))
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -490,6 +490,40 @@ public final class BannerCenter {
|
|||||||
postLoss(message)
|
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
|
/// 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
|
/// 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
|
/// 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
|
// 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.
|
// 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"
|
"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):
|
case let .toggleTask(title):
|
||||||
// The user's word for it, not the file's: they ticked a box. The card is named where
|
// 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
|
// 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)"
|
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
|
/// 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
|
/// 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).
|
/// clause it sits in already says which level it is).
|
||||||
|
|||||||
+337
-334
@@ -222,7 +222,7 @@ public enum TransferOperation: Sendable, Equatable {
|
|||||||
/// is simply rendered on top of whatever the latest reload produced.
|
/// is simply rendered on top of whatever the latest reload produced.
|
||||||
@MainActor
|
@MainActor
|
||||||
@Observable
|
@Observable
|
||||||
public final class BoardStore {
|
public final class BoardStore: HealHost {
|
||||||
|
|
||||||
// MARK: Read-side state
|
// MARK: Read-side state
|
||||||
|
|
||||||
@@ -244,23 +244,30 @@ public final class BoardStore {
|
|||||||
/// describe the tree currently on screen.
|
/// describe the tree currently on screen.
|
||||||
public private(set) var loadWarnings: [LoadWarning]
|
public private(set) var loadWarnings: [LoadWarning]
|
||||||
|
|
||||||
/// The cards the load that produced `snapshot` found holding loose files, exactly as the loader
|
/// **The pending work the load that produced `snapshot` found** — the typed defect stream
|
||||||
/// reported them — the loose-file carve-out's detection channel (01-storage-format.md § Fractal
|
/// (`IntegrityRules.Defect`, settled 2026-07-29). Replaced with the snapshot, like
|
||||||
/// layout ▸ Rules, settled 2026-07-28). Replaced with the snapshot, like `loadWarnings`, so it
|
/// `loadWarnings`, so it always describes the tree currently on screen.
|
||||||
/// always describes the tree currently on screen.
|
|
||||||
///
|
///
|
||||||
/// **Nothing renders it.** A loose file is not content — it reaches no view, and the card it
|
/// **Nothing renders it.** A defect is not content — it reaches no view, and the board draws
|
||||||
/// sits in draws exactly as it would without it. Its one consumer is
|
/// exactly as it would without it. Its one consumer is `runScheduledHeals()`, immediately below
|
||||||
/// `relocateLooseCardFiles()`, immediately below the reload that produced it.
|
/// the reload that produced it.
|
||||||
public private(set) var looseCardFiles: [LooseCardFiles]
|
public private(set) var defects: [IntegrityRules.Defect]
|
||||||
|
|
||||||
/// The legacy `deleted:` keys the load that produced `snapshot` found — the retired tombstone
|
/// The cards the last load found holding loose files — a view over `defects`, under the name it
|
||||||
/// model's migration input (01-storage-format.md § Deletion, resettled 2026-07-28), in the
|
/// has always had.
|
||||||
/// loose-file channel's idiom and replaced with the snapshot exactly as it is.
|
public var looseCardFiles: [LooseCardFiles] {
|
||||||
///
|
defects.compactMap { if case let .looseCardFiles(work) = $0 { work } else { nil } }
|
||||||
/// **Nothing renders it either.** Its one consumer is `migrateLegacyTombstones()`, immediately
|
}
|
||||||
/// below the reload that produced it.
|
|
||||||
public private(set) var legacyTombstones: [LegacyTombstone]
|
/// The legacy `deleted:` keys the last load found — a view over `defects`.
|
||||||
|
public var legacyTombstones: [LegacyTombstone] {
|
||||||
|
defects.compactMap { if case let .legacyTombstone(work) = $0 { work } else { nil } }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The claimed board-root names the last load found squatted — a view over `defects`.
|
||||||
|
public var claimedNameSquatters: [ClaimedNameSquatter] {
|
||||||
|
defects.compactMap { if case let .claimedNameSquatted(work) = $0 { work } else { nil } }
|
||||||
|
}
|
||||||
|
|
||||||
/// The standing read-side condition: the error from the last reload that failed, `nil` when the
|
/// The standing read-side condition: the error from the last reload that failed, `nil` when the
|
||||||
/// board is healthy. `BoardLoadError` already carries fail-fast's specifics — the offending path
|
/// board is healthy. `BoardLoadError` already carries fail-fast's specifics — the offending path
|
||||||
@@ -471,24 +478,14 @@ public final class BoardStore {
|
|||||||
@ObservationIgnored
|
@ObservationIgnored
|
||||||
private var quiescenceWaiters: [CheckedContinuation<Void, Never>] = []
|
private var quiescenceWaiters: [CheckedContinuation<Void, Never>] = []
|
||||||
|
|
||||||
/// The loose-file set the last relocation attempt was made against — the loop guard
|
/// **The scheduled-heal engine** (02-architecture.md ▸ Components ▸ HealScheduler): the six-step
|
||||||
/// `relocateLooseCardFiles()` documents. Empty means "nothing has been attempted against the
|
/// pattern the three healers below used to re-derive one by one, plus the memo each of them
|
||||||
/// current picture", which is both the opening state and what a clean board resets it to.
|
/// used to keep on its own.
|
||||||
|
///
|
||||||
|
/// A `let` beside `transient`, `banners` and `echoes`, and for their reason: the memos are
|
||||||
|
/// per-open state, and closing the board is the reset.
|
||||||
@ObservationIgnored
|
@ObservationIgnored
|
||||||
private var attemptedRelocation: Set<String> = []
|
let heals = HealScheduler()
|
||||||
|
|
||||||
/// The tombstone set the last migration attempt was made against — `attemptedRelocation`'s twin,
|
|
||||||
/// documented at `migrateLegacyTombstones()`.
|
|
||||||
@ObservationIgnored
|
|
||||||
private var attemptedTombstoneMigration: Set<String> = []
|
|
||||||
|
|
||||||
/// The board root's agent-guide picture the last refresh acted on — the third of the same loop
|
|
||||||
/// guard, documented at `refreshAgentGuide()`. `nil` means "nothing has been acted on against
|
|
||||||
/// the current picture", which is both the opening state and what a board whose guide is already
|
|
||||||
/// current resets it to (so the state it holds is never a guide's own text for longer than the
|
|
||||||
/// one reload that wrote it).
|
|
||||||
@ObservationIgnored
|
|
||||||
private var attemptedGuideRefresh: AgentGuide.State?
|
|
||||||
|
|
||||||
/// Awaited off the main actor **after** a tree walk finishes and **before** its result is
|
/// Awaited off the main actor **after** a tree walk finishes and **before** its result is
|
||||||
/// applied — the one seam this type keeps, `nil` in production.
|
/// applied — the one seam this type keeps, `nil` in production.
|
||||||
@@ -534,19 +531,18 @@ public final class BoardStore {
|
|||||||
/// The walk is synchronous because the caller has nothing to render until it lands; the
|
/// The walk is synchronous because the caller has nothing to render until it lands; the
|
||||||
/// asynchronous, off-main pipeline starts with the first reload.
|
/// asynchronous, off-main pipeline starts with the first reload.
|
||||||
///
|
///
|
||||||
/// **It writes nothing, the opened board's loose files included.** `looseCardFiles` is recorded
|
/// **It writes nothing, the opened board's defects included.** `defects` is recorded here and
|
||||||
/// here and acted on by whoever wired this store up — `BoardStoreRegistry.acquire` calls
|
/// acted on by whoever wired this store up — `BoardStoreRegistry.acquire` calls
|
||||||
/// `relocateLooseCardFiles()` once the watcher and the brackets exist, so the relocation is a
|
/// `runScheduledHeals()` once the watcher and the brackets exist, so a heal is a bracketed write
|
||||||
/// bracketed write with a reload behind it rather than a write into a board nothing is watching
|
/// with a reload behind it rather than a write into a board nothing is watching yet. A store
|
||||||
/// yet. A store built directly (a test, a storeless consumer) relocates when it is asked to, and
|
/// built directly (a test, a storeless consumer) heals when it is asked to, and on every reload
|
||||||
/// on every reload thereafter.
|
/// thereafter.
|
||||||
public init(rootURL: URL) throws(BoardLoadError) {
|
public init(rootURL: URL) throws(BoardLoadError) {
|
||||||
let result = try BoardLoader.load(boardRoot: rootURL)
|
let result = try BoardLoader.load(boardRoot: rootURL)
|
||||||
self.rootURL = rootURL
|
self.rootURL = rootURL
|
||||||
self.snapshot = result.model
|
self.snapshot = result.model
|
||||||
self.loadWarnings = result.warnings
|
self.loadWarnings = result.warnings
|
||||||
self.looseCardFiles = result.looseCardFiles
|
self.defects = result.defects
|
||||||
self.legacyTombstones = result.legacyTombstones
|
|
||||||
self.reloadFailure = nil
|
self.reloadFailure = nil
|
||||||
self.readOnlyLock = nil
|
self.readOnlyLock = nil
|
||||||
self.transient = TransientBoardState()
|
self.transient = TransientBoardState()
|
||||||
@@ -765,8 +761,7 @@ public final class BoardStore {
|
|||||||
// Breakage always heals on a success — it *is* the claim "the last reload failed", and
|
// Breakage always heals on a success — it *is* the claim "the last reload failed", and
|
||||||
// this one did not.
|
// this one did not.
|
||||||
reloadFailure = nil
|
reloadFailure = nil
|
||||||
looseCardFiles = result.looseCardFiles
|
defects = result.defects
|
||||||
legacyTombstones = result.legacyTombstones
|
|
||||||
reconcileLock(after: origin)
|
reconcileLock(after: origin)
|
||||||
// The registry write-through, for the same "not board structure" reason the lock
|
// The registry write-through, for the same "not board structure" reason the lock
|
||||||
// clearing sits out here: whether this board's row needs a new title, icon, or
|
// clearing sits out here: whether this board's row needs a new title, icon, or
|
||||||
@@ -774,29 +769,14 @@ public final class BoardStore {
|
|||||||
// guard), not a decision this store makes by comparing against its own prior
|
// guard), not a decision this store makes by comparing against its own prior
|
||||||
// snapshot.
|
// snapshot.
|
||||||
displayStateDelegate?()
|
displayStateDelegate?()
|
||||||
// Last, and after `reconcileLock` deliberately: this is the seam the two deferred
|
// **The reload tail** — one of the two seams the heal engine runs at (the other is
|
||||||
// app-initiated writes are armed on. A board that was locked read-only tolerated its
|
// `BoardStoreRegistry.acquire`), and after `reconcileLock` deliberately: this is where
|
||||||
// loose files and its legacy tombstones for exactly as long as the lock stood, and the
|
// the deferred app-initiated writes are armed. A board that was locked read-only
|
||||||
// reload that clears the lock is the reload that lets them move — see
|
// tolerated its defects for exactly as long as the lock stood, and the reload that
|
||||||
// `relocateLooseCardFiles()` and `migrateLegacyTombstones()`. The ordering cuts the
|
// clears the lock is the reload that heals them. The ordering cuts the other way too:
|
||||||
// other way too now that the probe is symmetric: a reconciling reload that *raises* the
|
// a reconciling reload that *raises* the unwritable-location lock raises it before this
|
||||||
// lock raises it before these three run, so none of them writes into a location the
|
// runs, so no heal writes into a location the same reload just learned is read-only.
|
||||||
// same reload just learned is read-only.
|
runScheduledHeals()
|
||||||
//
|
|
||||||
// The migration goes second only because the relocation is the older rule; they touch
|
|
||||||
// disjoint files (loose files beside an `index.md` vs the `deleted:` key inside one) and
|
|
||||||
// share one bracket-per-call posture, so neither can see the other's work half-done —
|
|
||||||
// each opens its own bracket and each is re-armed by the reload the other's write
|
|
||||||
// produces.
|
|
||||||
//
|
|
||||||
// The agent guide joins them last, and is the one of the three that runs on *every*
|
|
||||||
// board rather than only on one an older version or an outside writer left work in: it
|
|
||||||
// re-checks a single board-root file and writes only when the version marker says to
|
|
||||||
// (08-agent-integration.md ▸ The agent guide). Running it here rather than at open alone
|
|
||||||
// is what makes it self-healing — see `refreshAgentGuide()`.
|
|
||||||
relocateLooseCardFiles()
|
|
||||||
migrateLegacyTombstones()
|
|
||||||
refreshAgentGuide()
|
|
||||||
|
|
||||||
case let .failure(error):
|
case let .failure(error):
|
||||||
// `snapshot`, `loadWarnings` and the transient state are untouched: a failed reload
|
// `snapshot`, `loadWarnings` and the transient state are untouched: a failed reload
|
||||||
@@ -1398,7 +1378,13 @@ public final class BoardStore {
|
|||||||
for edit in edits {
|
for edit in edits {
|
||||||
// `.style(title: nil)`: `updateIndex` enriches it off the document it reads, so a
|
// `.style(title: nil)`: `updateIndex` enriches it off the document it reads, so a
|
||||||
// failure names the item by the title it still has (see `WriteOperation.style`).
|
// failure names the item by the title it still has (see `WriteOperation.style`).
|
||||||
try BoardWriter.updateIndex(inItemFolder: edit.folder, operation: .style(title: nil)) { document in
|
// `kind: .board` for the one subject with no identity — the board root, whose
|
||||||
|
// position nothing can infer (`BoardWriter.updateIndex`'s on-touch backfill).
|
||||||
|
try BoardWriter.updateIndex(
|
||||||
|
inItemFolder: edit.folder,
|
||||||
|
kind: edit.id == nil ? .board : nil,
|
||||||
|
operation: .style(title: nil)
|
||||||
|
) { document in
|
||||||
Self.apply(edit.background, to: FrontmatterKeys.background, in: &document)
|
Self.apply(edit.background, to: FrontmatterKeys.background, in: &document)
|
||||||
Self.apply(edit.icon, to: FrontmatterKeys.icon, in: &document)
|
Self.apply(edit.icon, to: FrontmatterKeys.icon, in: &document)
|
||||||
}
|
}
|
||||||
@@ -1437,14 +1423,22 @@ public final class BoardStore {
|
|||||||
}
|
}
|
||||||
) { _ in
|
) { _ in
|
||||||
for edit in edits {
|
for edit in edits {
|
||||||
try BoardWriter.updateIndex(inItemFolder: edit.folder, operation: .style(title: nil)) { document in
|
try BoardWriter.updateIndex(
|
||||||
|
inItemFolder: edit.folder,
|
||||||
|
kind: edit.id == nil ? .board : nil,
|
||||||
|
operation: .style(title: nil)
|
||||||
|
) { document in
|
||||||
Self.restore(edit.priorBackground, to: FrontmatterKeys.background, in: &document)
|
Self.restore(edit.priorBackground, to: FrontmatterKeys.background, in: &document)
|
||||||
Self.restore(edit.priorIcon, to: FrontmatterKeys.icon, in: &document)
|
Self.restore(edit.priorIcon, to: FrontmatterKeys.icon, in: &document)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} redo: { _ in
|
} redo: { _ in
|
||||||
for edit in edits {
|
for edit in edits {
|
||||||
try BoardWriter.updateIndex(inItemFolder: edit.folder, operation: .style(title: nil)) { document in
|
try BoardWriter.updateIndex(
|
||||||
|
inItemFolder: edit.folder,
|
||||||
|
kind: edit.id == nil ? .board : nil,
|
||||||
|
operation: .style(title: nil)
|
||||||
|
) { document in
|
||||||
Self.apply(edit.background, to: FrontmatterKeys.background, in: &document)
|
Self.apply(edit.background, to: FrontmatterKeys.background, in: &document)
|
||||||
Self.apply(edit.icon, to: FrontmatterKeys.icon, in: &document)
|
Self.apply(edit.icon, to: FrontmatterKeys.icon, in: &document)
|
||||||
}
|
}
|
||||||
@@ -1551,16 +1545,16 @@ public final class BoardStore {
|
|||||||
// degenerate reorder — "a move whose destination is the item's current parent degrades
|
// degenerate reorder — "a move whose destination is the item's current parent degrades
|
||||||
// to a plain reorder" — inside the *same* `performWrite`, so the pair rounds back as
|
// to a plain reorder" — inside the *same* `performWrite`, so the pair rounds back as
|
||||||
// one app-mediated reload rather than showing the card at the bottom for a frame.
|
// one app-mediated reload rather than showing the card at the bottom for a frame.
|
||||||
var rank = Ranks.insertionRank(amongVisible: visible.map(\.order), at: position)
|
// Ask, and on exhausted midpoint precision (01-storage-format.md § Ordering) compact
|
||||||
if rank == nil {
|
// and ask again — the shared two-step. The new card is not among the renumbered
|
||||||
// Midpoint precision exhausted between the anchor and its neighbour
|
// siblings (it was appended past them), so the compacted ladder lines up one-for-one
|
||||||
// (01-storage-format.md § Ordering). Compact, then place against the fresh ranks:
|
// with `visible` and nothing captured needs refreshing here.
|
||||||
// the new card is not among the renumbered siblings — it was appended past them —
|
guard let placed = try HealScheduler.placingRanks(
|
||||||
// so the compacted ladder lines up one-for-one with `visible`.
|
amongVisible: visible.map(\.order),
|
||||||
try BoardWriter.renumberVisibleChildren(of: laneFolder)
|
compacting: laneFolder,
|
||||||
rank = Ranks.insertionRank(amongVisible: Ranks.renumbered(count: visible.count), at: position)
|
{ Ranks.insertionRank(amongVisible: $0, at: position) }
|
||||||
}
|
) else { return id }
|
||||||
guard let rank else { return id }
|
let rank = placed.placement
|
||||||
|
|
||||||
_ = try BoardWriter.moveItem(
|
_ = try BoardWriter.moveItem(
|
||||||
at: laneFolder.appendingPathComponent(id.rawValue),
|
at: laneFolder.appendingPathComponent(id.rawValue),
|
||||||
@@ -1670,8 +1664,16 @@ public final class BoardStore {
|
|||||||
/// The title write every rename shares — the item-level one and the board's — spelled once so
|
/// The title write every rename shares — the item-level one and the board's — spelled once so
|
||||||
/// the empty-title rule (a missing key, never `title: ""`) cannot differ between a gesture and
|
/// the empty-title rule (a missing key, never `title: ""`) cannot differ between a gesture and
|
||||||
/// its own undo.
|
/// its own undo.
|
||||||
private static func setTitle(_ title: String?, at folder: URL) throws(BoardWriteError) {
|
///
|
||||||
try BoardWriter.updateIndex(inItemFolder: folder, operation: .rename(title: nil)) { document in
|
/// - Parameter kind: `.board` from the board rename, `nil` from an item's — the on-touch `kind`
|
||||||
|
/// backfill's one declared case, since a board root's folder name is a Finder document name
|
||||||
|
/// and position cannot answer for it (`BoardWriter.updateIndex`).
|
||||||
|
private static func setTitle(
|
||||||
|
_ title: String?,
|
||||||
|
at folder: URL,
|
||||||
|
kind: IntegrityRules.ObjectKind? = nil
|
||||||
|
) throws(BoardWriteError) {
|
||||||
|
try BoardWriter.updateIndex(inItemFolder: folder, kind: kind, operation: .rename(title: nil)) { document in
|
||||||
if let title {
|
if let title {
|
||||||
document.set(FrontmatterKeys.title, to: .string(title))
|
document.set(FrontmatterKeys.title, to: .string(title))
|
||||||
} else {
|
} else {
|
||||||
@@ -1988,7 +1990,7 @@ public final class BoardStore {
|
|||||||
let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
|
let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
|
||||||
// `.rename(title: nil)`: `updateIndex` enriches it off the document it reads, so a
|
// `.rename(title: nil)`: `updateIndex` enriches it off the document it reads, so a
|
||||||
// refusal names the board by the title it still has (see `WriteOperation.rename`).
|
// refusal names the board by the title it still has (see `WriteOperation.rename`).
|
||||||
try Self.setTitle(newTitle, at: folder)
|
try Self.setTitle(newTitle, at: folder, kind: .board)
|
||||||
}
|
}
|
||||||
guard landed != nil else { return }
|
guard landed != nil else { return }
|
||||||
|
|
||||||
@@ -2001,9 +2003,9 @@ public final class BoardStore {
|
|||||||
undoExpects: [.present(folder, .title(newTitle))],
|
undoExpects: [.present(folder, .title(newTitle))],
|
||||||
redoExpects: [.present(folder, .title(priorTitle))]
|
redoExpects: [.present(folder, .title(priorTitle))]
|
||||||
) { _ in
|
) { _ in
|
||||||
try Self.setTitle(priorTitle, at: folder)
|
try Self.setTitle(priorTitle, at: folder, kind: .board)
|
||||||
} redo: { _ in
|
} redo: { _ in
|
||||||
try Self.setTitle(newTitle, at: folder)
|
try Self.setTitle(newTitle, at: folder, kind: .board)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2040,19 +2042,21 @@ public final class BoardStore {
|
|||||||
var priorOrder = lanes[from].order
|
var priorOrder = lanes[from].order
|
||||||
var newOrder: Double?
|
var newOrder: Double?
|
||||||
let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
|
let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
|
||||||
var rank = Ranks.insertionRank(amongVisible: remaining.map(\.order), at: target)
|
// The shared two-step, over the *whole* strip: unlike the card-create case the dragged
|
||||||
if rank == nil {
|
// lane **is** among the renumbered children — it is a real folder on disk — so the ask
|
||||||
// Compact and place again. Unlike the card case the dragged lane *is* among the
|
// drops its own rung before consulting the neighbours, and a compaction refreshes the
|
||||||
// renumbered children — it is a real folder on disk — so its fresh rank is dropped
|
// prior rank the inverse has to restore.
|
||||||
// from the ladder before the neighbours are consulted.
|
guard let placed = try HealScheduler.placingRanks(
|
||||||
try BoardWriter.renumberVisibleChildren(of: root)
|
amongVisible: lanes.map(\.order),
|
||||||
let renumbered = Ranks.renumbered(count: lanes.count)
|
compacting: root,
|
||||||
priorOrder = renumbered[from]
|
{ ladder in
|
||||||
var compacted = renumbered
|
var compacted = ladder
|
||||||
compacted.remove(at: from)
|
compacted.remove(at: from)
|
||||||
rank = Ranks.insertionRank(amongVisible: compacted, at: target)
|
return Ranks.insertionRank(amongVisible: compacted, at: target)
|
||||||
}
|
}
|
||||||
guard let rank else { return }
|
) else { return }
|
||||||
|
if placed.renumbered { priorOrder = placed.ladder[from] }
|
||||||
|
let rank = placed.placement
|
||||||
newOrder = rank
|
newOrder = rank
|
||||||
|
|
||||||
_ = try BoardWriter.moveItem(
|
_ = try BoardWriter.moveItem(
|
||||||
@@ -2119,18 +2123,25 @@ public final class BoardStore {
|
|||||||
var priorOrders = members.map(\.order)
|
var priorOrders = members.map(\.order)
|
||||||
var rewrites: [(folder: URL, order: Double)] = []
|
var rewrites: [(folder: URL, order: Double)] = []
|
||||||
let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
|
let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
|
||||||
var ranks = Ranks.insertionRanks(amongVisible: remaining.map(\.order), at: target, count: members.count)
|
// `moveLane`'s two-step, plural: the dragged lanes *are* among the renumbered children,
|
||||||
if ranks == nil {
|
// so the ask drops their rungs before consulting the neighbours, and a compaction
|
||||||
// Compact and place again. The dragged lanes *are* among the renumbered children —
|
// refreshes the prior ranks the inverse restores.
|
||||||
// they are real folders on disk — so their fresh rungs are dropped from the ladder
|
guard let placed = try HealScheduler.placingRanks(
|
||||||
// before the neighbours are consulted, exactly as `moveLane` drops its one.
|
amongVisible: lanes.map(\.order),
|
||||||
try BoardWriter.renumberVisibleChildren(of: root)
|
compacting: root,
|
||||||
let renumbered = Array(zip(lanes, Ranks.renumbered(count: lanes.count)))
|
{ ladder in
|
||||||
priorOrders = renumbered.filter { ids.contains($0.0.id) }.map(\.1)
|
let rungs = Array(zip(lanes, ladder))
|
||||||
let compacted = renumbered.filter { !ids.contains($0.0.id) }.map(\.1)
|
return Ranks.insertionRanks(
|
||||||
ranks = Ranks.insertionRanks(amongVisible: compacted, at: target, count: members.count)
|
amongVisible: rungs.filter { !ids.contains($0.0.id) }.map(\.1),
|
||||||
|
at: target,
|
||||||
|
count: members.count
|
||||||
|
)
|
||||||
}
|
}
|
||||||
guard let ranks else { return }
|
) else { return }
|
||||||
|
if placed.renumbered {
|
||||||
|
priorOrders = Array(zip(lanes, placed.ladder)).filter { ids.contains($0.0.id) }.map(\.1)
|
||||||
|
}
|
||||||
|
let ranks = placed.placement
|
||||||
|
|
||||||
for (member, rank) in zip(members, ranks) {
|
for (member, rank) in zip(members, ranks) {
|
||||||
let folder = root.appendingPathComponent(member.id.rawValue, isDirectory: true)
|
let folder = root.appendingPathComponent(member.id.rawValue, isDirectory: true)
|
||||||
@@ -2291,21 +2302,28 @@ public final class BoardStore {
|
|||||||
var origins = Dictionary(uniqueKeysWithValues: members.map { ($0.id, (path: $0.path, order: $0.order)) })
|
var origins = Dictionary(uniqueKeysWithValues: members.map { ($0.id, (path: $0.path, order: $0.order)) })
|
||||||
var arrivals: [(id: ItemID, order: Double)] = []
|
var arrivals: [(id: ItemID, order: Double)] = []
|
||||||
let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
|
let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
|
||||||
var ranks = Ranks.insertionRanks(amongVisible: remaining.map(\.order), at: target, count: members.count)
|
// The shared two-step. The renumber assigns in display order over the lane's cards, so
|
||||||
if ranks == nil {
|
// the compacted ladder lines up one-for-one with `rendered`; members already in this
|
||||||
// Compact and place again. The renumber assigns in display order over the lane's
|
// lane are dropped from it before the neighbours are consulted, exactly as `moveLane`
|
||||||
// cards, so the compacted ladder lines up one-for-one with `rendered`; the members
|
// drops the dragged lane's own rung — and a compaction refreshes their captured origins.
|
||||||
// already in this lane are dropped from it before the neighbours are consulted,
|
guard let placed = try HealScheduler.placingRanks(
|
||||||
// exactly as `moveLane` drops the dragged lane's own rung.
|
amongVisible: rendered.map(\.order),
|
||||||
try BoardWriter.renumberVisibleChildren(of: laneFolder)
|
compacting: laneFolder,
|
||||||
let renumbered = Array(zip(rendered, Ranks.renumbered(count: rendered.count)))
|
{ ladder in
|
||||||
for (card, rank) in renumbered where ids.contains(card.id) {
|
let rungs = Array(zip(rendered, ladder))
|
||||||
|
return Ranks.insertionRanks(
|
||||||
|
amongVisible: rungs.filter { !ids.contains($0.0.id) }.map(\.1),
|
||||||
|
at: target,
|
||||||
|
count: members.count
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) else { return }
|
||||||
|
if placed.renumbered {
|
||||||
|
for (card, rank) in zip(rendered, placed.ladder) where ids.contains(card.id) {
|
||||||
origins[card.id] = (path: .card(lane: laneID, id: card.id), order: rank)
|
origins[card.id] = (path: .card(lane: laneID, id: card.id), order: rank)
|
||||||
}
|
}
|
||||||
let compacted = renumbered.filter { !ids.contains($0.0.id) }.map(\.1)
|
|
||||||
ranks = Ranks.insertionRanks(amongVisible: compacted, at: target, count: members.count)
|
|
||||||
}
|
}
|
||||||
guard let ranks else { return }
|
let ranks = placed.placement
|
||||||
|
|
||||||
for (member, rank) in zip(members, ranks) {
|
for (member, rank) in zip(members, ranks) {
|
||||||
arrivals.append((id: member.id, order: rank))
|
arrivals.append((id: member.id, order: rank))
|
||||||
@@ -2412,16 +2430,15 @@ public final class BoardStore {
|
|||||||
let laneFolder = ItemPath.lane(laneID).folder(under: root)
|
let laneFolder = ItemPath.lane(laneID).folder(under: root)
|
||||||
|
|
||||||
try? performWrite { () throws(BoardWriteError) -> Void in
|
try? performWrite { () throws(BoardWriteError) -> Void in
|
||||||
var ranks = Ranks.insertionRanks(amongVisible: rendered.map(\.order), at: placement, count: members.count)
|
// The shared two-step. The copies are not among the renumbered children — they do not
|
||||||
if ranks == nil {
|
// exist yet — so the compacted ladder lines up one-for-one with `rendered` and nothing
|
||||||
try BoardWriter.renumberVisibleChildren(of: laneFolder)
|
// captured needs refreshing.
|
||||||
ranks = Ranks.insertionRanks(
|
guard let placed = try HealScheduler.placingRanks(
|
||||||
amongVisible: Ranks.renumbered(count: rendered.count),
|
amongVisible: rendered.map(\.order),
|
||||||
at: placement,
|
compacting: laneFolder,
|
||||||
count: members.count
|
{ Ranks.insertionRanks(amongVisible: $0, at: placement, count: members.count) }
|
||||||
)
|
) else { return }
|
||||||
}
|
let ranks = placed.placement
|
||||||
guard let ranks else { return }
|
|
||||||
|
|
||||||
for (member, rank) in zip(members, ranks) {
|
for (member, rank) in zip(members, ranks) {
|
||||||
_ = try BoardWriter.copyItem(
|
_ = try BoardWriter.copyItem(
|
||||||
@@ -2562,16 +2579,13 @@ public final class BoardStore {
|
|||||||
let laneFolder = ItemPath.lane(laneID).folder(under: root)
|
let laneFolder = ItemPath.lane(laneID).folder(under: root)
|
||||||
|
|
||||||
try? performWrite { () throws(BoardWriteError) -> Void in
|
try? performWrite { () throws(BoardWriteError) -> Void in
|
||||||
var ranks = Ranks.insertionRanks(amongVisible: rendered.map(\.order), at: target, count: sources.count)
|
// The shared two-step; the arrivals are not among the renumbered children.
|
||||||
if ranks == nil {
|
guard let placed = try HealScheduler.placingRanks(
|
||||||
try BoardWriter.renumberVisibleChildren(of: laneFolder)
|
amongVisible: rendered.map(\.order),
|
||||||
ranks = Ranks.insertionRanks(
|
compacting: laneFolder,
|
||||||
amongVisible: Ranks.renumbered(count: rendered.count),
|
{ Ranks.insertionRanks(amongVisible: $0, at: target, count: sources.count) }
|
||||||
at: target,
|
) else { return }
|
||||||
count: sources.count
|
let ranks = placed.placement
|
||||||
)
|
|
||||||
}
|
|
||||||
guard let ranks else { return }
|
|
||||||
|
|
||||||
for (source, rank) in zip(sources, ranks) {
|
for (source, rank) in zip(sources, ranks) {
|
||||||
guard let arrived = try Self.materialize(
|
guard let arrived = try Self.materialize(
|
||||||
@@ -2676,16 +2690,13 @@ public final class BoardStore {
|
|||||||
let target = min(max(0, stripIndex), rendered.count)
|
let target = min(max(0, stripIndex), rendered.count)
|
||||||
|
|
||||||
try? performWrite { () throws(BoardWriteError) -> Void in
|
try? performWrite { () throws(BoardWriteError) -> Void in
|
||||||
var ranks = Ranks.insertionRanks(amongVisible: rendered.map(\.order), at: target, count: sources.count)
|
// The shared two-step; the arriving lanes are not among the renumbered children.
|
||||||
if ranks == nil {
|
guard let placed = try HealScheduler.placingRanks(
|
||||||
try BoardWriter.renumberVisibleChildren(of: root)
|
amongVisible: rendered.map(\.order),
|
||||||
ranks = Ranks.insertionRanks(
|
compacting: root,
|
||||||
amongVisible: Ranks.renumbered(count: rendered.count),
|
{ Ranks.insertionRanks(amongVisible: $0, at: target, count: sources.count) }
|
||||||
at: target,
|
) else { return }
|
||||||
count: sources.count
|
let ranks = placed.placement
|
||||||
)
|
|
||||||
}
|
|
||||||
guard let ranks else { return }
|
|
||||||
|
|
||||||
for (source, rank) in zip(sources, ranks) {
|
for (source, rank) in zip(sources, ranks) {
|
||||||
guard let arrived = try Self.materialize(
|
guard let arrived = try Self.materialize(
|
||||||
@@ -2792,7 +2803,12 @@ public final class BoardStore {
|
|||||||
/// Moves every loose file the last applied snapshot found beside a card's `index.md` into that
|
/// Moves every loose file the last applied snapshot found beside a card's `index.md` into that
|
||||||
/// card's `attachments/`, and posts one notice naming what moved — the **act** half of
|
/// card's `attachments/`, and posts one notice naming what moved — the **act** half of
|
||||||
/// 01-storage-format.md's loose-file carve-out (§ Fractal layout ▸ Rules, settled 2026-07-28,
|
/// 01-storage-format.md's loose-file carve-out (§ Fractal layout ▸ Rules, settled 2026-07-28,
|
||||||
/// "Lanework-owns-the-board"; the loader's `looseCardFiles` is the notice half).
|
/// "Lanework-owns-the-board"; the loader's loose-file defect is the notice half).
|
||||||
|
///
|
||||||
|
/// **Scheduling is the engine's** (`HealScheduler`): the resting-clear, the lock-and-writability
|
||||||
|
/// gate, the signature compare, the armed-before-attempt memo, the one bracket, the banner
|
||||||
|
/// posture and the clear-on-success are all its six steps, and this method is now only what is
|
||||||
|
/// genuinely this heal's — which Writer call the defect maps to, and what the notice names.
|
||||||
///
|
///
|
||||||
/// **It registers no undo step**, and unlike its neighbours that is not a deferral: nobody asked
|
/// **It registers no undo step**, and unlike its neighbours that is not a deferral: nobody asked
|
||||||
/// for it. The relocation is the app tidying its own house on a reload, not a gesture — there is
|
/// for it. The relocation is the app tidying its own house on a reload, not a gesture — there is
|
||||||
@@ -2800,58 +2816,23 @@ public final class BoardStore {
|
|||||||
/// the user never did. (It is `renumberVisibleChildren`'s posture: bookkeeping composes no event,
|
/// the user never did. (It is `renumberVisibleChildren`'s posture: bookkeeping composes no event,
|
||||||
/// 06-history-undo.md ▸ Commit messages.)
|
/// 06-history-undo.md ▸ Commit messages.)
|
||||||
///
|
///
|
||||||
/// **It is an ordinary app write and nothing more.** One `performWrite` bracket over the whole
|
/// **One bracket over the whole board's worth of relocation**, so the churn rounds back as a
|
||||||
/// board's worth of relocation, so the churn rounds back as a single app-mediated reload and (on
|
/// single app-mediated reload and (on git boards) a single commit — the style batch's rule,
|
||||||
/// git boards) a single commit — the style batch's rule, applied to a batch the app started
|
/// applied to a batch the app started itself. The snapshot is not touched here any more than it
|
||||||
/// itself. The snapshot is not touched here any more than it is anywhere else: the files move,
|
/// is anywhere else: the files move, the watcher notices, the reload lands.
|
||||||
/// the watcher notices, the reload lands.
|
|
||||||
///
|
///
|
||||||
/// ### The read-only lock defers it, it does not cancel it
|
/// **The write half re-verifies against disk**: `BoardWriter.relocateLooseFiles` re-reads each
|
||||||
///
|
/// name at write time (`isRelocatable`) and skips what has gone, so a card whose loose files
|
||||||
/// "The relocation … waits out any read-only lock — strays stay tolerated until it clears." A
|
/// vanished under the write contributes no line to the notice.
|
||||||
/// locked board returns here having written nothing **and having remembered nothing**, so the
|
|
||||||
/// next attempt is a fresh one. The arming seam is `land(_:generation:origin:)`: every lock
|
|
||||||
/// clears on a successful reload and nowhere else, and this runs at the end of every successful
|
|
||||||
/// reload, after `reconcileLock(after:)` — so the reload that lifts the lock is the reload that
|
|
||||||
/// performs the relocation, with no timer, no queue, and no second state to keep in step. The
|
|
||||||
/// same ordering covers the other direction, now that the probe is symmetric: a reconciling
|
|
||||||
/// reload that *raises* the unwritable-location lock raises it before this runs.
|
|
||||||
///
|
|
||||||
/// ### It cannot hot-loop
|
|
||||||
///
|
|
||||||
/// The relocation's own reload re-walks the tree, which is the loop the guard exists for. After
|
|
||||||
/// a success the walk finds nothing loose, `looseCardFiles` empties, and the memo below is
|
|
||||||
/// cleared — the ordinary resting state. After a *failure* the walk finds the same files again,
|
|
||||||
/// and an unguarded call would fail again, forever, at the speed of a directory walk. So an
|
|
||||||
/// attempt is made only when the loose-file set **differs from the last one attempted**: one
|
|
||||||
/// failure, one banner row, then silence until the picture on disk actually changes (a file
|
|
||||||
/// added, removed, or partially moved by the failed attempt itself — each of which is a
|
|
||||||
/// different set and so a fresh attempt).
|
|
||||||
///
|
|
||||||
/// The failure is the banner's already: `performWrite` posts every `BoardWriteError` before it
|
|
||||||
/// rethrows, and the rethrow is swallowed here like every other gesture with nothing else to do
|
|
||||||
/// about it. Files moved before the failure stay moved, and the notice names exactly those.
|
|
||||||
public func relocateLooseCardFiles() {
|
public func relocateLooseCardFiles() {
|
||||||
let work = looseCardFiles
|
let work = looseCardFiles
|
||||||
guard !work.isEmpty else {
|
|
||||||
// The resting state, and the memo's reset: a board with nothing loose has nothing to
|
|
||||||
// remember having tried.
|
|
||||||
attemptedRelocation = []
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Deferred, not abandoned — and deliberately *before* the memo is written, so the attempt
|
|
||||||
// this lock refused is not the attempt the guard below remembers.
|
|
||||||
guard readOnlyLock == nil else {
|
|
||||||
Self.logger.debug("loose-file relocation deferred — the board is read-only")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let signature = Self.relocationSignature(of: work)
|
|
||||||
guard signature != attemptedRelocation else { return }
|
|
||||||
attemptedRelocation = signature
|
|
||||||
|
|
||||||
let root = rootURL
|
let root = rootURL
|
||||||
var relocated: [BannerCenter.Relocation] = []
|
var relocated: [BannerCenter.Relocation] = []
|
||||||
try? performWrite { () throws(BoardWriteError) -> Void in
|
heals.run(
|
||||||
|
.looseCardFiles,
|
||||||
|
signature: Self.signature(of: work.map(IntegrityRules.Defect.looseCardFiles)),
|
||||||
|
on: self
|
||||||
|
) { () throws(BoardWriteError) -> Void in
|
||||||
for card in work {
|
for card in work {
|
||||||
let folder = root
|
let folder = root
|
||||||
.appendingPathComponent(card.laneID.rawValue, isDirectory: true)
|
.appendingPathComponent(card.laneID.rawValue, isDirectory: true)
|
||||||
@@ -2865,23 +2846,18 @@ public final class BoardStore {
|
|||||||
fileNames: moved.map { $0.sourceURL.lastPathComponent }
|
fileNames: moved.map { $0.sourceURL.lastPathComponent }
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
} posting: {
|
||||||
|
.relocatedLooseFiles(relocated)
|
||||||
}
|
}
|
||||||
banners.postRelocatedLooseFiles(relocated)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The loose-file picture as a comparable value: one entry per file, keyed by where it sits.
|
/// A defect list as the engine's comparable picture — every defect's own signature, flattened.
|
||||||
///
|
///
|
||||||
/// A `Set` rather than the array itself because the *identity* of the work is what matters, not
|
/// A `Set` rather than the array itself because the *identity* of the work is what matters, not
|
||||||
/// the order the walk happened to meet it in — and because two loads of an unchanged tree must
|
/// the order the walk happened to meet it in — and because two loads of an unchanged tree must
|
||||||
/// compare equal even if a lane's folder-name ordering shifted underneath them.
|
/// compare equal even if a lane's folder-name ordering shifted underneath them.
|
||||||
nonisolated static func relocationSignature(of work: [LooseCardFiles]) -> Set<String> {
|
nonisolated static func signature(of defects: [IntegrityRules.Defect]) -> Set<String> {
|
||||||
var signature: Set<String> = []
|
Set(defects.flatMap(\.signatures))
|
||||||
for card in work {
|
|
||||||
for name in card.fileNames {
|
|
||||||
signature.insert("\(card.laneID.rawValue)/\(card.cardID.rawValue)/\(name)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return signature
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Creates one card per file at `index` in `laneID`, each titled with its filename minus the
|
/// Creates one card per file at `index` in `laneID`, each titled with its filename minus the
|
||||||
@@ -2933,14 +2909,13 @@ public final class BoardStore {
|
|||||||
|
|
||||||
var created: [(folder: URL, source: URL)] = []
|
var created: [(folder: URL, source: URL)] = []
|
||||||
try? performWrite { () throws(BoardWriteError) -> Void in
|
try? performWrite { () throws(BoardWriteError) -> Void in
|
||||||
var ranks = Ranks.insertionRanks(
|
// The shared two-step; the created cards are not among the renumbered children.
|
||||||
amongVisible: rendered.map(\.order), at: target, count: urls.count)
|
guard let placed = try HealScheduler.placingRanks(
|
||||||
if ranks == nil {
|
amongVisible: rendered.map(\.order),
|
||||||
try BoardWriter.renumberVisibleChildren(of: laneFolder)
|
compacting: laneFolder,
|
||||||
ranks = Ranks.insertionRanks(
|
{ Ranks.insertionRanks(amongVisible: $0, at: target, count: urls.count) }
|
||||||
amongVisible: Ranks.renumbered(count: rendered.count), at: target, count: urls.count)
|
) else { return }
|
||||||
}
|
let ranks = placed.placement
|
||||||
guard let ranks else { return }
|
|
||||||
|
|
||||||
for (url, rank) in zip(urls, ranks) {
|
for (url, rank) in zip(urls, ranks) {
|
||||||
// Create then place, `commitPlaceholder`'s pair: the Writer's create appends after the
|
// Create then place, `commitPlaceholder`'s pair: the Writer's create appends after the
|
||||||
@@ -3050,13 +3025,17 @@ public final class BoardStore {
|
|||||||
// what its own rewrite overwrites and therefore what an undo has to put back.
|
// what its own rewrite overwrites and therefore what an undo has to put back.
|
||||||
var rewrites: [(folder: URL, from: Double, to: Double)] = []
|
var rewrites: [(folder: URL, from: Double, to: Double)] = []
|
||||||
let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
|
let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
|
||||||
var ladder = orders
|
// The shared two-step, with the *ask* being "are these ranks usable at all?": a
|
||||||
if !Self.isStrictlyAscending(orders) {
|
// permutation can only rewrite ranks that already separate the cards, so a ladder with
|
||||||
try BoardWriter.renumberVisibleChildren(of: laneFolder)
|
// ties is exhausted in exactly the sense the helper means, and the compacted one — which
|
||||||
// The renumber assigns in display order, so the compacted ladder lines up one-for-one
|
// is strictly ascending by construction — always answers. It lines up one-for-one with
|
||||||
// with `rendered` — the same alignment `commitPlaceholder` relies on.
|
// `rendered`, the same alignment `commitPlaceholder` relies on.
|
||||||
ladder = Ranks.renumbered(count: rendered.count)
|
guard let placed = try HealScheduler.placingRanks(
|
||||||
}
|
amongVisible: orders,
|
||||||
|
compacting: laneFolder,
|
||||||
|
{ Self.isStrictlyAscending($0) ? $0 : nil }
|
||||||
|
) else { return }
|
||||||
|
let ladder = placed.placement
|
||||||
for (destination, id) in plan.ordering.enumerated() {
|
for (destination, id) in plan.ordering.enumerated() {
|
||||||
guard let origin = positions[id], origin != destination else { continue }
|
guard let origin = positions[id], origin != destination else { continue }
|
||||||
let rank = ladder[destination]
|
let rank = ladder[destination]
|
||||||
@@ -3500,12 +3479,13 @@ public final class BoardStore {
|
|||||||
|
|
||||||
/// Migrates every legacy `deleted:` key the last applied snapshot found, and posts one notice —
|
/// Migrates every legacy `deleted:` key the last applied snapshot found, and posts one notice —
|
||||||
/// the **act** half of 01-storage-format.md § Deletion's migration rule ("Legacy `deleted:` keys
|
/// the **act** half of 01-storage-format.md § Deletion's migration rule ("Legacy `deleted:` keys
|
||||||
/// migrate on load-and-write, never destroy"; the loader's `legacyTombstones` is the notice half).
|
/// migrate on load-and-write, never destroy"; the loader's legacy-tombstone defect is the notice
|
||||||
|
/// half).
|
||||||
///
|
///
|
||||||
/// **`relocateLooseCardFiles()`'s twin in every mechanical respect**, deliberately: same tail hook
|
/// **`relocateLooseCardFiles()`'s twin in every mechanical respect**, and since 2026-07-29 that
|
||||||
/// on a successful reload, same lock deferral, same attempted-set loop guard, same one bracket
|
/// is true by construction rather than by two methods agreeing: both run on `HealScheduler`, so
|
||||||
/// for the whole board, same warning-tone loss row. Two migrations arriving in one release with
|
/// the tail hook, the lock deferral, the writability gate, the memo and the clear-on-success are
|
||||||
/// two different schedulings would be two things to keep honest.
|
/// one implementation. What is this heal's own is below.
|
||||||
///
|
///
|
||||||
/// ### The two acts
|
/// ### The two acts
|
||||||
///
|
///
|
||||||
@@ -3524,36 +3504,17 @@ public final class BoardStore {
|
|||||||
/// the work. A card whose stamp is missing or unparseable sorts as **oldest** (the retired sort's
|
/// the work. A card whose stamp is missing or unparseable sorts as **oldest** (the retired sort's
|
||||||
/// own rule: "a corrupt stamp must not outrank fresh deletions"), and ties fall to the loader's
|
/// own rule: "a corrupt stamp must not outrank fresh deletions"), and ties fall to the loader's
|
||||||
/// walk order — lane `order`, then card `order` — which is the deterministic tie-break the whole
|
/// walk order — lane `order`, then card `order` — which is the deterministic tie-break the whole
|
||||||
/// corpus already uses. `legacyTombstones` carries no timestamp of its own, so the stamps are
|
/// corpus already uses.
|
||||||
/// read out of the snapshot the same walk produced.
|
|
||||||
///
|
///
|
||||||
/// ### The read-only lock defers it, it does not cancel it
|
/// ### The write half re-verifies against disk
|
||||||
///
|
///
|
||||||
/// Exactly the relocation's posture, and stated there: a locked board returns having written
|
/// Each item's `deleted:` key is re-read at write time (`stillTombstoned(at:)`) and a key that
|
||||||
/// nothing **and having remembered nothing**, so the reload that lifts the lock is the reload
|
/// has gone — an agent removed it, another window migrated first — skips silently: "losing the
|
||||||
/// that performs the migration.
|
/// race to a foreign fix is success, never an error" (§ Validation and healing, generalized from
|
||||||
///
|
/// the Repair-races-a-vanished-duplicate precedent). Without it a lane whose key vanished under
|
||||||
/// ### It cannot hot-loop
|
/// the write would be rewritten for nothing, stamping `modified` on a file with no defect left.
|
||||||
///
|
|
||||||
/// The migration's own write triggers a reload, which re-walks the tree — the loop the guard
|
|
||||||
/// exists for. After a success the walk finds no keys, `legacyTombstones` empties, and the memo
|
|
||||||
/// clears. After a *failure* it finds the same keys again, and an unguarded call would fail
|
|
||||||
/// forever at the speed of a directory walk; so an attempt is made only when the tombstone set
|
|
||||||
/// **differs from the last one attempted**.
|
|
||||||
public func migrateLegacyTombstones() {
|
public func migrateLegacyTombstones() {
|
||||||
let work = legacyTombstones
|
let work = legacyTombstones
|
||||||
guard !work.isEmpty else {
|
|
||||||
attemptedTombstoneMigration = []
|
|
||||||
return
|
|
||||||
}
|
|
||||||
guard readOnlyLock == nil else {
|
|
||||||
Self.logger.debug("legacy tombstone migration deferred — the board is read-only")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let signature = Self.migrationSignature(of: work)
|
|
||||||
guard signature != attemptedTombstoneMigration else { return }
|
|
||||||
attemptedTombstoneMigration = signature
|
|
||||||
|
|
||||||
let root = rootURL
|
let root = rootURL
|
||||||
let cards = Self.migrationOrder(of: work, in: snapshot)
|
let cards = Self.migrationOrder(of: work, in: snapshot)
|
||||||
let lanes = work.filter { $0.kind == .lane }
|
let lanes = work.filter { $0.kind == .lane }
|
||||||
@@ -3563,30 +3524,45 @@ public final class BoardStore {
|
|||||||
// The ranks are minted exactly as a delete's are — head of the trash, threaded forward — so a
|
// The ranks are minted exactly as a delete's are — head of the trash, threaded forward — so a
|
||||||
// migrated card is indistinguishable on disk from one the user deletes today.
|
// migrated card is indistinguishable on disk from one the user deletes today.
|
||||||
var ladder = snapshot.trash.map(\.order)
|
var ladder = snapshot.trash.map(\.order)
|
||||||
try? performWrite { () throws(BoardWriteError) -> Void in
|
heals.run(
|
||||||
|
.legacyTombstone,
|
||||||
|
signature: Self.signature(of: work.map(IntegrityRules.Defect.legacyTombstone)),
|
||||||
|
on: self
|
||||||
|
) { () throws(BoardWriteError) -> Void in
|
||||||
for card in cards {
|
for card in cards {
|
||||||
guard let cardID = card.cardID else { continue }
|
guard let cardID = card.cardID else { continue }
|
||||||
|
let folder = ItemPath.card(lane: card.laneID, id: cardID).folder(under: root)
|
||||||
|
guard Self.stillTombstoned(at: folder) else { continue }
|
||||||
let rank = Ranks.insertAtHead(ofVisible: ladder)
|
let rank = Ranks.insertAtHead(ofVisible: ladder)
|
||||||
try BoardWriter.migrateTombstonedCard(
|
try BoardWriter.migrateTombstonedCard(at: folder, inBoard: root, order: rank)
|
||||||
at: ItemPath.card(lane: card.laneID, id: cardID).folder(under: root),
|
|
||||||
inBoard: root,
|
|
||||||
order: rank
|
|
||||||
)
|
|
||||||
ladder.insert(rank, at: 0)
|
ladder.insert(rank, at: 0)
|
||||||
movedCards.append(card.title)
|
movedCards.append(card.title)
|
||||||
}
|
}
|
||||||
for lane in lanes {
|
for lane in lanes {
|
||||||
try BoardWriter.migrateTombstonedLane(at: ItemPath.lane(lane.laneID).folder(under: root))
|
let folder = ItemPath.lane(lane.laneID).folder(under: root)
|
||||||
|
guard Self.stillTombstoned(at: folder) else { continue }
|
||||||
|
try BoardWriter.migrateTombstonedLane(at: folder)
|
||||||
returnedLanes.append(lane.title)
|
returnedLanes.append(lane.title)
|
||||||
}
|
}
|
||||||
|
} posting: {
|
||||||
|
.migratedTombstones(cards: movedCards, lanes: returnedLanes)
|
||||||
}
|
}
|
||||||
banners.postMigratedTombstones(cards: movedCards, lanes: returnedLanes)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The tombstone picture as a comparable value — `relocationSignature`'s shape, for its reason:
|
/// Whether the item at `folder` still carries a `deleted:` key — the migration's disk re-verify.
|
||||||
/// the *identity* of the work is what matters, not the order the walk happened to meet it in.
|
///
|
||||||
nonisolated static func migrationSignature(of work: [LegacyTombstone]) -> Set<String> {
|
/// **Presence, not validity**, exactly as `Lane`/`Card.isDeleted` reads it: a malformed timestamp
|
||||||
Set(work.map { "\($0.laneID.rawValue)/\($0.cardID?.rawValue ?? "")" })
|
/// still tombstones, and an explicit `deleted: null` is absence to both. A file that cannot be
|
||||||
|
/// read or parsed answers `false` — the conservative direction, since a migration is a *move* and
|
||||||
|
/// the one thing it must never do is move something on a guess.
|
||||||
|
nonisolated static func stillTombstoned(at folder: URL) -> Bool {
|
||||||
|
guard let data = try? Data(contentsOf: folder.appendingPathComponent(BoardLoader.indexFileName)),
|
||||||
|
let text = String(validating: data, as: UTF8.self),
|
||||||
|
let document = try? FrontmatterDocument.parse(text)
|
||||||
|
else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return !document.deleted.isMissing
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The `.card` tombstones in the order they should be filed into the trash — oldest `deleted:`
|
/// The `.card` tombstones in the order they should be filed into the trash — oldest `deleted:`
|
||||||
@@ -3616,17 +3592,52 @@ public final class BoardStore {
|
|||||||
.map(\.element)
|
.map(\.element)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - The claimed-name displacement
|
||||||
|
|
||||||
|
/// Moves a squatter off a board-root name the app claims, and posts the notice naming old and
|
||||||
|
/// new — the **act** half of the claimed-names ruling (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").
|
||||||
|
///
|
||||||
|
/// Today that is `.trash` and only `.trash`: a file or symlink sitting on the name the trash
|
||||||
|
/// needs, which breaks deletion for as long as it stands — which is exactly why the timing is
|
||||||
|
/// *scheduled* rather than on-touch (§ Validation and healing: "proactive when the defect is
|
||||||
|
/// load-bearing now"). `CLAUDE.md`'s squatter is displaced by the guide's own heal, which owns
|
||||||
|
/// that file end to end.
|
||||||
|
///
|
||||||
|
/// **Displacement, never destruction**, and never a mint: the freed name is left empty and the
|
||||||
|
/// *next delete* creates the real `.trash/`, exactly as it does on a board that never had one.
|
||||||
|
public func displaceClaimedNames() {
|
||||||
|
let work = claimedNameSquatters
|
||||||
|
let root = rootURL
|
||||||
|
var displaced: [BannerCenter.Displacement] = []
|
||||||
|
heals.run(
|
||||||
|
.claimedNameSquatted,
|
||||||
|
signature: Self.signature(of: work.map(IntegrityRules.Defect.claimedNameSquatted)),
|
||||||
|
on: self
|
||||||
|
) { () throws(BoardWriteError) -> Void in
|
||||||
|
for squatter in work {
|
||||||
|
// The Writer re-verifies and answers `nil` when the name freed itself under us.
|
||||||
|
guard let freed = try BoardWriter.displaceClaimedName(squatter, atBoardRoot: root) else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
displaced.append(BannerCenter.Displacement(name: squatter.name, movedTo: freed))
|
||||||
|
}
|
||||||
|
} posting: {
|
||||||
|
.displacedClaimedNames(displaced)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - The agent guide
|
// MARK: - The agent guide
|
||||||
|
|
||||||
/// Brings the board root's `CLAUDE.md` up to the current guide version, or leaves it exactly as
|
/// Brings the board root's `CLAUDE.md` up to the current guide version, or leaves it exactly as
|
||||||
/// it is — the whole of 08-agent-integration.md ▸ The agent guide's scheduling. The rule itself
|
/// it is — the whole of 08-agent-integration.md ▸ The agent guide's scheduling. The rule itself
|
||||||
/// is `AgentGuide.decide(_:)`, a pure function; this method is the I/O and the policy around it.
|
/// is `AgentGuide.decide(_:)`, a pure function; this method is the I/O and the policy around it.
|
||||||
///
|
///
|
||||||
/// **Run on every successful reload**, beside the relocation and the tombstone migration, and
|
/// **Run on every successful reload**, beside the other scheduled heals, and once more at open
|
||||||
/// once more at open (`BoardStoreRegistry.acquire`, which fires it after the watcher and the
|
/// (`BoardStoreRegistry.acquire`). That makes the guide *self-healing* rather than merely
|
||||||
/// brackets exist, for the reason stated there). That makes the guide *self-healing* rather than
|
/// written-once: a foreign deletion, a downgrade to an older guide, a board restored from a
|
||||||
/// merely written-once: a foreign deletion, a downgrade to an older guide, a board restored from
|
/// template carrying a stale one — each heals on the next reload, without a single new signal.
|
||||||
/// a template carrying a stale one — each heals on the next reload, without a single new signal.
|
|
||||||
/// It also pre-wires the Pro-era bounce 06-history-undo.md acknowledges by name, where undoing
|
/// It also pre-wires the Pro-era bounce 06-history-undo.md acknowledges by name, where undoing
|
||||||
/// an "Update agent guide (vN)" commit restores an older guide that the app immediately
|
/// an "Update agent guide (vN)" commit restores an older guide that the app immediately
|
||||||
/// re-upgrades.
|
/// re-upgrades.
|
||||||
@@ -3635,85 +3646,77 @@ public final class BoardStore {
|
|||||||
/// first-line parse — and no write at all. Nothing here touches the snapshot: the bytes land, the
|
/// first-line parse — and no write at all. Nothing here touches the snapshot: the bytes land, the
|
||||||
/// watcher notices, the reload applies, exactly like every other app write.
|
/// watcher notices, the reload applies, exactly like every other app write.
|
||||||
///
|
///
|
||||||
/// ### The read-only board is skipped, never banner-ed
|
/// ### The two skips, and the one displacement
|
||||||
///
|
///
|
||||||
/// Two gates, because two different things can be true. `performWrite` would refuse under the
|
/// `.skipUserFilenameTaken` is the standing exception (a rescue destination is not itself freed
|
||||||
/// read-only lock on its own, but that refusal is a thrown error and this is not a gesture — so
|
/// by a second displacement); `.displaceSquatterThenWrite` is the 2026-07-29 upgrade of the old
|
||||||
/// the lock is checked first, the relocation's own deferral idiom. That gate is now the one that
|
/// untouchable-skip, and it *does* post — a node of the user's moved aside owes the same
|
||||||
/// actually fires on an unwritable board: `BoardStoreRegistry.acquire` probes writability
|
/// warning-tone notice `.trash`'s displacement does. Both decisions are re-made inside the write
|
||||||
/// *before* it calls this method, so 02-architecture.md's "the open-time agent-guide write is
|
/// half, which is this heal's disk re-verify.
|
||||||
/// skipped-with-log, the `CLAUDE.user.md`-taken precedent" is honored by the lock being up
|
|
||||||
/// rather than by this method noticing on its own.
|
|
||||||
///
|
///
|
||||||
/// The `access(2)` check below stays anyway, as the second line of defense: it is the only gate
|
/// ### The signature is the board root's picture
|
||||||
/// covering the window *between* probes — a volume remounted read-only mid-session raises the
|
|
||||||
/// lock at the next reconciling reload, and a reload landing in that window would otherwise
|
|
||||||
/// reach the Writer, fail, and post a banner about a file the user never asked for. Both skips
|
|
||||||
/// are logged and neither is ever surfaced.
|
|
||||||
///
|
///
|
||||||
/// ### It cannot hot-loop
|
/// One failure, one row, then silence until something on disk actually changes — and the memo
|
||||||
///
|
/// clears on success, which is what lets a foreign deletion of the guide be healed again
|
||||||
/// `performWrite`'s bracket schedules a reload whether or not the write succeeded, and this runs
|
/// immediately (the picture "missing" is restored, and a standing memo would make that deletion
|
||||||
/// on every reload — so a *failing* guide write would retry forever at the speed of the debounce,
|
/// the one thing this could not heal).
|
||||||
/// posting a banner row each time. The guard is the relocation's exactly: an attempt is made only
|
|
||||||
/// when the board root's picture **differs from the one last acted on**. One failure, one row,
|
|
||||||
/// then silence until something on disk actually changes. The same memo is what keeps the two
|
|
||||||
/// skip cases from repeating their log line on every reload of an unchanged board.
|
|
||||||
public func refreshAgentGuide() {
|
public func refreshAgentGuide() {
|
||||||
let state = AgentGuide.inspect(atBoardRoot: rootURL)
|
let root = rootURL
|
||||||
|
let state = AgentGuide.inspect(atBoardRoot: root)
|
||||||
let decision = AgentGuide.decide(state)
|
let decision = AgentGuide.decide(state)
|
||||||
guard decision != .leaveAlone else {
|
// **A decision that writes nothing is no work at all**, and says so with an empty signature:
|
||||||
// The resting state, and the memo's reset: a board whose guide is current has nothing to
|
// the engine's resting-clear then costs no bracket, which matters because a bracket schedules
|
||||||
// remember having tried.
|
// a reload whether or not anything was written — a skip that opened one would tick forever.
|
||||||
attemptedGuideRefresh = nil
|
let signature: Set<String>
|
||||||
return
|
|
||||||
}
|
|
||||||
// Deferred, not abandoned — and deliberately *before* the memo is written, so the refresh a
|
|
||||||
// lock refused is not the one the guard below remembers.
|
|
||||||
guard readOnlyLock == nil else {
|
|
||||||
Self.logger.debug("agent-guide refresh deferred — the board is read-only")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
guard FileManager.default.isWritableFile(atPath: rootURL.path) else {
|
|
||||||
Self.logger.debug("agent-guide refresh skipped — the board's location is not writable")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
guard state != attemptedGuideRefresh else { return }
|
|
||||||
// Armed before anything is attempted, so a write that throws leaves it set — the memo's
|
|
||||||
// whole job is to remember pictures this store has already failed or refused to act on.
|
|
||||||
attemptedGuideRefresh = state
|
|
||||||
|
|
||||||
switch decision {
|
switch decision {
|
||||||
case .leaveAlone:
|
case .leaveAlone:
|
||||||
break // Ruled out above; the switch stays exhaustive so a new decision is a compile error.
|
signature = []
|
||||||
case .skipUntouchable:
|
|
||||||
Self.logger.debug("agent-guide refresh skipped — CLAUDE.md is not an ordinary file")
|
|
||||||
case .skipUserFilenameTaken:
|
case .skipUserFilenameTaken:
|
||||||
// The ruling's own outcome (08 ▸ Ownership): a user-authored CLAUDE.md that cannot be
|
// The ruling's own outcome (08 ▸ Ownership): a user-authored CLAUDE.md that cannot be
|
||||||
// rescued keeps its name, and the guide simply does not exist on this board.
|
// rescued keeps its name, and the guide simply does not exist on this board.
|
||||||
Self.logger.debug("agent-guide refresh skipped — CLAUDE.md is not the app's and CLAUDE.user.md is taken")
|
Self.logger.debug("agent-guide refresh skipped — CLAUDE.md is not the app's and CLAUDE.user.md is taken")
|
||||||
case .write, .displaceThenWrite:
|
signature = []
|
||||||
let root = rootURL
|
case .write, .displaceThenWrite, .displaceSquatterThenWrite:
|
||||||
let displace = decision == .displaceThenWrite
|
signature = [state.signature]
|
||||||
do {
|
|
||||||
// One bracket over the rescue move *and* the write: two files change, one
|
|
||||||
// app-mediated reload lands, and (in the Pro edition) one honestly-attributed commit
|
|
||||||
// records it.
|
|
||||||
try performWrite { () throws(BoardWriteError) -> Void in
|
|
||||||
try AgentGuide.install(atBoardRoot: root, displacingUserContent: displace)
|
|
||||||
}
|
}
|
||||||
// **Cleared on success, and this is load-bearing rather than tidy**: the memo keys on
|
|
||||||
// the picture that provoked the write, and a foreign deletion restores that exact
|
var displaced: [BannerCenter.Displacement] = []
|
||||||
// picture ("missing"). A memo left standing would make the deletion the one thing the
|
heals.run(
|
||||||
// self-heal could not heal.
|
.staleAgentGuide,
|
||||||
attemptedGuideRefresh = nil
|
signature: signature,
|
||||||
} catch {
|
on: self
|
||||||
// Already the banner's — `performWrite` posts every `BoardWriteError` before it
|
) { () throws(BoardWriteError) -> Void in
|
||||||
// rethrows — and there is nothing else a courtesy write can do about a failure. The
|
// One bracket over the displacement *and* the write: two files change, one app-mediated
|
||||||
// memo, left armed above, is what keeps it from being posted again every reload.
|
// reload lands, and (in the Pro edition) one honestly-attributed commit records it.
|
||||||
Self.logger.error("agent-guide write failed: \(error.localizedDescription, privacy: .public)")
|
guard let moved = try AgentGuide.install(atBoardRoot: root) else { return }
|
||||||
|
// The `CLAUDE.user.md` rescue is the settled, silent ownership rule; a squatter's
|
||||||
|
// displacement is announced.
|
||||||
|
guard !moved.wasUserContent else { return }
|
||||||
|
displaced.append(BannerCenter.Displacement(name: moved.name, movedTo: moved.movedTo))
|
||||||
|
} posting: {
|
||||||
|
.displacedClaimedNames(displaced)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// **Every scheduled heal, in order** — the engine's two seams call exactly this
|
||||||
|
/// (02-architecture.md ▸ Components ▸ HealScheduler: "fires uniformly at the reload tail and at
|
||||||
|
/// registry acquire, closing today's asymmetry where tombstone migration never fires at open").
|
||||||
|
///
|
||||||
|
/// **The claimed-name displacement goes first, and that ordering is load-bearing**: a card's
|
||||||
|
/// migration mints `<root>/.trash/`, which cannot be created while a file or symlink holds that
|
||||||
|
/// name — so a migration attempted ahead of the displacement fails *and arms its memo against an
|
||||||
|
/// unchanged tombstone picture*, which would leave the board unmigrated until something else on
|
||||||
|
/// disk changed. Running the displacement first closes the ruling's one-reload window inside a
|
||||||
|
/// single pass, because a heal's write lands synchronously even though its reload does not.
|
||||||
|
///
|
||||||
|
/// The rest of the order is immaterial: they touch disjoint files (a card's loose files, a
|
||||||
|
/// `deleted:` key inside an `index.md`, `CLAUDE.md`), each opens its own bracket, and each is
|
||||||
|
/// re-armed by the reload the others' writes produce, so none can see another's work half-done.
|
||||||
|
public func runScheduledHeals() {
|
||||||
|
displaceClaimedNames()
|
||||||
|
relocateLooseCardFiles()
|
||||||
|
migrateLegacyTombstones()
|
||||||
|
refreshAgentGuide()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Selection (delegated)
|
// MARK: - Selection (delegated)
|
||||||
|
|||||||
@@ -221,24 +221,25 @@ public final class BoardStoreRegistry {
|
|||||||
lastKnownRoot: rootURL
|
lastKnownRoot: rootURL
|
||||||
)
|
)
|
||||||
|
|
||||||
// The loose-file carve-out's first firing (01-storage-format.md § Fractal layout ▸ Rules,
|
// **The heal engine's open seam** (02-architecture.md ▸ Components ▸ HealScheduler: "fires
|
||||||
// settled 2026-07-28): files an agent or a hand-editor left beside a card's `index.md`
|
// uniformly at the reload tail and at registry acquire"). Everything an agent or a
|
||||||
// while this board was closed are relocated into `attachments/` now, with the notice.
|
// hand-editor left in this board while it was closed is healed now: loose files relocated
|
||||||
|
// into `attachments/`, legacy `deleted:` keys migrated, a squatter moved off a claimed name,
|
||||||
|
// the agent guide brought up to version — each with its own notice where it has one.
|
||||||
//
|
//
|
||||||
// **Here rather than in `BoardStore.init`**, and last rather than first: the store's own
|
// **Here rather than in `BoardStore.init`**, and last rather than first: the store's own
|
||||||
// init is one tree walk and no writes, and a relocation written before the brackets and the
|
// init is one tree walk and no writes, and a heal written before the brackets and the
|
||||||
// watcher exist would be a write nothing is watching — landing on disk with the snapshot
|
// watcher exist would be a write nothing is watching — landing on disk with the snapshot
|
||||||
// above it left one reload stale. By this line the pair is wired, so it is an ordinary
|
// above it left one reload stale. By this line the pair is wired, so each heal is an
|
||||||
// bracketed app write whose echo reload refreshes the board like any other. Every reload
|
// ordinary bracketed app write whose echo reload refreshes the board like any other. Every
|
||||||
// thereafter re-fires it from `BoardStore.land`; this call is only the one the opening walk
|
// reload thereafter re-fires them from `BoardStore.land`; this call is only the one the
|
||||||
// would otherwise have no reload behind.
|
// opening walk would otherwise have no reload behind.
|
||||||
store.relocateLooseCardFiles()
|
//
|
||||||
// The agent guide's first firing (08-agent-integration.md ▸ The agent guide), here for the
|
// **One call, all four** — before 2026-07-29 this seam named two of the three healers by
|
||||||
// same reason and with the same timing as the relocation above: a board opened with no
|
// hand, which is how the legacy-tombstone migration came to be the one heal that never fired
|
||||||
// `CLAUDE.md`, or with one an older version wrote, gets the current guide now — as a
|
// at open. A board opened, migrated nothing, and waited for an unrelated filesystem event to
|
||||||
// bracketed app write with a reload behind it, never as a write into a board nothing is
|
// do what opening should have done.
|
||||||
// watching. Every reload thereafter re-checks it from `BoardStore.land`.
|
store.runScheduledHeals()
|
||||||
store.refreshAgentGuide()
|
|
||||||
return store
|
return store
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -104,7 +104,23 @@ public final class EchoLedger: Sendable {
|
|||||||
/// is what those paths want: there is no session whose echoes they are.
|
/// is what those paths want: there is no session whose echoes they are.
|
||||||
@TaskLocal public static var current: EchoLedger?
|
@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() {}
|
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
|
/// 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.
|
/// write to the same path supersedes the receipt", because only the final content decides.
|
||||||
public func recordWrite(atPath path: String, hash: String) {
|
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
|
/// 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) {
|
public func recordDeletion(atPath path: String) {
|
||||||
receipts.withLock { store in
|
receipts.withLock { store in
|
||||||
Self.forget(&store, under: path)
|
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 + "/") {
|
for path in Array(store.keys) where path.hasPrefix(source + "/") {
|
||||||
store[destination + path.dropFirst(source.count)] = store.removeValue(forKey: path)
|
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[source] = pair
|
||||||
store[destination] = 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 + "/"
|
let prefix = path + "/"
|
||||||
for key in Array(store.keys) where key.hasPrefix(prefix) {
|
for key in Array(store.keys) where key.hasPrefix(prefix) {
|
||||||
store.removeValue(forKey: key)
|
store.removeValue(forKey: key)
|
||||||
@@ -217,7 +265,17 @@ public final class EchoLedger: Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public func receipt(atPath path: String) -> Receipt? {
|
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`.
|
/// 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.
|
/// byte under an item would make every copy foreign.
|
||||||
public func classify(_ observations: [String: Observation]) -> Provenance {
|
public func classify(_ observations: [String: Observation]) -> Provenance {
|
||||||
let held: [(path: String, receipt: Receipt)] = receipts.withLock { store in
|
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 }
|
guard !held.isEmpty else { return .foreign }
|
||||||
for entry in held {
|
for entry in held {
|
||||||
|
|||||||
@@ -0,0 +1,221 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+113
-29
@@ -25,6 +25,14 @@ import Foundation
|
|||||||
/// written, read, upgraded or validated by this app — that one rescue is its only creation
|
/// written, read, upgraded or validated by this app — that one rescue is its only creation
|
||||||
/// (08 ▸ `CLAUDE.user.md`).
|
/// (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
|
/// **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
|
/// 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
|
/// `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.
|
/// the app cannot read is a file whose marker it cannot honestly claim to have checked.
|
||||||
case file(text: String?)
|
case file(text: String?)
|
||||||
|
|
||||||
/// A symlink, a directory, or any other node that is not a regular file. **Symlinks are
|
/// A symlink, a directory, or any other node that is not a regular file — **a squatter on a
|
||||||
/// never followed or touched anywhere in this app** (01-storage-format.md § Fractal layout
|
/// claimed name**, and since 2026-07-29 not a resident (01-storage-format.md § Fractal
|
||||||
/// ▸ Rules), and a folder named `CLAUDE.md` is somebody's deliberate arrangement; neither
|
/// layout ▸ Rules: "Lanework owns the board, so an invalid artifact on a claimed name is a
|
||||||
/// is displaced or overwritten to make room for a courtesy file.
|
/// defect, not a resident").
|
||||||
case untouchable
|
///
|
||||||
|
/// 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(_:)`.
|
/// 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
|
/// rescue move re-checks this atomically anyway (`FileManager.moveItem` fails rather than
|
||||||
/// overwrite), so this is the decision's input, not its safety.
|
/// overwrite), so this is the decision's input, not its safety.
|
||||||
var userFilenameIsFree: Bool
|
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.
|
/// 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
|
/// 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.
|
/// 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
|
case skipUserFilenameTaken
|
||||||
|
|
||||||
/// `CLAUDE.md` is a symlink, a folder, or some other non-file. Skipped with a log.
|
/// `CLAUDE.md` is a symlink, a folder, or some other non-file: move it aside by the
|
||||||
case skipUntouchable
|
/// 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
|
/// The whole rule, as a pure function of `state` — so "never downgrade", "never clobber" and
|
||||||
@@ -128,8 +162,8 @@ enum AgentGuide {
|
|||||||
switch state.existing {
|
switch state.existing {
|
||||||
case .missing:
|
case .missing:
|
||||||
.write
|
.write
|
||||||
case .untouchable:
|
case .squatted:
|
||||||
.skipUntouchable
|
.displaceSquatterThenWrite
|
||||||
case let .file(text):
|
case let .file(text):
|
||||||
if let text, let installed = installedVersion(of: text) {
|
if let text, let installed = installedVersion(of: text) {
|
||||||
installed >= version ? .leaveAlone : .write
|
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
|
/// cheap enough (one `lstat`, plus a small file read only when there is a file to read) to run
|
||||||
/// on every reload.
|
/// on every reload.
|
||||||
///
|
///
|
||||||
/// **`attributesOfItem` throughout, never `fileExists`** — `lstat` semantics rather than `stat`:
|
/// **`lstat` semantics throughout, never `fileExists`** (`IntegrityRules.node(at:)`): a
|
||||||
/// a **dangling** symlink is a node that is *there* (the rescue move would fail on it, and this
|
/// **dangling** symlink is a node that is *there* — it holds the name, and it is displaced as a
|
||||||
/// app does not touch symlinks anyway), while `fileExists` follows the link, finds nothing, and
|
/// link rather than followed — while `fileExists` follows the link, finds nothing, and would
|
||||||
/// would call the name free.
|
/// call the name free.
|
||||||
static func inspect(atBoardRoot root: URL) -> State {
|
static func inspect(atBoardRoot root: URL) -> State {
|
||||||
State(
|
State(
|
||||||
existing: existingNode(at: root.appendingPathComponent(filename)),
|
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 {
|
private static func existingNode(at url: URL) -> Existing {
|
||||||
guard let type = (try? FileManager.default.attributesOfItem(atPath: url.path))?[.type]
|
guard let node = IntegrityRules.node(at: url) else { return .missing }
|
||||||
as? FileAttributeType
|
guard node == .file else { return .squatted }
|
||||||
else {
|
|
||||||
return .missing
|
|
||||||
}
|
|
||||||
guard type == .typeRegular else { return .untouchable }
|
|
||||||
// A regular file whose *contents* cannot be read reads as undecodable rather than as
|
// 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
|
// 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
|
// 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
|
/// `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
|
/// "user content is never destroyed" promise hold against a race rather than merely against a
|
||||||
/// stale read.
|
/// stale read.
|
||||||
static func install(
|
///
|
||||||
atBoardRoot root: URL,
|
/// **It re-verifies against disk** (01-storage-format.md § Validation and healing: "every
|
||||||
displacingUserContent displace: Bool
|
/// scheduled heal re-verifies its defect against disk at write time and no-ops when it is
|
||||||
) throws(BoardWriteError) {
|
/// 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)
|
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 {
|
do {
|
||||||
try FileManager.default.moveItem(at: guideURL, to: root.appendingPathComponent(userFilename))
|
try FileManager.default.moveItem(at: guideURL, to: root.appendingPathComponent(userFilename))
|
||||||
} catch {
|
} catch {
|
||||||
@@ -205,8 +249,48 @@ enum AgentGuide {
|
|||||||
reason: .io(message: "could not move the existing \(filename) aside to \(userFilename): \(error.localizedDescription)")
|
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)
|
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
|
// MARK: - The guide itself
|
||||||
|
|||||||
+133
-159
@@ -91,7 +91,10 @@ public enum BoardLoader: Sendable {
|
|||||||
|
|
||||||
/// Internal rather than `private`: `BoardWriter` names the same file, and the loader and
|
/// Internal rather than `private`: `BoardWriter` names the same file, and the loader and
|
||||||
/// the writer must never disagree about which file a folder's content lives in.
|
/// the writer must never disagree about which file a folder's content lives in.
|
||||||
static let indexFileName = "index.md"
|
///
|
||||||
|
/// The name itself is `IntegrityRules`', with every other reserved name — one table
|
||||||
|
/// (02-architecture.md ▸ Components).
|
||||||
|
static let indexFileName = IntegrityRules.indexFileName
|
||||||
|
|
||||||
/// The materialized trash container at board root (01-storage-format.md § Deletion, resettled
|
/// The materialized trash container at board root (01-storage-format.md § Deletion, resettled
|
||||||
/// 2026-07-28) — **app-claimed, never a stray**, joining `CLAUDE.md`, `CLAUDE.user.md` and the
|
/// 2026-07-28) — **app-claimed, never a stray**, joining `CLAUDE.md`, `CLAUDE.user.md` and the
|
||||||
@@ -103,8 +106,9 @@ public enum BoardLoader: Sendable {
|
|||||||
/// (`reservedRootNames`) so the rule holds even where hidden-file semantics don't.
|
/// (`reservedRootNames`) so the rule holds even where hidden-file semantics don't.
|
||||||
///
|
///
|
||||||
/// Internal rather than `private`: `BoardWriter` moves folders into and out of this exact
|
/// Internal rather than `private`: `BoardWriter` moves folders into and out of this exact
|
||||||
/// name, and a board can have only one trash.
|
/// name, and a board can have only one trash. The name is `IntegrityRules`', with the rest of
|
||||||
static let trashFolderName = ".trash"
|
/// the claimed-name table.
|
||||||
|
static let trashFolderName = IntegrityRules.trashFolderName
|
||||||
|
|
||||||
/// Board-root names the app claims, and therefore the names the lane walk skips **without a
|
/// Board-root names the app claims, and therefore the names the lane walk skips **without a
|
||||||
/// stray warning** (01-storage-format.md § Fractal layout ▸ Rules: "Three board-root names are
|
/// stray warning** (01-storage-format.md § Fractal layout ▸ Rules: "Three board-root names are
|
||||||
@@ -114,9 +118,10 @@ public enum BoardLoader: Sendable {
|
|||||||
/// files are listed because the claim is about names, and a future check that needs the set
|
/// files are listed because the claim is about names, and a future check that needs the set
|
||||||
/// should find it complete rather than build a second one. Compared lowercased, like
|
/// should find it complete rather than build a second one. Compared lowercased, like
|
||||||
/// `reservedCardChildNames` and for its reason — the filesystem this runs on usually is.
|
/// `reservedCardChildNames` and for its reason — the filesystem this runs on usually is.
|
||||||
static let reservedRootNames: Set<String> = [
|
///
|
||||||
trashFolderName, "claude.md", "claude.user.md", ".gitignore",
|
/// The table is `IntegrityRules.claimedRootNames`, which also carries what kind of node each
|
||||||
]
|
/// name is allowed to be — the fact the squatter-displacement heal turns on (ruled 2026-07-29).
|
||||||
|
static let reservedRootNames: Set<String> = IntegrityRules.claimedRootNameSet
|
||||||
|
|
||||||
/// The card-level names the app claims, and therefore the three the loose-file carve-out
|
/// The card-level names the app claims, and therefore the three the loose-file carve-out
|
||||||
/// never touches (01-storage-format.md § Fractal layout ▸ Rules: "Reserved card-level names
|
/// never touches (01-storage-format.md § Fractal layout ▸ Rules: "Reserved card-level names
|
||||||
@@ -129,10 +134,9 @@ public enum BoardLoader: Sendable {
|
|||||||
/// would hand the loose-file relocation a card's own content to move into `attachments/`.
|
/// would hand the loose-file relocation a card's own content to move into `attachments/`.
|
||||||
///
|
///
|
||||||
/// Internal rather than `private`: `BoardWriter.relocateLooseFiles` refuses the same three
|
/// Internal rather than `private`: `BoardWriter.relocateLooseFiles` refuses the same three
|
||||||
/// names on its own, so a caller passing a hand-made list cannot reach past this rule.
|
/// names on its own, so a caller passing a hand-made list cannot reach past this rule. The
|
||||||
static let reservedCardChildNames: Set<String> = [
|
/// table itself is `IntegrityRules`', with every other reserved name.
|
||||||
indexFileName, BoardWriter.attachmentsFolderName, "comments",
|
static let reservedCardChildNames: Set<String> = IntegrityRules.reservedCardChildNames
|
||||||
]
|
|
||||||
|
|
||||||
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "loader")
|
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "loader")
|
||||||
|
|
||||||
@@ -154,13 +158,22 @@ public enum BoardLoader: Sendable {
|
|||||||
logger.warning("\(warning.description, privacy: .public)")
|
logger.warning("\(warning.description, privacy: .public)")
|
||||||
}
|
}
|
||||||
|
|
||||||
// The carve-out's detection channel — deliberately *not* `warnings`, which is the
|
// **The one typed defect stream** (02-architecture.md ▸ Components ▸ IntegrityRules): what
|
||||||
// stray-*tolerance* vocabulary (see `LoadResult.looseCardFiles`).
|
// this walk found that is pending *work*, as distinct from `warnings`, which is the
|
||||||
var looseCardFiles: [LooseCardFiles] = []
|
// stray-*tolerance* vocabulary — information, not work. The two ad-hoc repair channels this
|
||||||
|
// replaced (loose files, legacy tombstones) are still readable under their own names as
|
||||||
|
// views over it (`LoadResult.looseCardFiles`, `.legacyTombstones`).
|
||||||
|
var defects: [IntegrityRules.Defect] = []
|
||||||
|
|
||||||
// The retired tombstone model's detection channel, on the same reasoning and in the same
|
// Detected before the walk, so a board whose `.trash` is squatted reports it even though
|
||||||
// idiom (see `LoadResult.legacyTombstones`).
|
// the trash read below finds nothing to parse. Read-only here, like every other detection:
|
||||||
var legacyTombstones: [LegacyTombstone] = []
|
// the displacement is the store's, through the Writer (the Repair precedent).
|
||||||
|
if let squatter = IntegrityRules.squattedClaimedName(atBoardRoot: boardRoot) {
|
||||||
|
defects.append(.claimedNameSquatted(squatter))
|
||||||
|
logger.warning(
|
||||||
|
"\(squatter.name, privacy: .public): claimed name held by \(squatter.found.description, privacy: .public) — to be displaced"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Legal per the frontmatter table, meaningless at board level — ignore and log, never
|
// Legal per the frontmatter table, meaningless at board level — ignore and log, never
|
||||||
// tombstone, and **never migrate**: "a `deleted:` key at board level remains meaningless
|
// tombstone, and **never migrate**: "a `deleted:` key at board level remains meaningless
|
||||||
@@ -210,24 +223,24 @@ public enum BoardLoader: Sendable {
|
|||||||
// Noticed, never acted on: the relocation is the store's, through the Writer.
|
// Noticed, never acted on: the relocation is the store's, through the Writer.
|
||||||
let loose = looseFileNames(in: cardURL)
|
let loose = looseFileNames(in: cardURL)
|
||||||
if !loose.isEmpty {
|
if !loose.isEmpty {
|
||||||
looseCardFiles.append(LooseCardFiles(
|
defects.append(.looseCardFiles(LooseCardFiles(
|
||||||
laneID: ItemID(rawValue: laneName),
|
laneID: ItemID(rawValue: laneName),
|
||||||
cardID: ItemID(rawValue: cardName),
|
cardID: ItemID(rawValue: cardName),
|
||||||
title: card.title.value,
|
title: card.title.value,
|
||||||
fileNames: loose
|
fileNames: loose
|
||||||
))
|
)))
|
||||||
logger.info("\(cardRelPath, privacy: .public): \(loose.count, privacy: .public) loose file(s) beside index.md — to be relocated into attachments/")
|
logger.info("\(cardRelPath, privacy: .public): \(loose.count, privacy: .public) loose file(s) beside index.md — to be relocated into attachments/")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detection only, the loose-file precedent exactly: the relocation into `.trash/`
|
// Detection only, the loose-file precedent exactly: the relocation into `.trash/`
|
||||||
// and the key's removal are the store's, through the Writer.
|
// and the key's removal are the store's, through the Writer.
|
||||||
if card.isDeleted {
|
if card.isDeleted {
|
||||||
legacyTombstones.append(LegacyTombstone(
|
defects.append(.legacyTombstone(LegacyTombstone(
|
||||||
kind: .card,
|
kind: .card,
|
||||||
laneID: ItemID(rawValue: laneName),
|
laneID: ItemID(rawValue: laneName),
|
||||||
cardID: ItemID(rawValue: cardName),
|
cardID: ItemID(rawValue: cardName),
|
||||||
title: card.title.value
|
title: card.title.value
|
||||||
))
|
)))
|
||||||
logger.info("\(cardRelPath, privacy: .public): legacy 'deleted' key — card to be relocated into \(trashFolderName, privacy: .public)/")
|
logger.info("\(cardRelPath, privacy: .public): legacy 'deleted' key — card to be relocated into \(trashFolderName, privacy: .public)/")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,12 +248,12 @@ public enum BoardLoader: Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !laneDocument.deleted.isMissing {
|
if !laneDocument.deleted.isMissing {
|
||||||
legacyTombstones.append(LegacyTombstone(
|
defects.append(.legacyTombstone(LegacyTombstone(
|
||||||
kind: .lane,
|
kind: .lane,
|
||||||
laneID: ItemID(rawValue: laneName),
|
laneID: ItemID(rawValue: laneName),
|
||||||
cardID: nil,
|
cardID: nil,
|
||||||
title: laneDocument.title.value
|
title: laneDocument.title.value
|
||||||
))
|
)))
|
||||||
logger.info("\(laneName, privacy: .public): legacy 'deleted' key — lane to be returned live with the key removed")
|
logger.info("\(laneName, privacy: .public): legacy 'deleted' key — lane to be returned live with the key removed")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,6 +276,7 @@ public enum BoardLoader: Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var trash: [Card] = []
|
var trash: [Card] = []
|
||||||
|
var trashKinds: [ItemID: IntegrityRules.ObjectKind] = [:]
|
||||||
for cardURL in trashCandidates(in: boardRoot) {
|
for cardURL in trashCandidates(in: boardRoot) {
|
||||||
let cardName = cardURL.lastPathComponent
|
let cardName = cardURL.lastPathComponent
|
||||||
let cardRelPath = trashFolderName + "/" + cardName
|
let cardRelPath = trashFolderName + "/" + cardName
|
||||||
@@ -274,7 +288,17 @@ public enum BoardLoader: Sendable {
|
|||||||
warn(.missingIndex(path: cardRelPath))
|
warn(.missingIndex(path: cardRelPath))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
trash.append(try parseCard(at: cardURL, path: cardRelPath))
|
let entry = try parseCard(at: cardURL, path: cardRelPath)
|
||||||
|
// **The trash's discriminator, applied where the flat container needs it**
|
||||||
|
// (01-storage-format.md § Deletion, re-ruled 2026-07-29): the *value* is trusted
|
||||||
|
// outright, and only an unrecognized value or no key at all falls through to shape.
|
||||||
|
// Reading the shape half is one directory listing, and only when the value did not
|
||||||
|
// answer — see `looksLikeALaneFolder(_:)`.
|
||||||
|
trashKinds[entry.id] = IntegrityRules.trashKind(
|
||||||
|
kindValue: entry.document.kind.value,
|
||||||
|
hasIdentityShapedChildIndex: looksLikeALaneFolder(cardURL)
|
||||||
|
)
|
||||||
|
trash.append(entry)
|
||||||
}
|
}
|
||||||
|
|
||||||
let model = BoardModel(
|
let model = BoardModel(
|
||||||
@@ -297,11 +321,24 @@ public enum BoardLoader: Sendable {
|
|||||||
return LoadResult(
|
return LoadResult(
|
||||||
model: model,
|
model: model,
|
||||||
warnings: warnings,
|
warnings: warnings,
|
||||||
looseCardFiles: looseCardFiles,
|
defects: defects,
|
||||||
legacyTombstones: legacyTombstones
|
trashKinds: trashKinds
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether a folder in `.trash/` has the *shape* of a lane — at least one identity-shaped child
|
||||||
|
/// holding its own `index.md` (01-storage-format.md § Deletion: "UUID-shaped children with
|
||||||
|
/// their own `index.md` → lane … else card").
|
||||||
|
///
|
||||||
|
/// Only reached when `kind` did not answer (`IntegrityRules.trashKind`'s `@autoclosure`), and
|
||||||
|
/// deliberately not a parse: this asks what the folder *looks like*, not whether anything inside
|
||||||
|
/// it would load. A trashed lane's cards are never enumerated as levels — the walk stops at a
|
||||||
|
/// trash entry exactly as it stops at a card under a lane.
|
||||||
|
private static func looksLikeALaneFolder(_ folder: URL) -> Bool {
|
||||||
|
let children = (try? directoryCandidates(in: folder)) ?? []
|
||||||
|
return children.contains { isUUIDShaped($0.lastPathComponent) && hasIndex($0) }
|
||||||
|
}
|
||||||
|
|
||||||
/// One card folder read into a `Card` — **the card parse, shared by both containers**.
|
/// One card folder read into a `Card` — **the card parse, shared by both containers**.
|
||||||
///
|
///
|
||||||
/// A trashed card is "an ordinary card in a special place" (03-board-ui.md § Trash), and this
|
/// A trashed card is "an ordinary card in a special place" (03-board-ui.md § Trash), and this
|
||||||
@@ -343,9 +380,11 @@ public enum BoardLoader: Sendable {
|
|||||||
/// **A `.trash` that is not a plain directory yields nothing**: a file by that name, or a
|
/// **A `.trash` that is not a plain directory yields nothing**: a file by that name, or a
|
||||||
/// *symlink* — "symlinks are never traversed" (01-storage-format.md § Fractal layout ▸ Rules),
|
/// *symlink* — "symlinks are never traversed" (01-storage-format.md § Fractal layout ▸ Rules),
|
||||||
/// and a symlinked trash would render bytes living outside the board that FSEvents never
|
/// and a symlinked trash would render bytes living outside the board that FSEvents never
|
||||||
/// reports. Logged rather than warned: `LoadWarning` is the stray vocabulary and a claimed
|
/// reports. Not a `LoadWarning`: that is the stray vocabulary, and a claimed name is not a
|
||||||
/// name is not a stray, so there is no case here that fits and nothing for a user to do about
|
/// stray. Since 2026-07-29 it is not merely logged either — the walk reports it as a
|
||||||
/// a name the app claims.
|
/// `Defect.claimedNameSquatted` (detected up in `load`, before the lanes) and a scheduled heal
|
||||||
|
/// displaces it. Until that heal lands the loader keeps this empty-trash read, which is exactly
|
||||||
|
/// the "window measured in one reload, not a standing state" the ruling accepts.
|
||||||
///
|
///
|
||||||
/// Entries are `directoryCandidates` — hidden entries and symlinks already excluded, in
|
/// Entries are `directoryCandidates` — hidden entries and symlinks already excluded, in
|
||||||
/// folder-name order — so the trash gets the same stray tolerance every other container gets,
|
/// folder-name order — so the trash gets the same stray tolerance every other container gets,
|
||||||
@@ -360,7 +399,7 @@ public enum BoardLoader: Sendable {
|
|||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
guard values.isDirectory == true, values.isSymbolicLink != true else {
|
guard values.isDirectory == true, values.isSymbolicLink != true else {
|
||||||
logger.warning("\(trashFolderName, privacy: .public): not a plain directory, treated as an empty trash")
|
logger.warning("\(trashFolderName, privacy: .public): not a plain directory, treated as an empty trash until the heal displaces it")
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
return (try? directoryCandidates(in: trashURL)) ?? []
|
return (try? directoryCandidates(in: trashURL)) ?? []
|
||||||
@@ -479,10 +518,6 @@ public enum BoardLoader: Sendable {
|
|||||||
.sorted { $0.localizedStandardCompare($1) == .orderedAscending }
|
.sorted { $0.localizedStandardCompare($1) == .orderedAscending }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The hex characters `isUUIDShaped` accepts in each `-`-delimited group — **both cases**,
|
|
||||||
/// per the shape-only identity predicate below.
|
|
||||||
private static let uuidGroupCharacters = Set("0123456789abcdefABCDEF")
|
|
||||||
|
|
||||||
/// Whether `name` has a UUID's shape — hex, `8-4-4-4-12`, **any case and any version** —
|
/// Whether `name` has a UUID's shape — hex, `8-4-4-4-12`, **any case and any version** —
|
||||||
/// gating lane/card level detection (01-storage-format.md § Fractal layout ▸ Rules, "Name
|
/// gating lane/card level detection (01-storage-format.md § Fractal layout ▸ Rules, "Name
|
||||||
/// shape gates level detection"). This is *the* identity predicate, and it is deliberately
|
/// shape gates level detection"). This is *the* identity predicate, and it is deliberately
|
||||||
@@ -509,10 +544,12 @@ public enum BoardLoader: Sendable {
|
|||||||
///
|
///
|
||||||
/// Internal rather than `private`: `BoardWriter.renumberVisibleChildren` walks the same
|
/// Internal rather than `private`: `BoardWriter.renumberVisibleChildren` walks the same
|
||||||
/// candidates the loader walked, and level detection has to be one rule, not two.
|
/// candidates the loader walked, and level detection has to be one rule, not two.
|
||||||
|
///
|
||||||
|
/// **The rule itself is `IntegrityRules.isIdentityShaped`** (settled 2026-07-29 — the one
|
||||||
|
/// vocabulary of object validity). This is the loader's spelling of it and nothing more: one
|
||||||
|
/// predicate, one implementation, no parallel derivation.
|
||||||
static func isUUIDShaped(_ name: String) -> Bool {
|
static func isUUIDShaped(_ name: String) -> Bool {
|
||||||
let groups = name.split(separator: "-", omittingEmptySubsequences: false)
|
IntegrityRules.isIdentityShaped(name)
|
||||||
guard groups.map(\.count) == [8, 4, 4, 4, 12] else { return false }
|
|
||||||
return groups.allSatisfy { $0.allSatisfy(uuidGroupCharacters.contains) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Direct subdirectories of `folder`, in deterministic (folder-name) order, excluding
|
/// Direct subdirectories of `folder`, in deterministic (folder-name) order, excluding
|
||||||
@@ -615,155 +652,92 @@ public enum BoardLoader: Sendable {
|
|||||||
///
|
///
|
||||||
/// - Parameter path: what the error names — `indexFileName` from every call site today, which is
|
/// - Parameter path: what the error names — `indexFileName` from every call site today, which is
|
||||||
/// what the card window's alert is about.
|
/// what the card window's alert is about.
|
||||||
|
///
|
||||||
|
/// **The rule is `IntegrityRules.validateIndex(_:path:kind:supportedSchema:)`**, generalized per
|
||||||
|
/// kind (02-architecture.md ▸ Components). This spelling stays because it is what the card
|
||||||
|
/// window asks — "would this load as a card?" — and because pinning the kind at the call site is
|
||||||
|
/// what keeps the outlet's gate from drifting when a second kind gains one.
|
||||||
public static func validateCardIndex(_ data: Data, path: String) throws(BoardLoadError) -> FrontmatterDocument {
|
public static func validateCardIndex(_ data: Data, path: String) throws(BoardLoadError) -> FrontmatterDocument {
|
||||||
let document = try parseDocument(data, path: path)
|
try IntegrityRules.validateIndex(data, path: path, kind: .card, supportedSchema: supportedSchema)
|
||||||
_ = try validatedSchema(in: document, path: path)
|
|
||||||
_ = try validatedOrder(in: document, path: path)
|
|
||||||
return document
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The per-field validators are `IntegrityRules`' — the rulebook (02-architecture.md ▸
|
||||||
|
/// Components). These two forward so the walk above reads as it always did.
|
||||||
private static func validatedSchema(in document: FrontmatterDocument, path: String) throws(BoardLoadError) -> Int {
|
private static func validatedSchema(in document: FrontmatterDocument, path: String) throws(BoardLoadError) -> Int {
|
||||||
switch document.schema {
|
try IntegrityRules.validatedSchema(in: document, path: path, supportedSchema: supportedSchema)
|
||||||
case .missing:
|
|
||||||
throw BoardLoadError(path: path, reason: .missingSchema)
|
|
||||||
case let .malformed(raw):
|
|
||||||
throw BoardLoadError(path: path, reason: .malformedSchema(raw: raw))
|
|
||||||
case let .valid(value):
|
|
||||||
guard value <= supportedSchema else {
|
|
||||||
throw BoardLoadError(path: path, reason: .schemaNewerThanApp(found: value))
|
|
||||||
}
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func validatedOrder(in document: FrontmatterDocument, path: String) throws(BoardLoadError) -> Double {
|
private static func validatedOrder(in document: FrontmatterDocument, path: String) throws(BoardLoadError) -> Double {
|
||||||
switch document.order {
|
try IntegrityRules.validatedOrder(in: document, path: path)
|
||||||
case .missing:
|
|
||||||
throw BoardLoadError(path: path, reason: .missingOrder)
|
|
||||||
case let .malformed(raw):
|
|
||||||
throw BoardLoadError(path: path, reason: .malformedOrder(raw: raw))
|
|
||||||
case let .valid(value):
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Result
|
// MARK: - Result
|
||||||
|
|
||||||
/// A successful load: the snapshot plus anything tolerated-but-notable encountered along the
|
/// A successful load: the snapshot, anything tolerated-but-notable encountered along the way, and
|
||||||
/// way. `warnings` is also logged as it accumulates (`os.Logger(subsystem: "dev.rzen.indie.Kanban",
|
/// the pending work the walk found. `warnings` is also logged as it accumulates
|
||||||
/// category: "loader")`) so it shows up in Console even if a caller never inspects it.
|
/// (`os.Logger(subsystem: "dev.rzen.indie.Kanban", category: "loader")`) so it shows up in Console
|
||||||
|
/// even if a caller never inspects it.
|
||||||
public struct LoadResult: Sendable {
|
public struct LoadResult: Sendable {
|
||||||
public var model: BoardModel
|
public var model: BoardModel
|
||||||
public var warnings: [LoadWarning]
|
public var warnings: [LoadWarning]
|
||||||
|
|
||||||
/// The cards this walk found carrying loose files, in the order the walk met them — the
|
/// **The typed defect stream** — everything this walk found that is pending *work*
|
||||||
/// loose-file carve-out's detection channel (01-storage-format.md § Fractal layout ▸ Rules,
|
/// (02-architecture.md ▸ Components ▸ IntegrityRules, settled 2026-07-29). One channel, not
|
||||||
/// settled 2026-07-28).
|
/// three: loose card files, legacy `deleted:` keys, and a claimed board-root name held by the
|
||||||
|
/// wrong kind of node all classify as `IntegrityRules.Defect`, carry their own signature, and
|
||||||
|
/// are healed by the one engine (`HealScheduler`).
|
||||||
///
|
///
|
||||||
/// **Its own field rather than a `LoadWarning` case**, because the two say opposite things.
|
/// **Deliberately not `warnings`**, which stays the *tolerate*-tier vocabulary: "this was
|
||||||
/// `warnings` is the *stray-tolerance* vocabulary: "this was ignored, it is staying exactly
|
/// ignored, it is staying exactly where it is, there is nothing to do". A defect says the
|
||||||
/// where it is, there is nothing to do". A loose card file is the one thing on a board that is
|
/// opposite — it is work, and the store acts on it. Folding the two would also throw away
|
||||||
/// **not** tolerated — it is pending work, and the store acts on it. Folding it into the
|
/// everything a heal needs (which lane, which card, which title, which names) and force it to be
|
||||||
/// warning channel would also mean throwing away everything the act needs (which lane, which
|
/// re-derived from a display string.
|
||||||
/// card, which title, which names) and re-deriving it from a display string.
|
|
||||||
///
|
///
|
||||||
/// Nothing renders this: a loose file is not content, and it reaches no view. Its one consumer
|
/// Nothing renders this. Order is the walk's: the board root's claimed names, then, lane by
|
||||||
/// is `BoardStore.relocateLooseCardFiles()`, which relocates and posts the notice.
|
/// lane, each lane's cards and then the lane itself.
|
||||||
///
|
///
|
||||||
/// Tombstoned cards are included, and cards under tombstoned lanes with them. Where a file
|
/// **Cards in `.trash/` are deliberately not walked for loose files in this version.** The
|
||||||
/// belongs on disk is a question about the *tree*, not about what the board is currently
|
/// loose-file defect is keyed by lane (`LooseCardFiles.laneID`, the store's path key) and a
|
||||||
/// rendering — the same reason the loader flags a tombstoned card at all rather than dropping
|
/// trashed card has no lane; widening the key is the store-side change that belongs with the
|
||||||
/// it.
|
/// store-side scheduling. Loose files beside a trashed card's `index.md` therefore keep the
|
||||||
///
|
/// ordinary stray posture — tolerated, preserved verbatim — and are tidied the moment the card
|
||||||
/// **Cards in `.trash/` are deliberately *not* walked for loose files in this version.** The
|
/// is restored into a lane, which is the only state in which they matter.
|
||||||
/// channel is keyed by lane (`LooseCardFiles.laneID`, the store's path key) and a trashed card
|
|
||||||
/// has no lane; widening the key is the store-side change that belongs with the store-side
|
|
||||||
/// scheduling. Loose files beside a trashed card's `index.md` therefore keep the ordinary
|
|
||||||
/// stray posture — tolerated, preserved verbatim — and are tidied the moment the card is
|
|
||||||
/// restored into a lane, which is the only state in which they matter.
|
|
||||||
public var looseCardFiles: [LooseCardFiles] = []
|
|
||||||
|
|
||||||
/// The legacy `deleted:` keys this walk found — the retired tombstone model's **migration
|
|
||||||
/// input** (01-storage-format.md § Deletion, resettled 2026-07-28: "Legacy `deleted:` keys
|
|
||||||
/// migrate on load-and-write, never destroy").
|
|
||||||
///
|
|
||||||
/// **The `looseCardFiles` idiom, for the same reason it exists**: `warnings` is the
|
|
||||||
/// stray-*tolerance* vocabulary — "this was ignored, it is staying exactly where it is, there
|
|
||||||
/// is nothing to do" — and a legacy tombstone is the opposite, pending work the store acts on.
|
|
||||||
/// Folding it into the warning channel would also throw away everything the act needs (which
|
|
||||||
/// lane, which card, which title) and force it to be re-derived from a display string.
|
|
||||||
///
|
|
||||||
/// Nothing renders this. Its consumer is the store, which relocates each `.card` into
|
|
||||||
/// `.trash/` with the key removed, strips each `.lane`'s key in place (a lane returns **live**
|
|
||||||
/// — resurrection is the safe direction), and posts the warning-tone notice. Like the
|
|
||||||
/// relocation it mirrors, the write is deferred under any read-only lock; the items stay
|
|
||||||
/// rendered through the retiring tombstone path until it lands (see this type's `BoardLoader`
|
|
||||||
/// note on the migration window).
|
|
||||||
///
|
///
|
||||||
/// Board-level `deleted:` never appears here — it is meaningless, ignored and logged
|
/// Board-level `deleted:` never appears here — it is meaningless, ignored and logged
|
||||||
/// (`LoadWarning.boardLevelDeletedIgnored`), and nothing about it is the app's to rewrite.
|
/// (`LoadWarning.boardLevelDeletedIgnored`), and nothing about it is the app's to rewrite.
|
||||||
|
public var defects: [IntegrityRules.Defect] = []
|
||||||
|
|
||||||
|
/// What each `.trash/` entry **is**, by the trash's own discriminator
|
||||||
|
/// (`IntegrityRules.trashKind`; 01-storage-format.md § Deletion, re-ruled 2026-07-29): the
|
||||||
|
/// `kind` value trusted outright, falling through to shape only when it does not answer.
|
||||||
|
///
|
||||||
|
/// A *reading*, not a rendering: `BoardModel.trash` parses every entry through the one card
|
||||||
|
/// parse (a trashed card is "an ordinary card in a special place"), and the container is flat,
|
||||||
|
/// so this is where the answer to "which of these was a lane?" lives until the lanes-in-trash
|
||||||
|
/// surface consumes it. Keyed by identity, so it survives the display sort.
|
||||||
|
public var trashKinds: [ItemID: IntegrityRules.ObjectKind] = [:]
|
||||||
|
|
||||||
|
/// The cards this walk found holding loose files — a **view over `defects`**, under the name it
|
||||||
|
/// has always had (01-storage-format.md § Fractal layout ▸ Rules, settled 2026-07-28).
|
||||||
|
///
|
||||||
|
/// In the order the walk met them, which is the order the relocation writes them in.
|
||||||
|
public var looseCardFiles: [LooseCardFiles] {
|
||||||
|
defects.compactMap { if case let .looseCardFiles(work) = $0 { work } else { nil } }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The legacy `deleted:` keys this walk found — the retired tombstone model's migration input,
|
||||||
|
/// as a **view over `defects`** (01-storage-format.md § Deletion).
|
||||||
///
|
///
|
||||||
/// Order is the walk's: a lane's tombstoned cards, then the lane itself, lane by lane.
|
/// Order is the walk's: a lane's tombstoned cards, then the lane itself, lane by lane.
|
||||||
public var legacyTombstones: [LegacyTombstone] = []
|
public var legacyTombstones: [LegacyTombstone] {
|
||||||
}
|
defects.compactMap { if case let .legacyTombstone(work) = $0 { work } else { nil } }
|
||||||
|
|
||||||
/// One item found carrying a legacy `deleted:` key — everything its migration and notice need,
|
|
||||||
/// and nothing more.
|
|
||||||
///
|
|
||||||
/// The path is carried as its identity components rather than as a URL — `LooseCardFiles`'
|
|
||||||
/// convention, for its reason: the write derives its path from the store's *current* root, which
|
|
||||||
/// may have been re-resolved since the load. `title` is the item's as written, `nil` for an
|
|
||||||
/// untitled one, because "Untitled" is a rendering and never a value (03-board-ui.md § Card face).
|
|
||||||
public struct LegacyTombstone: Sendable, Equatable {
|
|
||||||
/// Which migration this item takes — the two are genuinely different acts, not one act at two
|
|
||||||
/// levels: a card *moves* (into `.trash/`, at a minted top-of-trash rank) and a lane stays
|
|
||||||
/// exactly where it is (the key is stripped and it returns live).
|
|
||||||
public enum Kind: Sendable, Equatable {
|
|
||||||
case card
|
|
||||||
case lane
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public let kind: Kind
|
/// The claimed board-root name found held by the wrong kind of node, if any — a **view over
|
||||||
|
/// `defects`** (01-storage-format.md § Fractal layout ▸ Rules, ruled 2026-07-29).
|
||||||
/// The lane's own identity for `.lane`; the card's **containing** lane for `.card` — the
|
public var claimedNameSquatters: [ClaimedNameSquatter] {
|
||||||
/// context the relocation needs to find the folder at all.
|
defects.compactMap { if case let .claimedNameSquatted(work) = $0 { work } else { nil } }
|
||||||
public let laneID: ItemID
|
|
||||||
|
|
||||||
/// The card's identity for `.card`, `nil` for `.lane`. Two fields rather than an enum payload
|
|
||||||
/// so the common "which folder is this" question is one path join at every call site.
|
|
||||||
public let cardID: ItemID?
|
|
||||||
|
|
||||||
public let title: String?
|
|
||||||
|
|
||||||
public init(kind: Kind, laneID: ItemID, cardID: ItemID?, title: String?) {
|
|
||||||
self.kind = kind
|
|
||||||
self.laneID = laneID
|
|
||||||
self.cardID = cardID
|
|
||||||
self.title = title
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One card found holding files that belong in its `attachments/` — everything the relocation and
|
|
||||||
/// its notice need, and nothing more.
|
|
||||||
///
|
|
||||||
/// `title` is the card's as written, `nil` for an untitled one: "Untitled" is a rendering, never a
|
|
||||||
/// value (03-board-ui.md § Card face), so the phrasing layer decides what to call it. The path is
|
|
||||||
/// carried as its two identity components rather than as a URL, `ItemPath`'s convention,
|
|
||||||
/// so the write derives its path from the store's *current* root.
|
|
||||||
public struct LooseCardFiles: Sendable, Equatable {
|
|
||||||
public let laneID: ItemID
|
|
||||||
public let cardID: ItemID
|
|
||||||
public let title: String?
|
|
||||||
/// The loose files' names, in Finder order (`BoardLoader.looseFileNames`). Never empty — a card
|
|
||||||
/// with nothing loose contributes no entry at all.
|
|
||||||
public let fileNames: [String]
|
|
||||||
|
|
||||||
public init(laneID: ItemID, cardID: ItemID, title: String?, fileNames: [String]) {
|
|
||||||
self.laneID = laneID
|
|
||||||
self.cardID = cardID
|
|
||||||
self.title = title
|
|
||||||
self.fileNames = fileNames
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,11 +44,15 @@ public struct ItemID: Hashable, Sendable, RawRepresentable {
|
|||||||
self.rawValue = rawValue
|
self.rawValue = rawValue
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The comparison key: `rawValue` case-folded. Computed rather than stored so `ItemID` stays
|
/// The comparison key: `rawValue` case-folded, through the **one** canonicalization
|
||||||
/// one string wide and `rawValue` remains the single source of truth for what is on disk.
|
/// (`IntegrityRules.canonicalIdentity`, settled 2026-07-29 — the fold). Computed rather than
|
||||||
/// `lowercased()` is locale-independent, and every identity-shaped name is ASCII, so this is
|
/// stored so `ItemID` stays one string wide and `rawValue` remains the single source of truth
|
||||||
/// UUID-value canonicalization and nothing more.
|
/// for what is on disk.
|
||||||
var canonicalValue: String { rawValue.lowercased() }
|
///
|
||||||
|
/// The Writer compares *paths* rather than model values and reaches the same function directly;
|
||||||
|
/// before the fold it carried a private copy of this line, which is one line too many for a rule
|
||||||
|
/// that decides whether two folders are the same item.
|
||||||
|
var canonicalValue: String { IntegrityRules.canonicalIdentity(rawValue) }
|
||||||
|
|
||||||
public static func == (lhs: ItemID, rhs: ItemID) -> Bool {
|
public static func == (lhs: ItemID, rhs: ItemID) -> Bool {
|
||||||
lhs.canonicalValue == rhs.canonicalValue
|
lhs.canonicalValue == rhs.canonicalValue
|
||||||
|
|||||||
@@ -49,8 +49,31 @@ public enum BoardWriter: Sendable {
|
|||||||
/// `modified-by` the user typed or kept — the validated-then-verbatim contract outranks the
|
/// `modified-by` the user typed or kept — the validated-then-verbatim contract outranks the
|
||||||
/// clearing rule (01-storage-format.md § Frontmatter). That path goes through
|
/// clearing rule (01-storage-format.md § Frontmatter). That path goes through
|
||||||
/// `atomicReplace` directly; it does not belong here.
|
/// `atomicReplace` directly; it does not belong here.
|
||||||
|
///
|
||||||
|
/// ## The on-touch heal seam
|
||||||
|
///
|
||||||
|
/// **This is where latent defects are fixed** (01-storage-format.md § Validation and healing;
|
||||||
|
/// 02-architecture.md ▸ Components ▸ HealScheduler: "on-touch heals live at the Writer's
|
||||||
|
/// `updateIndex` seam, which consults IntegrityRules for pending on-touch work on the file it is
|
||||||
|
/// rewriting"). Between `edits` and the stamps, `IntegrityRules.healOnTouch` backfills a missing
|
||||||
|
/// `kind` — the file is being rewritten anyway, so the fix costs nothing and rides the host
|
||||||
|
/// write's single atomic rewrite, its `modified` stamp, and its commit. There is deliberately no
|
||||||
|
/// scheduled sweep for it (re-ruled 2026-07-29): outside `.trash/` the key is redundant with
|
||||||
|
/// position, and rewriting a whole board to add one would be churn for nothing.
|
||||||
|
///
|
||||||
|
/// The other two members of that class need no line here because they are already the editor's:
|
||||||
|
/// `FrontmatterDocument.set` collapses duplicate-key twins on every key it writes, and
|
||||||
|
/// `FrontmatterValue.emitScalar` quotes a value that needs it on first write. They are *named*
|
||||||
|
/// in `IntegrityRules.OnTouchHeal` rather than re-implemented — same class, no behavior change.
|
||||||
|
///
|
||||||
|
/// - Parameter kind: the object's kind where the caller knows it and position cannot answer —
|
||||||
|
/// **the board root**, whose folder name is a Finder document name rather than an identity.
|
||||||
|
/// `nil`, the default, derives it from position (`IntegrityRules.placement`), and stamps
|
||||||
|
/// nothing when position has no answer: a guessed kind on disk would be worse than an absent
|
||||||
|
/// one, because the trash's discriminator trusts what it finds.
|
||||||
public static func updateIndex(
|
public static func updateIndex(
|
||||||
inItemFolder folder: URL,
|
inItemFolder folder: URL,
|
||||||
|
kind: IntegrityRules.ObjectKind? = nil,
|
||||||
operation: WriteOperation,
|
operation: WriteOperation,
|
||||||
edits: (inout FrontmatterDocument) -> Void
|
edits: (inout FrontmatterDocument) -> Void
|
||||||
) throws(BoardWriteError) {
|
) throws(BoardWriteError) {
|
||||||
@@ -64,12 +87,49 @@ public enum BoardWriter: Sendable {
|
|||||||
try checkEditable(document, at: indexURL, operation: operation)
|
try checkEditable(document, at: indexURL, operation: operation)
|
||||||
|
|
||||||
edits(&document)
|
edits(&document)
|
||||||
|
// After `edits`, so a caller that wrote its own `kind` is left alone, and before the stamps,
|
||||||
|
// which outrank everything for their own reason.
|
||||||
|
IntegrityRules.healOnTouch(&document, kind: kind ?? derivedKind(ofItemFolder: folder))
|
||||||
document.set(FrontmatterKeys.modified, to: .date(Date()))
|
document.set(FrontmatterKeys.modified, to: .date(Date()))
|
||||||
document.remove(FrontmatterKeys.modifiedBy)
|
document.remove(FrontmatterKeys.modifiedBy)
|
||||||
|
|
||||||
try atomicReplace(text: document.serialized(), at: indexURL, operation: operation)
|
try atomicReplace(text: document.serialized(), at: indexURL, operation: operation)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The kind of the item in `folder`, read off its **position** — "level is position"
|
||||||
|
/// (01-storage-format.md § Fractal layout), with the trash's flat container falling through to
|
||||||
|
/// the discriminator that exists for exactly that (`IntegrityRules.trashKind`).
|
||||||
|
///
|
||||||
|
/// `nil` where position has no answer, which on a real board is only the board root (whose
|
||||||
|
/// writers name their kind outright) and off it is any hand-named folder — a test fixture, a
|
||||||
|
/// caller pointed at something that is not a level. Answering "board" there instead would stamp
|
||||||
|
/// a kind onto whatever was pointed at, which is the one thing the value-names-the-kind posture
|
||||||
|
/// cannot afford.
|
||||||
|
private static func derivedKind(ofItemFolder folder: URL) -> IntegrityRules.ObjectKind? {
|
||||||
|
switch IntegrityRules.placement(
|
||||||
|
ofFolderNamed: folder.lastPathComponent,
|
||||||
|
inParentNamed: folder.deletingLastPathComponent().lastPathComponent
|
||||||
|
) {
|
||||||
|
case .card:
|
||||||
|
return .card
|
||||||
|
case .lane:
|
||||||
|
return .lane
|
||||||
|
case .insideTrash:
|
||||||
|
// The value cannot have answered — a document carrying `kind` is never backfilled, so
|
||||||
|
// this is only reached for one that does not — which is precisely when shape decides.
|
||||||
|
return IntegrityRules.trashKind(
|
||||||
|
kindValue: nil,
|
||||||
|
hasIdentityShapedChildIndex: childCandidates(of: folder).contains {
|
||||||
|
FileManager.default.fileExists(
|
||||||
|
atPath: $0.appendingPathComponent(BoardLoader.indexFileName).path
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
case .unknown:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Atomic replace
|
// MARK: - Atomic replace
|
||||||
|
|
||||||
/// Writes `text` over `fileURL` atomically: a hidden temp file in the **same directory**,
|
/// Writes `text` over `fileURL` atomically: a hidden temp file in the **same directory**,
|
||||||
@@ -163,7 +223,11 @@ public enum BoardWriter: Sendable {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
try atomicReplace(text: newDocumentText(title: title, order: nil), at: indexURL, operation: operation)
|
try atomicReplace(
|
||||||
|
text: newDocumentText(title: title, order: nil, kind: .board),
|
||||||
|
at: indexURL,
|
||||||
|
operation: operation
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Creates a lane in a board: mints a fresh lowercase-UUIDv4 folder directly under
|
/// Creates a lane in a board: mints a fresh lowercase-UUIDv4 folder directly under
|
||||||
@@ -171,7 +235,7 @@ public enum BoardWriter: Sendable {
|
|||||||
/// `index.md`. Returns the new identity. See `createChild(inParent:title:operation:)` for
|
/// `index.md`. Returns the new identity. See `createChild(inParent:title:operation:)` for
|
||||||
/// the shared mechanics.
|
/// the shared mechanics.
|
||||||
public static func createLane(inBoard rootURL: URL, title: String?) throws(BoardWriteError) -> ItemID {
|
public static func createLane(inBoard rootURL: URL, title: String?) throws(BoardWriteError) -> ItemID {
|
||||||
try createChild(inParent: rootURL, title: title, operation: .createLane)
|
try createChild(inParent: rootURL, title: title, kind: .lane, operation: .createLane)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Creates a card in a lane: mints a fresh lowercase-UUIDv4 folder directly under
|
/// Creates a card in a lane: mints a fresh lowercase-UUIDv4 folder directly under
|
||||||
@@ -179,7 +243,7 @@ public enum BoardWriter: Sendable {
|
|||||||
/// Returns the new identity. See `createChild(inParent:title:operation:)` for the shared
|
/// Returns the new identity. See `createChild(inParent:title:operation:)` for the shared
|
||||||
/// mechanics.
|
/// mechanics.
|
||||||
public static func createCard(inLane laneURL: URL, title: String?) throws(BoardWriteError) -> ItemID {
|
public static func createCard(inLane laneURL: URL, title: String?) throws(BoardWriteError) -> ItemID {
|
||||||
try createChild(inParent: laneURL, title: title, operation: .createCard)
|
try createChild(inParent: laneURL, title: title, kind: .card, operation: .createCard)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The shared body of `createLane`/`createCard` — a lane under a board and a card under a
|
/// The shared body of `createLane`/`createCard` — a lane under a board and a card under a
|
||||||
@@ -203,6 +267,7 @@ public enum BoardWriter: Sendable {
|
|||||||
private static func createChild(
|
private static func createChild(
|
||||||
inParent parentFolder: URL,
|
inParent parentFolder: URL,
|
||||||
title: String?,
|
title: String?,
|
||||||
|
kind: IntegrityRules.ObjectKind,
|
||||||
operation: WriteOperation
|
operation: WriteOperation
|
||||||
) throws(BoardWriteError) -> ItemID {
|
) throws(BoardWriteError) -> ItemID {
|
||||||
try checkIsDirectory(parentFolder, describedAs: "parent folder", operation: operation)
|
try checkIsDirectory(parentFolder, describedAs: "parent folder", operation: operation)
|
||||||
@@ -212,7 +277,11 @@ public enum BoardWriter: Sendable {
|
|||||||
|
|
||||||
let folder = try mintUUIDFolder(in: parentFolder, operation: operation)
|
let folder = try mintUUIDFolder(in: parentFolder, operation: operation)
|
||||||
let indexURL = folder.appendingPathComponent(BoardLoader.indexFileName)
|
let indexURL = folder.appendingPathComponent(BoardLoader.indexFileName)
|
||||||
try atomicReplace(text: newDocumentText(title: title, order: order), at: indexURL, operation: operation)
|
try atomicReplace(
|
||||||
|
text: newDocumentText(title: title, order: order, kind: kind),
|
||||||
|
at: indexURL,
|
||||||
|
operation: operation
|
||||||
|
)
|
||||||
|
|
||||||
return ItemID(rawValue: folder.lastPathComponent)
|
return ItemID(rawValue: folder.lastPathComponent)
|
||||||
}
|
}
|
||||||
@@ -224,9 +293,21 @@ public enum BoardWriter: Sendable {
|
|||||||
/// `modified-by` is never written, matching the engine's absence-means-app-authored
|
/// `modified-by` is never written, matching the engine's absence-means-app-authored
|
||||||
/// convention (§ Frontmatter). Key order — `schema`, `title` (only when supplied), `order`
|
/// convention (§ Frontmatter). Key order — `schema`, `title` (only when supplied), `order`
|
||||||
/// (only when supplied — `nil` for a board, always present for a lane/card), `created`,
|
/// (only when supplied — `nil` for a board, always present for a lane/card), `created`,
|
||||||
/// `modified` — is simply the order `set` is called in, since each call appends a fresh key
|
/// `modified`, `kind` — is simply the order `set` is called in, since each call appends a fresh
|
||||||
/// before the closing delimiter of an otherwise-empty document.
|
/// key before the closing delimiter of an otherwise-empty document. `kind` goes last because
|
||||||
private static func newDocumentText(title: String?, order: Double?) -> String {
|
/// that is where the common table puts it, and because it is where the on-touch backfill appends
|
||||||
|
/// one on an older file: a created object and a healed one end up spelled the same way.
|
||||||
|
///
|
||||||
|
/// **`kind` is written at creation of every object** (01-storage-format.md § Frontmatter,
|
||||||
|
/// re-ruled 2026-07-29 — "consistency across the schema, even where position already answers").
|
||||||
|
/// Bundled and user templates carry files without it; they gain it lazily through the on-touch
|
||||||
|
/// backfill on the first write that rewrites them, which is exactly what that heal is for — no
|
||||||
|
/// template migration.
|
||||||
|
private static func newDocumentText(
|
||||||
|
title: String?,
|
||||||
|
order: Double?,
|
||||||
|
kind: IntegrityRules.ObjectKind
|
||||||
|
) -> String {
|
||||||
var document = FrontmatterDocument(body: "")
|
var document = FrontmatterDocument(body: "")
|
||||||
document.set(FrontmatterKeys.schema, to: .int(BoardLoader.supportedSchema))
|
document.set(FrontmatterKeys.schema, to: .int(BoardLoader.supportedSchema))
|
||||||
if let title {
|
if let title {
|
||||||
@@ -238,6 +319,7 @@ public enum BoardWriter: Sendable {
|
|||||||
let now = Date()
|
let now = Date()
|
||||||
document.set(FrontmatterKeys.created, to: .date(now))
|
document.set(FrontmatterKeys.created, to: .date(now))
|
||||||
document.set(FrontmatterKeys.modified, to: .date(now))
|
document.set(FrontmatterKeys.modified, to: .date(now))
|
||||||
|
document.set(FrontmatterKeys.kind, to: .string(kind.rawValue))
|
||||||
return document.serialized()
|
return document.serialized()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -273,7 +355,7 @@ public enum BoardWriter: Sendable {
|
|||||||
/// `parentFolder` (so the caller's `createDirectory`/`moveItem`/`copyItem` cannot lose a
|
/// `parentFolder` (so the caller's `createDirectory`/`moveItem`/`copyItem` cannot lose a
|
||||||
/// race with an existing entry), and any name in `taken` — the identities a collision repair
|
/// race with an existing entry), and any name in `taken` — the identities a collision repair
|
||||||
/// is minting *away* from, which are not necessarily on disk here. **`taken` is canonical**
|
/// is minting *away* from, which are not necessarily on disk here. **`taken` is canonical**
|
||||||
/// (lowercased, `canonicalIdentity`), which is what makes the `contains` a UUID-*value*
|
/// (lowercased, `IntegrityRules.canonicalIdentity` — the one canonicalization, shared with `ItemID`), which is what makes the `contains` a UUID-*value*
|
||||||
/// probe: the minted name is lowercase, so it can only match a canonical set. A freshly
|
/// probe: the minted name is lowercase, so it can only match a canonical set. A freshly
|
||||||
/// minted UUID hitting either is astronomically unlikely — 122 bits of randomness per mint —
|
/// minted UUID hitting either is astronomically unlikely — 122 bits of randomness per mint —
|
||||||
/// but the loop body is trivial precisely because the case it handles essentially never fires.
|
/// but the loop body is trivial precisely because the case it handles essentially never fires.
|
||||||
@@ -450,7 +532,7 @@ public enum BoardWriter: Sendable {
|
|||||||
/// `isUUIDShaped`; strays skipped, tombstones kept) — but only on an import, since a
|
/// `isUUIDShaped`; strays skipped, tombstones kept) — but only on an import, since a
|
||||||
/// same-board move cannot collide with anything but itself. The scan and every probe
|
/// same-board move cannot collide with anything but itself. The scan and every probe
|
||||||
/// against it are **by UUID value, not spelling** (`identities(inBoard:)` /
|
/// against it are **by UUID value, not spelling** (`identities(inBoard:)` /
|
||||||
/// `canonicalIdentity`): an arriving `55555555-…` collides with a resident `55555555-…`
|
/// `IntegrityRules.canonicalIdentity`): an arriving `55555555-…` collides with a resident `55555555-…`
|
||||||
/// spelled in uppercase, because those are one identity (§ Fractal layout ▸ Rules).
|
/// spelled in uppercase, because those are one identity (§ Fractal layout ▸ Rules).
|
||||||
/// 5. **Move the folder** (`FileManager.moveItem`, which degrades to copy+remove across
|
/// 5. **Move the folder** (`FileManager.moveItem`, which degrades to copy+remove across
|
||||||
/// volumes). A colliding *root* is renamed by moving it straight to its minted name
|
/// volumes). A colliding *root* is renamed by moving it straight to its minted name
|
||||||
@@ -507,10 +589,10 @@ public enum BoardWriter: Sendable {
|
|||||||
// "Which sibling is the item itself" is an identity question, so it is asked by
|
// "Which sibling is the item itself" is an identity question, so it is asked by
|
||||||
// UUID value (`canonicalIdentity`), not by spelling: the caller's URL and the
|
// UUID value (`canonicalIdentity`), not by spelling: the caller's URL and the
|
||||||
// directory listing can disagree in case for one and the same folder.
|
// directory listing can disagree in case for one and the same folder.
|
||||||
let selfIdentity = canonicalIdentity(sourceName)
|
let selfIdentity = IntegrityRules.canonicalIdentity(sourceName)
|
||||||
rank = Ranks.append(
|
rank = Ranks.append(
|
||||||
toVisible: siblings
|
toVisible: siblings
|
||||||
.filter { canonicalIdentity($0.folder.lastPathComponent) != selfIdentity }
|
.filter { IntegrityRules.canonicalIdentity($0.folder.lastPathComponent) != selfIdentity }
|
||||||
.map(\.order)
|
.map(\.order)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -528,7 +610,7 @@ public enum BoardWriter: Sendable {
|
|||||||
var reminted: [MoveResult.Remint] = []
|
var reminted: [MoveResult.Remint] = []
|
||||||
|
|
||||||
var arrivedName = sourceName
|
var arrivedName = sourceName
|
||||||
if existing.contains(canonicalIdentity(sourceName)) {
|
if existing.contains(IntegrityRules.canonicalIdentity(sourceName)) {
|
||||||
arrivedName = freshUUIDName(in: destinationParent, avoiding: reserved)
|
arrivedName = freshUUIDName(in: destinationParent, avoiding: reserved)
|
||||||
reserved.insert(arrivedName)
|
reserved.insert(arrivedName)
|
||||||
reminted.append(MoveResult.Remint(from: ItemID(rawValue: sourceName), to: ItemID(rawValue: arrivedName)))
|
reminted.append(MoveResult.Remint(from: ItemID(rawValue: sourceName), to: ItemID(rawValue: arrivedName)))
|
||||||
@@ -550,8 +632,8 @@ public enum BoardWriter: Sendable {
|
|||||||
|
|
||||||
if isImport {
|
if isImport {
|
||||||
let children = childCandidates(of: arrivedRoot)
|
let children = childCandidates(of: arrivedRoot)
|
||||||
reserved.formUnion(children.map { canonicalIdentity($0.lastPathComponent) })
|
reserved.formUnion(children.map { IntegrityRules.canonicalIdentity($0.lastPathComponent) })
|
||||||
for child in children where existing.contains(canonicalIdentity(child.lastPathComponent)) {
|
for child in children where existing.contains(IntegrityRules.canonicalIdentity(child.lastPathComponent)) {
|
||||||
let fresh = freshUUIDName(in: arrivedRoot, avoiding: reserved)
|
let fresh = freshUUIDName(in: arrivedRoot, avoiding: reserved)
|
||||||
reserved.insert(fresh)
|
reserved.insert(fresh)
|
||||||
try renameFolder(child, toSiblingNamed: fresh, operation: operation)
|
try renameFolder(child, toSiblingNamed: fresh, operation: operation)
|
||||||
@@ -584,7 +666,7 @@ public enum BoardWriter: Sendable {
|
|||||||
/// and the conservative direction here — a missed identity remints nothing, and a duplicate
|
/// and the conservative direction here — a missed identity remints nothing, and a duplicate
|
||||||
/// UUID in one board is the unspecified-behavior case the design already names, not a
|
/// UUID in one board is the unspecified-behavior case the design already names, not a
|
||||||
/// corruption.
|
/// corruption.
|
||||||
/// **Canonical, not verbatim**: every name is lowercased on the way in (`canonicalIdentity`),
|
/// **Canonical, not verbatim**: every name is lowercased on the way in (`IntegrityRules.canonicalIdentity`),
|
||||||
/// and every probe against the returned set must be too. Identity comparison is UUID-*value*
|
/// and every probe against the returned set must be too. Identity comparison is UUID-*value*
|
||||||
/// equality, never string equality (§ Fractal layout ▸ Rules, settled) — an arriving
|
/// equality, never string equality (§ Fractal layout ▸ Rules, settled) — an arriving
|
||||||
/// `55555555-…` and a resident `55555555-…` spelled uppercase are **one** identity, and a
|
/// `55555555-…` and a resident `55555555-…` spelled uppercase are **one** identity, and a
|
||||||
@@ -592,9 +674,9 @@ public enum BoardWriter: Sendable {
|
|||||||
private static func identities(inBoard boardRoot: URL) -> Set<String> {
|
private static func identities(inBoard boardRoot: URL) -> Set<String> {
|
||||||
var identities: Set<String> = []
|
var identities: Set<String> = []
|
||||||
for lane in childCandidates(of: boardRoot) {
|
for lane in childCandidates(of: boardRoot) {
|
||||||
identities.insert(canonicalIdentity(lane.lastPathComponent))
|
identities.insert(IntegrityRules.canonicalIdentity(lane.lastPathComponent))
|
||||||
for card in childCandidates(of: lane) {
|
for card in childCandidates(of: lane) {
|
||||||
identities.insert(canonicalIdentity(card.lastPathComponent))
|
identities.insert(IntegrityRules.canonicalIdentity(card.lastPathComponent))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// **The trash counts.** Board-wide uniqueness spans both containers (01-storage-format.md
|
// **The trash counts.** Board-wide uniqueness spans both containers (01-storage-format.md
|
||||||
@@ -605,19 +687,11 @@ public enum BoardWriter: Sendable {
|
|||||||
// This is also what makes `deleteCardToTrash`'s "collision is impossible" true rather than
|
// This is also what makes `deleteCardToTrash`'s "collision is impossible" true rather than
|
||||||
// hopeful: an import that would have produced the twin was reminted before it landed.
|
// hopeful: an import that would have produced the twin was reminted before it landed.
|
||||||
for card in childCandidates(of: trashFolder(inBoard: boardRoot)) {
|
for card in childCandidates(of: trashFolder(inBoard: boardRoot)) {
|
||||||
identities.insert(canonicalIdentity(card.lastPathComponent))
|
identities.insert(IntegrityRules.canonicalIdentity(card.lastPathComponent))
|
||||||
}
|
}
|
||||||
return identities
|
return identities
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A folder name reduced to its identity *value* — the same canonicalization `ItemID`'s
|
|
||||||
/// `==`/`hash(into:)` use (`BoardModel.swift`), applied where this writer must compare names
|
|
||||||
/// as strings because it is working with paths rather than model values. Every identity-shaped
|
|
||||||
/// name is ASCII hex and hyphens, so case folding is UUID-value canonicalization exactly.
|
|
||||||
private static func canonicalIdentity(_ folderName: String) -> String {
|
|
||||||
folderName.lowercased()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A folder's UUID-shaped subfolders in deterministic order — `directoryCandidates` (hidden
|
/// A folder's UUID-shaped subfolders in deterministic order — `directoryCandidates` (hidden
|
||||||
/// entries and symlinks already excluded) narrowed by `isUUIDShaped`, which is the loader's
|
/// entries and symlinks already excluded) narrowed by `isUUIDShaped`, which is the loader's
|
||||||
/// level-detection rule and therefore the only definition of "an identity-bearing child"
|
/// level-detection rule and therefore the only definition of "an identity-bearing child"
|
||||||
@@ -1016,6 +1090,13 @@ public enum BoardWriter: Sendable {
|
|||||||
document.remove(FrontmatterKeys.deleted)
|
document.remove(FrontmatterKeys.deleted)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if removingLegacyKey {
|
||||||
|
// Heal-marked: the migration is work the app started on its own, and its paths commit
|
||||||
|
// separately (06-history-undo.md ▸ Commit messages, ruled 2026-07-29). The ordinary
|
||||||
|
// delete this shares a body with is a *gesture* and is deliberately not marked.
|
||||||
|
EchoLedger.current?.markHeal(at: arrived)
|
||||||
|
EchoLedger.current?.markHeal(at: arrived.appendingPathComponent(BoardLoader.indexFileName))
|
||||||
|
}
|
||||||
|
|
||||||
return ItemID(rawValue: name)
|
return ItemID(rawValue: name)
|
||||||
}
|
}
|
||||||
@@ -1044,6 +1125,7 @@ public enum BoardWriter: Sendable {
|
|||||||
try updateIndex(inItemFolder: laneFolder, operation: operation) { document in
|
try updateIndex(inItemFolder: laneFolder, operation: operation) { document in
|
||||||
document.remove(FrontmatterKeys.deleted)
|
document.remove(FrontmatterKeys.deleted)
|
||||||
}
|
}
|
||||||
|
EchoLedger.current?.markHeal(at: laneFolder.appendingPathComponent(BoardLoader.indexFileName))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// **Deleting a lane is physical** — the folder and everything under it are removed
|
/// **Deleting a lane is physical** — the folder and everything under it are removed
|
||||||
@@ -1738,8 +1820,9 @@ public enum BoardWriter: Sendable {
|
|||||||
/// The one folder this app ever creates under a card — every other subfolder under
|
/// The one folder this app ever creates under a card — every other subfolder under
|
||||||
/// `attachments/` is tolerated but never made or named by the app (01-storage-format.md §
|
/// `attachments/` is tolerated but never made or named by the app (01-storage-format.md §
|
||||||
/// Attachments). Internal rather than `private`: `importAttachments` and `listAttachments`
|
/// Attachments). Internal rather than `private`: `importAttachments` and `listAttachments`
|
||||||
/// must never disagree about which folder holds a card's files.
|
/// must never disagree about which folder holds a card's files. The name itself is
|
||||||
static let attachmentsFolderName = "attachments"
|
/// `IntegrityRules`', with the rest of the reserved-name tables.
|
||||||
|
static let attachmentsFolderName = IntegrityRules.attachmentsFolderName
|
||||||
|
|
||||||
/// Imports files into a card's `attachments/` folder, creating it on first import — the
|
/// Imports files into a card's `attachments/` folder, creating it on first import — the
|
||||||
/// write side of 01-storage-format.md § Attachments. **Never refuses the drop**: a name
|
/// write side of 01-storage-format.md § Attachments. **Never refuses the drop**: a name
|
||||||
@@ -1872,10 +1955,17 @@ public enum BoardWriter: Sendable {
|
|||||||
/// **`cardFolder` must really be a card** (`checkIsCardFolder`, which is stricter than the
|
/// **`cardFolder` must really be a card** (`checkIsCardFolder`, which is stricter than the
|
||||||
/// UUID-shape guard the rest of this file uses): a lane's own loose files keep the verbatim
|
/// UUID-shape guard the rest of this file uses): a lane's own loose files keep the verbatim
|
||||||
/// posture, and no other write in the app has to tell the two levels apart.
|
/// posture, and no other write in the app has to tell the two levels apart.
|
||||||
|
///
|
||||||
|
/// - Parameter healMarked: whether the receipts this drops are marked as a heal's
|
||||||
|
/// (06-history-undo.md ▸ Commit messages, ruled 2026-07-29). `true` for the scheduled
|
||||||
|
/// relocation, which is app-initiated work that commits separately; `false` for the
|
||||||
|
/// import-boundary normalization below, which is **inline** — it batches with the gesture that
|
||||||
|
/// triggered it and belongs in that gesture's commit, not in a heal's.
|
||||||
@discardableResult
|
@discardableResult
|
||||||
public static func relocateLooseFiles(
|
public static func relocateLooseFiles(
|
||||||
_ names: [String],
|
_ names: [String],
|
||||||
inCard cardFolder: URL
|
inCard cardFolder: URL,
|
||||||
|
healMarked: Bool = true
|
||||||
) throws(BoardWriteError) -> [ImportedAttachment] {
|
) throws(BoardWriteError) -> [ImportedAttachment] {
|
||||||
guard !names.isEmpty else { return [] }
|
guard !names.isEmpty else { return [] }
|
||||||
|
|
||||||
@@ -1912,7 +2002,10 @@ public enum BoardWriter: Sendable {
|
|||||||
try FileManager.default.moveItem(at: sourceURL, to: landedURL)
|
try FileManager.default.moveItem(at: sourceURL, to: landedURL)
|
||||||
// A move pair, not a write: the bytes were not touched, only their place — and the
|
// A move pair, not a write: the bytes were not touched, only their place — and the
|
||||||
// pair is what tells the classifier the loose file's disappearance was the app's.
|
// pair is what tells the classifier the loose file's disappearance was the app's.
|
||||||
|
// Heal-marked: the relocation is app-initiated work whose paths commit separately
|
||||||
|
// (06-history-undo.md ▸ Commit messages, ruled 2026-07-29).
|
||||||
EchoLedger.current?.recordMove(from: sourceURL, to: landedURL)
|
EchoLedger.current?.recordMove(from: sourceURL, to: landedURL)
|
||||||
|
if healMarked { EchoLedger.current?.markHeal(at: landedURL) }
|
||||||
} catch {
|
} catch {
|
||||||
throw BoardWriteError(
|
throw BoardWriteError(
|
||||||
operation: operation,
|
operation: operation,
|
||||||
@@ -1939,7 +2032,14 @@ public enum BoardWriter: Sendable {
|
|||||||
/// A card with nothing loose is one directory listing and no write at all.
|
/// A card with nothing loose is one directory listing and no write at all.
|
||||||
@discardableResult
|
@discardableResult
|
||||||
public static func normalizeLooseFiles(inCard cardFolder: URL) throws(BoardWriteError) -> [ImportedAttachment] {
|
public static func normalizeLooseFiles(inCard cardFolder: URL) throws(BoardWriteError) -> [ImportedAttachment] {
|
||||||
try relocateLooseFiles(BoardLoader.looseFileNames(in: cardFolder), inCard: cardFolder)
|
// `healMarked: false` — an **inline** heal batches with the gesture that triggered it
|
||||||
|
// (01-storage-format.md § Validation and healing), so its paths are the paste's, not a
|
||||||
|
// heal's, and splitting them out would name a commit for work the user asked for.
|
||||||
|
try relocateLooseFiles(
|
||||||
|
BoardLoader.looseFileNames(in: cardFolder),
|
||||||
|
inCard: cardFolder,
|
||||||
|
healMarked: false
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The lane-level face of the same import-boundary normalization: every card of an arriving
|
/// The lane-level face of the same import-boundary normalization: every card of an arriving
|
||||||
@@ -2016,8 +2116,24 @@ public enum BoardWriter: Sendable {
|
|||||||
/// `fileExists` is the one test, and it is true for a directory as much as a file — a
|
/// `fileExists` is the one test, and it is true for a directory as much as a file — a
|
||||||
/// same-named *subfolder* blocks the name exactly like a file would, so an import never
|
/// same-named *subfolder* blocks the name exactly like a file would, so an import never
|
||||||
/// overwrites, renames, or descends into one; it just renames the incoming file instead.
|
/// overwrites, renames, or descends into one; it just renames the incoming file instead.
|
||||||
|
///
|
||||||
|
/// **The ladder itself is `freshName(for:in:)`**, shared with the claimed-name displacement
|
||||||
|
/// (`.trash` → `.trash 2`, ruled 2026-07-29): "Finder-style rename on collision" is one rule
|
||||||
|
/// wherever the app has to find a free name, and two copies of it would be two ladders to keep
|
||||||
|
/// climbing the same way.
|
||||||
private static func freshAttachmentName(for originalName: String, in folder: URL) -> String {
|
private static func freshAttachmentName(for originalName: String, in folder: URL) -> String {
|
||||||
guard FileManager.default.fileExists(atPath: folder.appendingPathComponent(originalName).path) else {
|
freshName(for: originalName, in: folder)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Finder-style collision-free name for `originalName` inside `folder` — see
|
||||||
|
/// `freshAttachmentName(for:in:)` for the splitting rules, which are Finder's own.
|
||||||
|
///
|
||||||
|
/// **`lstat` semantics, not `stat`**: a *dangling symlink* holds its name as firmly as any other
|
||||||
|
/// node, and `fileExists` — which follows links — would call that name free and hand the caller
|
||||||
|
/// a move that fails. `IntegrityRules.node(at:)` is the one probe, which is also what makes this
|
||||||
|
/// safe for the displacement, whose whole subject may itself be a symlink.
|
||||||
|
static func freshName(for originalName: String, in folder: URL) -> String {
|
||||||
|
guard IntegrityRules.node(at: folder.appendingPathComponent(originalName)) != nil else {
|
||||||
return originalName
|
return originalName
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2028,13 +2144,60 @@ public enum BoardWriter: Sendable {
|
|||||||
var counter = 2
|
var counter = 2
|
||||||
while true {
|
while true {
|
||||||
let candidate = ext.isEmpty ? "\(base) \(counter)" : "\(base) \(counter).\(ext)"
|
let candidate = ext.isEmpty ? "\(base) \(counter)" : "\(base) \(counter).\(ext)"
|
||||||
guard FileManager.default.fileExists(atPath: folder.appendingPathComponent(candidate).path) else {
|
guard IntegrityRules.node(at: folder.appendingPathComponent(candidate)) != nil else {
|
||||||
return candidate
|
return candidate
|
||||||
}
|
}
|
||||||
counter += 1
|
counter += 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// **Displaces a squatter off a claimed name** — the write half of the claimed-names ruling
|
||||||
|
/// (01-storage-format.md § Fractal layout ▸ Rules, ruled 2026-07-29: "A claimed name held by the
|
||||||
|
/// wrong kind of node is Lanework's to heal — by displacement").
|
||||||
|
///
|
||||||
|
/// A rename and nothing else: the node moves to its Finder-ladder name in the same folder
|
||||||
|
/// (`.trash` → `.trash 2`), **preserved verbatim, never destroyed** — the invariant that survives
|
||||||
|
/// the ruling is displacement-never-destruction. Its contents are never opened, and a symlink is
|
||||||
|
/// moved *as a link*, never followed (`FileManager.moveItem` renames the link itself).
|
||||||
|
///
|
||||||
|
/// **It re-verifies against disk** (§ Validation and healing: "every scheduled heal re-verifies
|
||||||
|
/// its defect against disk at write time and no-ops when it is gone"): `nil` when the name is
|
||||||
|
/// free again, or when the right kind of node is now sitting there — losing the race to a
|
||||||
|
/// foreign fix is success, never an error.
|
||||||
|
///
|
||||||
|
/// **It stamps nothing.** A heal that only renames never opens an `index.md`, so the existing
|
||||||
|
/// write discipline decides and there is no rule to add (§ Validation and healing).
|
||||||
|
///
|
||||||
|
/// - Returns: the name the squatter now has, or `nil` when the defect was already gone.
|
||||||
|
@discardableResult
|
||||||
|
public static func displaceClaimedName(
|
||||||
|
_ squatter: ClaimedNameSquatter,
|
||||||
|
atBoardRoot root: URL
|
||||||
|
) throws(BoardWriteError) -> String? {
|
||||||
|
let operation = WriteOperation.displaceClaimedName(name: squatter.name)
|
||||||
|
let occupied = root.appendingPathComponent(squatter.name)
|
||||||
|
guard let found = IntegrityRules.node(at: occupied), found != squatter.expected else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let freed = freshName(for: squatter.name, in: root)
|
||||||
|
let destination = root.appendingPathComponent(freed)
|
||||||
|
do {
|
||||||
|
try FileManager.default.moveItem(at: occupied, to: destination)
|
||||||
|
} catch {
|
||||||
|
throw BoardWriteError(
|
||||||
|
operation: operation,
|
||||||
|
path: occupied.path,
|
||||||
|
reason: .io(message: "could not move it aside to '\(freed)': \(error.localizedDescription)")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// A folder move like any other as far as provenance goes — and heal-marked, because the app
|
||||||
|
// started this on its own (06-history-undo.md ▸ Commit messages, the heal class).
|
||||||
|
EchoLedger.current?.recordMove(from: occupied, to: destination)
|
||||||
|
EchoLedger.current?.markHeal(at: destination)
|
||||||
|
return freed
|
||||||
|
}
|
||||||
|
|
||||||
/// The card's flat attachment listing (01-storage-format.md § Attachments, "the app's
|
/// The card's flat attachment listing (01-storage-format.md § Attachments, "the app's
|
||||||
/// attachment surfaces … are flat: top-level files only"): the top-level *files* of
|
/// attachment surfaces … are flat: top-level files only"): the top-level *files* of
|
||||||
/// `attachments/`, subfolders and hidden files excluded, symlinks excluded, sorted
|
/// `attachments/`, subfolders and hidden files excluded, symlinks excluded, sorted
|
||||||
@@ -2412,6 +2575,19 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
|
|||||||
/// second phrasing for "couldn't move a file you have never seen" would explain nothing.
|
/// second phrasing for "couldn't move a file you have never seen" would explain nothing.
|
||||||
case agentGuide
|
case agentGuide
|
||||||
|
|
||||||
|
/// A wrong-kinded node being moved off a board-root name the app claims — a file or symlink
|
||||||
|
/// squatting `.trash` (01-storage-format.md § Fractal layout ▸ Rules, ruled 2026-07-29: "moved
|
||||||
|
/// aside by a scheduled heal via the Finder-style rename ladder").
|
||||||
|
///
|
||||||
|
/// Its own case on `.relocateLooseFile`'s and `.agentGuide`'s reasoning: this is work the *app*
|
||||||
|
/// started on its own, on a node the user may not know is a problem, and a banner saying the app
|
||||||
|
/// "couldn't delete" or "couldn't move" something would name a gesture that never happened.
|
||||||
|
/// `name` is the claimed name as the app spells it — the name the user would recognize.
|
||||||
|
///
|
||||||
|
/// The `CLAUDE.md` squatter takes `.agentGuide` instead, because the guide's own write bracket
|
||||||
|
/// owns that file end to end and has one outcome the user could care about.
|
||||||
|
case displaceClaimedName(name: String)
|
||||||
|
|
||||||
/// A Preview task-list checkbox being ticked or unticked (05-card-window.md ▸ Preview).
|
/// A Preview task-list checkbox being ticked or unticked (05-card-window.md ▸ Preview).
|
||||||
///
|
///
|
||||||
/// Its own case rather than a fold into `.rename`'s or `.style`'s neighbourhood, on the
|
/// Its own case rather than a fold into `.rename`'s or `.style`'s neighbourhood, on the
|
||||||
@@ -2459,7 +2635,8 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
|
|||||||
public func withTitle(_ title: String?) -> WriteOperation {
|
public func withTitle(_ title: String?) -> WriteOperation {
|
||||||
switch self {
|
switch self {
|
||||||
case .createBoard, .createLane, .createCard, .importAttachment, .listAttachments,
|
case .createBoard, .createLane, .createCard, .importAttachment, .listAttachments,
|
||||||
.removeAttachment, .renumberChildren, .relocateLooseFile, .agentGuide:
|
.removeAttachment, .renumberChildren, .relocateLooseFile, .agentGuide,
|
||||||
|
.displaceClaimedName:
|
||||||
self
|
self
|
||||||
case .move: .move(title: title)
|
case .move: .move(title: title)
|
||||||
case .reorder: .reorder(title: title)
|
case .reorder: .reorder(title: title)
|
||||||
@@ -2506,6 +2683,7 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
|
|||||||
case .renumberChildren: "renumber children"
|
case .renumberChildren: "renumber children"
|
||||||
case let .relocateLooseFile(filename): "relocate loose file '\(filename)'"
|
case let .relocateLooseFile(filename): "relocate loose file '\(filename)'"
|
||||||
case .agentGuide: "update the agent guide"
|
case .agentGuide: "update the agent guide"
|
||||||
|
case let .displaceClaimedName(name): "move a stray '\(name)' aside"
|
||||||
case let .toggleTask(title): Self.phrase("toggle a checkbox in", title)
|
case let .toggleTask(title): Self.phrase("toggle a checkbox in", title)
|
||||||
case let .editBody(title): Self.phrase("save the body of", title)
|
case let .editBody(title): Self.phrase("save the body of", title)
|
||||||
case let .rawSource(title): Self.phrase("apply source changes to", title)
|
case let .rawSource(title): Self.phrase("apply source changes to", title)
|
||||||
|
|||||||
@@ -568,7 +568,13 @@ public enum FrontmatterKeys {
|
|||||||
public static let icon = "icon"
|
public static let icon = "icon"
|
||||||
public static let iconColor = "iconColor"
|
public static let iconColor = "iconColor"
|
||||||
|
|
||||||
|
/// The object's kind — `board`, `lane`, `card` (01-storage-format.md § Frontmatter ▸ Common to
|
||||||
|
/// all levels, re-ruled 2026-07-29). Written at creation of every object, backfilled on touch
|
||||||
|
/// when absent (`IntegrityRules.healOnTouch`), and never stripped.
|
||||||
|
public static let kind = "kind"
|
||||||
|
|
||||||
public static let schemaOwned: Set<String> = [
|
public static let schemaOwned: Set<String> = [
|
||||||
schema, title, order, width, created, modified, modifiedBy, deleted, background, icon, iconColor
|
schema, title, order, width, created, modified, modifiedBy, deleted, background, icon,
|
||||||
|
iconColor, kind,
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,6 +96,16 @@ extension FrontmatterDocument {
|
|||||||
/// Schema-owned, not an unknown key: the app clears it on every app-mediated write.
|
/// Schema-owned, not an unknown key: the app clears it on every app-mediated write.
|
||||||
public var modifiedBy: FieldValue<String> { read(FrontmatterKeys.modifiedBy, Self.string) }
|
public var modifiedBy: FieldValue<String> { read(FrontmatterKeys.modifiedBy, Self.string) }
|
||||||
|
|
||||||
|
/// The object's kind as written — `board`, `lane`, `card` (01-storage-format.md § Frontmatter,
|
||||||
|
/// re-ruled 2026-07-29). Lenient like every other string field: any scalar coerces to the text
|
||||||
|
/// the author typed, and **the value is never policed** — a reading outside the schema's three
|
||||||
|
/// is a perfectly good `.valid` here, and the one consumer that acts on it (the trash's
|
||||||
|
/// discriminator, `IntegrityRules.trashKind`) falls through to shape for anything it does not
|
||||||
|
/// recognize rather than correcting the file.
|
||||||
|
///
|
||||||
|
/// `.missing` — no key, or an explicit null — is what the on-touch backfill answers to.
|
||||||
|
public var kind: FieldValue<String> { read(FrontmatterKeys.kind, Self.string) }
|
||||||
|
|
||||||
// MARK: -
|
// MARK: -
|
||||||
|
|
||||||
private func read<Value>(_ key: String, _ transform: (YAMLValue, String) -> Value?) -> FieldValue<Value> {
|
private func read<Value>(_ key: String, _ transform: (YAMLValue, String) -> Value?) -> FieldValue<Value> {
|
||||||
|
|||||||
@@ -0,0 +1,582 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// **The one pure vocabulary of object validity** — 01-storage-format.md § Validation and healing
|
||||||
|
/// (settled 2026-07-29), 02-architecture.md ▸ Components ▸ IntegrityRules.
|
||||||
|
///
|
||||||
|
/// Every rule in the storage format that refuses, tolerates, recovers or repairs is an instance of
|
||||||
|
/// one five-verdict taxonomy (`Verdict`), and this type is where the taxonomy's *rules* live: the
|
||||||
|
/// identity predicate and its canonical form, the reserved-name tables, per-kind index validation,
|
||||||
|
/// the trash's `kind` discriminator, the on-touch heals, and the typed `Defect` vocabulary the
|
||||||
|
/// loader reports.
|
||||||
|
///
|
||||||
|
/// ### It consolidates rules, it does not relocate enforcement
|
||||||
|
///
|
||||||
|
/// **Loader and Writer remain the enforcement points and call in.** The loader still walks and
|
||||||
|
/// still throws; the Writer still refuses and still writes. What moved here is the *deciding* — so
|
||||||
|
/// no mechanism re-derives a rule the next one also needs, and adding an object kind (the enhanced
|
||||||
|
/// schema's `comment`) adds its field table and shape rules in one place rather than a parallel
|
||||||
|
/// mechanism. A service smeared across the read/write/orchestration boundaries would be worse than
|
||||||
|
/// the discipline it replaced, which is why nothing here touches a `BoardStore` or schedules
|
||||||
|
/// anything: the scheduled-heal engine is `HealScheduler`, on the other side of the layering.
|
||||||
|
///
|
||||||
|
/// ### Pure
|
||||||
|
///
|
||||||
|
/// Every function here is a function of its arguments. Two of them are *about* the filesystem —
|
||||||
|
/// `placement(ofFolderNamed:inParentNamed:)` and `trashKind(kindValue:hasIdentityShapedChildIndex:)`
|
||||||
|
/// — and take the facts they need as parameters rather than reading disk themselves, so the rules
|
||||||
|
/// are pinned by the suite without a filesystem in the way. The one call that does read (the
|
||||||
|
/// squatter probe, `node(at:)`) is a plain `lstat` classification with no policy in it at all.
|
||||||
|
public enum IntegrityRules: Sendable {
|
||||||
|
|
||||||
|
// MARK: - The five verdicts
|
||||||
|
|
||||||
|
/// The taxonomy every detectable defect classifies into — exactly one verdict each, and the
|
||||||
|
/// verdict fixes everything downstream (surface, write behavior, race posture), so no mechanism
|
||||||
|
/// ever re-reasons its posture individually (01-storage-format.md § Validation and healing).
|
||||||
|
///
|
||||||
|
/// Nothing switches over this today, deliberately: it is the vocabulary the rules below are
|
||||||
|
/// *written in*, and each rule already names its own verdict at its own site. It exists as a
|
||||||
|
/// type so that a new rule has to answer "which verdict is this?" before it has anywhere to go.
|
||||||
|
public enum Verdict: Sendable, Equatable, CaseIterable {
|
||||||
|
/// Fail-fast — the defect defeats rendering or ordering (`BoardLoadError`).
|
||||||
|
case refuse
|
||||||
|
/// Readable-but-uneditable shapes: the file renders fine and every app write to it fails
|
||||||
|
/// loudly, per file (`FrontmatterDocument.UneditableShape`).
|
||||||
|
case refuseWrites
|
||||||
|
/// Outside the schema's claim — strays, symlinks, case-twins, lane- and board-level
|
||||||
|
/// `deleted:`. Preserved verbatim, logged, never rendered (`LoadWarning`).
|
||||||
|
case tolerate
|
||||||
|
/// A sensible reading exists (the coercion rulebook, last-wins, null-as-missing, the rescue
|
||||||
|
/// family): silent, read-side only, bytes preserved (`FieldValue`).
|
||||||
|
case coerce
|
||||||
|
/// An app-owned invariant is violated *and* a lossless canonical repair exists — **the only
|
||||||
|
/// verdict that writes** (`Defect`, healed inline, on touch, or on schedule).
|
||||||
|
case heal
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - The identity predicate and its canonical form
|
||||||
|
|
||||||
|
/// The hex characters `isIdentityShaped` accepts in each `-`-delimited group — **both cases**,
|
||||||
|
/// per the shape-only predicate below.
|
||||||
|
private static let identityGroupCharacters = Set("0123456789abcdefABCDEF")
|
||||||
|
|
||||||
|
/// **The identity predicate** — whether `name` has a UUID's shape: hex, `8-4-4-4-12`, **any
|
||||||
|
/// case and any version** (01-storage-format.md § Fractal layout ▸ Rules, "Name shape gates
|
||||||
|
/// level detection"). Deliberately shape-only: lowercase v4 is the app's *emission* rule, not
|
||||||
|
/// the gate.
|
||||||
|
///
|
||||||
|
/// - **Any case.** `uuidgen(1)` and `UUID().uuidString` both print uppercase, so a strict
|
||||||
|
/// lowercase gate would turn an agent's standard-tool card into a silently skipped stray —
|
||||||
|
/// the worst failure mode for a files-first app. Accept liberally, emit conservatively.
|
||||||
|
/// - **Any version.** The version and variant nibbles protect no invariant here — an agent's v7
|
||||||
|
/// is exactly as unique as a v4 — and recognizing the folder-naming *convention* is the job,
|
||||||
|
/// not re-deriving RFC 4122 conformance on every folder of every load.
|
||||||
|
///
|
||||||
|
/// Equivalent to "does `UUID(uuidString:)` parse it", kept as a manual scan because that is the
|
||||||
|
/// cheaper answer on the hot path and needs no bridging.
|
||||||
|
///
|
||||||
|
/// **One rule, one implementation**: `BoardLoader.isUUIDShaped` is this function under the
|
||||||
|
/// loader's own spelling, and `BoardWriter` reaches it through that. Recognizing a name is not
|
||||||
|
/// the same as *comparing* two of them — see `canonicalIdentity`.
|
||||||
|
public static func isIdentityShaped(_ name: String) -> Bool {
|
||||||
|
let groups = name.split(separator: "-", omittingEmptySubsequences: false)
|
||||||
|
guard groups.map(\.count) == [8, 4, 4, 4, 12] else { return false }
|
||||||
|
return groups.allSatisfy { $0.allSatisfy(identityGroupCharacters.contains) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **The canonical form of an identity** — a folder name reduced to its UUID *value*.
|
||||||
|
///
|
||||||
|
/// Identity comparison is UUID-value equality, never string equality (01-storage-format.md
|
||||||
|
/// § Fractal layout ▸ Rules, settled): an arriving `55555555-…` and a resident `55555555-…`
|
||||||
|
/// spelled uppercase are **one** identity. Every identity-shaped name is ASCII hex and hyphens,
|
||||||
|
/// where locale-independent case folding is UUID-value canonicalization exactly.
|
||||||
|
///
|
||||||
|
/// **The one derivation** (settled 2026-07-29 — the fold): `ItemID`'s `==`/`hash(into:)`
|
||||||
|
/// canonicalize through this function, and so do the Writer's string-level checks, which
|
||||||
|
/// compare *paths* rather than model values (`BoardWriter.identities(inBoard:)`, the
|
||||||
|
/// import-boundary collision probe, `freshUUIDName`'s `taken` set). The Writer's former private
|
||||||
|
/// `canonicalIdentity` was a second copy of this one line; a second copy of a rule this
|
||||||
|
/// load-bearing is a bug waiting for the day the two disagree.
|
||||||
|
///
|
||||||
|
/// Total on any string: off-shape input compares by its own lowercasing, which is the harmless
|
||||||
|
/// reading (`ItemID` stays total for the same reason).
|
||||||
|
public static func canonicalIdentity(_ name: String) -> String {
|
||||||
|
name.lowercased()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - The reserved-name tables
|
||||||
|
|
||||||
|
/// The board's trash container (01-storage-format.md § Deletion) — a **directory** name.
|
||||||
|
public static let trashFolderName = ".trash"
|
||||||
|
|
||||||
|
/// A card's attachment folder (01-storage-format.md § Attachments) — the one folder the app
|
||||||
|
/// ever creates under a card.
|
||||||
|
public static let attachmentsFolderName = "attachments"
|
||||||
|
|
||||||
|
/// The file every level's content lives in.
|
||||||
|
public static let indexFileName = "index.md"
|
||||||
|
|
||||||
|
/// **The card-level reserved names** (01-storage-format.md § Fractal layout ▸ Rules): the
|
||||||
|
/// card's own `index.md` plus the two reserved children. `comments` is listed because the schema
|
||||||
|
/// reserves the name, not because anything writes it yet.
|
||||||
|
///
|
||||||
|
/// **Compared lowercased**, because the filesystem this runs on usually is: a file spelled
|
||||||
|
/// `Index.md` *is* the card's index to `fileExists`, and a case-sensitive reservation check
|
||||||
|
/// would hand the loose-file relocation a card's own content to move into `attachments/`.
|
||||||
|
public static let reservedCardChildNames: Set<String> = [
|
||||||
|
indexFileName, attachmentsFolderName, "comments",
|
||||||
|
]
|
||||||
|
|
||||||
|
/// What kind of node a name is allowed to be.
|
||||||
|
///
|
||||||
|
/// `String`-backed so a defect's signature has a stable token to spell (`description` is prose
|
||||||
|
/// for a log line and must stay free to change without re-arming a memo).
|
||||||
|
public enum NodeKind: String, Sendable, Equatable, CustomStringConvertible {
|
||||||
|
case file
|
||||||
|
case directory
|
||||||
|
/// Never followed, never traversed, never resolved — `lstat` semantics everywhere in this
|
||||||
|
/// app (01-storage-format.md § Fractal layout ▸ Rules). A symlink is a *node that is there*,
|
||||||
|
/// whatever it points at, which is why it is its own case rather than the type of its
|
||||||
|
/// target.
|
||||||
|
case symlink
|
||||||
|
|
||||||
|
public var description: String {
|
||||||
|
switch self {
|
||||||
|
case .file: "a file"
|
||||||
|
case .directory: "a folder"
|
||||||
|
case .symlink: "a symbolic link"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **A board-root name the app claims** — the one scope on the verbatim promise
|
||||||
|
/// (01-storage-format.md § Fractal layout ▸ Rules: "Three board-root names are app-claimed, not
|
||||||
|
/// strays", plus `.trash/` from § Deletion).
|
||||||
|
public struct ClaimedName: Sendable, Equatable {
|
||||||
|
public let name: String
|
||||||
|
/// What the app needs the name to *be*. A node of any other kind is not a resident — it is
|
||||||
|
/// an invalid artifact on a name Lanework owns.
|
||||||
|
public let expected: NodeKind
|
||||||
|
/// Whether a wrong-kinded node on this name is displaced by the scheduled heal (ruled
|
||||||
|
/// 2026-07-29 — "Lanework owns the board"), or left exactly where it is.
|
||||||
|
///
|
||||||
|
/// `false` for the two names that are *destinations* or not the app's to police:
|
||||||
|
/// `CLAUDE.user.md` is where a markerless `CLAUDE.md` is rescued **to**, and freeing a
|
||||||
|
/// destination by a second displacement would cascade renames (the settled skip stands —
|
||||||
|
/// 08-agent-integration.md); `.gitignore` is seeded once and then the user's to edit
|
||||||
|
/// (06-history-undo.md ▸ Repository hygiene), and nothing in the app reads it.
|
||||||
|
public let displacesSquatters: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The claimed-name table — **one place**, where these names were scattered across the loader,
|
||||||
|
/// the guide, and the trash writer before (02-architecture.md ▸ Components: "the reserved-name
|
||||||
|
/// tables … today scattered").
|
||||||
|
///
|
||||||
|
/// `CLAUDE.md`'s squatter is displaced by the **agent guide's** own heal, which already owns
|
||||||
|
/// that file's whole decision (`AgentGuide.Decision.displaceSquatterThenWrite`); `.trash`'s is
|
||||||
|
/// its own scheduled heal, because nothing else ever writes that name.
|
||||||
|
public static let claimedRootNames: [ClaimedName] = [
|
||||||
|
ClaimedName(name: trashFolderName, expected: .directory, displacesSquatters: true),
|
||||||
|
ClaimedName(name: "CLAUDE.md", expected: .file, displacesSquatters: true),
|
||||||
|
ClaimedName(name: "CLAUDE.user.md", expected: .file, displacesSquatters: false),
|
||||||
|
ClaimedName(name: ".gitignore", expected: .file, displacesSquatters: false),
|
||||||
|
]
|
||||||
|
|
||||||
|
/// The claimed names as the lane walk needs them: lowercased, for a `contains` against a
|
||||||
|
/// directory entry. Compared lowercased for `reservedCardChildNames`' reason.
|
||||||
|
public static let claimedRootNameSet: Set<String> = Set(claimedRootNames.map { $0.name.lowercased() })
|
||||||
|
|
||||||
|
/// What is sitting at `url`, by `lstat` and nothing else — `nil` when nothing is there.
|
||||||
|
///
|
||||||
|
/// **`attributesOfItem`, never `fileExists`**: a dangling symlink is a node that is *there*
|
||||||
|
/// (a move onto it would fail, and this app does not touch symlinks anyway), while `fileExists`
|
||||||
|
/// follows the link, finds nothing, and would call the name free.
|
||||||
|
public static func node(at url: URL) -> NodeKind? {
|
||||||
|
guard let type = (try? FileManager.default.attributesOfItem(atPath: url.path))?[.type]
|
||||||
|
as? FileAttributeType
|
||||||
|
else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
switch type {
|
||||||
|
case .typeSymbolicLink: return .symlink
|
||||||
|
case .typeDirectory: return .directory
|
||||||
|
default: return .file
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The claimed-name defect at a board root, or `nil` when every claimed name is free or held by
|
||||||
|
/// the right kind of node — the **detection** half of the squatter-displacement ruling
|
||||||
|
/// (01-storage-format.md § Fractal layout ▸ Rules, ruled 2026-07-29).
|
||||||
|
///
|
||||||
|
/// Only names whose `displacesSquatters` is `true` can produce one, and only `.trash` is
|
||||||
|
/// answered here: `CLAUDE.md`'s squatter is the agent guide's, detected by `AgentGuide.inspect`
|
||||||
|
/// in the same read that decides everything else about that file, so answering it twice would be
|
||||||
|
/// two mechanisms racing to displace one node.
|
||||||
|
public static func squattedClaimedName(atBoardRoot root: URL) -> ClaimedNameSquatter? {
|
||||||
|
guard let claimed = claimedRootNames.first(where: { $0.name == trashFolderName }),
|
||||||
|
let found = node(at: root.appendingPathComponent(claimed.name)),
|
||||||
|
found != claimed.expected
|
||||||
|
else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return ClaimedNameSquatter(name: claimed.name, found: found, expected: claimed.expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Object kinds
|
||||||
|
|
||||||
|
/// The kinds the schema knows (01-storage-format.md § Frontmatter ▸ Common to all levels, the
|
||||||
|
/// `kind` row). `comment` is reserved with the enhanced schema and deliberately absent until it
|
||||||
|
/// lands — an unrecognized value on disk is trusted as itself and never policed, so nothing here
|
||||||
|
/// has to anticipate it.
|
||||||
|
public enum ObjectKind: String, Sendable, Equatable, CaseIterable {
|
||||||
|
case board
|
||||||
|
case lane
|
||||||
|
case card
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where a folder's **position** places it — "level is position" (01-storage-format.md
|
||||||
|
/// § Fractal layout) as a value, decided from two names and nothing else.
|
||||||
|
public enum Placement: Sendable, Equatable {
|
||||||
|
/// Its parent is identity-shaped, so it is a card: a lane's children are the only
|
||||||
|
/// identity-bearing folders under an identity-bearing folder.
|
||||||
|
case card
|
||||||
|
/// Identity-shaped, under something that is neither a lane nor the trash — a lane.
|
||||||
|
case lane
|
||||||
|
/// Inside `.trash/`, where the container is flat and **position cannot answer**: use
|
||||||
|
/// `trashKind(kindValue:hasIdentityShapedChildIndex:)`.
|
||||||
|
case insideTrash
|
||||||
|
/// Position says nothing. A board root reaches this (its folder name is a Finder document
|
||||||
|
/// name, not an identity), and so does any hand-named folder — which is why the answer is
|
||||||
|
/// "unknown" rather than "board": guessing here would stamp `kind: board` onto whatever a
|
||||||
|
/// caller happened to point at.
|
||||||
|
case unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The placement rule, in the order the questions can be answered.
|
||||||
|
///
|
||||||
|
/// The trash check sits *between* the two identity checks deliberately: a trashed card and a
|
||||||
|
/// live lane are both identity-shaped folders whose parent is not, and the container is the only
|
||||||
|
/// thing that tells them apart.
|
||||||
|
public static func placement(ofFolderNamed name: String, inParentNamed parent: String) -> Placement {
|
||||||
|
if isIdentityShaped(parent) { return .card }
|
||||||
|
if parent.lowercased() == trashFolderName { return .insideTrash }
|
||||||
|
if isIdentityShaped(name) { return .lane }
|
||||||
|
return .unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **The trash's `kind` discriminator** (01-storage-format.md § Deletion, re-ruled 2026-07-29 —
|
||||||
|
/// the value-names-the-kind posture): depth defines meaning on the live board, but the trash is
|
||||||
|
/// flat, and an empty lane folder is shape-identical to a card folder.
|
||||||
|
///
|
||||||
|
/// **The value is trusted outright** — `kind: lane` → lane, `kind: card` → card — so an external
|
||||||
|
/// writer's `kind: lane` on what looks card-shaped is honored, never policed and never
|
||||||
|
/// corroborated. An unrecognized value or no key at all falls through to **shape**:
|
||||||
|
/// identity-shaped children with their own `index.md` → lane (the key backfills on the next
|
||||||
|
/// touch), else card.
|
||||||
|
///
|
||||||
|
/// `kind: board` in the trash is *not* a third answer: a board is not a thing that can be
|
||||||
|
/// trashed, so the value is unrecognized here and shape decides — the same shrug an arbitrary
|
||||||
|
/// string gets.
|
||||||
|
///
|
||||||
|
/// The shape half is `@autoclosure` so that the rule stays a pure function of two facts while
|
||||||
|
/// its caller pays for the directory listing **only when the value did not answer** — which on
|
||||||
|
/// a board written by this app is never.
|
||||||
|
public static func trashKind(
|
||||||
|
kindValue: String?,
|
||||||
|
hasIdentityShapedChildIndex: @autoclosure () -> Bool
|
||||||
|
) -> ObjectKind {
|
||||||
|
switch kindValue.flatMap(ObjectKind.init(rawValue:)) {
|
||||||
|
case .lane: return .lane
|
||||||
|
case .card: return .card
|
||||||
|
case .board, nil: return hasIdentityShapedChildIndex() ? .lane : .card
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Per-field validation (the rulebook)
|
||||||
|
|
||||||
|
/// `schema`, validated: present, well-formed, not newer than this app (01-storage-format.md
|
||||||
|
/// § Malformed input). Required at every level.
|
||||||
|
public static func validatedSchema(
|
||||||
|
in document: FrontmatterDocument,
|
||||||
|
path: String,
|
||||||
|
supportedSchema: Int
|
||||||
|
) throws(BoardLoadError) -> Int {
|
||||||
|
switch document.schema {
|
||||||
|
case .missing:
|
||||||
|
throw BoardLoadError(path: path, reason: .missingSchema)
|
||||||
|
case let .malformed(raw):
|
||||||
|
throw BoardLoadError(path: path, reason: .malformedSchema(raw: raw))
|
||||||
|
case let .valid(value):
|
||||||
|
guard value <= supportedSchema else {
|
||||||
|
throw BoardLoadError(path: path, reason: .schemaNewerThanApp(found: value))
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `order`, validated: present and well-formed. Required on lanes and cards, **never** on the
|
||||||
|
/// board itself — which is the whole of the per-kind difference in the tables today.
|
||||||
|
public static func validatedOrder(
|
||||||
|
in document: FrontmatterDocument,
|
||||||
|
path: String
|
||||||
|
) throws(BoardLoadError) -> Double {
|
||||||
|
switch document.order {
|
||||||
|
case .missing:
|
||||||
|
throw BoardLoadError(path: path, reason: .missingOrder)
|
||||||
|
case let .malformed(raw):
|
||||||
|
throw BoardLoadError(path: path, reason: .malformedOrder(raw: raw))
|
||||||
|
case let .valid(value):
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether an object of `kind` must carry `order` — the per-kind field table, as a rule rather
|
||||||
|
/// than as two hand-written call sites in the loader's walk.
|
||||||
|
public static func requiresOrder(_ kind: ObjectKind) -> Bool {
|
||||||
|
switch kind {
|
||||||
|
case .board: false
|
||||||
|
case .lane, .card: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Per-kind index validation** — the loader's own checks, in its own order, over bytes that
|
||||||
|
/// need not be on disk yet (02-architecture.md ▸ Components: "the card validator generalized per
|
||||||
|
/// kind — board, lane, card, the enhanced schema's comment when it lands").
|
||||||
|
///
|
||||||
|
/// Exactly the checks `BoardLoader.load` runs on an object of that kind, through its own
|
||||||
|
/// functions: decode + parse, then `schema`, then `order` where the kind requires it. Nothing
|
||||||
|
/// further is checked, because nothing else *is*: `title` is optional, unknown keys are the
|
||||||
|
/// point of the outlet the card validator serves, and the body is free text.
|
||||||
|
///
|
||||||
|
/// It deliberately does **not** check `uneditableShape`: that refusal exists for surgical span
|
||||||
|
/// edits, and the raw-source Apply this serves replaces the whole file — a flow-mapping
|
||||||
|
/// frontmatter is precisely one of the things the escape hatch exists to let a user rewrite.
|
||||||
|
public static func validateIndex(
|
||||||
|
_ data: Data,
|
||||||
|
path: String,
|
||||||
|
kind: ObjectKind,
|
||||||
|
supportedSchema: Int
|
||||||
|
) throws(BoardLoadError) -> FrontmatterDocument {
|
||||||
|
let document = try BoardLoader.parseDocument(data, path: path)
|
||||||
|
_ = try validatedSchema(in: document, path: path, supportedSchema: supportedSchema)
|
||||||
|
if requiresOrder(kind) {
|
||||||
|
_ = try validatedOrder(in: document, path: path)
|
||||||
|
}
|
||||||
|
return document
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Why this document refuses every app write, or `nil` when it can be edited in place — the
|
||||||
|
/// **refuse-writes** verdict's whole rule (01-storage-format.md § Frontmatter, the
|
||||||
|
/// readable-but-uneditable shapes).
|
||||||
|
///
|
||||||
|
/// The analysis itself lives on `FrontmatterDocument`, computed at parse from the span
|
||||||
|
/// structure it alone has; naming it here is what puts the verdict in the vocabulary rather than
|
||||||
|
/// leaving it as a property one call site happens to read.
|
||||||
|
public static func uneditableShape(of document: FrontmatterDocument) -> FrontmatterDocument.UneditableShape? {
|
||||||
|
document.uneditableShape
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - On-touch heals
|
||||||
|
|
||||||
|
/// A latent defect fixed by folding into a write that is **already rewriting that file**
|
||||||
|
/// (01-storage-format.md § Validation and healing: "on-touch when the defect is latent"). Never
|
||||||
|
/// a scheduled sweep, never a write of its own — an on-touch heal rides its host write's single
|
||||||
|
/// atomic rewrite and its host's `modified` stamp, and composes no event of its own.
|
||||||
|
public enum OnTouchHeal: Sendable, Equatable {
|
||||||
|
/// `kind` was missing and has been stamped with the object's own kind (re-ruled 2026-07-29
|
||||||
|
/// — the common-schema row). **On-touch only, never a scheduled backfill sweep**: the key is
|
||||||
|
/// consequential only inside `.trash/`, and a sweep would rewrite every file on the board to
|
||||||
|
/// add a key that is redundant with position everywhere else.
|
||||||
|
case kindBackfilled(ObjectKind)
|
||||||
|
|
||||||
|
/// A key written twice collapsed to its winning (last) occurrence — the span editor's
|
||||||
|
/// duplicate-key twin removal (`FrontmatterDocument.set`). Named here because it *is* an
|
||||||
|
/// on-touch heal and was only ever documented as an editing detail: last-wins is the read
|
||||||
|
/// rule, and the write that touches the key is where the twins stop being able to resurrect.
|
||||||
|
case duplicateKeyTwinsRemoved(key: String)
|
||||||
|
|
||||||
|
/// A value that needed quoting got it on its first app write — the colon rescue's
|
||||||
|
/// quote-on-first-write (`FrontmatterValue.emitScalar`). The same class as the twin removal
|
||||||
|
/// and named for the same reason: the app writes the value correctly the first time it has
|
||||||
|
/// any reason to write it at all, and never on a file it was not already rewriting.
|
||||||
|
case quotedOnFirstWrite(key: String)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **The on-touch heal seam**: the pending latent work on the document a write is already
|
||||||
|
/// rewriting, applied (02-architecture.md ▸ Components: "on-touch heals live at the Writer's
|
||||||
|
/// `updateIndex` seam, which consults IntegrityRules for pending on-touch work on the file it is
|
||||||
|
/// rewriting").
|
||||||
|
///
|
||||||
|
/// Only the `kind` backfill is applied *here*; the other two members of the class are applied by
|
||||||
|
/// `FrontmatterDocument`'s own editor on every key it writes, and are named in `OnTouchHeal`
|
||||||
|
/// rather than re-implemented. That is the honest shape of "the same class, named, no behavior
|
||||||
|
/// change".
|
||||||
|
///
|
||||||
|
/// - **Missing only.** A present `kind` is never rewritten, never corroborated, and never
|
||||||
|
/// stripped — the value names the kind and consumers trust it outright. An explicit `kind:`
|
||||||
|
/// with nothing after it reads as missing, like every other null (the null-as-missing rule),
|
||||||
|
/// and so backfills.
|
||||||
|
/// - **`kind == nil` stamps nothing.** Position cannot always answer (`Placement.unknown`), and
|
||||||
|
/// a guessed kind written to disk would be worse than an absent one: the trash discriminator
|
||||||
|
/// trusts what it finds.
|
||||||
|
/// - Parameter kind: `@autoclosure` so a caller whose answer costs a directory listing (the
|
||||||
|
/// trash's shape fallback) pays for it only on a file that actually needs the backfill.
|
||||||
|
@discardableResult
|
||||||
|
public static func healOnTouch(
|
||||||
|
_ document: inout FrontmatterDocument,
|
||||||
|
kind: @autoclosure () -> ObjectKind?
|
||||||
|
) -> [OnTouchHeal] {
|
||||||
|
guard document.kind.isMissing, let kind = kind() else { return [] }
|
||||||
|
document.set(FrontmatterKeys.kind, to: .string(kind.rawValue))
|
||||||
|
return [.kindBackfilled(kind)]
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - The typed defect stream
|
||||||
|
|
||||||
|
/// **One defect, typed** — what the loader reports as pending *work*, as distinct from the
|
||||||
|
/// tolerate-tier `LoadWarning`s it reports as information (02-architecture.md ▸ Components:
|
||||||
|
/// "`LoadResult`'s ad-hoc repair channels (loose files, legacy tombstones) become one typed
|
||||||
|
/// defect stream; tolerate-tier warnings stay warnings").
|
||||||
|
///
|
||||||
|
/// The distinction is the whole reason the two channels are not one: a warning says "this was
|
||||||
|
/// ignored, it is staying exactly where it is, there is nothing to do", and a defect says the
|
||||||
|
/// opposite. Folding defects into the warning channel would also throw away everything the heal
|
||||||
|
/// needs (which lane, which card, which title, which names) and force it to be re-derived from a
|
||||||
|
/// display string.
|
||||||
|
public enum Defect: Sendable, Equatable {
|
||||||
|
/// A card holding files that belong in its `attachments/` (the loose-file carve-out).
|
||||||
|
case looseCardFiles(LooseCardFiles)
|
||||||
|
/// An item carrying a legacy `deleted:` key (the retired tombstone model's migration input).
|
||||||
|
case legacyTombstone(LegacyTombstone)
|
||||||
|
/// A claimed board-root name held by the wrong kind of node (ruled 2026-07-29).
|
||||||
|
case claimedNameSquatted(ClaimedNameSquatter)
|
||||||
|
|
||||||
|
/// The scheduled-heal classes, which are also the engine's memo keys and its
|
||||||
|
/// banner-posture rows (`HealScheduler`).
|
||||||
|
///
|
||||||
|
/// `staleAgentGuide` has no `Defect` case, and that asymmetry is honest rather than an
|
||||||
|
/// oversight: the guide's defect is a property of one board-root file's *version marker*,
|
||||||
|
/// read at the moment of healing (`AgentGuide.inspect`), not something a tree walk reports.
|
||||||
|
/// It is a class here because the engine treats it exactly like the others — same gates,
|
||||||
|
/// same memo, same clear-on-success.
|
||||||
|
public enum Class: Sendable, Equatable, Hashable, CaseIterable {
|
||||||
|
case looseCardFiles
|
||||||
|
case legacyTombstone
|
||||||
|
case claimedNameSquatted
|
||||||
|
case staleAgentGuide
|
||||||
|
}
|
||||||
|
|
||||||
|
public var healClass: Class {
|
||||||
|
switch self {
|
||||||
|
case .looseCardFiles: .looseCardFiles
|
||||||
|
case .legacyTombstone: .legacyTombstone
|
||||||
|
case .claimedNameSquatted: .claimedNameSquatted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **The defect's identity as a comparable string** — the memo's unit (01-storage-format.md
|
||||||
|
/// § Validation and healing: "re-armed only by a changed defect signature").
|
||||||
|
///
|
||||||
|
/// What matters is the *identity* of the work, never the order the walk happened to meet it
|
||||||
|
/// in, which is why the engine compares sets of these rather than arrays of defects: two
|
||||||
|
/// loads of an unchanged tree must compare equal even if a lane's folder-name ordering
|
||||||
|
/// shifted underneath them.
|
||||||
|
public var signatures: [String] {
|
||||||
|
switch self {
|
||||||
|
case let .looseCardFiles(work):
|
||||||
|
work.fileNames.map { "loose:\(work.laneID.rawValue)/\(work.cardID.rawValue)/\($0)" }
|
||||||
|
case let .legacyTombstone(work):
|
||||||
|
["tombstone:\(work.laneID.rawValue)/\(work.cardID?.rawValue ?? "")"]
|
||||||
|
case let .claimedNameSquatted(work):
|
||||||
|
// The node *kind* is part of the picture: a squatter replaced by a different kind
|
||||||
|
// of squatter is a new defect, and a heal that failed on one has no claim to have
|
||||||
|
// failed on the other.
|
||||||
|
["claimed:\(work.name):\(work.found.rawValue)"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - The defect payloads
|
||||||
|
|
||||||
|
/// One card found holding files that belong in its `attachments/` — everything the relocation and
|
||||||
|
/// its notice need, and nothing more (01-storage-format.md § Fractal layout ▸ Rules, settled
|
||||||
|
/// 2026-07-28, "Lanework-owns-the-board").
|
||||||
|
///
|
||||||
|
/// `title` is the card's as written, `nil` for an untitled one: "Untitled" is a rendering, never a
|
||||||
|
/// value (03-board-ui.md § Card face), so the phrasing layer decides what to call it. The path is
|
||||||
|
/// carried as its two identity components rather than as a URL, `ItemPath`'s convention, so the
|
||||||
|
/// write derives its path from the store's *current* root.
|
||||||
|
public struct LooseCardFiles: Sendable, Equatable {
|
||||||
|
public let laneID: ItemID
|
||||||
|
public let cardID: ItemID
|
||||||
|
public let title: String?
|
||||||
|
/// The loose files' names, in Finder order (`BoardLoader.looseFileNames`). Never empty — a card
|
||||||
|
/// with nothing loose contributes no defect at all.
|
||||||
|
public let fileNames: [String]
|
||||||
|
|
||||||
|
public init(laneID: ItemID, cardID: ItemID, title: String?, fileNames: [String]) {
|
||||||
|
self.laneID = laneID
|
||||||
|
self.cardID = cardID
|
||||||
|
self.title = title
|
||||||
|
self.fileNames = fileNames
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One item found carrying a legacy `deleted:` key — everything its migration and notice need, and
|
||||||
|
/// nothing more (01-storage-format.md § Deletion: "Legacy `deleted:` keys migrate on load-and-write,
|
||||||
|
/// never destroy").
|
||||||
|
///
|
||||||
|
/// The path is carried as its identity components rather than as a URL — `LooseCardFiles`'
|
||||||
|
/// convention, for its reason. `title` is the item's as written, `nil` for an untitled one.
|
||||||
|
public struct LegacyTombstone: Sendable, Equatable {
|
||||||
|
/// Which migration this item takes — the two are genuinely different acts, not one act at two
|
||||||
|
/// levels: a card *moves* (into `.trash/`, at a minted top-of-trash rank) and a lane stays
|
||||||
|
/// exactly where it is (the key is stripped and it returns live).
|
||||||
|
public enum Kind: Sendable, Equatable {
|
||||||
|
case card
|
||||||
|
case lane
|
||||||
|
}
|
||||||
|
|
||||||
|
public let kind: Kind
|
||||||
|
|
||||||
|
/// The lane's own identity for `.lane`; the card's **containing** lane for `.card` — the context
|
||||||
|
/// the relocation needs to find the folder at all.
|
||||||
|
public let laneID: ItemID
|
||||||
|
|
||||||
|
/// The card's identity for `.card`, `nil` for `.lane`. Two fields rather than an enum payload so
|
||||||
|
/// the common "which folder is this" question is one path join at every call site.
|
||||||
|
public let cardID: ItemID?
|
||||||
|
|
||||||
|
public let title: String?
|
||||||
|
|
||||||
|
public init(kind: Kind, laneID: ItemID, cardID: ItemID?, title: String?) {
|
||||||
|
self.kind = kind
|
||||||
|
self.laneID = laneID
|
||||||
|
self.cardID = cardID
|
||||||
|
self.title = title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A claimed board-root name held by the wrong kind of node — a file or symlink squatting `.trash`,
|
||||||
|
/// a directory or symlink squatting `CLAUDE.md` (01-storage-format.md § Fractal layout ▸ Rules,
|
||||||
|
/// ruled 2026-07-29: "A claimed name held by the wrong kind of node is Lanework's to heal — by
|
||||||
|
/// displacement").
|
||||||
|
///
|
||||||
|
/// **An invalid artifact, not a resident.** The heal moves it aside via the Finder-style rename
|
||||||
|
/// ladder (`.trash` → `.trash 2`), preserved verbatim and never destroyed, with a warning-tone
|
||||||
|
/// notice naming old and new — the invariant that survives is displacement-never-destruction.
|
||||||
|
public struct ClaimedNameSquatter: Sendable, Equatable {
|
||||||
|
/// The claimed name, exactly as the app spells it (`.trash`, `CLAUDE.md`).
|
||||||
|
public let name: String
|
||||||
|
/// What is actually sitting there — never followed if it is a symlink.
|
||||||
|
public let found: IntegrityRules.NodeKind
|
||||||
|
/// What the app needs the name to be.
|
||||||
|
public let expected: IntegrityRules.NodeKind
|
||||||
|
|
||||||
|
public init(name: String, found: IntegrityRules.NodeKind, expected: IntegrityRules.NodeKind) {
|
||||||
|
self.name = name
|
||||||
|
self.found = found
|
||||||
|
self.expected = expected
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,8 +11,10 @@ import Testing
|
|||||||
/// 2. **The decision is pure** — write, leave alone, displace, or skip, from what the two claimed
|
/// 2. **The decision is pure** — write, leave alone, displace, or skip, from what the two claimed
|
||||||
/// board-root names look like on disk and nothing else.
|
/// board-root names look like on disk and nothing else.
|
||||||
/// 3. **Nothing the user owns is ever destroyed** — a markerless `CLAUDE.md` is rescued, a taken
|
/// 3. **Nothing the user owns is ever destroyed** — a markerless `CLAUDE.md` is rescued, a taken
|
||||||
/// `CLAUDE.user.md` cancels the write outright, a symlink or a folder is not touched at all, and
|
/// `CLAUDE.user.md` cancels the write outright, a symlink or a folder wearing the name is
|
||||||
/// a current guide is not even opened for writing.
|
/// *displaced* rather than clobbered (ruled 2026-07-29 — the claimed-name rule; it replaced an
|
||||||
|
/// untouchable-skip, and displacement-never-destruction is what survives), and a current guide is
|
||||||
|
/// not even opened for writing.
|
||||||
///
|
///
|
||||||
/// Every on-disk claim is read back as **raw bytes**, never through a snapshot: the promises are
|
/// Every on-disk claim is read back as **raw bytes**, never through a snapshot: the promises are
|
||||||
/// about the files. `WriterFixture`, `Ident` and `Item` come from `WriterTestSupport.swift`.
|
/// about the files. `WriterFixture`, `Ident` and `Item` come from `WriterTestSupport.swift`.
|
||||||
@@ -176,10 +178,18 @@ struct AgentGuideDecisionTests {
|
|||||||
#expect(AgentGuide.decide(state(.missing, userFileFree: false)) == .write)
|
#expect(AgentGuide.decide(state(.missing, userFileFree: false)) == .write)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("A symlink or a folder is never touched, free name or not")
|
/// **Updated 2026-07-29** — the claimed-name squatter ruling (01-storage-format.md § Fractal
|
||||||
func untouchableIsSkipped() {
|
/// layout ▸ Rules) upgraded this case from a skip to a displacement: Lanework owns the board, so
|
||||||
#expect(AgentGuide.decide(state(.untouchable)) == .skipUntouchable)
|
/// a folder or symlink on a name the app claims is an invalid artifact, not a resident. It is
|
||||||
#expect(AgentGuide.decide(state(.untouchable, userFileFree: false)) == .skipUntouchable)
|
/// moved aside by the Finder ladder and never destroyed.
|
||||||
|
///
|
||||||
|
/// **Free name or not is still irrelevant here**, but for a new reason: `CLAUDE.user.md` is the
|
||||||
|
/// *rescue* destination for user content, and a squatter is not rescued to it — it goes to
|
||||||
|
/// `CLAUDE.md 2`, so the other name's state has no bearing on the decision.
|
||||||
|
@Test("A symlink or a folder is displaced, free name or not")
|
||||||
|
func squatterIsDisplaced() {
|
||||||
|
#expect(AgentGuide.decide(state(.squatted)) == .displaceSquatterThenWrite)
|
||||||
|
#expect(AgentGuide.decide(state(.squatted, userFileFree: false)) == .displaceSquatterThenWrite)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -338,10 +348,11 @@ struct AgentGuideStoreTests {
|
|||||||
#expect(store.banners.oneShots.isEmpty, "a skip is a log line, not a banner")
|
#expect(store.banners.oneShots.isEmpty, "a skip is a log line, not a banner")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Symlinks are never followed or touched anywhere in this app (01-storage-format.md § Fractal
|
/// **Updated 2026-07-29** — the claimed-name squatter ruling. A symlink wearing the guide's name
|
||||||
/// layout ▸ Rules) — including one wearing the guide's name.
|
/// is still never *followed*: it is moved aside **as a link** (`lstat` semantics all the way
|
||||||
@Test("A symlinked CLAUDE.md is left as a symlink, and its target is untouched")
|
/// down), its target is never opened, and the guide is written on the freed name.
|
||||||
func symlinkedGuideIsSkipped() throws {
|
@Test("A symlinked CLAUDE.md is displaced as a link, and its target is untouched")
|
||||||
|
func symlinkedGuideIsDisplaced() throws {
|
||||||
let fixture = try makeBoard()
|
let fixture = try makeBoard()
|
||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
let target = try fixture.file("elsewhere.md", Data("# somewhere else\n".utf8))
|
let target = try fixture.file("elsewhere.md", Data("# somewhere else\n".utf8))
|
||||||
@@ -350,10 +361,20 @@ struct AgentGuideStoreTests {
|
|||||||
|
|
||||||
store.refreshAgentGuide()
|
store.refreshAgentGuide()
|
||||||
|
|
||||||
let destination = try FileManager.default.destinationOfSymbolicLink(atPath: guideURL(in: fixture).path)
|
// The link moved, still a link, still pointing where it pointed — never resolved, never
|
||||||
#expect(destination == "elsewhere.md", "the link is still a link")
|
// written through.
|
||||||
#expect(try Data(contentsOf: target) == Data("# somewhere else\n".utf8), "and it was not written through")
|
// Finder's own splitting: `CLAUDE.md` → `CLAUDE 2.md` (the ladder splits after the last
|
||||||
|
// dot), exactly as `.trash` → `.trash 2` for an extension-less name.
|
||||||
|
let moved = fixture.root.appendingPathComponent("CLAUDE 2.md")
|
||||||
|
#expect(try FileManager.default.destinationOfSymbolicLink(atPath: moved.path) == "elsewhere.md")
|
||||||
|
#expect(try Data(contentsOf: target) == Data("# somewhere else\n".utf8))
|
||||||
|
// And the freed name now carries the guide.
|
||||||
|
#expect(try fixture.data(AgentGuide.filename) == Data(AgentGuide.content.utf8))
|
||||||
|
// A squatter's displacement is announced — the relocation-style warning-tone notice, naming
|
||||||
|
// old and new. The rescue to CLAUDE.user.md is silent; this is not that.
|
||||||
#expect(!fixture.exists(AgentGuide.userFilename))
|
#expect(!fixture.exists(AgentGuide.userFilename))
|
||||||
|
#expect(store.banners.losses.count == 1)
|
||||||
|
#expect(store.banners.losses.first?.message == "Renamed 'CLAUDE.md' to 'CLAUDE 2.md' — Lanework needs that name")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The rescue name is checked with `lstat` semantics, so a **broken** symlink counts as taken:
|
/// The rescue name is checked with `lstat` semantics, so a **broken** symlink counts as taken:
|
||||||
@@ -391,8 +412,11 @@ struct AgentGuideStoreTests {
|
|||||||
#expect(try fixture.data(AgentGuide.userFilename) == Data("# secret\n".utf8))
|
#expect(try fixture.data(AgentGuide.userFilename) == Data("# secret\n".utf8))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("A folder named CLAUDE.md is left alone")
|
/// **Updated 2026-07-29** — the claimed-name squatter ruling: a folder on the guide's name is an
|
||||||
func directoryGuideIsSkipped() throws {
|
/// invalid artifact, not a resident. It moves aside whole, **contents preserved verbatim**, and
|
||||||
|
/// the guide takes the freed name.
|
||||||
|
@Test("A folder named CLAUDE.md is displaced whole, contents intact")
|
||||||
|
func directoryGuideIsDisplaced() throws {
|
||||||
let fixture = try makeBoard()
|
let fixture = try makeBoard()
|
||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
try fixture.file("\(AgentGuide.filename)/inside.txt", Data("inside".utf8))
|
try fixture.file("\(AgentGuide.filename)/inside.txt", Data("inside".utf8))
|
||||||
@@ -400,10 +424,30 @@ struct AgentGuideStoreTests {
|
|||||||
|
|
||||||
store.refreshAgentGuide()
|
store.refreshAgentGuide()
|
||||||
|
|
||||||
#expect(try fixture.data("\(AgentGuide.filename)/inside.txt") == Data("inside".utf8))
|
#expect(try fixture.data("CLAUDE 2.md/inside.txt") == Data("inside".utf8))
|
||||||
|
#expect(try fixture.data(AgentGuide.filename) == Data(AgentGuide.content.utf8))
|
||||||
|
// Displacement, never a rescue: `CLAUDE.user.md` is where *user content* goes, and a folder
|
||||||
|
// on a file's name is not that.
|
||||||
#expect(!fixture.exists(AgentGuide.userFilename))
|
#expect(!fixture.exists(AgentGuide.userFilename))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The ladder climbs rather than overwriting: a board that already has a `CLAUDE.md 2` gets a
|
||||||
|
/// `CLAUDE.md 3`, Finder-style, one collision at a time.
|
||||||
|
@Test("The displacement climbs the Finder ladder past a taken name")
|
||||||
|
func displacementClimbsTheLadder() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
try fixture.file("\(AgentGuide.filename)/inside.txt", Data("inside".utf8))
|
||||||
|
try writeRoot("CLAUDE 2.md", Data("someone else's\n".utf8), in: fixture)
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
|
||||||
|
store.refreshAgentGuide()
|
||||||
|
|
||||||
|
#expect(try fixture.data("CLAUDE 2.md") == Data("someone else's\n".utf8), "untouched")
|
||||||
|
#expect(try fixture.data("CLAUDE 3.md/inside.txt") == Data("inside".utf8))
|
||||||
|
#expect(try fixture.data(AgentGuide.filename) == Data(AgentGuide.content.utf8))
|
||||||
|
}
|
||||||
|
|
||||||
/// 02-architecture.md § Write-failure surfacing: "The open-time agent-guide write … is
|
/// 02-architecture.md § Write-failure surfacing: "The open-time agent-guide write … is
|
||||||
/// skipped-with-log, the `CLAUDE.user.md`-taken precedent." A board on a read-only volume must
|
/// skipped-with-log, the `CLAUDE.user.md`-taken precedent." A board on a read-only volume must
|
||||||
/// not spend a banner on a courtesy file.
|
/// not spend a banner on a courtesy file.
|
||||||
|
|||||||
@@ -224,6 +224,41 @@ struct BoardStoreRegistryTests {
|
|||||||
registry.release(store)
|
registry.release(store)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// **Every scheduled heal fires at open** (02-architecture.md ▸ Components ▸ HealScheduler,
|
||||||
|
/// settled 2026-07-29: "fires uniformly at the reload tail and at registry acquire, closing
|
||||||
|
/// today's asymmetry where tombstone migration never fires at open").
|
||||||
|
///
|
||||||
|
/// Before the engine this seam named two of the three healers by hand, which is how the
|
||||||
|
/// legacy-tombstone migration came to be the one heal that never fired at open: a board opened,
|
||||||
|
/// migrated nothing, and waited for an unrelated filesystem event to do what opening should have
|
||||||
|
/// done. The three defects below are healed by `acquire` alone — no watcher event, no reload
|
||||||
|
/// beyond the one each heal's own write produces.
|
||||||
|
@Test("Opening a board runs every scheduled heal, migration included")
|
||||||
|
func acquireRunsEveryScheduledHeal() async throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
// One of each: a legacy tombstone, a loose card file, and a squatter on a claimed name.
|
||||||
|
try fixture.item(
|
||||||
|
"\(Ident.lane2)/\(Ident.card3)",
|
||||||
|
"---\nschema: 1\norder: 1024\ntitle: Tombstoned\ndeleted: 2026-01-01T00:00:00Z\n---\nbody\n"
|
||||||
|
)
|
||||||
|
try fixture.file("\(Ident.lane1)/\(Ident.card1)/notes.txt", Data("notes".utf8))
|
||||||
|
try fixture.file(".trash", Data("squatter".utf8))
|
||||||
|
let registry = BoardStoreRegistry()
|
||||||
|
|
||||||
|
let store = try registry.acquire(fixture.root)
|
||||||
|
defer { registry.release(store) }
|
||||||
|
|
||||||
|
// The migration — the one that used to wait for an unrelated event.
|
||||||
|
await waitUntil { fixture.exists(".trash/\(Ident.card3)") }
|
||||||
|
#expect(fixture.exists(".trash/\(Ident.card3)"))
|
||||||
|
#expect(try !fixture.indexText(".trash/\(Ident.card3)").contains("deleted:"))
|
||||||
|
// The relocation, the displacement, and the guide — the three that already did.
|
||||||
|
#expect(try fixture.data("\(Ident.lane1)/\(Ident.card1)/attachments/notes.txt") == Data("notes".utf8))
|
||||||
|
#expect(try fixture.data(".trash 2") == Data("squatter".utf8))
|
||||||
|
#expect(fixture.exists(AgentGuide.filename))
|
||||||
|
}
|
||||||
|
|
||||||
@Test("Acquiring a root that does not exist throws the loader's own error")
|
@Test("Acquiring a root that does not exist throws the loader's own error")
|
||||||
func acquireOfAMissingRootThrows() async throws {
|
func acquireOfAMissingRootThrows() async throws {
|
||||||
let registry = BoardStoreRegistry()
|
let registry = BoardStoreRegistry()
|
||||||
|
|||||||
@@ -60,7 +60,12 @@ private enum Child {
|
|||||||
// `Ident` and `Item` live in `WriterTestSupport.swift` — shared with `WriteFidelityTests.swift`.
|
// `Ident` and `Item` live in `WriterTestSupport.swift` — shared with `WriteFidelityTests.swift`.
|
||||||
|
|
||||||
/// The keys a move or a copy is allowed to have touched; every other line must be byte-identical.
|
/// The keys a move or a copy is allowed to have touched; every other line must be byte-identical.
|
||||||
private let rewrittenKeys = [FrontmatterKeys.order, FrontmatterKeys.modified, FrontmatterKeys.modifiedBy]
|
/// `kind` joins them as of 2026-07-29: a file without one gains it on the first app write that
|
||||||
|
/// rewrites it (the integrity service's on-touch heal), which is a key this write is allowed to have
|
||||||
|
/// touched exactly like the stamps.
|
||||||
|
private let rewrittenKeys = [
|
||||||
|
FrontmatterKeys.order, FrontmatterKeys.modified, FrontmatterKeys.modifiedBy, FrontmatterKeys.kind,
|
||||||
|
]
|
||||||
|
|
||||||
/// A folder's UUID-shaped children keyed by the `title` inside them. A copy remints every folder
|
/// A folder's UUID-shaped children keyed by the `title` inside them. A copy remints every folder
|
||||||
/// it materializes, so the file's own content is the only way back to "which card is which".
|
/// it materializes, so the file's own content is the only way back to "which card is which".
|
||||||
@@ -567,7 +572,10 @@ struct BoardWriterCreateBoardTests {
|
|||||||
#expect(abs(modified.timeIntervalSinceNow) < 60)
|
#expect(abs(modified.timeIntervalSinceNow) < 60)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func keyOrderIsSchemaTitleCreatedModified() throws {
|
/// **`kind` is written at creation of every object** (01-storage-format.md § Frontmatter,
|
||||||
|
/// re-ruled 2026-07-29) and goes last, where the common table puts it — and where the on-touch
|
||||||
|
/// backfill appends one on an older file, so a created object and a healed one read the same.
|
||||||
|
@Test func keyOrderIsSchemaTitleCreatedModifiedKind() throws {
|
||||||
let fixture = try WriterFixture()
|
let fixture = try WriterFixture()
|
||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
let root = fixture.url("MyBoard.kanban")
|
let root = fixture.url("MyBoard.kanban")
|
||||||
@@ -575,7 +583,8 @@ struct BoardWriterCreateBoardTests {
|
|||||||
try BoardWriter.createBoard(at: root, title: "My Board")
|
try BoardWriter.createBoard(at: root, title: "My Board")
|
||||||
|
|
||||||
let document = try FrontmatterDocument.parse(fixture.indexText("MyBoard.kanban"))
|
let document = try FrontmatterDocument.parse(fixture.indexText("MyBoard.kanban"))
|
||||||
#expect(document.keys == ["schema", "title", "created", "modified"])
|
#expect(document.keys == ["schema", "title", "created", "modified", "kind"])
|
||||||
|
#expect(document.kind == .valid("board"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func aNilTitleWritesNoTitleKeyAndTheLoaderReadsItMissing() throws {
|
@Test func aNilTitleWritesNoTitleKeyAndTheLoaderReadsItMissing() throws {
|
||||||
@@ -586,7 +595,7 @@ struct BoardWriterCreateBoardTests {
|
|||||||
try BoardWriter.createBoard(at: root, title: nil)
|
try BoardWriter.createBoard(at: root, title: nil)
|
||||||
|
|
||||||
let document = try FrontmatterDocument.parse(fixture.indexText("Untitled.kanban"))
|
let document = try FrontmatterDocument.parse(fixture.indexText("Untitled.kanban"))
|
||||||
#expect(document.keys == ["schema", "created", "modified"])
|
#expect(document.keys == ["schema", "created", "modified", "kind"])
|
||||||
#expect(!document.contains(FrontmatterKeys.title))
|
#expect(!document.contains(FrontmatterKeys.title))
|
||||||
|
|
||||||
let result = try BoardLoader.load(boardRoot: root)
|
let result = try BoardLoader.load(boardRoot: root)
|
||||||
@@ -656,14 +665,15 @@ struct BoardWriterCreateChildTests {
|
|||||||
#expect(first != second)
|
#expect(first != second)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func laneKeyOrderIsSchemaTitleOrderCreatedModified() throws {
|
@Test func laneKeyOrderIsSchemaTitleOrderCreatedModifiedKind() throws {
|
||||||
let fixture = try WriterFixture()
|
let fixture = try WriterFixture()
|
||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
|
|
||||||
let laneID = try BoardWriter.createLane(inBoard: fixture.root, title: "Lane")
|
let laneID = try BoardWriter.createLane(inBoard: fixture.root, title: "Lane")
|
||||||
|
|
||||||
let document = try FrontmatterDocument.parse(fixture.indexText(laneID.rawValue))
|
let document = try FrontmatterDocument.parse(fixture.indexText(laneID.rawValue))
|
||||||
#expect(document.keys == ["schema", "title", "order", "created", "modified"])
|
#expect(document.keys == ["schema", "title", "order", "created", "modified", "kind"])
|
||||||
|
#expect(document.kind == .valid("lane"))
|
||||||
#expect(document.schema == .valid(1))
|
#expect(document.schema == .valid(1))
|
||||||
#expect(document.modifiedBy == .missing)
|
#expect(document.modifiedBy == .missing)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -516,3 +516,171 @@ struct EchoLedgerStoreTests {
|
|||||||
#expect(log.lines == ["Board changed: 1 lane edited"])
|
#expect(log.lines == ["Board changed: 1 lane edited"])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Heal-marked receipts
|
||||||
|
|
||||||
|
/// **The Writer's heal operations drop heal-marked receipts** (06-history-undo.md ▸ Commit messages,
|
||||||
|
/// ruled 2026-07-29: "attribution machinery like the author split, never message tagging").
|
||||||
|
///
|
||||||
|
/// **Inert in base beyond the ledger itself**: nothing here reads the flag and nothing renders it —
|
||||||
|
/// it is what pro-m1's committer will read to split a heal's paths into their own commit, and these
|
||||||
|
/// tests pin the seam it will read from, not a committer that does not exist yet.
|
||||||
|
@Suite("EchoLedger — heal-marked receipts")
|
||||||
|
struct EchoLedgerHealMarkTests {
|
||||||
|
|
||||||
|
/// An ordinary gesture is not a heal, and says so by default.
|
||||||
|
@Test("An ordinary write is not heal-marked")
|
||||||
|
func ordinaryWritesAreNotHeals() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let ledger = EchoLedger()
|
||||||
|
let folder = fixture.url(lane1)
|
||||||
|
|
||||||
|
try EchoLedger.$current.withValue(ledger) {
|
||||||
|
try BoardWriter.updateIndex(inItemFolder: folder, operation: .style(title: nil)) { document in
|
||||||
|
document.set(FrontmatterKeys.width, to: .int(3))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(!ledger.isHeal(at: folder.appendingPathComponent("index.md")))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The loose-file relocation — an app-initiated heal, so its landed file's receipt is marked.
|
||||||
|
@Test("The loose-file relocation marks what it moved")
|
||||||
|
func relocationMarksItsMoves() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let ledger = EchoLedger()
|
||||||
|
let cardFolder = fixture.url("\(lane1)/\(card1)")
|
||||||
|
try fixture.file("\(lane1)/\(card1)/notes.txt", Data("notes".utf8))
|
||||||
|
|
||||||
|
try EchoLedger.$current.withValue(ledger) {
|
||||||
|
_ = try BoardWriter.relocateLooseFiles(["notes.txt"], inCard: cardFolder)
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(ledger.isHeal(at: cardFolder.appendingPathComponent("attachments/notes.txt")))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **The import-boundary normalization is not marked**, and the distinction is the design's:
|
||||||
|
/// an *inline* heal batches with the gesture that triggered it, so its paths belong in that
|
||||||
|
/// gesture's commit rather than in a heal's own.
|
||||||
|
@Test("The paste boundary's normalization is not heal-marked")
|
||||||
|
func inlineNormalizationIsNotAHeal() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let ledger = EchoLedger()
|
||||||
|
let cardFolder = fixture.url("\(lane1)/\(card1)")
|
||||||
|
try fixture.file("\(lane1)/\(card1)/notes.txt", Data("notes".utf8))
|
||||||
|
|
||||||
|
try EchoLedger.$current.withValue(ledger) {
|
||||||
|
_ = try BoardWriter.normalizeLooseFiles(inCard: cardFolder)
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(!ledger.isHeal(at: cardFolder.appendingPathComponent("attachments/notes.txt")))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The legacy-tombstone migration marks both halves of what it wrote — and the ordinary delete
|
||||||
|
/// it shares a body with does not, because that one is a gesture.
|
||||||
|
@Test("The tombstone migration marks, the delete beside it does not")
|
||||||
|
func migrationMarksButDeleteDoesNot() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let ledger = EchoLedger()
|
||||||
|
try fixture.item(
|
||||||
|
"\(lane1)/\(Ident.card3)",
|
||||||
|
"---\nschema: 1\norder: 4096\ntitle: Tombstoned\ndeleted: 2026-01-01T00:00:00Z\n---\nbody\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
try EchoLedger.$current.withValue(ledger) {
|
||||||
|
_ = try BoardWriter.migrateTombstonedCard(
|
||||||
|
at: fixture.url("\(lane1)/\(Ident.card3)"),
|
||||||
|
inBoard: fixture.root,
|
||||||
|
order: 1024
|
||||||
|
)
|
||||||
|
_ = try BoardWriter.deleteCardToTrash(
|
||||||
|
at: fixture.url("\(lane1)/\(card1)"),
|
||||||
|
inBoard: fixture.root,
|
||||||
|
order: 2048
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let migrated = fixture.url(".trash/\(Ident.card3)")
|
||||||
|
#expect(ledger.isHeal(at: migrated))
|
||||||
|
#expect(ledger.isHeal(at: migrated.appendingPathComponent("index.md")))
|
||||||
|
let deleted = fixture.url(".trash/\(card1)")
|
||||||
|
#expect(!ledger.isHeal(at: deleted))
|
||||||
|
#expect(!ledger.isHeal(at: deleted.appendingPathComponent("index.md")))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The agent guide's write, and the claimed-name displacement — both app-initiated, both marked.
|
||||||
|
@Test("The guide write and a displacement are heals")
|
||||||
|
func guideAndDisplacementAreHeals() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let ledger = EchoLedger()
|
||||||
|
try Data("squatter".utf8).write(to: fixture.root.appendingPathComponent(".trash"))
|
||||||
|
|
||||||
|
try EchoLedger.$current.withValue(ledger) {
|
||||||
|
_ = try BoardWriter.displaceClaimedName(
|
||||||
|
ClaimedNameSquatter(name: ".trash", found: .file, expected: .directory),
|
||||||
|
atBoardRoot: fixture.root
|
||||||
|
)
|
||||||
|
_ = try AgentGuide.install(atBoardRoot: fixture.root)
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(ledger.isHeal(at: fixture.root.appendingPathComponent(".trash 2")))
|
||||||
|
#expect(ledger.isHeal(at: fixture.root.appendingPathComponent(AgentGuide.filename)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Supersession drops the mark with the receipt it belonged to**: a later ordinary write to a
|
||||||
|
/// healed path is exactly the case where the path stops being the heal's alone.
|
||||||
|
@Test("An ordinary write over a healed path clears the mark")
|
||||||
|
func supersessionClearsTheMark() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let ledger = EchoLedger()
|
||||||
|
let index = fixture.url(lane1).appendingPathComponent("index.md")
|
||||||
|
|
||||||
|
ledger.recordWrite(at: index, text: "one")
|
||||||
|
ledger.markHeal(at: index)
|
||||||
|
#expect(ledger.isHeal(at: index))
|
||||||
|
|
||||||
|
ledger.recordWrite(at: index, text: "two")
|
||||||
|
#expect(!ledger.isHeal(at: index))
|
||||||
|
#expect(ledger.receipt(at: index) == .content(hash: EchoLedger.hash(of: "two")))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A mark with no receipt to attach to describes nothing, so it creates nothing.
|
||||||
|
@Test("Marking an unknown path is a no-op")
|
||||||
|
func markingAnUnknownPathIsANoOp() {
|
||||||
|
let ledger = EchoLedger()
|
||||||
|
ledger.markHeal(atPath: "/nowhere/index.md")
|
||||||
|
#expect(ledger.receipt(atPath: "/nowhere/index.md") == nil)
|
||||||
|
#expect(!ledger.isHeal(atPath: "/nowhere/index.md"))
|
||||||
|
#expect(ledger.outstandingReceipts == 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A move is one fact under two keys, so marking either end marks both — the same rule that
|
||||||
|
/// retires both ends when one is consumed.
|
||||||
|
@Test("Marking one end of a move marks both")
|
||||||
|
func markingAMoveMarksBothEnds() {
|
||||||
|
let ledger = EchoLedger()
|
||||||
|
ledger.recordMove(fromPath: "/b/lane/card", toPath: "/b/.trash/card")
|
||||||
|
ledger.markHeal(atPath: "/b/.trash/card")
|
||||||
|
|
||||||
|
#expect(ledger.isHeal(atPath: "/b/.trash/card"))
|
||||||
|
#expect(ledger.isHeal(atPath: "/b/lane/card"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The flag changes **nothing** about classification: a heal is an app write like any other, and
|
||||||
|
/// the render path never consults the ledger at all.
|
||||||
|
@Test("A heal mark does not change provenance")
|
||||||
|
func markDoesNotChangeClassification() {
|
||||||
|
let ledger = EchoLedger()
|
||||||
|
let index = "/b/lane/index.md"
|
||||||
|
ledger.recordWrite(atPath: index, hash: EchoLedger.hash(of: "one"))
|
||||||
|
ledger.markHeal(atPath: index)
|
||||||
|
|
||||||
|
#expect(ledger.classify([index: .content(hash: EchoLedger.hash(of: "one"))]) == .appMediated)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,265 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import Kanban
|
||||||
|
|
||||||
|
/// **The scheduled-heal engine** (02-architecture.md ▸ Components ▸ HealScheduler;
|
||||||
|
/// 01-storage-format.md § Validation and healing, settled 2026-07-29) — the six-step pattern the
|
||||||
|
/// three healers used to re-derive one by one, pinned once.
|
||||||
|
///
|
||||||
|
/// Each healer's own end-to-end behavior stays where it always lived (`LooseFileRelocationTests`,
|
||||||
|
/// `TrashWriteTests`, `AgentGuideTests`); what is here is the pattern itself, plus the two places the
|
||||||
|
/// generalization *changed* behavior on purpose — the writability gate, which was only the guide's,
|
||||||
|
/// and the explicit clear-on-success, which was only the guide's too.
|
||||||
|
|
||||||
|
// MARK: - Fixtures
|
||||||
|
|
||||||
|
/// A board whose one card has a loose file — the shape that gives the engine real work through the
|
||||||
|
/// simplest healer.
|
||||||
|
@MainActor
|
||||||
|
private func makeLooseFileBoard() throws -> (fixture: WriterFixture, cardPath: String) {
|
||||||
|
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, "\(Ident.lane1)/\(Ident.card1)")
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private final class BracketLog {
|
||||||
|
private(set) var begins = 0
|
||||||
|
|
||||||
|
func attach(to store: BoardStore) {
|
||||||
|
store.watcherBrackets = (begin: { self.begins += 1 }, end: {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - The gates
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Suite("HealScheduler ▸ the gates")
|
||||||
|
struct HealSchedulerGateTests {
|
||||||
|
|
||||||
|
/// **Step 1** — no work is not a failed attempt: an empty signature clears the memo, which is
|
||||||
|
/// what makes a healthy board forget it ever tried.
|
||||||
|
@Test("No work rests, and clears the memo")
|
||||||
|
func noWorkRests() throws {
|
||||||
|
let board = try makeLooseFileBoard()
|
||||||
|
defer { board.fixture.tearDown() }
|
||||||
|
try board.fixture.file("\(board.cardPath)/notes.txt", Data("notes".utf8))
|
||||||
|
let store = try BoardStore(rootURL: board.fixture.root)
|
||||||
|
|
||||||
|
store.relocateLooseCardFiles()
|
||||||
|
#expect(store.heals.memo(for: .looseCardFiles) == nil, "cleared on success")
|
||||||
|
|
||||||
|
// Nothing loose left in the snapshot's own reading — the resting path.
|
||||||
|
store.relocateLooseCardFiles()
|
||||||
|
#expect(store.heals.memo(for: .looseCardFiles) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Step 2, the lock half** — defer, never abandon, and remember nothing, so the reload that
|
||||||
|
/// lifts the lock is the reload that heals.
|
||||||
|
@Test("A read-only board defers and remembers nothing")
|
||||||
|
func lockDefersWithoutArming() throws {
|
||||||
|
let board = try makeLooseFileBoard()
|
||||||
|
defer { board.fixture.tearDown() }
|
||||||
|
try board.fixture.file("\(board.cardPath)/notes.txt", Data("notes".utf8))
|
||||||
|
let store = try BoardStore(rootURL: board.fixture.root)
|
||||||
|
let brackets = BracketLog()
|
||||||
|
brackets.attach(to: store)
|
||||||
|
store.enterUnwritableLock(.permissionDenied)
|
||||||
|
|
||||||
|
store.relocateLooseCardFiles()
|
||||||
|
|
||||||
|
#expect(brackets.begins == 0)
|
||||||
|
#expect(store.heals.memo(for: .looseCardFiles) == nil, "a refused attempt is not a remembered one")
|
||||||
|
#expect(board.fixture.exists("\(board.cardPath)/notes.txt"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Step 2, the writability half — generalized 2026-07-29.** This `access(2)` check was the
|
||||||
|
/// agent guide's private defense; it now covers every healer, because it is the only gate that
|
||||||
|
/// covers the window *between* writability probes. Without it a heal on a root that went
|
||||||
|
/// read-only mid-session reaches the Writer, fails, and banners about work the user never asked
|
||||||
|
/// for.
|
||||||
|
@Test("An unwritable board root is skipped silently, for every healer")
|
||||||
|
func unwritableRootIsSkippedSilently() throws {
|
||||||
|
let board = try makeLooseFileBoard()
|
||||||
|
defer { board.fixture.tearDown() }
|
||||||
|
try board.fixture.file("\(board.cardPath)/notes.txt", Data("notes".utf8))
|
||||||
|
let store = try BoardStore(rootURL: board.fixture.root)
|
||||||
|
let brackets = BracketLog()
|
||||||
|
brackets.attach(to: store)
|
||||||
|
// Read-only root, with no lock standing — the mid-session window the probe cannot see.
|
||||||
|
try FileManager.default.setAttributes(
|
||||||
|
[.posixPermissions: 0o500],
|
||||||
|
ofItemAtPath: board.fixture.root.path
|
||||||
|
)
|
||||||
|
defer {
|
||||||
|
try? FileManager.default.setAttributes(
|
||||||
|
[.posixPermissions: 0o755],
|
||||||
|
ofItemAtPath: board.fixture.root.path
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
store.runScheduledHeals()
|
||||||
|
|
||||||
|
#expect(brackets.begins == 0, "no write was attempted at all")
|
||||||
|
#expect(store.banners.oneShots.isEmpty, "and nothing was said about it")
|
||||||
|
#expect(store.banners.losses.isEmpty)
|
||||||
|
#expect(store.heals.memo(for: .looseCardFiles) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Steps 3 and 4** — armed before the attempt, so a failure is remembered; one failure, one
|
||||||
|
/// row, then silence until the picture on disk actually changes.
|
||||||
|
@Test("A failure arms the memo and is not retried")
|
||||||
|
func failureArmsTheMemo() throws {
|
||||||
|
let board = try makeLooseFileBoard()
|
||||||
|
defer { board.fixture.tearDown() }
|
||||||
|
try board.fixture.file("\(board.cardPath)/notes.txt", Data("notes".utf8))
|
||||||
|
let store = try BoardStore(rootURL: board.fixture.root)
|
||||||
|
let brackets = BracketLog()
|
||||||
|
brackets.attach(to: store)
|
||||||
|
// The card folder is writable but its `attachments/` cannot be created: the relocation's
|
||||||
|
// write half fails, with the defect still on disk afterwards.
|
||||||
|
try FileManager.default.setAttributes(
|
||||||
|
[.posixPermissions: 0o500],
|
||||||
|
ofItemAtPath: board.fixture.url(board.cardPath).path
|
||||||
|
)
|
||||||
|
defer {
|
||||||
|
try? FileManager.default.setAttributes(
|
||||||
|
[.posixPermissions: 0o755],
|
||||||
|
ofItemAtPath: board.fixture.url(board.cardPath).path
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
store.relocateLooseCardFiles()
|
||||||
|
let armed = store.heals.memo(for: .looseCardFiles)
|
||||||
|
store.relocateLooseCardFiles()
|
||||||
|
|
||||||
|
#expect(armed != nil, "armed before the attempt, so the throw leaves it set")
|
||||||
|
#expect(store.heals.memo(for: .looseCardFiles) == armed)
|
||||||
|
#expect(brackets.begins == 1, "the second call was refused by the memo, not attempted")
|
||||||
|
#expect(store.banners.oneShots.count == 1, "one failure, one row")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Step 6, generalized 2026-07-29** — the memo clears *explicitly* on success, where before
|
||||||
|
/// only the guide did this and the others merely happened to converge because the next walk
|
||||||
|
/// found no work. It matters when the same defect comes back: a foreign undo restores the exact
|
||||||
|
/// picture the heal just fixed, and a standing memo would make that the one thing the self-heal
|
||||||
|
/// could not heal.
|
||||||
|
@Test("The same picture heals again after a success")
|
||||||
|
func theSamePictureHealsAgain() throws {
|
||||||
|
let board = try makeLooseFileBoard()
|
||||||
|
defer { board.fixture.tearDown() }
|
||||||
|
try board.fixture.file("\(board.cardPath)/notes.txt", Data("notes".utf8))
|
||||||
|
let store = try BoardStore(rootURL: board.fixture.root)
|
||||||
|
|
||||||
|
store.relocateLooseCardFiles()
|
||||||
|
#expect(try board.fixture.data("\(board.cardPath)/attachments/notes.txt") == Data("notes".utf8))
|
||||||
|
#expect(store.heals.memo(for: .looseCardFiles) == nil)
|
||||||
|
|
||||||
|
// Somebody puts it back, byte for byte — the same defect signature as before.
|
||||||
|
try FileManager.default.removeItem(at: board.fixture.url("\(board.cardPath)/attachments"))
|
||||||
|
try board.fixture.file("\(board.cardPath)/notes.txt", Data("notes".utf8))
|
||||||
|
store.relocateLooseCardFiles()
|
||||||
|
|
||||||
|
#expect(try board.fixture.data("\(board.cardPath)/attachments/notes.txt") == Data("notes".utf8))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The memos are per class, so one heal's failure never silences another's work.
|
||||||
|
@Test("Memos are per defect class")
|
||||||
|
func memosArePerClass() throws {
|
||||||
|
let board = try makeLooseFileBoard()
|
||||||
|
defer { board.fixture.tearDown() }
|
||||||
|
try board.fixture.file("\(board.cardPath)/notes.txt", Data("notes".utf8))
|
||||||
|
let store = try BoardStore(rootURL: board.fixture.root)
|
||||||
|
|
||||||
|
store.runScheduledHeals()
|
||||||
|
|
||||||
|
// The guide wrote (its class rests cleared), the relocation ran (so does its own), and
|
||||||
|
// neither class's memo is holding the other's picture.
|
||||||
|
#expect(store.heals.memo(for: .looseCardFiles) == nil)
|
||||||
|
#expect(store.heals.memo(for: .staleAgentGuide) == nil)
|
||||||
|
#expect(store.heals.memo(for: .legacyTombstone) == nil)
|
||||||
|
#expect(try board.fixture.data("\(board.cardPath)/attachments/notes.txt") == Data("notes".utf8))
|
||||||
|
#expect(board.fixture.exists(AgentGuide.filename))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - The inline renumber-and-retry
|
||||||
|
|
||||||
|
@Suite("HealScheduler ▸ the renumber two-step")
|
||||||
|
struct HealSchedulerRenumberTests {
|
||||||
|
|
||||||
|
/// A ladder with room answers on the **first** ask — no compaction, and the ladder comes back
|
||||||
|
/// exactly as it went in.
|
||||||
|
@Test("A usable ladder never renumbers")
|
||||||
|
func usableLadderNeverRenumbers() throws {
|
||||||
|
let fixture = try WriterFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
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: "A"))
|
||||||
|
try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "B"))
|
||||||
|
let before = try fixture.indexText("\(Ident.lane1)/\(Ident.card1)")
|
||||||
|
|
||||||
|
let placed = try #require(try HealScheduler.placingRanks(
|
||||||
|
amongVisible: [1024, 2048],
|
||||||
|
compacting: fixture.url(Ident.lane1),
|
||||||
|
{ Ranks.insertionRank(amongVisible: $0, at: 1) }
|
||||||
|
))
|
||||||
|
|
||||||
|
#expect(placed.placement == 1536)
|
||||||
|
#expect(placed.ladder == [1024, 2048])
|
||||||
|
#expect(!placed.renumbered)
|
||||||
|
#expect(try fixture.indexText("\(Ident.lane1)/\(Ident.card1)") == before, "nothing was rewritten")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Exhausted precision compacts the parent's visible children and asks again — **against the
|
||||||
|
/// fresh ladder**, which is what the caller then has to place among.
|
||||||
|
@Test("Exhausted precision compacts and answers against the fresh ladder")
|
||||||
|
func exhaustedPrecisionCompacts() throws {
|
||||||
|
let fixture = try WriterFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
try fixture.item("", Item.board)
|
||||||
|
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
|
||||||
|
// Adjacent doubles: no representable midpoint between them.
|
||||||
|
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1", title: "A"))
|
||||||
|
try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "1.0000000000000002", title: "B"))
|
||||||
|
|
||||||
|
let orders = try [Ident.card1, Ident.card2].map {
|
||||||
|
try #require(FrontmatterDocument.parse(fixture.indexText("\(Ident.lane1)/\($0)")).order.value)
|
||||||
|
}
|
||||||
|
let placed = try #require(try HealScheduler.placingRanks(
|
||||||
|
amongVisible: orders,
|
||||||
|
compacting: fixture.url(Ident.lane1),
|
||||||
|
{ Ranks.insertionRank(amongVisible: $0, at: 1) }
|
||||||
|
))
|
||||||
|
|
||||||
|
#expect(placed.renumbered)
|
||||||
|
#expect(placed.ladder == [1024, 2048])
|
||||||
|
#expect(placed.placement == 1536)
|
||||||
|
// The compaction really landed on disk — the two cards now hold the fresh ladder.
|
||||||
|
let after = try [Ident.card1, Ident.card2].map {
|
||||||
|
try FrontmatterDocument.parse(fixture.indexText("\(Ident.lane1)/\($0)")).order
|
||||||
|
}
|
||||||
|
#expect(after == [.valid(1024), .valid(2048)])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A compacted ladder that still has no answer is "write nothing" — every call site's own
|
||||||
|
/// posture, spelled once.
|
||||||
|
@Test("No answer after compacting is nil")
|
||||||
|
func noAnswerAfterCompactingIsNil() throws {
|
||||||
|
let fixture = try WriterFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
try fixture.item("", Item.board)
|
||||||
|
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
|
||||||
|
|
||||||
|
let placed = try HealScheduler.placingRanks(
|
||||||
|
amongVisible: [],
|
||||||
|
compacting: fixture.url(Ident.lane1),
|
||||||
|
{ (_: [Double]) -> Double? in nil }
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(placed == nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -68,7 +68,10 @@ private let card3 = ItemID(rawValue: Ident.card3)
|
|||||||
/// through a rename byte-for-byte, in order.
|
/// through a rename byte-for-byte, in order.
|
||||||
private func untouchedLines(_ text: String) -> [Substring] {
|
private func untouchedLines(_ text: String) -> [Substring] {
|
||||||
text.split(separator: "\n", omittingEmptySubsequences: false).filter {
|
text.split(separator: "\n", omittingEmptySubsequences: false).filter {
|
||||||
!$0.hasPrefix("modified") && !$0.hasPrefix("title:")
|
// `kind:` joins the filtered keys as of 2026-07-29: a file without one gains it on the
|
||||||
|
// first app write that rewrites it — the integrity service's on-touch heal, riding this
|
||||||
|
// write's own atomic rewrite (01-storage-format.md § Validation and healing).
|
||||||
|
!$0.hasPrefix("modified") && !$0.hasPrefix("title:") && !$0.hasPrefix("kind:")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,500 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import Kanban
|
||||||
|
|
||||||
|
/// **The integrity service's rules** (01-storage-format.md § Validation and healing, settled
|
||||||
|
/// 2026-07-29; 02-architecture.md ▸ Components ▸ IntegrityRules): the one vocabulary of object
|
||||||
|
/// validity, pinned as the pure functions it is.
|
||||||
|
///
|
||||||
|
/// The *scheduling* of heals lives next door (`HealSchedulerTests`), and each heal's own end-to-end
|
||||||
|
/// behavior stays in the suite that always owned it (`LooseFileRelocationTests`, `TrashStorageTests`,
|
||||||
|
/// `AgentGuideTests`). What is here is the consolidation itself: that the identity predicate, the
|
||||||
|
/// canonical form, the reserved-name tables, per-kind validation, the trash discriminator, the
|
||||||
|
/// on-touch heals and the defect vocabulary each have exactly one implementation and behave as the
|
||||||
|
/// design states them.
|
||||||
|
|
||||||
|
// MARK: - The identity predicate and its canonical form
|
||||||
|
|
||||||
|
@Suite("IntegrityRules ▸ identity")
|
||||||
|
struct IntegrityIdentityTests {
|
||||||
|
|
||||||
|
/// Shape-only: hex, `8-4-4-4-12`, **any case, any version** — lowercase v4 is the app's emission
|
||||||
|
/// rule, not the gate.
|
||||||
|
@Test("The predicate is shape-only — any case, any version")
|
||||||
|
func predicateIsShapeOnly() {
|
||||||
|
#expect(IntegrityRules.isIdentityShaped("11111111-1111-4111-8111-111111111111"))
|
||||||
|
#expect(IntegrityRules.isIdentityShaped("11111111-1111-4111-8111-111111111111".uppercased()))
|
||||||
|
// v7's version nibble, and a variant nibble RFC 4122 would reject — both accepted.
|
||||||
|
#expect(IntegrityRules.isIdentityShaped("0195a3f0-0000-7000-0000-000000000000"))
|
||||||
|
#expect(!IntegrityRules.isIdentityShaped("notes"))
|
||||||
|
#expect(!IntegrityRules.isIdentityShaped("11111111-1111-4111-8111-11111111111"))
|
||||||
|
#expect(!IntegrityRules.isIdentityShaped("11111111111141118111111111111111"))
|
||||||
|
#expect(!IntegrityRules.isIdentityShaped("gggggggg-1111-4111-8111-111111111111"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **One predicate, one implementation** — the loader's spelling forwards to it, which is what
|
||||||
|
/// "no parallel derivation" means in practice.
|
||||||
|
@Test("The loader's gate is this predicate")
|
||||||
|
func loaderSharesThePredicate() {
|
||||||
|
for name in ["11111111-1111-4111-8111-111111111111", "NOTES", "", ".trash"] {
|
||||||
|
#expect(BoardLoader.isUUIDShaped(name) == IntegrityRules.isIdentityShaped(name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **The fold** (settled 2026-07-29): `ItemID`'s equality and the Writer's string-level checks
|
||||||
|
/// canonicalize through the same function. Before it, the Writer carried a private copy — one
|
||||||
|
/// line, and one line too many for a rule that decides whether two folders are the same item.
|
||||||
|
@Test("ItemID's equality is this canonical form")
|
||||||
|
func itemIDSharesTheCanonicalForm() {
|
||||||
|
let lower = "55555555-5555-4555-8555-555555555555"
|
||||||
|
#expect(IntegrityRules.canonicalIdentity(lower.uppercased()) == lower)
|
||||||
|
#expect(ItemID(rawValue: lower) == ItemID(rawValue: lower.uppercased()))
|
||||||
|
#expect(ItemID(rawValue: lower).hashValue == ItemID(rawValue: lower.uppercased()).hashValue)
|
||||||
|
// rawValue still round-trips byte-perfect — canonicalization is for *comparing*, never for
|
||||||
|
// storing.
|
||||||
|
#expect(ItemID(rawValue: lower.uppercased()).rawValue == lower.uppercased())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Total on anything: an off-shape name compares by its own lowercasing, the harmless reading.
|
||||||
|
@Test("The canonical form is total")
|
||||||
|
func canonicalFormIsTotal() {
|
||||||
|
#expect(IntegrityRules.canonicalIdentity("Notes") == "notes")
|
||||||
|
#expect(IntegrityRules.canonicalIdentity("") == "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - The reserved-name tables
|
||||||
|
|
||||||
|
@Suite("IntegrityRules ▸ reserved names")
|
||||||
|
struct IntegrityReservedNameTests {
|
||||||
|
|
||||||
|
/// One table, read by the loader under its own names.
|
||||||
|
@Test("The tables are the loader's")
|
||||||
|
func tablesAreShared() {
|
||||||
|
#expect(BoardLoader.reservedCardChildNames == IntegrityRules.reservedCardChildNames)
|
||||||
|
#expect(BoardLoader.reservedRootNames == IntegrityRules.claimedRootNameSet)
|
||||||
|
#expect(BoardLoader.trashFolderName == IntegrityRules.trashFolderName)
|
||||||
|
#expect(BoardWriter.attachmentsFolderName == IntegrityRules.attachmentsFolderName)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The claimed names carry **what kind of node each may be** — the fact the displacement heal
|
||||||
|
/// turns on, and the reason the table is a list of values rather than a `Set<String>`.
|
||||||
|
@Test("Each claimed name declares its node kind and whether it displaces")
|
||||||
|
func claimedNamesDeclareTheirKind() throws {
|
||||||
|
let trash = try #require(IntegrityRules.claimedRootNames.first { $0.name == ".trash" })
|
||||||
|
#expect(trash.expected == .directory)
|
||||||
|
#expect(trash.displacesSquatters)
|
||||||
|
|
||||||
|
let guide = try #require(IntegrityRules.claimedRootNames.first { $0.name == "CLAUDE.md" })
|
||||||
|
#expect(guide.expected == .file)
|
||||||
|
#expect(guide.displacesSquatters)
|
||||||
|
|
||||||
|
// The standing exception: a rescue *destination* is never itself freed by a second
|
||||||
|
// displacement, which would cascade renames (08-agent-integration.md ▸ Ownership).
|
||||||
|
let userFile = try #require(IntegrityRules.claimedRootNames.first { $0.name == "CLAUDE.user.md" })
|
||||||
|
#expect(!userFile.displacesSquatters)
|
||||||
|
// Seeded once, then the user's to edit (06-history-undo.md ▸ Repository hygiene).
|
||||||
|
let gitignore = try #require(IntegrityRules.claimedRootNames.first { $0.name == ".gitignore" })
|
||||||
|
#expect(!gitignore.displacesSquatters)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `lstat` semantics: a **dangling** symlink is a node that is there.
|
||||||
|
@Test("The node probe never follows a link")
|
||||||
|
func nodeProbeUsesLstat() throws {
|
||||||
|
let root = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("IntegrityRulesTests-\(UUID().uuidString)", isDirectory: true)
|
||||||
|
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
|
||||||
|
try Data("x".utf8).write(to: root.appendingPathComponent("file.txt"))
|
||||||
|
try FileManager.default.createDirectory(at: root.appendingPathComponent("dir"), withIntermediateDirectories: true)
|
||||||
|
try FileManager.default.createSymbolicLink(
|
||||||
|
atPath: root.appendingPathComponent("dangling").path,
|
||||||
|
withDestinationPath: "nowhere"
|
||||||
|
)
|
||||||
|
try FileManager.default.createSymbolicLink(
|
||||||
|
atPath: root.appendingPathComponent("toFile").path,
|
||||||
|
withDestinationPath: "file.txt"
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(IntegrityRules.node(at: root.appendingPathComponent("file.txt")) == .file)
|
||||||
|
#expect(IntegrityRules.node(at: root.appendingPathComponent("dir")) == .directory)
|
||||||
|
#expect(IntegrityRules.node(at: root.appendingPathComponent("dangling")) == .symlink)
|
||||||
|
#expect(IntegrityRules.node(at: root.appendingPathComponent("toFile")) == .symlink)
|
||||||
|
#expect(IntegrityRules.node(at: root.appendingPathComponent("absent")) == nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Placement and the trash discriminator
|
||||||
|
|
||||||
|
@Suite("IntegrityRules ▸ kind")
|
||||||
|
struct IntegrityKindTests {
|
||||||
|
|
||||||
|
/// "Level is position", as two names and nothing else.
|
||||||
|
@Test("Placement reads position")
|
||||||
|
func placementReadsPosition() {
|
||||||
|
let uuid = "11111111-1111-4111-8111-111111111111"
|
||||||
|
#expect(IntegrityRules.placement(ofFolderNamed: uuid, inParentNamed: uuid) == .card)
|
||||||
|
#expect(IntegrityRules.placement(ofFolderNamed: uuid, inParentNamed: "MyBoard.kanban") == .lane)
|
||||||
|
#expect(IntegrityRules.placement(ofFolderNamed: uuid, inParentNamed: ".trash") == .insideTrash)
|
||||||
|
// A board root's folder name is a Finder document name, so position cannot answer for it —
|
||||||
|
// and neither can it for a hand-named folder. "Unknown" rather than "board" is what keeps a
|
||||||
|
// guessed kind off disk.
|
||||||
|
#expect(IntegrityRules.placement(ofFolderNamed: "MyBoard.kanban", inParentNamed: "Documents") == .unknown)
|
||||||
|
#expect(IntegrityRules.placement(ofFolderNamed: "notes", inParentNamed: "MyBoard.kanban") == .unknown)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **The value is trusted outright** — no corroboration, no policing (re-ruled 2026-07-29).
|
||||||
|
@Test("The trash discriminator trusts the value")
|
||||||
|
func trashDiscriminatorTrustsTheValue() {
|
||||||
|
// Honored even against the shape: a card-shaped folder saying `lane` is a lane.
|
||||||
|
#expect(IntegrityRules.trashKind(kindValue: "lane", hasIdentityShapedChildIndex: false) == .lane)
|
||||||
|
// And a lane-shaped folder saying `card` is a card.
|
||||||
|
#expect(IntegrityRules.trashKind(kindValue: "card", hasIdentityShapedChildIndex: true) == .card)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An unrecognized value, or none, falls through to **shape**.
|
||||||
|
@Test("An unrecognized value falls through to shape")
|
||||||
|
func unrecognizedValueFallsToShape() {
|
||||||
|
#expect(IntegrityRules.trashKind(kindValue: nil, hasIdentityShapedChildIndex: true) == .lane)
|
||||||
|
#expect(IntegrityRules.trashKind(kindValue: nil, hasIdentityShapedChildIndex: false) == .card)
|
||||||
|
#expect(IntegrityRules.trashKind(kindValue: "widget", hasIdentityShapedChildIndex: true) == .lane)
|
||||||
|
#expect(IntegrityRules.trashKind(kindValue: "", hasIdentityShapedChildIndex: false) == .card)
|
||||||
|
// `board` is not a third answer in the trash — a board cannot be trashed, so shape decides.
|
||||||
|
#expect(IntegrityRules.trashKind(kindValue: "board", hasIdentityShapedChildIndex: true) == .lane)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Per-kind validation
|
||||||
|
|
||||||
|
@Suite("IntegrityRules ▸ validation")
|
||||||
|
struct IntegrityValidationTests {
|
||||||
|
|
||||||
|
private func bytes(_ text: String) -> Data { Data(text.utf8) }
|
||||||
|
|
||||||
|
/// The per-kind field table: `order` is required on lanes and cards, **never** on the board.
|
||||||
|
@Test("Order is required per kind")
|
||||||
|
func orderIsRequiredPerKind() {
|
||||||
|
#expect(!IntegrityRules.requiresOrder(.board))
|
||||||
|
#expect(IntegrityRules.requiresOrder(.lane))
|
||||||
|
#expect(IntegrityRules.requiresOrder(.card))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A board index validates without an order")
|
||||||
|
func boardValidatesWithoutOrder() throws {
|
||||||
|
let document = try IntegrityRules.validateIndex(
|
||||||
|
bytes("---\nschema: 1\ntitle: Board\n---\nbody\n"),
|
||||||
|
path: "index.md",
|
||||||
|
kind: .board,
|
||||||
|
supportedSchema: 1
|
||||||
|
)
|
||||||
|
#expect(document.title == .valid("Board"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A lane or card index without an order is refused")
|
||||||
|
func laneAndCardRequireOrder() {
|
||||||
|
for kind in [IntegrityRules.ObjectKind.lane, .card] {
|
||||||
|
#expect(throws: BoardLoadError(path: "index.md", reason: .missingOrder)) {
|
||||||
|
try IntegrityRules.validateIndex(
|
||||||
|
bytes("---\nschema: 1\n---\nbody\n"),
|
||||||
|
path: "index.md",
|
||||||
|
kind: kind,
|
||||||
|
supportedSchema: 1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The card window's gate is this rule at `kind: .card` — one function, not a copy.
|
||||||
|
@Test("validateCardIndex is validateIndex at card")
|
||||||
|
func cardValidatorIsTheGeneralOne() {
|
||||||
|
let missingOrder = bytes("---\nschema: 1\n---\nbody\n")
|
||||||
|
#expect(throws: BoardLoadError(path: "index.md", reason: .missingOrder)) {
|
||||||
|
try BoardLoader.validateCardIndex(missingOrder, path: "index.md")
|
||||||
|
}
|
||||||
|
let newer = bytes("---\nschema: 99\norder: 1\n---\n")
|
||||||
|
#expect(throws: BoardLoadError(path: "index.md", reason: .schemaNewerThanApp(found: 99))) {
|
||||||
|
try BoardLoader.validateCardIndex(newer, path: "index.md")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The refuse-writes verdict's rule, named in the vocabulary rather than left as a property one
|
||||||
|
/// call site happens to read.
|
||||||
|
@Test("The uneditable shape is the document's, named here")
|
||||||
|
func uneditableShapeIsNamed() throws {
|
||||||
|
let flow = try FrontmatterDocument.parse("---\n{schema: 1, order: 1024}\n---\nbody\n")
|
||||||
|
#expect(IntegrityRules.uneditableShape(of: flow) == .keyWithoutOwnLine)
|
||||||
|
let ordinary = try FrontmatterDocument.parse("---\nschema: 1\norder: 1024\n---\nbody\n")
|
||||||
|
#expect(IntegrityRules.uneditableShape(of: ordinary) == nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - On-touch heals
|
||||||
|
|
||||||
|
@Suite("IntegrityRules ▸ on-touch heals")
|
||||||
|
struct IntegrityOnTouchTests {
|
||||||
|
|
||||||
|
@Test("A missing kind backfills with the object's own kind")
|
||||||
|
func missingKindBackfills() throws {
|
||||||
|
var document = try FrontmatterDocument.parse("---\nschema: 1\norder: 1024\n---\nbody\n")
|
||||||
|
let heals = IntegrityRules.healOnTouch(&document, kind: .card)
|
||||||
|
#expect(heals == [.kindBackfilled(.card)])
|
||||||
|
#expect(document.kind == .valid("card"))
|
||||||
|
// Appended before the closing delimiter, last — where the common table puts it.
|
||||||
|
#expect(document.keys == ["schema", "order", "kind"])
|
||||||
|
#expect(document.body == "body\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **The value is never rewritten, never corroborated, never stripped** — consumers trust it.
|
||||||
|
@Test("A present kind is left exactly as written")
|
||||||
|
func presentKindIsLeftAlone() throws {
|
||||||
|
var document = try FrontmatterDocument.parse("---\nschema: 1\norder: 1\nkind: widget\n---\n")
|
||||||
|
#expect(IntegrityRules.healOnTouch(&document, kind: .card).isEmpty)
|
||||||
|
#expect(document.kind == .valid("widget"))
|
||||||
|
// Even a value that contradicts position: the trash reader honors it, so the writer must not
|
||||||
|
// "correct" it out from under whoever wrote it.
|
||||||
|
var lane = try FrontmatterDocument.parse("---\nschema: 1\norder: 1\nkind: lane\n---\n")
|
||||||
|
#expect(IntegrityRules.healOnTouch(&lane, kind: .card).isEmpty)
|
||||||
|
#expect(lane.kind == .valid("lane"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Null-as-missing, the engine's own rule, applied here: a key started and never given a value
|
||||||
|
/// is absent, and so backfills.
|
||||||
|
@Test("An explicit null backfills")
|
||||||
|
func explicitNullBackfills() throws {
|
||||||
|
var document = try FrontmatterDocument.parse("---\nschema: 1\norder: 1\nkind:\n---\n")
|
||||||
|
#expect(IntegrityRules.healOnTouch(&document, kind: .lane) == [.kindBackfilled(.lane)])
|
||||||
|
#expect(document.kind == .valid("lane"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **A guessed kind is worse than an absent one**: where position cannot answer, nothing is
|
||||||
|
/// stamped.
|
||||||
|
@Test("No kind, no stamp")
|
||||||
|
func unknownKindStampsNothing() throws {
|
||||||
|
var document = try FrontmatterDocument.parse("---\nschema: 1\norder: 1\n---\n")
|
||||||
|
#expect(IntegrityRules.healOnTouch(&document, kind: nil).isEmpty)
|
||||||
|
#expect(document.kind == .missing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - The defect vocabulary
|
||||||
|
|
||||||
|
@Suite("IntegrityRules ▸ defects")
|
||||||
|
struct IntegrityDefectTests {
|
||||||
|
|
||||||
|
private let lane = ItemID(rawValue: "11111111-1111-4111-8111-111111111111")
|
||||||
|
private let card = ItemID(rawValue: "55555555-5555-4555-8555-555555555555")
|
||||||
|
|
||||||
|
/// Each defect knows its heal class — the memo key and the banner-posture row.
|
||||||
|
@Test("Every defect names its class")
|
||||||
|
func defectsNameTheirClass() {
|
||||||
|
#expect(IntegrityRules.Defect.looseCardFiles(
|
||||||
|
LooseCardFiles(laneID: lane, cardID: card, title: nil, fileNames: ["a.txt"])
|
||||||
|
).healClass == .looseCardFiles)
|
||||||
|
#expect(IntegrityRules.Defect.legacyTombstone(
|
||||||
|
LegacyTombstone(kind: .lane, laneID: lane, cardID: nil, title: nil)
|
||||||
|
).healClass == .legacyTombstone)
|
||||||
|
#expect(IntegrityRules.Defect.claimedNameSquatted(
|
||||||
|
ClaimedNameSquatter(name: ".trash", found: .file, expected: .directory)
|
||||||
|
).healClass == .claimedNameSquatted)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **One signature per file for the loose-file defect**, so a card that gains or loses a single
|
||||||
|
/// loose file is a different picture and earns a fresh attempt.
|
||||||
|
@Test("A loose-file defect signs per file")
|
||||||
|
func looseFilesSignPerFile() {
|
||||||
|
let defect = IntegrityRules.Defect.looseCardFiles(
|
||||||
|
LooseCardFiles(laneID: lane, cardID: card, title: "T", fileNames: ["a.txt", "b.txt"])
|
||||||
|
)
|
||||||
|
#expect(defect.signatures.count == 2)
|
||||||
|
#expect(Set(defect.signatures) == [
|
||||||
|
"loose:\(lane.rawValue)/\(card.rawValue)/a.txt",
|
||||||
|
"loose:\(lane.rawValue)/\(card.rawValue)/b.txt",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The title is *not* in the signature: renaming a card does not make its pending relocation a
|
||||||
|
/// new defect to retry.
|
||||||
|
@Test("Signatures identify the work, not its display")
|
||||||
|
func signaturesIdentifyTheWork() {
|
||||||
|
let one = IntegrityRules.Defect.legacyTombstone(
|
||||||
|
LegacyTombstone(kind: .card, laneID: lane, cardID: card, title: "Before")
|
||||||
|
)
|
||||||
|
let two = IntegrityRules.Defect.legacyTombstone(
|
||||||
|
LegacyTombstone(kind: .card, laneID: lane, cardID: card, title: "After")
|
||||||
|
)
|
||||||
|
#expect(one.signatures == two.signatures)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The classes are disjoint by construction — a memo for one heal can never collide with
|
||||||
|
/// another's picture.
|
||||||
|
@Test("Signatures are namespaced per class")
|
||||||
|
func signaturesAreNamespaced() {
|
||||||
|
let loose = IntegrityRules.Defect.looseCardFiles(
|
||||||
|
LooseCardFiles(laneID: lane, cardID: card, title: nil, fileNames: ["x"])
|
||||||
|
)
|
||||||
|
let tombstone = IntegrityRules.Defect.legacyTombstone(
|
||||||
|
LegacyTombstone(kind: .card, laneID: lane, cardID: card, title: nil)
|
||||||
|
)
|
||||||
|
let squatter = IntegrityRules.Defect.claimedNameSquatted(
|
||||||
|
ClaimedNameSquatter(name: ".trash", found: .symlink, expected: .directory)
|
||||||
|
)
|
||||||
|
let all = Set(loose.signatures + tombstone.signatures + squatter.signatures)
|
||||||
|
#expect(all.count == 3)
|
||||||
|
#expect(squatter.signatures == ["claimed:.trash:symlink"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - The `kind` key, through the Writer
|
||||||
|
|
||||||
|
/// **`kind` is written at creation of every object and backfills on touch** (01-storage-format.md
|
||||||
|
/// § Frontmatter, re-ruled 2026-07-29) — the two halves of the rule, at the seam that implements
|
||||||
|
/// them (`BoardWriter.createBoard`/`createLane`/`createCard`, and `BoardWriter.updateIndex`).
|
||||||
|
///
|
||||||
|
/// **On-touch only, never a scheduled sweep**: nothing in the app walks a board adding this key, and
|
||||||
|
/// these tests are written so that a future sweep would fail them (an untouched sibling keeps no
|
||||||
|
/// `kind` at all).
|
||||||
|
@Suite("The kind key ▸ stamping and backfill")
|
||||||
|
struct ObjectKindWriteTests {
|
||||||
|
|
||||||
|
private func kind(of relativePath: String, in fixture: WriterFixture) throws -> FieldValue<String> {
|
||||||
|
try FrontmatterDocument.parse(fixture.indexText(relativePath)).kind
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Every create path stamps its own kind")
|
||||||
|
func createPathsStamp() throws {
|
||||||
|
let fixture = try WriterFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let root = fixture.url("MyBoard.kanban")
|
||||||
|
|
||||||
|
try BoardWriter.createBoard(at: root, title: "Board")
|
||||||
|
let lane = try BoardWriter.createLane(inBoard: root, title: "Todo")
|
||||||
|
let card = try BoardWriter.createCard(
|
||||||
|
inLane: root.appendingPathComponent(lane.rawValue, isDirectory: true),
|
||||||
|
title: "Fix login"
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(try kind(of: "MyBoard.kanban", in: fixture) == .valid("board"))
|
||||||
|
#expect(try kind(of: "MyBoard.kanban/\(lane.rawValue)", in: fixture) == .valid("lane"))
|
||||||
|
#expect(try kind(of: "MyBoard.kanban/\(lane.rawValue)/\(card.rawValue)", in: fixture) == .valid("card"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The backfill reads **position** — a lane's child is a card, a board root's child is a lane —
|
||||||
|
/// so an older file gains the *right* value without the caller being asked for one.
|
||||||
|
@Test("A rewrite backfills a missing kind from position")
|
||||||
|
func rewriteBackfillsFromPosition() throws {
|
||||||
|
let fixture = try WriterFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
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: "A"))
|
||||||
|
try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "B"))
|
||||||
|
|
||||||
|
try BoardWriter.updateIndex(
|
||||||
|
inItemFolder: fixture.url("\(Ident.lane1)/\(Ident.card1)"),
|
||||||
|
operation: .style(title: nil)
|
||||||
|
) { $0.set(FrontmatterKeys.background, to: .string("fern")) }
|
||||||
|
try BoardWriter.updateIndex(
|
||||||
|
inItemFolder: fixture.url(Ident.lane1),
|
||||||
|
operation: .style(title: nil)
|
||||||
|
) { $0.set(FrontmatterKeys.background, to: .string("fern")) }
|
||||||
|
|
||||||
|
#expect(try kind(of: "\(Ident.lane1)/\(Ident.card1)", in: fixture) == .valid("card"))
|
||||||
|
#expect(try kind(of: Ident.lane1, in: fixture) == .valid("lane"))
|
||||||
|
// **On touch only**: the sibling nobody wrote to still has no `kind`, which is exactly what
|
||||||
|
// "never a scheduled backfill sweep" means on disk.
|
||||||
|
#expect(try kind(of: "\(Ident.lane1)/\(Ident.card2)", in: fixture) == .missing)
|
||||||
|
#expect(try kind(of: "", in: fixture) == .missing)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The board root is the one file whose kind position cannot answer, so its writers **declare**
|
||||||
|
/// it — and the declaration is what the backfill uses.
|
||||||
|
@Test("The board root's kind is declared by its writers")
|
||||||
|
func boardRootKindIsDeclared() throws {
|
||||||
|
let fixture = try WriterFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
try fixture.item("", Item.board)
|
||||||
|
|
||||||
|
try BoardWriter.updateIndex(inItemFolder: fixture.root, kind: .board, operation: .rename(title: nil)) {
|
||||||
|
$0.set(FrontmatterKeys.title, to: .string("Renamed"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(try kind(of: "", in: fixture) == .valid("board"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Undeclared and unanswerable by position → **nothing is stamped**. A guessed kind on disk
|
||||||
|
/// would be worse than an absent one, because the trash's discriminator trusts what it finds.
|
||||||
|
@Test("A hand-named folder is stamped with nothing")
|
||||||
|
func unknownPositionStampsNothing() throws {
|
||||||
|
let fixture = try WriterFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let folder = try fixture.item("notes", Item.rich(order: "1024", title: "Hand-made"))
|
||||||
|
|
||||||
|
try BoardWriter.updateIndex(inItemFolder: folder, operation: .style(title: nil)) {
|
||||||
|
$0.set(FrontmatterKeys.background, to: .string("fern"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(try kind(of: "notes", in: fixture) == .missing)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inside `.trash/` position cannot answer either, so **shape** does — the same rule the trash
|
||||||
|
/// reader uses, so a backfilled value and a read value can never disagree.
|
||||||
|
@Test("A trashed folder backfills by shape")
|
||||||
|
func trashBackfillsByShape() throws {
|
||||||
|
let fixture = try WriterFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
try fixture.item("", Item.board)
|
||||||
|
try fixture.item(".trash/\(Ident.card1)", Item.rich(order: "1024", title: "A trashed card"))
|
||||||
|
try fixture.item(".trash/\(Ident.lane2)", Item.rich(order: "2048", title: "A trashed lane"))
|
||||||
|
try fixture.item(".trash/\(Ident.lane2)/\(Ident.card3)", Item.rich(order: "1024", title: "Its card"))
|
||||||
|
|
||||||
|
for path in [".trash/\(Ident.card1)", ".trash/\(Ident.lane2)"] {
|
||||||
|
try BoardWriter.updateIndex(inItemFolder: fixture.url(path), operation: .reorder(title: nil)) {
|
||||||
|
$0.set(FrontmatterKeys.order, to: .double(4096))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(try kind(of: ".trash/\(Ident.card1)", in: fixture) == .valid("card"))
|
||||||
|
#expect(try kind(of: ".trash/\(Ident.lane2)", in: fixture) == .valid("lane"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **The trash move's own rank mint is a touch** — 01's own example of where the key earns its
|
||||||
|
/// keep ("any Writer rewrite of that lane's `index.md`, the trash move's rank mint included").
|
||||||
|
@Test("Deleting a card backfills its kind on the way into the trash")
|
||||||
|
func deleteBackfillsOnTheWayIn() throws {
|
||||||
|
let fixture = try WriterFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
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: "A"))
|
||||||
|
|
||||||
|
try BoardWriter.deleteCardToTrash(
|
||||||
|
at: fixture.url("\(Ident.lane1)/\(Ident.card1)"),
|
||||||
|
inBoard: fixture.root,
|
||||||
|
order: 1024
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(try kind(of: ".trash/\(Ident.card1)", in: fixture) == .valid("card"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Templates gain the key **lazily**, through this same backfill — there is no template
|
||||||
|
/// migration, by design.
|
||||||
|
@Test("A copy carries what the source had, and heals what it did not")
|
||||||
|
func copiesInheritAndHeal() throws {
|
||||||
|
let fixture = try WriterFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
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: "A"))
|
||||||
|
|
||||||
|
let copy = try BoardWriter.copyItem(
|
||||||
|
at: fixture.url("\(Ident.lane1)/\(Ident.card1)"),
|
||||||
|
toParent: fixture.url(Ident.lane1),
|
||||||
|
order: 2048,
|
||||||
|
stamps: .fork
|
||||||
|
)
|
||||||
|
|
||||||
|
// The copy's own `index.md` is rewritten by the copy (stamps, order), so it heals in flight.
|
||||||
|
#expect(try kind(of: "\(Ident.lane1)/\(copy.rawValue)", in: fixture) == .valid("card"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -47,7 +47,8 @@ private func makeBoard() throws -> WriterFixture {
|
|||||||
/// through a resize byte-for-byte, in order.
|
/// through a resize byte-for-byte, in order.
|
||||||
private func untouchedLines(_ text: String) -> [Substring] {
|
private func untouchedLines(_ text: String) -> [Substring] {
|
||||||
text.split(separator: "\n", omittingEmptySubsequences: false).filter {
|
text.split(separator: "\n", omittingEmptySubsequences: false).filter {
|
||||||
!$0.hasPrefix("modified") && !$0.hasPrefix("width:")
|
// `kind:` — the on-touch backfill rides every app rewrite of a file that lacks one.
|
||||||
|
!$0.hasPrefix("modified") && !$0.hasPrefix("width:") && !$0.hasPrefix("kind:")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -74,7 +74,9 @@ private let card4 = ItemID(rawValue: Ident.card4)
|
|||||||
/// survives the filter — it is not `icon:`, and the app offers no control for it.
|
/// survives the filter — it is not `icon:`, and the app offers no control for it.
|
||||||
private func untouchedLines(_ text: String) -> [Substring] {
|
private func untouchedLines(_ text: String) -> [Substring] {
|
||||||
text.split(separator: "\n", omittingEmptySubsequences: false).filter {
|
text.split(separator: "\n", omittingEmptySubsequences: false).filter {
|
||||||
|
// `kind:` — the on-touch backfill rides every app rewrite of a file that lacks one.
|
||||||
!$0.hasPrefix("modified") && !$0.hasPrefix("background:") && !$0.hasPrefix("icon:")
|
!$0.hasPrefix("modified") && !$0.hasPrefix("background:") && !$0.hasPrefix("icon:")
|
||||||
|
&& !$0.hasPrefix("kind:")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -840,3 +840,105 @@ struct TrashIdentityTests {
|
|||||||
#expect(try fixture.indexText("destination/.trash/\(Ident.card1)").contains("title: Trashed twin"))
|
#expect(try fixture.indexText("destination/.trash/\(Ident.card1)").contains("title: Trashed twin"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - The trash's kind discriminator
|
||||||
|
|
||||||
|
/// **`kind:` discriminates inside `.trash/`** (01-storage-format.md § Deletion, re-ruled
|
||||||
|
/// 2026-07-29): depth defines meaning on the live board, but the trash is flat, and an empty lane
|
||||||
|
/// folder is shape-identical to a card folder. The reader **trusts the value**, and only an
|
||||||
|
/// unrecognized value or no key at all falls through to shape.
|
||||||
|
///
|
||||||
|
/// The verdict rides `LoadResult.trashKinds` — a *reading*, not a rendering: every entry still
|
||||||
|
/// parses through the one card parse (a trashed card is "an ordinary card in a special place"), and
|
||||||
|
/// nothing is hidden or dropped on account of its kind.
|
||||||
|
@Suite("BoardLoader ▸ the trash's kind discriminator")
|
||||||
|
struct TrashKindDiscriminatorTests {
|
||||||
|
|
||||||
|
@Test("kind: card is honored even against the shape")
|
||||||
|
func cardValueBeatsShape() throws {
|
||||||
|
let fixture = try TrashFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let entry = uuidName()
|
||||||
|
let child = uuidName()
|
||||||
|
|
||||||
|
try fixture.index("", "schema: 1\n")
|
||||||
|
// Lane-shaped on disk — a UUID-named child with its own index.md — and yet it says card.
|
||||||
|
try fixture.index(".trash/\(entry)", "schema: 1\norder: 1024\nkind: card\n")
|
||||||
|
try fixture.index(".trash/\(entry)/\(child)", "schema: 1\norder: 1024\n")
|
||||||
|
|
||||||
|
let result = try BoardLoader.load(boardRoot: fixture.root)
|
||||||
|
|
||||||
|
#expect(result.trashKinds[ItemID(rawValue: entry)] == .card)
|
||||||
|
// Trusting the value is not policing it: the entry still loads, and its child is still not a
|
||||||
|
// level (the walk stops at a trash entry exactly as it stops at a card).
|
||||||
|
#expect(result.model.trash.map(\.id.rawValue) == [entry])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("kind: lane is honored even against the shape")
|
||||||
|
func laneValueBeatsShape() throws {
|
||||||
|
let fixture = try TrashFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let entry = uuidName()
|
||||||
|
|
||||||
|
try fixture.index("", "schema: 1\n")
|
||||||
|
// Card-shaped on disk — no children at all — and yet it says lane. An external writer's
|
||||||
|
// `kind: lane` is honored, never policed.
|
||||||
|
try fixture.index(".trash/\(entry)", "schema: 1\norder: 1024\nkind: lane\n")
|
||||||
|
|
||||||
|
let result = try BoardLoader.load(boardRoot: fixture.root)
|
||||||
|
#expect(result.trashKinds[ItemID(rawValue: entry)] == .lane)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// No key, or a value outside the schema's three, falls through to shape — UUID-shaped children
|
||||||
|
/// with their own `index.md` → lane, else card.
|
||||||
|
@Test("An unrecognized value or no key falls through to shape")
|
||||||
|
func unrecognizedFallsToShape() throws {
|
||||||
|
let fixture = try TrashFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let bare = uuidName()
|
||||||
|
let laneShaped = uuidName()
|
||||||
|
let child = uuidName()
|
||||||
|
let odd = uuidName()
|
||||||
|
|
||||||
|
try fixture.index("", "schema: 1\n")
|
||||||
|
try fixture.index(".trash/\(bare)", "schema: 1\norder: 1024\n")
|
||||||
|
try fixture.index(".trash/\(laneShaped)", "schema: 1\norder: 2048\n")
|
||||||
|
try fixture.index(".trash/\(laneShaped)/\(child)", "schema: 1\norder: 1024\n")
|
||||||
|
try fixture.index(".trash/\(odd)", "schema: 1\norder: 3072\nkind: widget\n")
|
||||||
|
|
||||||
|
let result = try BoardLoader.load(boardRoot: fixture.root)
|
||||||
|
|
||||||
|
#expect(result.trashKinds[ItemID(rawValue: bare)] == .card)
|
||||||
|
#expect(result.trashKinds[ItemID(rawValue: laneShaped)] == .lane)
|
||||||
|
#expect(result.trashKinds[ItemID(rawValue: odd)] == .card, "unrecognized, and card-shaped")
|
||||||
|
// The unrecognized value is preserved verbatim — never corrected, never stripped.
|
||||||
|
#expect(result.model.trash.first { $0.id.rawValue == odd }?.document.kind == .valid("widget"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A folder whose `kind` is *shape-derived* today keeps that reading only until it is touched —
|
||||||
|
/// at which point the backfill writes the same answer down. The two rules are one function, so
|
||||||
|
/// the read and the write can never disagree.
|
||||||
|
@Test("The reading a shape produces is the value the backfill writes")
|
||||||
|
func shapeReadingMatchesTheBackfill() throws {
|
||||||
|
let fixture = try TrashFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let entry = uuidName()
|
||||||
|
let child = uuidName()
|
||||||
|
|
||||||
|
try fixture.index("", "schema: 1\n")
|
||||||
|
try fixture.index(".trash/\(entry)", "schema: 1\norder: 1024\n")
|
||||||
|
try fixture.index(".trash/\(entry)/\(child)", "schema: 1\norder: 1024\n")
|
||||||
|
|
||||||
|
let before = try BoardLoader.load(boardRoot: fixture.root)
|
||||||
|
#expect(before.trashKinds[ItemID(rawValue: entry)] == .lane)
|
||||||
|
|
||||||
|
try BoardWriter.updateIndex(
|
||||||
|
inItemFolder: fixture.root.appendingPathComponent(".trash/\(entry)"),
|
||||||
|
operation: .reorder(title: nil)
|
||||||
|
) { $0.set(FrontmatterKeys.order, to: .double(4096)) }
|
||||||
|
|
||||||
|
let after = try BoardLoader.load(boardRoot: fixture.root)
|
||||||
|
#expect(after.model.trash.first?.document.kind == .valid("lane"))
|
||||||
|
#expect(after.trashKinds[ItemID(rawValue: entry)] == .lane)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -87,7 +87,8 @@ private let newer = ItemID(rawValue: More.newer)
|
|||||||
/// through a delete byte-for-byte, in order.
|
/// through a delete byte-for-byte, in order.
|
||||||
private func untouchedLines(_ text: String) -> [Substring] {
|
private func untouchedLines(_ text: String) -> [Substring] {
|
||||||
text.split(separator: "\n", omittingEmptySubsequences: false).filter {
|
text.split(separator: "\n", omittingEmptySubsequences: false).filter {
|
||||||
!$0.hasPrefix("modified") && !$0.hasPrefix("order:")
|
// `kind:` — the on-touch backfill rides every app rewrite of a file that lacks one.
|
||||||
|
!$0.hasPrefix("modified") && !$0.hasPrefix("order:") && !$0.hasPrefix("kind:")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -105,7 +105,9 @@ private func document(_ fixture: WriterFixture, _ relativePath: String) throws -
|
|||||||
/// byte-identical, unknown keys and their comments included.
|
/// byte-identical, unknown keys and their comments included.
|
||||||
private func untouchedLines(_ text: String) -> [Substring] {
|
private func untouchedLines(_ text: String) -> [Substring] {
|
||||||
text.split(separator: "\n", omittingEmptySubsequences: false).filter {
|
text.split(separator: "\n", omittingEmptySubsequences: false).filter {
|
||||||
!$0.hasPrefix("modified")
|
// `kind:` — the on-touch backfill rides every app rewrite of a file that lacks one, so a
|
||||||
|
// round trip lands the same bytes *plus* the backfilled key, which is not the undo's doing.
|
||||||
|
!$0.hasPrefix("modified") && !$0.hasPrefix("kind:")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,9 @@ The defining consequence: anything that can read and write files is a first-clas
|
|||||||
|
|
||||||
Lanework is in early development. This list tracks what has actually shipped and grows milestone by milestone; the full design lives in [DESIGN/](DESIGN/).
|
Lanework is in early development. This list tracks what has actually shipped and grows milestone by milestone; the full design lives in [DESIGN/](DESIGN/).
|
||||||
|
|
||||||
|
- **Validation and healing** — one integrity service owns every rule about what a valid object is (the identity predicate and its canonical form, the per-kind field tables, the reserved-name tables, the trash's `kind` discriminator) and one engine runs every repair that writes. Defects classify into one five-verdict taxonomy — refuse, refuse writes, tolerate, coerce, heal — and the verdict decides everything downstream, so no mechanism re-reasons its posture on its own. Heals are *inline* when a gesture cannot proceed without them, *on-touch* when the defect is latent (a missing `kind` key backfills on any write that was rewriting that file anyway — never a sweep), and *scheduled* when the defect degrades the board while it stands: loose card files, legacy `deleted:` keys, a squatter on a claimed name, and a stale agent guide all run on one engine that defers under the read-only lock, re-verifies against disk before writing, never retries a failure in a loop, and fires at board open and at every reload alike.
|
||||||
- **Storage contract, read side** — frontmatter engine with a byte-perfect round-trip guarantee (unknown keys, comments, and formatting survive every rewrite; duplicate keys read last-wins; wrong-type scalars coerce read-side), gapped fractional ordering (Ranks), and a fail-fast board loader with UUID-gated level detection, warning-collecting skips, and a reserved `.trash/` container read by the very same card parse the lanes use — pinned by a golden fixture suite of 18 on-disk boards.
|
- **Storage contract, read side** — frontmatter engine with a byte-perfect round-trip guarantee (unknown keys, comments, and formatting survive every rewrite; duplicate keys read last-wins; wrong-type scalars coerce read-side), gapped fractional ordering (Ranks), and a fail-fast board loader with UUID-gated level detection, warning-collecting skips, and a reserved `.trash/` container read by the very same card parse the lanes use — pinned by a golden fixture suite of 18 on-disk boards.
|
||||||
- **Storage contract, write side** — BoardWriter turns every mutation into an atomic temp-file+rename over exactly the files it touches: creates mint lowercase-UUIDv4 identities and `.kanban` packages; moves keep the UUID (with per-folder collision repair at the cross-board import boundary); copies mint fresh identities at every level; deletes move a card's folder into the board's reserved `.trash/` at a caller-minted top rank, restore is the ordinary move back out, and purge is physical; attachment imports never overwrite and never refuse (Finder-style renames). Every app write stamps `modified`, clears `modified-by`, and preserves everything it didn't change byte-for-byte; readable-but-uneditable frontmatter shapes refuse loudly instead of corrupting. Strays are preserved verbatim everywhere with exactly one carve-out: a loose *file* dropped beside a card's `index.md` belongs in that card's `attachments/`, so the app moves it there — Finder-renamed on collision, byte-faithfully, without touching `index.md` — and says so in a dismissable warning row naming the card and the files. Detection stays read-only in the loader; the move is an ordinary bracketed write that waits out the read-only lock and never retries a failure in a loop. Stray *folders*, symlinks, and everything at board or lane level keep the verbatim posture untouched, and a paste normalizes at the import boundary so a pasted card lands already tidy.
|
- **Storage contract, write side** — BoardWriter turns every mutation into an atomic temp-file+rename over exactly the files it touches: creates mint lowercase-UUIDv4 identities and `.kanban` packages; moves keep the UUID (with per-folder collision repair at the cross-board import boundary); copies mint fresh identities at every level; deletes move a card's folder into the board's reserved `.trash/` at a caller-minted top rank, restore is the ordinary move back out, and purge is physical; attachment imports never overwrite and never refuse (Finder-style renames). Every app write stamps `modified`, clears `modified-by`, and preserves everything it didn't change byte-for-byte; readable-but-uneditable frontmatter shapes refuse loudly instead of corrupting. Strays are preserved verbatim everywhere with exactly one carve-out: a loose *file* dropped beside a card's `index.md` belongs in that card's `attachments/`, so the app moves it there — Finder-renamed on collision, byte-faithfully, without touching `index.md` — and says so in a dismissable warning row naming the card and the files. Detection stays read-only in the loader; the move is an ordinary bracketed write that waits out the read-only lock and never retries a failure in a loop. Stray *folders*, symlinks, and everything at board or lane level keep the verbatim posture untouched, and a paste normalizes at the import boundary so a pasted card lands already tidy. The one other scope on that promise is the handful of board-root names the app claims: a file or symlink squatting `.trash/`, a folder squatting `CLAUDE.md`, is moved aside by the same Finder-style ladder rather than deleted or worked around, since deletion is broken while it stands.
|
||||||
- **Live store** — every open board is one shared, watched, in-memory snapshot: an FSEvents folder watcher (debounced, `.git`-filtered, origin-reconciling) drives whole-tree reloads with a generation guard and single-flight coalescing; write brackets suppress self-echo, and a per-board **write-provenance ledger** — in-memory, dying with the session — records a content hash, an absence marker or an old→new pair for every file the app writes, so a landing reload can tell its own echo from an outside edit file by file (final content decides: byte-identical is the app's, one byte different is somebody else's); a file-identity-keyed store registry refcounts stores and watchers across windows and absorbs root renames via bookmark re-resolution (a vanished root locks the board and watches for its return); plus the board registry (recents, bookmarks, cached counts), the banner center's single precedence order, the dirty-buffer guard, and transient UI state.
|
- **Live store** — every open board is one shared, watched, in-memory snapshot: an FSEvents folder watcher (debounced, `.git`-filtered, origin-reconciling) drives whole-tree reloads with a generation guard and single-flight coalescing; write brackets suppress self-echo, and a per-board **write-provenance ledger** — in-memory, dying with the session — records a content hash, an absence marker or an old→new pair for every file the app writes, so a landing reload can tell its own echo from an outside edit file by file (final content decides: byte-identical is the app's, one byte different is somebody else's); a file-identity-keyed store registry refcounts stores and watchers across windows and absorbs root renames via bookmark re-resolution (a vanished root locks the board and watches for its return); plus the board registry (recents, bookmarks, cached counts), the banner center's single precedence order, the dirty-buffer guard, and transient UI state.
|
||||||
- **Window architecture** — the three window types and their lifecycle: a welcome window (below), one board window per root (per-board frame memory, repositioned onto a live screen), and at-most-one card window per card (last-used size, cascaded, then per-card frame memory once you've placed one; follows its card across lanes; dismisses the moment its card leaves the board — into the trash, with its deleted lane, purged, or moved to another board). Closing a board window or quitting runs one strict close flush — card sessions end, pending work drains, the registry is stamped — before the store tears down; launch restores the boards whose open-now flags survived quit (or crash), a preference gating only whether the flags are consulted.
|
- **Window architecture** — the three window types and their lifecycle: a welcome window (below), one board window per root (per-board frame memory, repositioned onto a live screen), and at-most-one card window per card (last-used size, cascaded, then per-card frame memory once you've placed one; follows its card across lanes; dismisses the moment its card leaves the board — into the trash, with its deleted lane, purged, or moved to another board). Closing a board window or quitting runs one strict close flush — card sessions end, pending work drains, the registry is stamped — before the store tears down; launch restores the boards whose open-now flags survived quit (or crash), a preference gating only whether the flags are consulted.
|
||||||
- **The board** — every lane always on screen, the window's width dividing across the lanes' width units with no horizontal scroll: cards flow into as many interior masonry columns as a lane is wide, a right-edge drag resizes between whole units by growing the *window* (snapping at the gap with release hysteresis, hard-stopping at the screen with rubber-band feedback), and ⌥⌘→/⌥⌘← re-divide the existing width instead. Lane chrome is a per-lane SF Symbol (unknown names fall back leniently), title or untitled placeholder, a card-count badge that counts exactly what's rendered, and a new-card button — the whole title bar doubling as the drag surface, a plain click selecting the lane and movement carrying it away.
|
- **The board** — every lane always on screen, the window's width dividing across the lanes' width units with no horizontal scroll: cards flow into as many interior masonry columns as a lane is wide, a right-edge drag resizes between whole units by growing the *window* (snapping at the gap with release hysteresis, hard-stopping at the screen with rubber-band feedback), and ⌥⌘→/⌥⌘← re-divide the existing width instead. Lane chrome is a per-lane SF Symbol (unknown names fall back leniently), title or untitled placeholder, a card-count badge that counts exactly what's rendered, and a new-card button — the whole title bar doubling as the drag surface, a plain click selecting the lane and movement carrying it away.
|
||||||
@@ -46,7 +47,7 @@ Lanework is in early development. This list tracks what has actually shipped and
|
|||||||
|
|
||||||
- **Undo** — ⌘Z and ⇧⌘Z are native macOS undo, per board: one stack owned by the board's session and shared by every window over it, so a card window's ⌘Z crosses the same step the board window's does, and another board's never does. Every app-mediated mutation registers an inverse at the write boundary — create, move, reorder, rename, restyle, resize, delete, and an Edit session's whole run of saves — with a restore registering as the ordinary move it is — one gesture to one step, named in the app's own vocabulary so the Edit menu reads "Undo Move 3 Cards" and the toolbar's twins light up and dim with it. Undoing is a real write, never an in-memory revert: it goes through the same atomic writer, echoes back through the watcher, and refreshes every window. Because the app is not the only writer, each step re-checks its target the moment you press ⌘Z — field by field, against what its own write left — and a step the disk has moved past is **skipped rather than applied**, with a quiet row saying which item changed outside Lanework, while ⌘Z falls through to the next step; a step that merely failed to write (a full disk, an unplugged volume) stays put to be retried. Permanent deletion and the duplicate-id repair are deliberately outside it — the confirmation is the safety — attachment add and remove register nothing in v1, and foreign edits never join the stack. The read-only lock disables Undo and Redo with every other mutating command and gives them back, stack intact, when it clears. The stack lives with the session and dies at close, standard macOS behaviour. Both editions ship it: base runs the native stack, and Lanework Pro binds git behind the same seam without changing a keystroke.
|
- **Undo** — ⌘Z and ⇧⌘Z are native macOS undo, per board: one stack owned by the board's session and shared by every window over it, so a card window's ⌘Z crosses the same step the board window's does, and another board's never does. Every app-mediated mutation registers an inverse at the write boundary — create, move, reorder, rename, restyle, resize, delete, and an Edit session's whole run of saves — with a restore registering as the ordinary move it is — one gesture to one step, named in the app's own vocabulary so the Edit menu reads "Undo Move 3 Cards" and the toolbar's twins light up and dim with it. Undoing is a real write, never an in-memory revert: it goes through the same atomic writer, echoes back through the watcher, and refreshes every window. Because the app is not the only writer, each step re-checks its target the moment you press ⌘Z — field by field, against what its own write left — and a step the disk has moved past is **skipped rather than applied**, with a quiet row saying which item changed outside Lanework, while ⌘Z falls through to the next step; a step that merely failed to write (a full disk, an unplugged volume) stays put to be retried. Permanent deletion and the duplicate-id repair are deliberately outside it — the confirmation is the safety — attachment add and remove register nothing in v1, and foreign edits never join the stack. The read-only lock disables Undo and Redo with every other mutating command and gives them back, stack intact, when it clears. The stack lives with the session and dies at close, standard macOS behaviour. Both editions ship it: base runs the native stack, and Lanework Pro binds git behind the same seam without changing a keystroke.
|
||||||
|
|
||||||
- **The agent guide** — every board root carries a `CLAUDE.md` the app writes and keeps current: a condensed, agent-facing rendition of the schema — the folder layout, ordering arithmetic, creating and moving cards, the `.trash/` convention, `attachments/`, `modified-by` self-stamping, the colour and icon palettes, and the git etiquette — so any file-capable agent dropped into the folder already knows how to work the board. It is app-owned and version-gated by a marker in its first line: rewritten when missing or older, left byte-for-byte alone when current or newer, and re-checked on every reload, so a guide deleted or rolled back from outside heals by itself. A `CLAUDE.md` that isn't the app's is never clobbered — it moves to `CLAUDE.user.md` (the user's own extension point, which the app otherwise never touches), and if that name is taken the app simply doesn't write a guide. A symlink or folder wearing the name is left alone, and a board on a read-only volume is skipped in silence: the guide is a courtesy and never an interruption.
|
- **The agent guide** — every board root carries a `CLAUDE.md` the app writes and keeps current: a condensed, agent-facing rendition of the schema — the folder layout, ordering arithmetic, creating and moving cards, the `.trash/` convention, `attachments/`, `modified-by` self-stamping, the colour and icon palettes, and the git etiquette — so any file-capable agent dropped into the folder already knows how to work the board. It is app-owned and version-gated by a marker in its first line: rewritten when missing or older, left byte-for-byte alone when current or newer, and re-checked on every reload, so a guide deleted or rolled back from outside heals by itself. A `CLAUDE.md` that isn't the app's is never clobbered — it moves to `CLAUDE.user.md` (the user's own extension point, which the app otherwise never touches), and if that name is taken the app simply doesn't write a guide. A symlink or folder wearing the name is moved aside — Finder-style, never destroyed, with a quiet row naming where it went — because the app owns that name; and a board on a read-only volume is skipped in silence: the guide is a courtesy and never an interruption.
|
||||||
|
|
||||||
- **Accessibility** — the board is a real VoiceOver surface, not a grid of unlabelled rectangles: lanes are containers read as "⟨title⟩, lane, N cards" (the count is the filter's, like the visible badge), each card is one flattened element carrying its title, its attachment count and its cut-pending state, and traversal follows card `order` rather than masonry column position. VO-Space toggles selection through the same funnel a ⌘-click uses, context-menu rows double as custom actions, lane titles are headings for the rotor, and the trash column pins last. A **live board announces itself**: a foreign edit lands as one polite, non-interrupting digest per reload — "Board changed: 2 cards edited, 1 card added" — while the app's own writes stay silent. Which is which is decided **per file by the write-provenance ledger**, never by which kind of reload delivered it: a reconciling sweep on wake or reactivation announces whatever changed in the blind window (the app never vouches for changes it didn't witness), and a foreign edit that lands on a file the app had just written is still announced. A card that disappears under the cursor is named rather than merely lost ("Card 'Fix login' was deleted externally"), with focus recovering to its lane; when the lane went too, the announcement names the *lane* and its count and focus walks up then sideways to whatever now holds its position. Bracketed operations say one thing at completion and never their internal churn, and the banner strip is an announced element in its own right — the read-only lock and reload breakage speak when they appear and when they clear. **Every size in the app is relative**: there is not one hard-coded point size left — the card face, the lane header, the masonry, the style editor's wells and every window's floor derive from the system body font, so the whole board grows with the system text size while the no-horizontal-scroll rule holds (the lanes compress, the strip never scrolls) and titles keep truncating gracefully. The system's visual accommodations are wired throughout: **Increase Contrast** thickens every border and selection ring and gives card and lane plates an outline they don't otherwise have, **Reduce Transparency** turns the transient search bar's glass and the trash column's washes solid, and **Reduce Motion** has a variant for every animated surface in the app — movement goes instant, appear/disappear goes crossfade, uniformly, the live-reload seam included. **A coloured board computes its own text colour.** The board background is the one surface the app lets a colour sit behind text, so the ink is chosen rather than assumed: WCAG relative luminance against the ≥ 4.5:1 threshold, with an `#RRGGBBAA` value composited over the window background of the appearance you are actually in — so lane and trash headers take light or dark glyphs on their own and re-decide the moment you switch to Dark Mode. One path serves both halves of the styling vocabulary: the twelve palette wells are pinned by a test that checks the ink the app *picks* for each of them in both appearances (a dark palette board is now readable in Light Mode, which it was not), and a hand-written hex — which stays fully honoured from disk — gets the identical computation as it renders. Nothing is ever said by colour alone (selection is a ring plus a trait, a cut card is dimmed plus "cut, pending paste", the trash header is hatched plus labelled, a mixed batch reads "mixed"), and under **Full Keyboard Access** the board is a single visible tab stop with the arrow grammar inside it while every control around it — lane buttons, popovers, the style grids, welcome rows, template tiles — is Tab-reachable, arrow-navigable and labelled.
|
- **Accessibility** — the board is a real VoiceOver surface, not a grid of unlabelled rectangles: lanes are containers read as "⟨title⟩, lane, N cards" (the count is the filter's, like the visible badge), each card is one flattened element carrying its title, its attachment count and its cut-pending state, and traversal follows card `order` rather than masonry column position. VO-Space toggles selection through the same funnel a ⌘-click uses, context-menu rows double as custom actions, lane titles are headings for the rotor, and the trash column pins last. A **live board announces itself**: a foreign edit lands as one polite, non-interrupting digest per reload — "Board changed: 2 cards edited, 1 card added" — while the app's own writes stay silent. Which is which is decided **per file by the write-provenance ledger**, never by which kind of reload delivered it: a reconciling sweep on wake or reactivation announces whatever changed in the blind window (the app never vouches for changes it didn't witness), and a foreign edit that lands on a file the app had just written is still announced. A card that disappears under the cursor is named rather than merely lost ("Card 'Fix login' was deleted externally"), with focus recovering to its lane; when the lane went too, the announcement names the *lane* and its count and focus walks up then sideways to whatever now holds its position. Bracketed operations say one thing at completion and never their internal churn, and the banner strip is an announced element in its own right — the read-only lock and reload breakage speak when they appear and when they clear. **Every size in the app is relative**: there is not one hard-coded point size left — the card face, the lane header, the masonry, the style editor's wells and every window's floor derive from the system body font, so the whole board grows with the system text size while the no-horizontal-scroll rule holds (the lanes compress, the strip never scrolls) and titles keep truncating gracefully. The system's visual accommodations are wired throughout: **Increase Contrast** thickens every border and selection ring and gives card and lane plates an outline they don't otherwise have, **Reduce Transparency** turns the transient search bar's glass and the trash column's washes solid, and **Reduce Motion** has a variant for every animated surface in the app — movement goes instant, appear/disappear goes crossfade, uniformly, the live-reload seam included. **A coloured board computes its own text colour.** The board background is the one surface the app lets a colour sit behind text, so the ink is chosen rather than assumed: WCAG relative luminance against the ≥ 4.5:1 threshold, with an `#RRGGBBAA` value composited over the window background of the appearance you are actually in — so lane and trash headers take light or dark glyphs on their own and re-decide the moment you switch to Dark Mode. One path serves both halves of the styling vocabulary: the twelve palette wells are pinned by a test that checks the ink the app *picks* for each of them in both appearances (a dark palette board is now readable in Light Mode, which it was not), and a hand-written hex — which stays fully honoured from disk — gets the identical computation as it renders. Nothing is ever said by colour alone (selection is a ring plus a trait, a cut card is dimmed plus "cut, pending paste", the trash header is hatched plus labelled, a mixed batch reads "mixed"), and under **Full Keyboard Access** the board is a single visible tab stop with the arrow grammar inside it while every control around it — lane buttons, popovers, the style grids, welcome rows, template tiles — is Tab-reachable, arrow-navigable and labelled.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user