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:
2026-07-29 15:45:48 -04:00
parent 0d846c634e
commit 3a9db2e78b
27 changed files with 3226 additions and 617 deletions
+113 -29
View File
@@ -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
View File
@@ -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 } }
}
}
+9 -5
View File
@@ -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
+211 -33
View File
@@ -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)
+7 -1
View File
@@ -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,
]
}
+10
View File
@@ -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> {
+582
View File
@@ -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
}
}