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
|
||||
// instant rather than merely close (`BoardWriter.newDocumentText`'s convention).
|
||||
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.title, to: .string(title))
|
||||
}
|
||||
@@ -591,7 +594,7 @@ enum TemplateEngine {
|
||||
operation: WriteOperation
|
||||
) throws(Failure) {
|
||||
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))}"))
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -490,6 +490,40 @@ public final class BannerCenter {
|
||||
postLoss(message)
|
||||
}
|
||||
|
||||
/// One claimed board-root name whose squatter was moved aside — what
|
||||
/// `displacedClaimedNamesMessage(for:)` names.
|
||||
///
|
||||
/// Both names are carried because the notice owes **old and new** (01-storage-format.md
|
||||
/// § Fractal layout ▸ Rules, ruled 2026-07-29: "with the relocation-style warning-tone notice
|
||||
/// naming old and new"): the user needs to know which of their files moved *and* where to find
|
||||
/// it, and a sentence naming only one of the two would be half an answer.
|
||||
public struct Displacement: Sendable, Equatable {
|
||||
/// The claimed name the app took back — `.trash`, `CLAUDE.md`.
|
||||
public let name: String
|
||||
/// The Finder-ladder name the displaced node now has — `.trash 2`.
|
||||
public let movedTo: String
|
||||
|
||||
public init(name: String, movedTo: String) {
|
||||
self.name = name
|
||||
self.movedTo = movedTo
|
||||
}
|
||||
}
|
||||
|
||||
/// **The claimed-name displacement** (01-storage-format.md § Fractal layout ▸ Rules, ruled
|
||||
/// 2026-07-29): a folder, file or symlink was sitting on a name the app owns, the app moved it
|
||||
/// aside — preserved verbatim, never destroyed — and this is the row that says so.
|
||||
///
|
||||
/// **A loss row, on `postRelocatedLooseFiles`' exact reasoning**, which is also what the ruling
|
||||
/// asks for by name ("the relocation-style warning-tone notice"): the app moved something of the
|
||||
/// user's that they did not ask it to move, so it must be said out loud, must not evaporate
|
||||
/// unread, and must not rank as an error, because nothing failed.
|
||||
///
|
||||
/// A displacement that displaced nothing posts nothing.
|
||||
public func postDisplacedClaimedNames(_ displacements: [Displacement]) {
|
||||
guard let message = Self.displacedClaimedNamesMessage(for: displacements) else { return }
|
||||
postLoss(message)
|
||||
}
|
||||
|
||||
/// Posts the skipped-folders loss row for a Finder drop that imported its files but refused its
|
||||
/// folders (04-interactions.md ▸ Selection, drag & drop, "Folders are refused at hover"): "a
|
||||
/// mixed drag proposes for its files only, and the drop imports the files while a one-shot
|
||||
@@ -757,6 +791,13 @@ public final class BannerCenter {
|
||||
// touched, and nothing is lost: the board works exactly as well without the guide, which
|
||||
// is why every *refusal* to write it is a log line and only a real I/O failure gets here.
|
||||
"Couldn't update the agent guide"
|
||||
case let .displaceClaimedName(name):
|
||||
// **The name, quoted, and what the app wanted with it** — the failure's mirror of the
|
||||
// success row ("Renamed '.trash' to '.trash 2' — Lanework needs that name"). It names
|
||||
// the *consequence* the user can act on rather than the mechanics: while the name is
|
||||
// held, the feature that needs it does not work, and the fix is theirs (move or rename
|
||||
// the thing sitting there) because the app has just demonstrated it cannot.
|
||||
"Couldn't move '\(name)' aside — Lanework needs that name"
|
||||
case let .toggleTask(title):
|
||||
// The user's word for it, not the file's: they ticked a box. The card is named where
|
||||
// the read that preceded the flip learned its title, so a body write that refused says
|
||||
@@ -957,6 +998,27 @@ public final class BannerCenter {
|
||||
return "\(clauses.joined(separator: " and ")) — \(tail)"
|
||||
}
|
||||
|
||||
/// The claimed-name displacement's line — the relocation's own voice (the act first, the subject
|
||||
/// after an em dash), naming **old and new** as the ruling requires.
|
||||
///
|
||||
/// - **One name**: "Renamed '.trash' to '.trash 2' — Lanework needs that name". The tail is the
|
||||
/// whole explanation the row owes: the user did not rename anything, and without it the
|
||||
/// sentence would read as an act they had somehow just taken. It says *needs the name* rather
|
||||
/// than anything about what was there, because what was there is the user's business and
|
||||
/// still exists, under the name the row just gave them.
|
||||
/// - **Several**: folded to a count in the relocation's idiom — "Renamed 2 items — Lanework
|
||||
/// needs those names". Two claimed names can be squatted at once (a board somebody unpacked
|
||||
/// over an old one), and a two-clause sentence would be longer than the row.
|
||||
///
|
||||
/// `nil` when nothing moved — a displacement that displaced nothing is not news.
|
||||
public nonisolated static func displacedClaimedNamesMessage(for displacements: [Displacement]) -> String? {
|
||||
guard let only = displacements.first else { return nil }
|
||||
guard displacements.count == 1 else {
|
||||
return "Renamed \(displacements.count) items — Lanework needs those names"
|
||||
}
|
||||
return "Renamed '\(only.name)' to '\(only.movedTo)' — Lanework needs that name"
|
||||
}
|
||||
|
||||
/// A sole migrated item's name: its title in quotes, or the untitled rendering the relocation
|
||||
/// line already uses ("an untitled card" / "an untitled lane" are one phrase here, because the
|
||||
/// clause it sits in already says which level it is).
|
||||
|
||||
+341
-338
@@ -222,7 +222,7 @@ public enum TransferOperation: Sendable, Equatable {
|
||||
/// is simply rendered on top of whatever the latest reload produced.
|
||||
@MainActor
|
||||
@Observable
|
||||
public final class BoardStore {
|
||||
public final class BoardStore: HealHost {
|
||||
|
||||
// MARK: Read-side state
|
||||
|
||||
@@ -244,23 +244,30 @@ public final class BoardStore {
|
||||
/// describe the tree currently on screen.
|
||||
public private(set) var loadWarnings: [LoadWarning]
|
||||
|
||||
/// The cards the load that produced `snapshot` found holding loose files, exactly as the loader
|
||||
/// reported them — the loose-file carve-out's detection channel (01-storage-format.md § Fractal
|
||||
/// layout ▸ Rules, settled 2026-07-28). Replaced with the snapshot, like `loadWarnings`, so it
|
||||
/// always describes the tree currently on screen.
|
||||
/// **The pending work the load that produced `snapshot` found** — the typed defect stream
|
||||
/// (`IntegrityRules.Defect`, settled 2026-07-29). Replaced with the snapshot, like
|
||||
/// `loadWarnings`, so it always describes the tree currently on screen.
|
||||
///
|
||||
/// **Nothing renders it.** A loose file is not content — it reaches no view, and the card it
|
||||
/// sits in draws exactly as it would without it. Its one consumer is
|
||||
/// `relocateLooseCardFiles()`, immediately below the reload that produced it.
|
||||
public private(set) var looseCardFiles: [LooseCardFiles]
|
||||
/// **Nothing renders it.** A defect is not content — it reaches no view, and the board draws
|
||||
/// exactly as it would without it. Its one consumer is `runScheduledHeals()`, immediately below
|
||||
/// the reload that produced it.
|
||||
public private(set) var defects: [IntegrityRules.Defect]
|
||||
|
||||
/// The legacy `deleted:` keys the load that produced `snapshot` found — the retired tombstone
|
||||
/// model's migration input (01-storage-format.md § Deletion, resettled 2026-07-28), in the
|
||||
/// loose-file channel's idiom and replaced with the snapshot exactly as it is.
|
||||
///
|
||||
/// **Nothing renders it either.** Its one consumer is `migrateLegacyTombstones()`, immediately
|
||||
/// below the reload that produced it.
|
||||
public private(set) var legacyTombstones: [LegacyTombstone]
|
||||
/// The cards the last load found holding loose files — a view over `defects`, under the name it
|
||||
/// has always had.
|
||||
public var looseCardFiles: [LooseCardFiles] {
|
||||
defects.compactMap { if case let .looseCardFiles(work) = $0 { work } else { nil } }
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// board is healthy. `BoardLoadError` already carries fail-fast's specifics — the offending path
|
||||
@@ -471,24 +478,14 @@ public final class BoardStore {
|
||||
@ObservationIgnored
|
||||
private var quiescenceWaiters: [CheckedContinuation<Void, Never>] = []
|
||||
|
||||
/// The loose-file set the last relocation attempt was made against — the loop guard
|
||||
/// `relocateLooseCardFiles()` documents. Empty means "nothing has been attempted against the
|
||||
/// current picture", which is both the opening state and what a clean board resets it to.
|
||||
/// **The scheduled-heal engine** (02-architecture.md ▸ Components ▸ HealScheduler): the six-step
|
||||
/// pattern the three healers below used to re-derive one by one, plus the memo each of them
|
||||
/// 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
|
||||
private var attemptedRelocation: Set<String> = []
|
||||
|
||||
/// 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?
|
||||
let heals = HealScheduler()
|
||||
|
||||
/// 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.
|
||||
@@ -534,19 +531,18 @@ public final class BoardStore {
|
||||
/// The walk is synchronous because the caller has nothing to render until it lands; the
|
||||
/// asynchronous, off-main pipeline starts with the first reload.
|
||||
///
|
||||
/// **It writes nothing, the opened board's loose files included.** `looseCardFiles` is recorded
|
||||
/// here and acted on by whoever wired this store up — `BoardStoreRegistry.acquire` calls
|
||||
/// `relocateLooseCardFiles()` once the watcher and the brackets exist, so the relocation is a
|
||||
/// bracketed write with a reload behind it rather than a write into a board nothing is watching
|
||||
/// yet. A store built directly (a test, a storeless consumer) relocates when it is asked to, and
|
||||
/// on every reload thereafter.
|
||||
/// **It writes nothing, the opened board's defects included.** `defects` is recorded here and
|
||||
/// acted on by whoever wired this store up — `BoardStoreRegistry.acquire` calls
|
||||
/// `runScheduledHeals()` once the watcher and the brackets exist, so a heal is a bracketed write
|
||||
/// with a reload behind it rather than a write into a board nothing is watching yet. A store
|
||||
/// built directly (a test, a storeless consumer) heals when it is asked to, and on every reload
|
||||
/// thereafter.
|
||||
public init(rootURL: URL) throws(BoardLoadError) {
|
||||
let result = try BoardLoader.load(boardRoot: rootURL)
|
||||
self.rootURL = rootURL
|
||||
self.snapshot = result.model
|
||||
self.loadWarnings = result.warnings
|
||||
self.looseCardFiles = result.looseCardFiles
|
||||
self.legacyTombstones = result.legacyTombstones
|
||||
self.defects = result.defects
|
||||
self.reloadFailure = nil
|
||||
self.readOnlyLock = nil
|
||||
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
|
||||
// this one did not.
|
||||
reloadFailure = nil
|
||||
looseCardFiles = result.looseCardFiles
|
||||
legacyTombstones = result.legacyTombstones
|
||||
defects = result.defects
|
||||
reconcileLock(after: origin)
|
||||
// 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
|
||||
@@ -774,29 +769,14 @@ public final class BoardStore {
|
||||
// guard), not a decision this store makes by comparing against its own prior
|
||||
// snapshot.
|
||||
displayStateDelegate?()
|
||||
// Last, and after `reconcileLock` deliberately: this is the seam the two deferred
|
||||
// app-initiated writes are armed on. A board that was locked read-only tolerated its
|
||||
// loose files and its legacy tombstones for exactly as long as the lock stood, and the
|
||||
// reload that clears the lock is the reload that lets them move — see
|
||||
// `relocateLooseCardFiles()` and `migrateLegacyTombstones()`. The ordering cuts the
|
||||
// other way too now that the probe is symmetric: a reconciling reload that *raises* the
|
||||
// lock raises it before these three run, so none of them writes into a location the
|
||||
// same reload just learned is read-only.
|
||||
//
|
||||
// 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()
|
||||
// **The reload tail** — one of the two seams the heal engine runs at (the other is
|
||||
// `BoardStoreRegistry.acquire`), and after `reconcileLock` deliberately: this is where
|
||||
// the deferred app-initiated writes are armed. A board that was locked read-only
|
||||
// tolerated its defects for exactly as long as the lock stood, and the reload that
|
||||
// clears the lock is the reload that heals them. The ordering cuts the other way too:
|
||||
// a reconciling reload that *raises* the unwritable-location lock raises it before this
|
||||
// runs, so no heal writes into a location the same reload just learned is read-only.
|
||||
runScheduledHeals()
|
||||
|
||||
case let .failure(error):
|
||||
// `snapshot`, `loadWarnings` and the transient state are untouched: a failed reload
|
||||
@@ -1398,7 +1378,13 @@ public final class BoardStore {
|
||||
for edit in edits {
|
||||
// `.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`).
|
||||
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.icon, to: FrontmatterKeys.icon, in: &document)
|
||||
}
|
||||
@@ -1437,14 +1423,22 @@ public final class BoardStore {
|
||||
}
|
||||
) { _ in
|
||||
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.priorIcon, to: FrontmatterKeys.icon, in: &document)
|
||||
}
|
||||
}
|
||||
} redo: { _ in
|
||||
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.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
|
||||
// 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.
|
||||
var rank = Ranks.insertionRank(amongVisible: visible.map(\.order), at: position)
|
||||
if rank == nil {
|
||||
// Midpoint precision exhausted between the anchor and its neighbour
|
||||
// (01-storage-format.md § Ordering). Compact, then place against the fresh ranks:
|
||||
// the new card is not among the renumbered siblings — it was appended past them —
|
||||
// so the compacted ladder lines up one-for-one with `visible`.
|
||||
try BoardWriter.renumberVisibleChildren(of: laneFolder)
|
||||
rank = Ranks.insertionRank(amongVisible: Ranks.renumbered(count: visible.count), at: position)
|
||||
}
|
||||
guard let rank else { return id }
|
||||
// Ask, and on exhausted midpoint precision (01-storage-format.md § Ordering) compact
|
||||
// and ask again — the shared two-step. The new card is not among the renumbered
|
||||
// siblings (it was appended past them), so the compacted ladder lines up one-for-one
|
||||
// with `visible` and nothing captured needs refreshing here.
|
||||
guard let placed = try HealScheduler.placingRanks(
|
||||
amongVisible: visible.map(\.order),
|
||||
compacting: laneFolder,
|
||||
{ Ranks.insertionRank(amongVisible: $0, at: position) }
|
||||
) else { return id }
|
||||
let rank = placed.placement
|
||||
|
||||
_ = try BoardWriter.moveItem(
|
||||
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 empty-title rule (a missing key, never `title: ""`) cannot differ between a gesture and
|
||||
/// 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 {
|
||||
document.set(FrontmatterKeys.title, to: .string(title))
|
||||
} else {
|
||||
@@ -1988,7 +1990,7 @@ public final class BoardStore {
|
||||
let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
|
||||
// `.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`).
|
||||
try Self.setTitle(newTitle, at: folder)
|
||||
try Self.setTitle(newTitle, at: folder, kind: .board)
|
||||
}
|
||||
guard landed != nil else { return }
|
||||
|
||||
@@ -2001,9 +2003,9 @@ public final class BoardStore {
|
||||
undoExpects: [.present(folder, .title(newTitle))],
|
||||
redoExpects: [.present(folder, .title(priorTitle))]
|
||||
) { _ in
|
||||
try Self.setTitle(priorTitle, at: folder)
|
||||
try Self.setTitle(priorTitle, at: folder, kind: .board)
|
||||
} 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 newOrder: Double?
|
||||
let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
|
||||
var rank = Ranks.insertionRank(amongVisible: remaining.map(\.order), at: target)
|
||||
if rank == nil {
|
||||
// Compact and place again. Unlike the card case the dragged lane *is* among the
|
||||
// renumbered children — it is a real folder on disk — so its fresh rank is dropped
|
||||
// from the ladder before the neighbours are consulted.
|
||||
try BoardWriter.renumberVisibleChildren(of: root)
|
||||
let renumbered = Ranks.renumbered(count: lanes.count)
|
||||
priorOrder = renumbered[from]
|
||||
var compacted = renumbered
|
||||
compacted.remove(at: from)
|
||||
rank = Ranks.insertionRank(amongVisible: compacted, at: target)
|
||||
}
|
||||
guard let rank else { return }
|
||||
// The shared two-step, over the *whole* strip: unlike the card-create case the dragged
|
||||
// lane **is** among the renumbered children — it is a real folder on disk — so the ask
|
||||
// drops its own rung before consulting the neighbours, and a compaction refreshes the
|
||||
// prior rank the inverse has to restore.
|
||||
guard let placed = try HealScheduler.placingRanks(
|
||||
amongVisible: lanes.map(\.order),
|
||||
compacting: root,
|
||||
{ ladder in
|
||||
var compacted = ladder
|
||||
compacted.remove(at: from)
|
||||
return Ranks.insertionRank(amongVisible: compacted, at: target)
|
||||
}
|
||||
) else { return }
|
||||
if placed.renumbered { priorOrder = placed.ladder[from] }
|
||||
let rank = placed.placement
|
||||
newOrder = rank
|
||||
|
||||
_ = try BoardWriter.moveItem(
|
||||
@@ -2119,18 +2123,25 @@ public final class BoardStore {
|
||||
var priorOrders = members.map(\.order)
|
||||
var rewrites: [(folder: URL, order: Double)] = []
|
||||
let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
|
||||
var ranks = Ranks.insertionRanks(amongVisible: remaining.map(\.order), at: target, count: members.count)
|
||||
if ranks == nil {
|
||||
// Compact and place again. The dragged lanes *are* among the renumbered children —
|
||||
// they are real folders on disk — so their fresh rungs are dropped from the ladder
|
||||
// before the neighbours are consulted, exactly as `moveLane` drops its one.
|
||||
try BoardWriter.renumberVisibleChildren(of: root)
|
||||
let renumbered = Array(zip(lanes, Ranks.renumbered(count: lanes.count)))
|
||||
priorOrders = renumbered.filter { ids.contains($0.0.id) }.map(\.1)
|
||||
let compacted = renumbered.filter { !ids.contains($0.0.id) }.map(\.1)
|
||||
ranks = Ranks.insertionRanks(amongVisible: compacted, at: target, count: members.count)
|
||||
// `moveLane`'s two-step, plural: the dragged lanes *are* among the renumbered children,
|
||||
// so the ask drops their rungs before consulting the neighbours, and a compaction
|
||||
// refreshes the prior ranks the inverse restores.
|
||||
guard let placed = try HealScheduler.placingRanks(
|
||||
amongVisible: lanes.map(\.order),
|
||||
compacting: root,
|
||||
{ ladder in
|
||||
let rungs = Array(zip(lanes, ladder))
|
||||
return Ranks.insertionRanks(
|
||||
amongVisible: rungs.filter { !ids.contains($0.0.id) }.map(\.1),
|
||||
at: target,
|
||||
count: members.count
|
||||
)
|
||||
}
|
||||
) else { return }
|
||||
if placed.renumbered {
|
||||
priorOrders = Array(zip(lanes, placed.ladder)).filter { ids.contains($0.0.id) }.map(\.1)
|
||||
}
|
||||
guard let ranks else { return }
|
||||
let ranks = placed.placement
|
||||
|
||||
for (member, rank) in zip(members, ranks) {
|
||||
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 arrivals: [(id: ItemID, order: Double)] = []
|
||||
let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
|
||||
var ranks = Ranks.insertionRanks(amongVisible: remaining.map(\.order), at: target, count: members.count)
|
||||
if ranks == nil {
|
||||
// Compact and place again. The renumber assigns in display order over the lane's
|
||||
// cards, so the compacted ladder lines up one-for-one with `rendered`; the members
|
||||
// already in this lane are dropped from it before the neighbours are consulted,
|
||||
// exactly as `moveLane` drops the dragged lane's own rung.
|
||||
try BoardWriter.renumberVisibleChildren(of: laneFolder)
|
||||
let renumbered = Array(zip(rendered, Ranks.renumbered(count: rendered.count)))
|
||||
for (card, rank) in renumbered where ids.contains(card.id) {
|
||||
// The shared two-step. The renumber assigns in display order over the lane's cards, so
|
||||
// the compacted ladder lines up one-for-one with `rendered`; members already in this
|
||||
// lane are dropped from it before the neighbours are consulted, exactly as `moveLane`
|
||||
// drops the dragged lane's own rung — and a compaction refreshes their captured origins.
|
||||
guard let placed = try HealScheduler.placingRanks(
|
||||
amongVisible: rendered.map(\.order),
|
||||
compacting: laneFolder,
|
||||
{ ladder in
|
||||
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)
|
||||
}
|
||||
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) {
|
||||
arrivals.append((id: member.id, order: rank))
|
||||
@@ -2412,16 +2430,15 @@ public final class BoardStore {
|
||||
let laneFolder = ItemPath.lane(laneID).folder(under: root)
|
||||
|
||||
try? performWrite { () throws(BoardWriteError) -> Void in
|
||||
var ranks = Ranks.insertionRanks(amongVisible: rendered.map(\.order), at: placement, count: members.count)
|
||||
if ranks == nil {
|
||||
try BoardWriter.renumberVisibleChildren(of: laneFolder)
|
||||
ranks = Ranks.insertionRanks(
|
||||
amongVisible: Ranks.renumbered(count: rendered.count),
|
||||
at: placement,
|
||||
count: members.count
|
||||
)
|
||||
}
|
||||
guard let ranks else { return }
|
||||
// The shared two-step. The copies are not among the renumbered children — they do not
|
||||
// exist yet — so the compacted ladder lines up one-for-one with `rendered` and nothing
|
||||
// captured needs refreshing.
|
||||
guard let placed = try HealScheduler.placingRanks(
|
||||
amongVisible: rendered.map(\.order),
|
||||
compacting: laneFolder,
|
||||
{ Ranks.insertionRanks(amongVisible: $0, at: placement, count: members.count) }
|
||||
) else { return }
|
||||
let ranks = placed.placement
|
||||
|
||||
for (member, rank) in zip(members, ranks) {
|
||||
_ = try BoardWriter.copyItem(
|
||||
@@ -2562,16 +2579,13 @@ public final class BoardStore {
|
||||
let laneFolder = ItemPath.lane(laneID).folder(under: root)
|
||||
|
||||
try? performWrite { () throws(BoardWriteError) -> Void in
|
||||
var ranks = Ranks.insertionRanks(amongVisible: rendered.map(\.order), at: target, count: sources.count)
|
||||
if ranks == nil {
|
||||
try BoardWriter.renumberVisibleChildren(of: laneFolder)
|
||||
ranks = Ranks.insertionRanks(
|
||||
amongVisible: Ranks.renumbered(count: rendered.count),
|
||||
at: target,
|
||||
count: sources.count
|
||||
)
|
||||
}
|
||||
guard let ranks else { return }
|
||||
// The shared two-step; the arrivals are not among the renumbered children.
|
||||
guard let placed = try HealScheduler.placingRanks(
|
||||
amongVisible: rendered.map(\.order),
|
||||
compacting: laneFolder,
|
||||
{ Ranks.insertionRanks(amongVisible: $0, at: target, count: sources.count) }
|
||||
) else { return }
|
||||
let ranks = placed.placement
|
||||
|
||||
for (source, rank) in zip(sources, ranks) {
|
||||
guard let arrived = try Self.materialize(
|
||||
@@ -2676,16 +2690,13 @@ public final class BoardStore {
|
||||
let target = min(max(0, stripIndex), rendered.count)
|
||||
|
||||
try? performWrite { () throws(BoardWriteError) -> Void in
|
||||
var ranks = Ranks.insertionRanks(amongVisible: rendered.map(\.order), at: target, count: sources.count)
|
||||
if ranks == nil {
|
||||
try BoardWriter.renumberVisibleChildren(of: root)
|
||||
ranks = Ranks.insertionRanks(
|
||||
amongVisible: Ranks.renumbered(count: rendered.count),
|
||||
at: target,
|
||||
count: sources.count
|
||||
)
|
||||
}
|
||||
guard let ranks else { return }
|
||||
// The shared two-step; the arriving lanes are not among the renumbered children.
|
||||
guard let placed = try HealScheduler.placingRanks(
|
||||
amongVisible: rendered.map(\.order),
|
||||
compacting: root,
|
||||
{ Ranks.insertionRanks(amongVisible: $0, at: target, count: sources.count) }
|
||||
) else { return }
|
||||
let ranks = placed.placement
|
||||
|
||||
for (source, rank) in zip(sources, ranks) {
|
||||
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
|
||||
/// 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,
|
||||
/// "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
|
||||
/// 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,
|
||||
/// 06-history-undo.md ▸ Commit messages.)
|
||||
///
|
||||
/// **It is an ordinary app write and nothing more.** One `performWrite` bracket over the whole
|
||||
/// board's worth of relocation, so the churn rounds back as a single app-mediated reload and (on
|
||||
/// git boards) a single commit — the style batch's rule, applied to a batch the app started
|
||||
/// itself. The snapshot is not touched here any more than it is anywhere else: the files move,
|
||||
/// the watcher notices, the reload lands.
|
||||
/// **One bracket over the whole board's worth of relocation**, so the churn rounds back as a
|
||||
/// single app-mediated reload and (on git boards) a single commit — the style batch's rule,
|
||||
/// applied to a batch the app started itself. The snapshot is not touched here any more than it
|
||||
/// is anywhere else: the files move, the watcher notices, the reload lands.
|
||||
///
|
||||
/// ### The read-only lock defers it, it does not cancel it
|
||||
///
|
||||
/// "The relocation … waits out any read-only lock — strays stay tolerated until it clears." A
|
||||
/// 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.
|
||||
/// **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
|
||||
/// vanished under the write contributes no line to the notice.
|
||||
public func relocateLooseCardFiles() {
|
||||
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
|
||||
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 {
|
||||
let folder = root
|
||||
.appendingPathComponent(card.laneID.rawValue, isDirectory: true)
|
||||
@@ -2865,23 +2846,18 @@ public final class BoardStore {
|
||||
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
|
||||
/// 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.
|
||||
nonisolated static func relocationSignature(of work: [LooseCardFiles]) -> Set<String> {
|
||||
var signature: Set<String> = []
|
||||
for card in work {
|
||||
for name in card.fileNames {
|
||||
signature.insert("\(card.laneID.rawValue)/\(card.cardID.rawValue)/\(name)")
|
||||
}
|
||||
}
|
||||
return signature
|
||||
nonisolated static func signature(of defects: [IntegrityRules.Defect]) -> Set<String> {
|
||||
Set(defects.flatMap(\.signatures))
|
||||
}
|
||||
|
||||
/// 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)] = []
|
||||
try? performWrite { () throws(BoardWriteError) -> Void in
|
||||
var ranks = Ranks.insertionRanks(
|
||||
amongVisible: rendered.map(\.order), at: target, count: urls.count)
|
||||
if ranks == nil {
|
||||
try BoardWriter.renumberVisibleChildren(of: laneFolder)
|
||||
ranks = Ranks.insertionRanks(
|
||||
amongVisible: Ranks.renumbered(count: rendered.count), at: target, count: urls.count)
|
||||
}
|
||||
guard let ranks else { return }
|
||||
// The shared two-step; the created cards are not among the renumbered children.
|
||||
guard let placed = try HealScheduler.placingRanks(
|
||||
amongVisible: rendered.map(\.order),
|
||||
compacting: laneFolder,
|
||||
{ Ranks.insertionRanks(amongVisible: $0, at: target, count: urls.count) }
|
||||
) else { return }
|
||||
let ranks = placed.placement
|
||||
|
||||
for (url, rank) in zip(urls, ranks) {
|
||||
// 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.
|
||||
var rewrites: [(folder: URL, from: Double, to: Double)] = []
|
||||
let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
|
||||
var ladder = orders
|
||||
if !Self.isStrictlyAscending(orders) {
|
||||
try BoardWriter.renumberVisibleChildren(of: laneFolder)
|
||||
// The renumber assigns in display order, so the compacted ladder lines up one-for-one
|
||||
// with `rendered` — the same alignment `commitPlaceholder` relies on.
|
||||
ladder = Ranks.renumbered(count: rendered.count)
|
||||
}
|
||||
// The shared two-step, with the *ask* being "are these ranks usable at all?": a
|
||||
// permutation can only rewrite ranks that already separate the cards, so a ladder with
|
||||
// ties is exhausted in exactly the sense the helper means, and the compacted one — which
|
||||
// is strictly ascending by construction — always answers. It lines up one-for-one with
|
||||
// `rendered`, the same alignment `commitPlaceholder` relies on.
|
||||
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() {
|
||||
guard let origin = positions[id], origin != destination else { continue }
|
||||
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 —
|
||||
/// 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
|
||||
/// on a successful reload, same lock deferral, same attempted-set loop guard, same one bracket
|
||||
/// for the whole board, same warning-tone loss row. Two migrations arriving in one release with
|
||||
/// two different schedulings would be two things to keep honest.
|
||||
/// **`relocateLooseCardFiles()`'s twin in every mechanical respect**, and since 2026-07-29 that
|
||||
/// is true by construction rather than by two methods agreeing: both run on `HealScheduler`, so
|
||||
/// the tail hook, the lock deferral, the writability gate, the memo and the clear-on-success are
|
||||
/// one implementation. What is this heal's own is below.
|
||||
///
|
||||
/// ### 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
|
||||
/// 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
|
||||
/// corpus already uses. `legacyTombstones` carries no timestamp of its own, so the stamps are
|
||||
/// read out of the snapshot the same walk produced.
|
||||
/// corpus already uses.
|
||||
///
|
||||
/// ### 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
|
||||
/// nothing **and having remembered nothing**, so the reload that lifts the lock is the reload
|
||||
/// that performs the migration.
|
||||
///
|
||||
/// ### It cannot hot-loop
|
||||
///
|
||||
/// 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**.
|
||||
/// Each item's `deleted:` key is re-read at write time (`stillTombstoned(at:)`) and a key that
|
||||
/// has gone — an agent removed it, another window migrated first — skips silently: "losing the
|
||||
/// 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
|
||||
/// the write would be rewritten for nothing, stamping `modified` on a file with no defect left.
|
||||
public func migrateLegacyTombstones() {
|
||||
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 cards = Self.migrationOrder(of: work, in: snapshot)
|
||||
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
|
||||
// migrated card is indistinguishable on disk from one the user deletes today.
|
||||
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 {
|
||||
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)
|
||||
try BoardWriter.migrateTombstonedCard(
|
||||
at: ItemPath.card(lane: card.laneID, id: cardID).folder(under: root),
|
||||
inBoard: root,
|
||||
order: rank
|
||||
)
|
||||
try BoardWriter.migrateTombstonedCard(at: folder, inBoard: root, order: rank)
|
||||
ladder.insert(rank, at: 0)
|
||||
movedCards.append(card.title)
|
||||
}
|
||||
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)
|
||||
}
|
||||
} 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:
|
||||
/// 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> {
|
||||
Set(work.map { "\($0.laneID.rawValue)/\($0.cardID?.rawValue ?? "")" })
|
||||
/// Whether the item at `folder` still carries a `deleted:` key — the migration's disk re-verify.
|
||||
///
|
||||
/// **Presence, not validity**, exactly as `Lane`/`Card.isDeleted` reads it: a malformed timestamp
|
||||
/// 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:`
|
||||
@@ -3616,17 +3592,52 @@ public final class BoardStore {
|
||||
.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
|
||||
|
||||
/// 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
|
||||
/// 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
|
||||
/// once more at open (`BoardStoreRegistry.acquire`, which fires it after the watcher and the
|
||||
/// brackets exist, for the reason stated there). That makes the guide *self-healing* rather than
|
||||
/// merely written-once: a foreign deletion, a downgrade to an older guide, a board restored from
|
||||
/// a template carrying a stale one — each heals on the next reload, without a single new signal.
|
||||
/// **Run on every successful reload**, beside the other scheduled heals, and once more at open
|
||||
/// (`BoardStoreRegistry.acquire`). That makes the guide *self-healing* rather than merely
|
||||
/// written-once: a foreign deletion, a downgrade to an older guide, a board restored from 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
|
||||
/// an "Update agent guide (vN)" commit restores an older guide that the app immediately
|
||||
/// 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
|
||||
/// 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
|
||||
/// read-only lock on its own, but that refusal is a thrown error and this is not a gesture — so
|
||||
/// the lock is checked first, the relocation's own deferral idiom. That gate is now the one that
|
||||
/// actually fires on an unwritable board: `BoardStoreRegistry.acquire` probes writability
|
||||
/// *before* it calls this method, so 02-architecture.md's "the open-time agent-guide write is
|
||||
/// 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.
|
||||
/// `.skipUserFilenameTaken` is the standing exception (a rescue destination is not itself freed
|
||||
/// by a second displacement); `.displaceSquatterThenWrite` is the 2026-07-29 upgrade of the old
|
||||
/// untouchable-skip, and it *does* post — a node of the user's moved aside owes the same
|
||||
/// warning-tone notice `.trash`'s displacement does. Both decisions are re-made inside the write
|
||||
/// half, which is this heal's disk re-verify.
|
||||
///
|
||||
/// The `access(2)` check below stays anyway, as the second line of defense: it is the only gate
|
||||
/// 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.
|
||||
/// ### The signature is the board root's picture
|
||||
///
|
||||
/// ### It cannot hot-loop
|
||||
///
|
||||
/// `performWrite`'s bracket schedules a reload whether or not the write succeeded, and this runs
|
||||
/// on every reload — so a *failing* guide write would retry forever at the speed of the debounce,
|
||||
/// 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.
|
||||
/// 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
|
||||
/// immediately (the picture "missing" is restored, and a standing memo would make that deletion
|
||||
/// the one thing this could not heal).
|
||||
public func refreshAgentGuide() {
|
||||
let state = AgentGuide.inspect(atBoardRoot: rootURL)
|
||||
let root = rootURL
|
||||
let state = AgentGuide.inspect(atBoardRoot: root)
|
||||
let decision = AgentGuide.decide(state)
|
||||
guard decision != .leaveAlone else {
|
||||
// The resting state, and the memo's reset: a board whose guide is current has nothing to
|
||||
// remember having tried.
|
||||
attemptedGuideRefresh = nil
|
||||
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
|
||||
|
||||
// **A decision that writes nothing is no work at all**, and says so with an empty signature:
|
||||
// the engine's resting-clear then costs no bracket, which matters because a bracket schedules
|
||||
// a reload whether or not anything was written — a skip that opened one would tick forever.
|
||||
let signature: Set<String>
|
||||
switch decision {
|
||||
case .leaveAlone:
|
||||
break // Ruled out above; the switch stays exhaustive so a new decision is a compile error.
|
||||
case .skipUntouchable:
|
||||
Self.logger.debug("agent-guide refresh skipped — CLAUDE.md is not an ordinary file")
|
||||
signature = []
|
||||
case .skipUserFilenameTaken:
|
||||
// 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.
|
||||
Self.logger.debug("agent-guide refresh skipped — CLAUDE.md is not the app's and CLAUDE.user.md is taken")
|
||||
case .write, .displaceThenWrite:
|
||||
let root = rootURL
|
||||
let displace = decision == .displaceThenWrite
|
||||
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
|
||||
// picture ("missing"). A memo left standing would make the deletion the one thing the
|
||||
// self-heal could not heal.
|
||||
attemptedGuideRefresh = nil
|
||||
} catch {
|
||||
// Already the banner's — `performWrite` posts every `BoardWriteError` before it
|
||||
// rethrows — and there is nothing else a courtesy write can do about a failure. The
|
||||
// memo, left armed above, is what keeps it from being posted again every reload.
|
||||
Self.logger.error("agent-guide write failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
signature = []
|
||||
case .write, .displaceThenWrite, .displaceSquatterThenWrite:
|
||||
signature = [state.signature]
|
||||
}
|
||||
|
||||
var displaced: [BannerCenter.Displacement] = []
|
||||
heals.run(
|
||||
.staleAgentGuide,
|
||||
signature: signature,
|
||||
on: self
|
||||
) { () throws(BoardWriteError) -> Void in
|
||||
// One bracket over the displacement *and* the write: two files change, one app-mediated
|
||||
// reload lands, and (in the Pro edition) one honestly-attributed commit records it.
|
||||
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)
|
||||
|
||||
@@ -221,24 +221,25 @@ public final class BoardStoreRegistry {
|
||||
lastKnownRoot: rootURL
|
||||
)
|
||||
|
||||
// The loose-file carve-out's first firing (01-storage-format.md § Fractal layout ▸ Rules,
|
||||
// settled 2026-07-28): files an agent or a hand-editor left beside a card's `index.md`
|
||||
// while this board was closed are relocated into `attachments/` now, with the notice.
|
||||
// **The heal engine's open seam** (02-architecture.md ▸ Components ▸ HealScheduler: "fires
|
||||
// uniformly at the reload tail and at registry acquire"). Everything an agent or a
|
||||
// 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
|
||||
// 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
|
||||
// above it left one reload stale. By this line the pair is wired, so it is an ordinary
|
||||
// bracketed app write whose echo reload refreshes the board like any other. Every reload
|
||||
// thereafter re-fires it from `BoardStore.land`; this call is only the one the 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
|
||||
// same reason and with the same timing as the relocation above: a board opened with no
|
||||
// `CLAUDE.md`, or with one an older version wrote, gets the current guide now — as a
|
||||
// bracketed app write with a reload behind it, never as a write into a board nothing is
|
||||
// watching. Every reload thereafter re-checks it from `BoardStore.land`.
|
||||
store.refreshAgentGuide()
|
||||
// above it left one reload stale. By this line the pair is wired, so each heal is an
|
||||
// ordinary bracketed app write whose echo reload refreshes the board like any other. Every
|
||||
// reload thereafter re-fires them from `BoardStore.land`; this call is only the one the
|
||||
// opening walk would otherwise have no reload behind.
|
||||
//
|
||||
// **One call, all four** — before 2026-07-29 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.
|
||||
store.runScheduledHeals()
|
||||
return store
|
||||
}
|
||||
|
||||
|
||||
@@ -104,7 +104,23 @@ public final class EchoLedger: Sendable {
|
||||
/// is what those paths want: there is no session whose echoes they are.
|
||||
@TaskLocal public static var current: EchoLedger?
|
||||
|
||||
private let receipts = Mutex<[String: Receipt]>([:])
|
||||
/// One receipt plus its attributes. Split from `Receipt` so the public vocabulary stays the
|
||||
/// three outcomes it always was and an attribute can be added without every reader learning a
|
||||
/// new shape.
|
||||
private struct Entry: Sendable, Equatable {
|
||||
var receipt: Receipt
|
||||
/// **Whether the write that dropped this receipt was a heal** (06-history-undo.md ▸ Commit
|
||||
/// messages, ruled 2026-07-29: "the Writer's heal operations drop heal-marked receipts in
|
||||
/// the EchoLedger — attribution machinery like the author split, never message tagging").
|
||||
///
|
||||
/// **Inert in base beyond the ledger itself.** Nothing here reads it and nothing renders it;
|
||||
/// it is the flag pro-m1's committer reads to split a heal's paths into their own commit,
|
||||
/// and it is stored rather than derived because by commit time the only thing that still
|
||||
/// knows a path was healed is the receipt.
|
||||
var isHeal: Bool = false
|
||||
}
|
||||
|
||||
private let receipts = Mutex<[String: Entry]>([:])
|
||||
|
||||
public init() {}
|
||||
|
||||
@@ -145,7 +161,10 @@ public final class EchoLedger: Sendable {
|
||||
/// The path-and-hash form — **supersession** lives here, as a plain overwrite: "a newer app
|
||||
/// write to the same path supersedes the receipt", because only the final content decides.
|
||||
public func recordWrite(atPath path: String, hash: String) {
|
||||
receipts.withLock { $0[path] = .content(hash: hash) }
|
||||
// The heal mark is **not** carried over: supersession replaces the whole receipt, and a
|
||||
// later ordinary write to the same path is exactly the case where the path stops being the
|
||||
// heal's alone. `markHeal(at:)` is called after the write it describes, never before.
|
||||
receipts.withLock { $0[path] = Entry(receipt: .content(hash: hash)) }
|
||||
}
|
||||
|
||||
/// An attachment import — "attachment imports hash during the copy (the bytes stream through
|
||||
@@ -174,7 +193,7 @@ public final class EchoLedger: Sendable {
|
||||
public func recordDeletion(atPath path: String) {
|
||||
receipts.withLock { store in
|
||||
Self.forget(&store, under: path)
|
||||
store[path] = .absence
|
||||
store[path] = Entry(receipt: .absence)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,13 +211,42 @@ public final class EchoLedger: Sendable {
|
||||
for path in Array(store.keys) where path.hasPrefix(source + "/") {
|
||||
store[destination + path.dropFirst(source.count)] = store.removeValue(forKey: path)
|
||||
}
|
||||
let pair = Receipt.move(from: source, to: destination)
|
||||
let pair = Entry(receipt: .move(from: source, to: destination))
|
||||
store[source] = pair
|
||||
store[destination] = pair
|
||||
}
|
||||
}
|
||||
|
||||
private static func forget(_ store: inout [String: Receipt], under path: String) {
|
||||
/// **Marks the receipt at `path` as a heal** — the Writer's heal operations call this on the
|
||||
/// paths they touched, after the bytes land (06-history-undo.md ▸ Commit messages, ruled
|
||||
/// 2026-07-29).
|
||||
///
|
||||
/// A no-op where there is no receipt (a Writer call outside any bracket, a test): a mark with no
|
||||
/// receipt to attach to would describe nothing. It never *creates* a receipt for the same reason
|
||||
/// — the receipt is the record of a completed write, and this only ever adds an attribute to
|
||||
/// one that already exists.
|
||||
///
|
||||
/// Both ends of a move pair are one fact filed under two keys, so marking either marks both.
|
||||
public func markHeal(at url: URL) {
|
||||
markHeal(atPath: Self.key(url))
|
||||
}
|
||||
|
||||
public func markHeal(atPath path: String) {
|
||||
receipts.withLock { store in
|
||||
guard var entry = store[path] else { return }
|
||||
entry.isHeal = true
|
||||
store[path] = entry
|
||||
if case let .move(from, to) = entry.receipt {
|
||||
let other = path == from ? to : from
|
||||
if var twin = store[other] {
|
||||
twin.isHeal = true
|
||||
store[other] = twin
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func forget(_ store: inout [String: Entry], under path: String) {
|
||||
let prefix = path + "/"
|
||||
for key in Array(store.keys) where key.hasPrefix(prefix) {
|
||||
store.removeValue(forKey: key)
|
||||
@@ -217,7 +265,17 @@ public final class EchoLedger: Sendable {
|
||||
}
|
||||
|
||||
public func receipt(atPath path: String) -> Receipt? {
|
||||
receipts.withLock { $0[path] }
|
||||
receipts.withLock { $0[path]?.receipt }
|
||||
}
|
||||
|
||||
/// Whether the receipt at this path is heal-marked — `false` for a path with no receipt at all,
|
||||
/// which is the same shrug every other read here gives an unknown path.
|
||||
public func isHeal(at url: URL) -> Bool {
|
||||
isHeal(atPath: Self.key(url))
|
||||
}
|
||||
|
||||
public func isHeal(atPath path: String) -> Bool {
|
||||
receipts.withLock { $0[path]?.isHeal ?? false }
|
||||
}
|
||||
|
||||
/// Every path the ledger holds a receipt for strictly *below* `folder`.
|
||||
@@ -247,7 +305,7 @@ public final class EchoLedger: Sendable {
|
||||
/// byte under an item would make every copy foreign.
|
||||
public func classify(_ observations: [String: Observation]) -> Provenance {
|
||||
let held: [(path: String, receipt: Receipt)] = receipts.withLock { store in
|
||||
observations.keys.compactMap { path in store[path].map { (path, $0) } }
|
||||
observations.keys.compactMap { path in store[path].map { (path, $0.receipt) } }
|
||||
}
|
||||
guard !held.isEmpty else { return .foreign }
|
||||
for entry in held {
|
||||
|
||||
@@ -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
|
||||
/// (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
|
||||
/// 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
|
||||
@@ -82,11 +90,17 @@ enum AgentGuide {
|
||||
/// the app cannot read is a file whose marker it cannot honestly claim to have checked.
|
||||
case file(text: String?)
|
||||
|
||||
/// A symlink, a directory, or any other node that is not a regular file. **Symlinks are
|
||||
/// never followed or touched anywhere in this app** (01-storage-format.md § Fractal layout
|
||||
/// ▸ Rules), and a folder named `CLAUDE.md` is somebody's deliberate arrangement; neither
|
||||
/// is displaced or overwritten to make room for a courtesy file.
|
||||
case untouchable
|
||||
/// A symlink, a directory, or any other node that is not a regular file — **a squatter on a
|
||||
/// claimed name**, and since 2026-07-29 not a resident (01-storage-format.md § Fractal
|
||||
/// layout ▸ Rules: "Lanework owns the board, so an invalid artifact on a claimed name is a
|
||||
/// defect, not a resident").
|
||||
///
|
||||
/// 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(_:)`.
|
||||
@@ -97,6 +111,20 @@ enum AgentGuide {
|
||||
/// rescue move re-checks this atomically anyway (`FileManager.moveItem` fails rather than
|
||||
/// overwrite), so this is the decision's input, not its safety.
|
||||
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.
|
||||
@@ -112,10 +140,16 @@ enum AgentGuide {
|
||||
|
||||
/// 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.
|
||||
///
|
||||
/// **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
|
||||
|
||||
/// `CLAUDE.md` is a symlink, a folder, or some other non-file. Skipped with a log.
|
||||
case skipUntouchable
|
||||
/// `CLAUDE.md` is a symlink, a folder, or some other non-file: move it aside by the
|
||||
/// 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
|
||||
@@ -128,8 +162,8 @@ enum AgentGuide {
|
||||
switch state.existing {
|
||||
case .missing:
|
||||
.write
|
||||
case .untouchable:
|
||||
.skipUntouchable
|
||||
case .squatted:
|
||||
.displaceSquatterThenWrite
|
||||
case let .file(text):
|
||||
if let text, let installed = installedVersion(of: text) {
|
||||
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
|
||||
/// on every reload.
|
||||
///
|
||||
/// **`attributesOfItem` throughout, never `fileExists`** — `lstat` semantics rather than `stat`:
|
||||
/// a **dangling** symlink is a node that is *there* (the rescue move would fail on it, and this
|
||||
/// app does not touch symlinks anyway), while `fileExists` follows the link, finds nothing, and
|
||||
/// would call the name free.
|
||||
/// **`lstat` semantics throughout, never `fileExists`** (`IntegrityRules.node(at:)`): a
|
||||
/// **dangling** symlink is a node that is *there* — it holds the name, and it is displaced as a
|
||||
/// link rather than followed — while `fileExists` follows the link, finds nothing, and would
|
||||
/// call the name free.
|
||||
static func inspect(atBoardRoot root: URL) -> State {
|
||||
State(
|
||||
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 {
|
||||
guard let type = (try? FileManager.default.attributesOfItem(atPath: url.path))?[.type]
|
||||
as? FileAttributeType
|
||||
else {
|
||||
return .missing
|
||||
}
|
||||
guard type == .typeRegular else { return .untouchable }
|
||||
guard let node = IntegrityRules.node(at: url) else { return .missing }
|
||||
guard node == .file else { return .squatted }
|
||||
// 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
|
||||
// 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
|
||||
/// "user content is never destroyed" promise hold against a race rather than merely against a
|
||||
/// stale read.
|
||||
static func install(
|
||||
atBoardRoot root: URL,
|
||||
displacingUserContent displace: Bool
|
||||
) throws(BoardWriteError) {
|
||||
///
|
||||
/// **It re-verifies against disk** (01-storage-format.md § Validation and healing: "every
|
||||
/// scheduled heal re-verifies its defect against disk at write time and no-ops when it is
|
||||
/// 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)
|
||||
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 {
|
||||
try FileManager.default.moveItem(at: guideURL, to: root.appendingPathComponent(userFilename))
|
||||
} catch {
|
||||
@@ -205,8 +249,48 @@ enum AgentGuide {
|
||||
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)
|
||||
// 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
|
||||
|
||||
+133
-159
@@ -91,7 +91,10 @@ public enum BoardLoader: Sendable {
|
||||
|
||||
/// 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.
|
||||
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
|
||||
/// 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.
|
||||
///
|
||||
/// Internal rather than `private`: `BoardWriter` moves folders into and out of this exact
|
||||
/// name, and a board can have only one trash.
|
||||
static let trashFolderName = ".trash"
|
||||
/// name, and a board can have only one trash. The name is `IntegrityRules`', with the rest of
|
||||
/// 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
|
||||
/// 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
|
||||
/// 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.
|
||||
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
|
||||
/// 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/`.
|
||||
///
|
||||
/// 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.
|
||||
static let reservedCardChildNames: Set<String> = [
|
||||
indexFileName, BoardWriter.attachmentsFolderName, "comments",
|
||||
]
|
||||
/// names on its own, so a caller passing a hand-made list cannot reach past this rule. The
|
||||
/// table itself is `IntegrityRules`', with every other reserved name.
|
||||
static let reservedCardChildNames: Set<String> = IntegrityRules.reservedCardChildNames
|
||||
|
||||
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)")
|
||||
}
|
||||
|
||||
// The carve-out's detection channel — deliberately *not* `warnings`, which is the
|
||||
// stray-*tolerance* vocabulary (see `LoadResult.looseCardFiles`).
|
||||
var looseCardFiles: [LooseCardFiles] = []
|
||||
// **The one typed defect stream** (02-architecture.md ▸ Components ▸ IntegrityRules): what
|
||||
// this walk found that is pending *work*, as distinct from `warnings`, which is the
|
||||
// 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
|
||||
// idiom (see `LoadResult.legacyTombstones`).
|
||||
var legacyTombstones: [LegacyTombstone] = []
|
||||
// Detected before the walk, so a board whose `.trash` is squatted reports it even though
|
||||
// the trash read below finds nothing to parse. Read-only here, like every other detection:
|
||||
// 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
|
||||
// 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.
|
||||
let loose = looseFileNames(in: cardURL)
|
||||
if !loose.isEmpty {
|
||||
looseCardFiles.append(LooseCardFiles(
|
||||
defects.append(.looseCardFiles(LooseCardFiles(
|
||||
laneID: ItemID(rawValue: laneName),
|
||||
cardID: ItemID(rawValue: cardName),
|
||||
title: card.title.value,
|
||||
fileNames: loose
|
||||
))
|
||||
)))
|
||||
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/`
|
||||
// and the key's removal are the store's, through the Writer.
|
||||
if card.isDeleted {
|
||||
legacyTombstones.append(LegacyTombstone(
|
||||
defects.append(.legacyTombstone(LegacyTombstone(
|
||||
kind: .card,
|
||||
laneID: ItemID(rawValue: laneName),
|
||||
cardID: ItemID(rawValue: cardName),
|
||||
title: card.title.value
|
||||
))
|
||||
)))
|
||||
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 {
|
||||
legacyTombstones.append(LegacyTombstone(
|
||||
defects.append(.legacyTombstone(LegacyTombstone(
|
||||
kind: .lane,
|
||||
laneID: ItemID(rawValue: laneName),
|
||||
cardID: nil,
|
||||
title: laneDocument.title.value
|
||||
))
|
||||
)))
|
||||
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 trashKinds: [ItemID: IntegrityRules.ObjectKind] = [:]
|
||||
for cardURL in trashCandidates(in: boardRoot) {
|
||||
let cardName = cardURL.lastPathComponent
|
||||
let cardRelPath = trashFolderName + "/" + cardName
|
||||
@@ -274,7 +288,17 @@ public enum BoardLoader: Sendable {
|
||||
warn(.missingIndex(path: cardRelPath))
|
||||
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(
|
||||
@@ -297,11 +321,24 @@ public enum BoardLoader: Sendable {
|
||||
return LoadResult(
|
||||
model: model,
|
||||
warnings: warnings,
|
||||
looseCardFiles: looseCardFiles,
|
||||
legacyTombstones: legacyTombstones
|
||||
defects: defects,
|
||||
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**.
|
||||
///
|
||||
/// 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
|
||||
/// *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
|
||||
/// reports. Logged rather than warned: `LoadWarning` is the stray vocabulary and a claimed
|
||||
/// name is not a stray, so there is no case here that fits and nothing for a user to do about
|
||||
/// a name the app claims.
|
||||
/// reports. Not a `LoadWarning`: that is the stray vocabulary, and a claimed name is not a
|
||||
/// stray. Since 2026-07-29 it is not merely logged either — the walk reports it as a
|
||||
/// `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
|
||||
/// folder-name order — so the trash gets the same stray tolerance every other container gets,
|
||||
@@ -360,7 +399,7 @@ public enum BoardLoader: Sendable {
|
||||
return []
|
||||
}
|
||||
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 (try? directoryCandidates(in: trashURL)) ?? []
|
||||
@@ -479,10 +518,6 @@ public enum BoardLoader: Sendable {
|
||||
.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** —
|
||||
/// 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
|
||||
@@ -509,10 +544,12 @@ public enum BoardLoader: Sendable {
|
||||
///
|
||||
/// Internal rather than `private`: `BoardWriter.renumberVisibleChildren` walks the same
|
||||
/// 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 {
|
||||
let groups = name.split(separator: "-", omittingEmptySubsequences: false)
|
||||
guard groups.map(\.count) == [8, 4, 4, 4, 12] else { return false }
|
||||
return groups.allSatisfy { $0.allSatisfy(uuidGroupCharacters.contains) }
|
||||
IntegrityRules.isIdentityShaped(name)
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// 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 {
|
||||
let document = try parseDocument(data, path: path)
|
||||
_ = try validatedSchema(in: document, path: path)
|
||||
_ = try validatedOrder(in: document, path: path)
|
||||
return document
|
||||
try IntegrityRules.validateIndex(data, path: path, kind: .card, supportedSchema: supportedSchema)
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
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
|
||||
}
|
||||
try IntegrityRules.validatedSchema(in: document, path: path, supportedSchema: supportedSchema)
|
||||
}
|
||||
|
||||
private 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
|
||||
}
|
||||
try IntegrityRules.validatedOrder(in: document, path: path)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Result
|
||||
|
||||
/// A successful load: the snapshot plus anything tolerated-but-notable encountered along the
|
||||
/// way. `warnings` is also logged as it accumulates (`os.Logger(subsystem: "dev.rzen.indie.Kanban",
|
||||
/// category: "loader")`) so it shows up in Console even if a caller never inspects it.
|
||||
/// A successful load: the snapshot, anything tolerated-but-notable encountered along the way, and
|
||||
/// the pending work the walk found. `warnings` is also logged as it accumulates
|
||||
/// (`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 var model: BoardModel
|
||||
public var warnings: [LoadWarning]
|
||||
|
||||
/// The cards this walk found carrying loose files, in the order the walk met them — the
|
||||
/// loose-file carve-out's detection channel (01-storage-format.md § Fractal layout ▸ Rules,
|
||||
/// settled 2026-07-28).
|
||||
/// **The typed defect stream** — everything this walk found that is pending *work*
|
||||
/// (02-architecture.md ▸ Components ▸ IntegrityRules, settled 2026-07-29). One channel, not
|
||||
/// 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.
|
||||
/// `warnings` is the *stray-tolerance* vocabulary: "this was ignored, it is staying exactly
|
||||
/// where it is, there is nothing to do". A loose card file is the one thing on a board that is
|
||||
/// **not** tolerated — it is pending work, and the store acts on it. Folding it into the
|
||||
/// warning channel would also mean throwing away everything the act needs (which lane, which
|
||||
/// card, which title, which names) and re-deriving it from a display string.
|
||||
/// **Deliberately not `warnings`**, which stays the *tolerate*-tier vocabulary: "this was
|
||||
/// ignored, it is staying exactly where it is, there is nothing to do". A defect says the
|
||||
/// opposite — it is work, and the store acts on it. Folding the two would also throw away
|
||||
/// everything a heal needs (which lane, which card, which title, which names) and force it to be
|
||||
/// re-derived from a display string.
|
||||
///
|
||||
/// Nothing renders this: a loose file is not content, and it reaches no view. Its one consumer
|
||||
/// is `BoardStore.relocateLooseCardFiles()`, which relocates and posts the notice.
|
||||
/// Nothing renders this. Order is the walk's: the board root's claimed names, then, lane by
|
||||
/// lane, each lane's cards and then the lane itself.
|
||||
///
|
||||
/// Tombstoned cards are included, and cards under tombstoned lanes with them. Where a file
|
||||
/// belongs on disk is a question about the *tree*, not about what the board is currently
|
||||
/// rendering — the same reason the loader flags a tombstoned card at all rather than dropping
|
||||
/// it.
|
||||
///
|
||||
/// **Cards in `.trash/` are deliberately *not* walked for loose files in this version.** The
|
||||
/// 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).
|
||||
/// **Cards in `.trash/` are deliberately not walked for loose files in this version.** The
|
||||
/// loose-file defect 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.
|
||||
///
|
||||
/// Board-level `deleted:` never appears here — it is meaningless, ignored and logged
|
||||
/// (`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.
|
||||
public var legacyTombstones: [LegacyTombstone] = []
|
||||
}
|
||||
|
||||
/// 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 var legacyTombstones: [LegacyTombstone] {
|
||||
defects.compactMap { if case let .legacyTombstone(work) = $0 { work } else { nil } }
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// 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).
|
||||
public var claimedNameSquatters: [ClaimedNameSquatter] {
|
||||
defects.compactMap { if case let .claimedNameSquatted(work) = $0 { work } else { nil } }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -44,11 +44,15 @@ public struct ItemID: Hashable, Sendable, RawRepresentable {
|
||||
self.rawValue = rawValue
|
||||
}
|
||||
|
||||
/// The comparison key: `rawValue` case-folded. Computed rather than stored so `ItemID` stays
|
||||
/// one string wide and `rawValue` remains the single source of truth for what is on disk.
|
||||
/// `lowercased()` is locale-independent, and every identity-shaped name is ASCII, so this is
|
||||
/// UUID-value canonicalization and nothing more.
|
||||
var canonicalValue: String { rawValue.lowercased() }
|
||||
/// The comparison key: `rawValue` case-folded, through the **one** canonicalization
|
||||
/// (`IntegrityRules.canonicalIdentity`, settled 2026-07-29 — the fold). Computed rather than
|
||||
/// stored so `ItemID` stays one string wide and `rawValue` remains the single source of truth
|
||||
/// for what is on disk.
|
||||
///
|
||||
/// 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 {
|
||||
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
|
||||
/// clearing rule (01-storage-format.md § Frontmatter). That path goes through
|
||||
/// `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(
|
||||
inItemFolder folder: URL,
|
||||
kind: IntegrityRules.ObjectKind? = nil,
|
||||
operation: WriteOperation,
|
||||
edits: (inout FrontmatterDocument) -> Void
|
||||
) throws(BoardWriteError) {
|
||||
@@ -64,12 +87,49 @@ public enum BoardWriter: Sendable {
|
||||
try checkEditable(document, at: indexURL, operation: operation)
|
||||
|
||||
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.remove(FrontmatterKeys.modifiedBy)
|
||||
|
||||
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
|
||||
|
||||
/// 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
|
||||
@@ -171,7 +235,7 @@ public enum BoardWriter: Sendable {
|
||||
/// `index.md`. Returns the new identity. See `createChild(inParent:title:operation:)` for
|
||||
/// the shared mechanics.
|
||||
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
|
||||
@@ -179,7 +243,7 @@ public enum BoardWriter: Sendable {
|
||||
/// Returns the new identity. See `createChild(inParent:title:operation:)` for the shared
|
||||
/// mechanics.
|
||||
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
|
||||
@@ -203,6 +267,7 @@ public enum BoardWriter: Sendable {
|
||||
private static func createChild(
|
||||
inParent parentFolder: URL,
|
||||
title: String?,
|
||||
kind: IntegrityRules.ObjectKind,
|
||||
operation: WriteOperation
|
||||
) throws(BoardWriteError) -> ItemID {
|
||||
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 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)
|
||||
}
|
||||
@@ -224,9 +293,21 @@ public enum BoardWriter: Sendable {
|
||||
/// `modified-by` is never written, matching the engine's absence-means-app-authored
|
||||
/// convention (§ Frontmatter). Key order — `schema`, `title` (only when supplied), `order`
|
||||
/// (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
|
||||
/// before the closing delimiter of an otherwise-empty document.
|
||||
private static func newDocumentText(title: String?, order: Double?) -> String {
|
||||
/// `modified`, `kind` — is simply the order `set` is called in, since each call appends a fresh
|
||||
/// key before the closing delimiter of an otherwise-empty document. `kind` goes last because
|
||||
/// 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: "")
|
||||
document.set(FrontmatterKeys.schema, to: .int(BoardLoader.supportedSchema))
|
||||
if let title {
|
||||
@@ -238,6 +319,7 @@ public enum BoardWriter: Sendable {
|
||||
let now = Date()
|
||||
document.set(FrontmatterKeys.created, to: .date(now))
|
||||
document.set(FrontmatterKeys.modified, to: .date(now))
|
||||
document.set(FrontmatterKeys.kind, to: .string(kind.rawValue))
|
||||
return document.serialized()
|
||||
}
|
||||
|
||||
@@ -273,7 +355,7 @@ public enum BoardWriter: Sendable {
|
||||
/// `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
|
||||
/// 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
|
||||
/// 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.
|
||||
@@ -450,7 +532,7 @@ public enum BoardWriter: Sendable {
|
||||
/// `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
|
||||
/// 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).
|
||||
/// 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
|
||||
@@ -507,10 +589,10 @@ public enum BoardWriter: Sendable {
|
||||
// "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
|
||||
// directory listing can disagree in case for one and the same folder.
|
||||
let selfIdentity = canonicalIdentity(sourceName)
|
||||
let selfIdentity = IntegrityRules.canonicalIdentity(sourceName)
|
||||
rank = Ranks.append(
|
||||
toVisible: siblings
|
||||
.filter { canonicalIdentity($0.folder.lastPathComponent) != selfIdentity }
|
||||
.filter { IntegrityRules.canonicalIdentity($0.folder.lastPathComponent) != selfIdentity }
|
||||
.map(\.order)
|
||||
)
|
||||
}
|
||||
@@ -528,7 +610,7 @@ public enum BoardWriter: Sendable {
|
||||
var reminted: [MoveResult.Remint] = []
|
||||
|
||||
var arrivedName = sourceName
|
||||
if existing.contains(canonicalIdentity(sourceName)) {
|
||||
if existing.contains(IntegrityRules.canonicalIdentity(sourceName)) {
|
||||
arrivedName = freshUUIDName(in: destinationParent, avoiding: reserved)
|
||||
reserved.insert(arrivedName)
|
||||
reminted.append(MoveResult.Remint(from: ItemID(rawValue: sourceName), to: ItemID(rawValue: arrivedName)))
|
||||
@@ -550,8 +632,8 @@ public enum BoardWriter: Sendable {
|
||||
|
||||
if isImport {
|
||||
let children = childCandidates(of: arrivedRoot)
|
||||
reserved.formUnion(children.map { canonicalIdentity($0.lastPathComponent) })
|
||||
for child in children where existing.contains(canonicalIdentity(child.lastPathComponent)) {
|
||||
reserved.formUnion(children.map { IntegrityRules.canonicalIdentity($0.lastPathComponent) })
|
||||
for child in children where existing.contains(IntegrityRules.canonicalIdentity(child.lastPathComponent)) {
|
||||
let fresh = freshUUIDName(in: arrivedRoot, avoiding: reserved)
|
||||
reserved.insert(fresh)
|
||||
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
|
||||
/// UUID in one board is the unspecified-behavior case the design already names, not a
|
||||
/// 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*
|
||||
/// equality, never string equality (§ Fractal layout ▸ Rules, settled) — an arriving
|
||||
/// `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> {
|
||||
var identities: Set<String> = []
|
||||
for lane in childCandidates(of: boardRoot) {
|
||||
identities.insert(canonicalIdentity(lane.lastPathComponent))
|
||||
identities.insert(IntegrityRules.canonicalIdentity(lane.lastPathComponent))
|
||||
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
|
||||
@@ -605,19 +687,11 @@ public enum BoardWriter: Sendable {
|
||||
// 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.
|
||||
for card in childCandidates(of: trashFolder(inBoard: boardRoot)) {
|
||||
identities.insert(canonicalIdentity(card.lastPathComponent))
|
||||
identities.insert(IntegrityRules.canonicalIdentity(card.lastPathComponent))
|
||||
}
|
||||
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
|
||||
/// 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"
|
||||
@@ -1016,6 +1090,13 @@ public enum BoardWriter: Sendable {
|
||||
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)
|
||||
}
|
||||
@@ -1044,6 +1125,7 @@ public enum BoardWriter: Sendable {
|
||||
try updateIndex(inItemFolder: laneFolder, operation: operation) { document in
|
||||
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
|
||||
@@ -1738,8 +1820,9 @@ public enum BoardWriter: Sendable {
|
||||
/// 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). Internal rather than `private`: `importAttachments` and `listAttachments`
|
||||
/// must never disagree about which folder holds a card's files.
|
||||
static let attachmentsFolderName = "attachments"
|
||||
/// must never disagree about which folder holds a card's files. The name itself is
|
||||
/// `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
|
||||
/// 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
|
||||
/// 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.
|
||||
///
|
||||
/// - 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
|
||||
public static func relocateLooseFiles(
|
||||
_ names: [String],
|
||||
inCard cardFolder: URL
|
||||
inCard cardFolder: URL,
|
||||
healMarked: Bool = true
|
||||
) throws(BoardWriteError) -> [ImportedAttachment] {
|
||||
guard !names.isEmpty else { return [] }
|
||||
|
||||
@@ -1912,7 +2002,10 @@ public enum BoardWriter: Sendable {
|
||||
try FileManager.default.moveItem(at: sourceURL, to: landedURL)
|
||||
// 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.
|
||||
// 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)
|
||||
if healMarked { EchoLedger.current?.markHeal(at: landedURL) }
|
||||
} catch {
|
||||
throw BoardWriteError(
|
||||
operation: operation,
|
||||
@@ -1939,7 +2032,14 @@ public enum BoardWriter: Sendable {
|
||||
/// A card with nothing loose is one directory listing and no write at all.
|
||||
@discardableResult
|
||||
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
|
||||
@@ -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
|
||||
/// 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.
|
||||
///
|
||||
/// **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 {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -2028,13 +2144,60 @@ public enum BoardWriter: Sendable {
|
||||
var counter = 2
|
||||
while true {
|
||||
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
|
||||
}
|
||||
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
|
||||
/// attachment surfaces … are flat: top-level files only"): the top-level *files* of
|
||||
/// `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.
|
||||
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).
|
||||
///
|
||||
/// 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 {
|
||||
switch self {
|
||||
case .createBoard, .createLane, .createCard, .importAttachment, .listAttachments,
|
||||
.removeAttachment, .renumberChildren, .relocateLooseFile, .agentGuide:
|
||||
.removeAttachment, .renumberChildren, .relocateLooseFile, .agentGuide,
|
||||
.displaceClaimedName:
|
||||
self
|
||||
case .move: .move(title: title)
|
||||
case .reorder: .reorder(title: title)
|
||||
@@ -2506,6 +2683,7 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
|
||||
case .renumberChildren: "renumber children"
|
||||
case let .relocateLooseFile(filename): "relocate loose file '\(filename)'"
|
||||
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 .editBody(title): Self.phrase("save the body of", 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 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> = [
|
||||
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.
|
||||
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: -
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user