Files
lanework/Kanban/Storage/BoardLoader.swift
T
rzen 8014bde7c6 Lanes delete into the trash — storage, loader, writer, and undo
Phase 1 of the lanes-in-trash card (2026-07-29 ruling, docs led the
code): lane delete is a move into .trash/ with the subtree intact,
arriving at top trash rank — no destructive delete remains outside
the trash.

TrashedLane opaque unit (id/schema/title/order/heldCards) beside
trash cards — deliberately not a Lane, so no card-shaped surface can
believe an empty subtree. Loader's trash walk trusts the kind VALUE
(lane → opaque unit w/ held-card count counted at the loader's own
unit; card → ordinary card; absent/unrecognized → UUID-children
shape, empty-kindless falls to card per 01's honest limit). Writer:
moveIntoTrash generalized with kind passed never derived (an empty
lane would re-derive as card), deleteLaneToTrash mints against the
whole-container rank ladder. Retired: migrateTombstonedLane (lane
deleted: now ignored — loads live, bytes inert, tolerate-tier
warning), removeLane, captureSubtree/recreateSubtree and the
subtree-snapshot machinery. Undo inverse = move back to captured
strip position, redo replays at captured trash rank. Purge walks
lane subtrees; TrashModel.Freight phrases confirms with lane freight
("…and its 5 cards"). ItemPath gains .trashLane; resolve interleaves
the trash by rank; SearchFilter matches lane rows by title only.
Trashed-lane card windows dismiss and pending cuts void via the
ordinary vanish rule — no new plumbing.

Phase 2 (rendering, selection grammar, drag, a11y, agent guide)
follows. Both schemes 1858 tests / 318 suites green.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-30 16:16:57 -04:00

1186 lines
68 KiB
Swift

import Foundation
import os
/// Walks a board's folder tree and produces an immutable `BoardModel` snapshot — a pure
/// function of the tree (02-architecture.md § Layering ▸ Components). Enforces the fractal
/// layout's fail-fast and skip rules (01-storage-format.md § Fractal layout, Malformed input)
/// so a bad file either loudly rejects the whole load or is cleanly ignored — never a silent
/// partial result.
///
/// Level is position: root `index.md` → board, depth-1 folders → lanes, depth-2 folders →
/// cards. **Name shape gates level detection** (01-storage-format.md § Fractal layout ▸
/// Rules): only a folder whose name has a UUID's shape — hex, `8-4-4-4-12`, **any case and any
/// version** — is a lane/card *candidate* at those depths; see `isUUIDShaped` below for exactly
/// what's checked.
/// Anything else — even a directory holding a perfectly valid `index.md` — is a stray: skipped
/// with a `.nonUUIDFolderIgnored` warning, preserved verbatim on disk, and never descended
/// into. A hand-made `notes/` folder (or a broken `index.md` inside one) can never brick a
/// load; only a UUID-shaped candidate that is itself missing `index.md` still gets the older
/// `.missingIndex` warning, and only a UUID-shaped candidate's `index.md` can fail-fast.
///
/// Reserved child names (`attachments/`, `comments/`) only matter as children *of a card*
/// (01-storage-format.md § Fractal layout ▸ Rules), and cards are leaves *structurally*: the
/// walk stops at depth 2, so nothing below a card is ever a level candidate. Doubly so under the
/// shape rule — `attachments` and `comments` are non-UUID-shaped and would read as strays, not
/// levels, so they never need special-casing against the stray warning.
///
/// **Two reads inside a card folder**, both of them flat name listings and nothing more — neither
/// opens a file, descends, warns, or fails a load; each degrades to `[]`:
///
/// - `attachmentNames(in:)` — `attachments/`, feeding `Card.attachments`. The board window's face
/// needs it before a card window exists (the quiet paperclip indicator — 03-board-ui.md § Card
/// face), and the snapshot is where it reads from.
/// - `looseFileNames(in:)` — the card folder *itself*, feeding `LoadResult.looseCardFiles`. This is
/// the loose-file carve-out's **detection** half (01-storage-format.md § Fractal layout ▸ Rules,
/// settled 2026-07-28): a regular file sitting beside a card's `index.md` belongs in
/// `attachments/`, and the app relocates it. Detection stays read-only *here* — this loader is a
/// pure function of the tree and writes nothing, ever (the Repair precedent); the relocation is a
/// Writer-mediated app write the store schedules off the snapshot
/// (`BoardStore.relocateLooseCardFiles`).
///
/// Everything else about a card folder's contents remains outside this loader's business.
///
/// Symlinks: a lane/card candidate that is itself a symlink is treated as a stray and never
/// followed, whether it points to a file or a directory — this loader does not resolve
/// cross-volume or cyclic trees.
///
/// ## The trash is a container, not a level
///
/// `<root>/.trash/` is a **reserved, app-claimed board-root name** holding card *and lane* folders
/// interleaved directly (01-storage-format.md § Deletion, resettled 2026-07-28; lanes joined
/// 2026-07-29) — no `index.md` of its own. The walk therefore treats it as a second container beside
/// the lanes, parsing its UUID-shaped children with the same fail-fast on `schema`/`order` and the
/// same skip-and-warn rules the lane walk uses. Being reserved, it is **never a stray** and never
/// warns; absent, the trash is simply empty.
///
/// **`kind:` decides what each entry is** (`IntegrityRules.trashKind`): depth defines meaning on the
/// live board, but the trash is flat and an empty lane folder is shape-identical to a card folder.
/// A card lands in `BoardModel.trash` parsed like any card; a lane lands in
/// `BoardModel.trashedLanes` as an **opaque unit** — title, rank, and a count of the cards it holds
/// — and its subtree is never walked into the snapshot (03-board-ui.md § Trash).
///
/// ## The migration window
///
/// The tombstone model is retired: no `deleted:` key is ever written again. A key found on a **card**
/// is *migration input* — the card relocates into `.trash/` with the key removed. A key on a lane or
/// on the board is meaningless and stays where it is (ignored + logged, the tolerate tier: lanes
/// stopped migrating on 2026-07-29, when they gained a real trash of their own). Detection is
/// read-only here, the loose-file carve-out's posture exactly: this loader reports what it found
/// through `LoadResult.legacyTombstones` and the store schedules the Writer-mediated fix.
///
/// **Until that write lands, such cards still load through the retiring tombstone path** — a
/// `deleted:`-carrying card stays filed under its lane with `isDeleted` set. That is a deliberate
/// intermediate, not an oversight: the migration's whole promise is "never destroy", and the safe
/// direction while the fix is still pending (it is deferred under any read-only lock, and can be
/// pending for a whole session) is for nothing to silently vanish from view before its folder has
/// actually moved. The window closes per board on the first successful migration write.
public enum BoardLoader: Sendable {
/// Schema version this app understands; anything higher fails fast
/// (01-storage-format.md § Malformed input). Internal rather than `private`: read by
/// `BoardLoadError.Reason.description` below, and it is also the version `BoardWriter`
/// stamps into files it creates — one symbol, so the app can never write a file its own
/// loader would reject as newer-than-supported.
static let supportedSchema = 1
/// Board-level key for `BoardModel.template` — not schema-owned in the engine's sense
/// (`FrontmatterKeys.schemaOwned`), because its value is opaque and read raw here rather
/// than through a typed `FrontmatterDocument` accessor.
///
/// Internal rather than `private` for `indexFileName`'s reason: `TemplateEngine` writes this
/// key on a Save as Template copy — "the one writer of keyed files is Save as Template"
/// (09-templates.md ▸ Storage) — and the reader and that one writer must never disagree about
/// how it is spelled.
static let templateKey = "template"
/// Internal rather than `private`: `BoardWriter` names the same file, and the loader and
/// the writer must never disagree about which file a folder's content lives in.
///
/// The 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
/// seeded `.gitignore` on the claimed list.
///
/// Dot-prefixed, which is doing real work rather than being decoration: `directoryCandidates`
/// skips hidden entries, so the container can never be mistaken for a lane candidate and can
/// never earn a `.nonUUIDFolderIgnored` warning. The lane walk skips it by name as well
/// (`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. 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
/// app-claimed, not strays", plus `.trash/` from § Deletion).
///
/// Only `.trash` is a *folder* and so the only one the directory walk could ever reach; the
/// 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.
///
/// 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
/// … untouched"): 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 —
/// `attachments/` is still the one folder this app ever creates under a card.
///
/// **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/`.
///
/// 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. 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")
// MARK: - Entry point
public static func load(
boardRoot: URL,
historyRanker: IdentityHistoryRanker? = nil
) throws(BoardLoadError) -> LoadResult {
try checkIsReadableDirectory(boardRoot)
let boardIndexURL = boardRoot.appendingPathComponent(indexFileName)
guard FileManager.default.fileExists(atPath: boardIndexURL.path) else {
throw BoardLoadError(path: indexFileName, reason: .boardRootMissingIndex)
}
let boardDocument = try readDocument(at: boardIndexURL, path: indexFileName)
let boardSchema = try validatedSchema(in: boardDocument, path: indexFileName)
var warnings: [LoadWarning] = []
func warn(_ warning: LoadWarning) {
warnings.append(warning)
logger.warning("\(warning.description, privacy: .public)")
}
// **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 coerce tier's trace** (01-storage-format.md § Frontmatter, ruled 2026-07-29: "A
// no-sensible-reading fallback logs: field, path, and raw text, carried as coerce-tier entries
// in the integrity service's Defect stream"). Called once per document this walk parses, at
// every level, because the rule is about *fields* and every level has them — and called here
// rather than inside `readDocument` for the reason the whole defect stream lives in `load`: the
// reading functions are pure and total, and the walk is what owns what it found.
//
// `logger.info`, not `warning`: the value rendered as its default, nothing is degraded, and the
// line exists to be findable later rather than to be noticed now ("no banner, no behavior
// change").
func noteCoercions(in document: FrontmatterDocument, at path: String) {
let fields = document.coercedFields
guard !fields.isEmpty else { return }
defects.append(.coercedFrontmatter(CoercedFrontmatter(path: path, fields: fields)))
for field in fields {
logger.info(
"\(path, privacy: .public): '\(field.key, privacy: .public)' has no sensible reading — \(field.raw, privacy: .public) — rendering the field's default"
)
}
}
// 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
// — ignored and logged, preserved verbatim" (01-storage-format.md § Deletion). It is
// deliberately absent from `legacyTombstones`: there is no item to relocate and no key
// the app has any business removing from a file it was told to leave alone.
if !boardDocument.deleted.isMissing {
warn(.boardLevelDeletedIgnored)
}
noteCoercions(in: boardDocument, at: indexFileName)
// Every lane the walk read, in folder order — **not** `Lane` values yet. The board-wide
// identity dedupe below decides which folders render at all, and a `Lane` is built only on
// the far side of that decision, because a `Lane` carrying a withheld card would be exactly
// the snapshot the invariant forbids.
var walkedLanes: [WalkedLane] = []
for laneURL in try directoryCandidates(in: boardRoot) {
let laneName = laneURL.lastPathComponent
// The app-claimed board-root names are not strays and must not warn as such. Hidden
// entries never reach here anyway (`.trash` included), so this is the rule stated
// rather than the mechanism relied on.
guard !reservedRootNames.contains(laneName.lowercased()) else { continue }
guard isUUIDShaped(laneName) else {
warn(.nonUUIDFolderIgnored(path: laneName))
continue
}
guard hasIndex(laneURL) else {
warn(.missingIndex(path: laneName))
continue
}
let lanePath = laneName + "/" + indexFileName
let laneDocument = try readDocument(at: laneURL.appendingPathComponent(indexFileName), path: lanePath)
let laneSchema = try validatedSchema(in: laneDocument, path: lanePath)
let laneOrder = try validatedOrder(in: laneDocument, path: lanePath)
noteCoercions(in: laneDocument, at: lanePath)
var cards: [Card] = []
for cardURL in try directoryCandidates(in: laneURL) {
let cardName = cardURL.lastPathComponent
let cardRelPath = laneName + "/" + cardName
guard isUUIDShaped(cardName) else {
warn(.nonUUIDFolderIgnored(path: cardRelPath))
continue
}
guard hasIndex(cardURL) else {
warn(.missingIndex(path: cardRelPath))
continue
}
let card = try parseCard(at: cardURL, path: cardRelPath)
noteCoercions(in: card.document, at: cardRelPath + "/" + indexFileName)
// **The card-level claimed name** (01-storage-format.md § Fractal layout ▸ Rules,
// extended 2026-07-29 — "the rule is level-uniform"): a file or symlink wearing
// `attachments` breaks every import into this card, every Finder drop on it and the
// window's listing for as long as it stands, so it is scheduled work exactly as a
// squatted `.trash` is. Detection is read-only here, like every other defect; the
// displacement is the store's, through the Writer.
for squatter in IntegrityRules.squattedClaimedNames(inCardAt: cardURL, path: cardRelPath) {
defects.append(.claimedNameSquatted(squatter))
logger.warning(
"\(cardRelPath, privacy: .public)/\(squatter.name, privacy: .public): claimed name held by \(squatter.found.description, privacy: .public) — to be displaced"
)
}
// Noticed, never acted on: the relocation is the store's, through the Writer.
let loose = looseFileNames(in: cardURL)
if !loose.isEmpty {
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 {
defects.append(.legacyTombstone(LegacyTombstone(
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)/")
}
cards.append(card)
}
// **A lane's legacy `deleted:` is tolerate-tier, not work** (01-storage-format.md
// § Deletion, lane clause re-ruled 2026-07-29): "a lane carrying `deleted:` simply loads
// live with the key ignored — no migration machinery, no key-strip write, no notice
// (backward compatibility deliberately not funded …; the key is inert, preserved verbatim
// like any unhandled key, logged)". A warning, exactly like the board-level key: this was
// ignored, it is staying exactly where it is, there is nothing to do.
if !laneDocument.deleted.isMissing {
warn(.laneLevelDeletedIgnored(path: laneName))
}
walkedLanes.append(WalkedLane(
name: laneName,
schema: laneSchema,
order: laneOrder,
document: laneDocument,
cards: Ranks.sortedForDisplay(cards, order: \.order, name: { $0.id.rawValue })
))
}
var trash: [Card] = []
var trashedLanes: [TrashedLane] = []
/// Every trash entry as the dedupe needs it, kind-blind — the container is one flat list to
/// the identity rule, whatever the snapshot splits it into.
var trashEntries: [(id: ItemID, title: String?, order: Double)] = []
var trashKinds: [ItemID: IntegrityRules.ObjectKind] = [:]
for entryURL in trashCandidates(in: boardRoot) {
let entryName = entryURL.lastPathComponent
let entryRelPath = trashFolderName + "/" + entryName
guard isUUIDShaped(entryName) else {
warn(.nonUUIDFolderIgnored(path: entryRelPath))
continue
}
guard hasIndex(entryURL) else {
warn(.missingIndex(path: entryRelPath))
continue
}
// The two kinds are validated identically — `schema` and `order` are required of both
// (`IntegrityRules.requiresOrder`) — so the strict parse happens once, before the
// discriminator, and a malformed entry fails fast whichever kind it turns out to be.
let entryPath = entryRelPath + "/" + indexFileName
let document = try readDocument(at: entryURL.appendingPathComponent(indexFileName), path: entryPath)
let schema = try validatedSchema(in: document, path: entryPath)
let order = try validatedOrder(in: document, path: entryPath)
noteCoercions(in: document, at: entryPath)
// **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.
//
// The children are listed at most once per entry and only where an answer needs them —
// the shape fallback asks when `kind` did not, the lane arm asks for the count — so a
// board of trashed cards written by this app pays for no directory reads at all.
var listed: [URL]?
func children() -> [URL] {
if let listed { return listed }
let found = identityShapedChildren(of: entryURL)
listed = found
return found
}
let kind = IntegrityRules.trashKind(
kindValue: document.kind.value,
hasIdentityShapedChildIndex: !children().isEmpty
)
let id = ItemID(rawValue: entryName)
trashKinds[id] = kind
trashEntries.append((id: id, title: document.title.value, order: order))
switch kind {
case .lane:
// **The subtree is counted, never walked** (03-board-ui.md § Trash: an opaque unit
// showing its title and held-card count). The count is the same listing the shape
// fallback asks for, so a `kind: lane` entry pays for exactly one directory read and
// a kindless one pays for none extra.
trashedLanes.append(TrashedLane(
id: id,
schema: schema,
title: document.title,
order: order,
heldCards: children().count,
document: document
))
case .card, .board:
// `kind: board` never reaches here as itself — `trashKind` treats it as unrecognized
// and answers by shape — so this arm is the card answer and nothing else.
trash.append(Card(
id: id,
schema: schema,
title: document.title,
created: document.created,
modified: document.modified,
modifiedBy: document.modifiedBy,
deleted: document.deleted,
background: document.background,
icon: document.icon,
iconColor: document.iconColor,
order: order,
attachments: attachmentNames(in: entryURL),
document: document
))
}
}
// **The board-wide identity dedupe** (01-storage-format.md § Fractal layout ▸ Rules:
// "Duplicate ids within a board are never tolerated … the loader keeps exactly one occurrence
// per id, board-wide"). Live lanes *and* `.trash/`, because board-wide means both containers —
// and a snapshot carrying two items with equal ids is the one thing SwiftUI's `ForEach` does
// not tolerate, which is why this is subtractive rather than advisory.
//
// Display order is settled here rather than in the `BoardModel` call below, because the
// dedupe's last tie-break *is* traversal order and the rule needs the occurrences in it.
let orderedLanes = Ranks.sortedForDisplay(walkedLanes, order: \.order, name: \.name)
let orderedTrash = Ranks.sortedForDisplay(trash, order: \.order, name: { $0.id.rawValue })
let orderedTrashedLanes = Ranks.sortedForDisplay(trashedLanes, order: \.order, name: { $0.id.rawValue })
// The trash's own display order, **both kinds at once** — the column interleaves them by rank
// (03-board-ui.md § Trash), and the dedupe's last tie-break is stated in traversal order, so
// the two kinds are merged before the rule sees them rather than after.
let orderedTrashEntries = Ranks.sortedForDisplay(trashEntries, order: \.order, name: { $0.id.rawValue })
let verdict = dedupeIdentities(
inBoardAt: boardRoot,
lanes: orderedLanes,
trash: orderedTrashEntries,
historyRanker: historyRanker
)
// Case twins are the **tolerate** tier: a spelling artifact of the item that rendered, with
// nothing to do about it (§ Fractal layout ▸ Rules). They warn, exactly like every other
// skipped stray, and are deliberately absent from `defects`.
for twin in verdict.caseTwins {
warn(.caseTwinIgnored(path: twin.path, winner: twin.winner))
}
// Content duplicates are **work**: withheld here, reminted by the store's scheduled heal
// (re-ruled 2026-07-29 — the loader itself still never writes).
for duplicate in verdict.duplicates {
defects.append(.duplicateIdentity(duplicate))
logger.warning(
"\(duplicate.path, privacy: .public): duplicate id, withheld in favour of \(duplicate.winner, privacy: .public) — to be reminted"
)
}
let withheld = Set(verdict.duplicates.map(\.path) + verdict.caseTwins.map(\.path))
let model = BoardModel(
rootURL: boardRoot,
schema: boardSchema,
title: boardDocument.title,
created: boardDocument.created,
modified: boardDocument.modified,
modifiedBy: boardDocument.modifiedBy,
deleted: boardDocument.deleted,
background: boardDocument.background,
icon: boardDocument.icon,
iconColor: boardDocument.iconColor,
template: boardDocument.value(for: templateKey),
lanes: orderedLanes.compactMap { $0.rendered(withholding: withheld) },
trash: orderedTrash.filter { !withheld.contains(trashFolderName + "/" + $0.id.rawValue) },
trashedLanes: orderedTrashedLanes.filter { !withheld.contains(trashFolderName + "/" + $0.id.rawValue) },
document: boardDocument
)
return LoadResult(
model: model,
warnings: warnings,
defects: defects,
// Keyed by identity, so a withheld entry's reading has to go with it: two folders sharing
// an id would otherwise leave a `kind` answer standing for the *other* one — the exact
// ambiguity the dedupe exists to remove.
trashKinds: trashKinds.filter { !withheld.contains(trashFolderName + "/" + $0.key.rawValue) }
)
}
// MARK: - The board-wide identity dedupe
/// Every identity-bearing folder in the board, deduped — the loader's half of the rule whose
/// *deciding* is `IntegrityRules.dedupe(_:)`.
///
/// **The gate comes first, and it is why this costs nothing on a healthy board**: a pass over the
/// names alone answers "does any identity appear twice at all", and on the overwhelmingly common
/// answer — no — nothing further happens: no `creationDateKey` reads, no history probes, no
/// grouping. The disk reads below are paid for only by a board that actually has a collision.
///
/// **Withheld subtrees are still walked** (settled — the import boundary's finest-grain rule read
/// for the heal): a withheld *lane*'s cards are ordinary depth-2 occurrences and enter the
/// inventory like any other, so a collision nested inside a losing lane is its own withheld
/// occurrence with its own remint. Nothing deeper is an occurrence at all — "level is position",
/// and a UUID-shaped folder under a card is content.
///
/// **The container side is told to the rule, not derived from the path**: this walk knows which
/// container it is in, and the container boundary is the rule's *first* tie-break (01-storage-format.md
/// § Fractal layout ▸ Rules, stated 2026-07-29 — a live occurrence keeps the identity regardless of
/// age), so re-deriving it from a `".trash/"` prefix downstream would be parsing back a fact that
/// was in hand here.
///
/// Occurrence order is the traversal order the rule's *last* tie-break is stated in: lanes in
/// display order, each lane immediately followed by its cards in display order, then `.trash/`'s
/// flat entries in display order. The trash sits last only because the walk meets it last —
/// nothing rides on that any more, because the straddle case is decided by rung 0 long before
/// traversal order is consulted.
private static func dedupeIdentities(
inBoardAt root: URL,
lanes: [WalkedLane],
trash: [(id: ItemID, title: String?, order: Double)],
historyRanker: IdentityHistoryRanker?
) -> IntegrityRules.DedupeVerdict {
typealias Container = IntegrityRules.IdentityOccurrence.Container
// (path, name, container, title) in traversal order — the inventory, before anything is read
// from disk.
var inventory: [(path: String, name: String, container: Container, title: String?)] = []
for lane in lanes {
inventory.append((
path: lane.name,
name: lane.name,
container: .live,
title: lane.document.title.value
))
for card in lane.cards {
inventory.append((
path: lane.name + "/" + card.id.rawValue,
name: card.id.rawValue,
container: .live,
title: card.title.value
))
}
}
for entry in trash {
inventory.append((
path: trashFolderName + "/" + entry.id.rawValue,
name: entry.id.rawValue,
container: .trashed,
title: entry.title
))
}
// The gate.
var seen: Set<String> = []
var collides = false
for entry in inventory where !seen.insert(IntegrityRules.canonicalIdentity(entry.name)).inserted {
collides = true
break
}
guard collides else { return IntegrityRules.DedupeVerdict(duplicates: [], caseTwins: []) }
return IntegrityRules.dedupe(inventory.map { entry in
IntegrityRules.IdentityOccurrence(
path: entry.path,
name: entry.name,
container: entry.container,
title: entry.title,
birth: birthDate(of: root.appendingPathComponent(entry.path, isDirectory: true)),
historyRank: historyRanker?.rank(entry.path)
)
})
}
/// A folder's filesystem birth date, `nil` when the volume does not keep one or the read fails —
/// the second rung of the earlier-occurrence-wins ladder (§ Fractal layout ▸ Rules: "without
/// history, the older folder (filesystem birth date) wins").
///
/// `nil` is a first-class answer rather than a fallback date: an unreadable birth date must drop
/// the comparison to traversal order, and substituting `.distantPast` here would silently make an
/// unreadable folder *win* every comparison it entered.
private static func birthDate(of folder: URL) -> Date? {
(try? folder.resourceValues(forKeys: [.creationDateKey]))?.creationDate
}
/// One lane as the walk read it, before the dedupe decided what renders — a `Lane` minus the
/// decision, which is the only reason it exists rather than the walk building `Lane` values
/// directly.
///
/// It carries the folder *name* rather than an `ItemID` because the dedupe's whole subject is
/// spelling and identity being different questions, and the verbatim name is what answers both.
private struct WalkedLane {
let name: String
let schema: Int
let order: Double
let document: FrontmatterDocument
/// Already in display order — the traversal the dedupe's last tie-break is stated in.
let cards: [Card]
/// The `Lane` this becomes, or `nil` when the lane folder itself is withheld — a withheld
/// lane takes its subtree out of the snapshot with it, and its cards' own collisions were
/// already decided (they are occurrences in their own right).
func rendered(withholding withheld: Set<String>) -> Lane? {
guard !withheld.contains(name) else { return nil }
return Lane(
id: ItemID(rawValue: name),
schema: schema,
title: document.title,
created: document.created,
modified: document.modified,
modifiedBy: document.modifiedBy,
deleted: document.deleted,
background: document.background,
icon: document.icon,
iconColor: document.iconColor,
order: order,
width: document.width,
cards: cards.filter { !withheld.contains(name + "/" + $0.id.rawValue) },
document: document
)
}
}
// MARK: - The earlier-occurrence-wins history seam
/// **Where git path history plugs into the duplicate-id winner rule** (01-storage-format.md
/// § Fractal layout ▸ Rules: "on git boards, the path history already tracks outranks the
/// newcomer (both tracked: the path that entered history first)").
///
/// A seam rather than an implementation because the first rung of that ladder is unbuildable in
/// base: base Lanework links no git machinery at all (12-editions.md; `scripts/verify-editions.sh`
/// scans the base binary to prove it), so the loader consults an injected ranker and falls through
/// to birth date and traversal order when there is none — which is every base board, and every Pro
/// board without a repo.
///
/// Deliberately one closure and no protocol: the loader asks one question — "how early did this
/// path enter history" — and pro-m1's implementation answers it from `git log --diff-filter=A
/// --follow`-shaped plumbing behind the edition seam. `nil` means "untracked, or no history
/// here", which the rule reads as *outranked by anything tracked*.
///
/// - Parameter rank: keyed by the occurrence's **board-root-relative path**, which is what a
/// repo's path history knows; lower is earlier.
public struct IdentityHistoryRanker: Sendable {
public let rank: @Sendable (String) -> Int?
public init(rank: @escaping @Sendable (String) -> Int?) {
self.rank = rank
}
}
/// A folder's **identity-shaped children holding their own `index.md`** — the cards a trash
/// entry would render as, which is two answers in one listing (01-storage-format.md § Deletion:
/// "UUID-shaped children with their own `index.md` → lane … else card"; 03-board-ui.md § Trash:
/// the row's held-card count).
///
/// Deliberately not a parse: this asks what the folder *holds*, 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 — so the count is a fact about the freight
/// and the entry stays opaque.
private static func identityShapedChildren(of folder: URL) -> [URL] {
let children = (try? directoryCandidates(in: folder)) ?? []
return children.filter { isUUIDShaped($0.lastPathComponent) && hasIndex($0) }
}
/// One card folder read into a `Card` — the lane walk's card parse.
///
/// The trash's own walk reads its entries inline instead, because the container is flat and its
/// kind is `kind:`'s to answer before a `Card` can be built at all: the two share their strict
/// `schema`/`order` validation (`IntegrityRules`' rulebook, one rule) rather than sharing a
/// function that has already decided what it is reading.
///
/// `path` is root-relative and names the *folder*; the errors this throws name its `index.md`.
/// Callers guard `isUUIDShaped` and `hasIndex` first, exactly as the lane walk always has.
private static func parseCard(at cardURL: URL, path: String) throws(BoardLoadError) -> Card {
let cardPath = path + "/" + indexFileName
let document = try readDocument(at: cardURL.appendingPathComponent(indexFileName), path: cardPath)
let schema = try validatedSchema(in: document, path: cardPath)
let order = try validatedOrder(in: document, path: cardPath)
return Card(
id: ItemID(rawValue: cardURL.lastPathComponent),
schema: schema,
title: document.title,
created: document.created,
modified: document.modified,
modifiedBy: document.modifiedBy,
deleted: document.deleted,
background: document.background,
icon: document.icon,
iconColor: document.iconColor,
order: order,
attachments: attachmentNames(in: cardURL),
document: document
)
}
/// The candidate entry folders inside `<root>/.trash/`, or `[]` when there is no trash.
///
/// **Absent is empty, not an error** — the container is minted by the first delete, so most
/// boards never have one, and a board without a trash is a board with an empty trash.
///
/// **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. 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.
/// **Nothing below an entry is ever enumerated as a level**: the walk stops at a trash entry
/// exactly as it stops at a card under a lane, so a lane-shaped folder here reads as one opaque
/// entry (a trashed lane, by `kind` or by shape) and its cards are counted rather than walked.
private static func trashCandidates(in boardRoot: URL) -> [URL] {
let trashURL = boardRoot.appendingPathComponent(trashFolderName, isDirectory: true)
guard let values = try? trashURL.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey]) else {
return []
}
guard values.isDirectory == true, values.isSymbolicLink != true else {
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)) ?? []
}
// MARK: - Filesystem helpers
private static func checkIsReadableDirectory(_ url: URL) throws(BoardLoadError) {
var isDirectory: ObjCBool = false
guard FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory) else {
throw BoardLoadError(path: ".", reason: .unreadableRoot(message: "no such file or directory"))
}
guard isDirectory.boolValue else {
throw BoardLoadError(path: ".", reason: .notADirectory)
}
}
private static func hasIndex(_ folder: URL) -> Bool {
FileManager.default.fileExists(atPath: folder.appendingPathComponent(indexFileName).path)
}
/// The names of `<card>/attachments/`'s **top-level regular files** — the flat view
/// 01-storage-format.md § Attachments specifies ("top-level files only"; "subfolders are
/// tolerated, preserved verbatim … and not surfaced"). `[]` when there is no `attachments/`.
///
/// Three exclusions, the same three `directoryCandidates` makes and for the same reasons:
/// hidden entries (`.DS_Store` and friends are not the user's attachments), directories (a
/// subfolder stays reachable through Reveal in Finder and through body-relative paths, but
/// never appears as an attachment), and symlinks (this loader resolves nothing — the same
/// stance the level walk takes).
///
/// **Finder order** (`localizedStandardCompare`), so `"shot 2.png"` sorts before
/// `"shot 10.png"`: the order has to be stable across loads, and where it is already the
/// sidebar's order it may as well be the same one.
///
/// Failure is silent: an unlistable directory yields `[]`. Fail-fast is reserved for
/// structure (01-storage-format.md § Malformed input), and this field decorates a card — a
/// permissions race here must never be the reason a whole board refuses to open.
///
/// Internal rather than `private`: `BoardWriter.listAttachments` — the card window sidebar's
/// authoritative listing — answers through this same function behind its own card-folder
/// guard, so the face and the sidebar can never disagree about what a card's attachments are.
/// It is also why the folder name is read off `BoardWriter`, which owns it as the one folder
/// the app ever creates under a card.
static func attachmentNames(in cardFolder: URL) -> [String] {
let folder = cardFolder.appendingPathComponent(
BoardWriter.attachmentsFolderName, isDirectory: true
)
guard let entries = try? FileManager.default.contentsOfDirectory(
at: folder,
includingPropertiesForKeys: [.isRegularFileKey, .isSymbolicLinkKey],
options: [.skipsHiddenFiles]
) else {
return []
}
return entries
.filter { url in
guard let values = try? url.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey]) else {
return false
}
return values.isRegularFile == true && values.isSymbolicLink != true
}
.map(\.lastPathComponent)
.sorted { $0.localizedStandardCompare($1) == .orderedAscending }
}
/// A card folder's **loose top-level files** — the one carve-out to uniform stray tolerance
/// (01-storage-format.md § Fractal layout ▸ Rules, settled 2026-07-28, "Lanework-owns-the-board"):
/// "a regular file sitting beside a card's `index.md` (not `attachments/`, not a reserved name)
/// belongs in `attachments/`, and the app moves it there".
///
/// **This function only notices.** It opens nothing, moves nothing, and creates nothing; the
/// relocation is `BoardWriter.relocateLooseFiles`, run through the store's write bracket. A load
/// is a pure function of the tree and stays one.
///
/// Four exclusions, three of them `attachmentNames(in:)`' own and for its reasons:
///
/// - **Directories.** The carve-out is exactly *files*. A stray folder in a card — a nested
/// clone, a hand-made subfolder — keeps the verbatim posture, because "relocating a directory
/// into the flat attachment model would be wrong".
/// - **Symlinks**, never touched and never traversed (§ Rules) — the same stance the level walk
/// takes. `isSymbolicLink` is checked *beside* `isRegularFile` rather than trusted to imply
/// it, exactly as `directoryCandidates` does, so a link pointing at a file is excluded on its
/// own account.
/// - **Hidden entries.** `.DS_Store` and friends are not the user's files, and relocating one
/// would surface it in a card's attachment list — the loudest possible way to be wrong about
/// a file nobody wrote on purpose. It is also what keeps a crashed write's dot-prefixed
/// residue out of the relocation.
/// - **The reserved card-level names** (`reservedCardChildNames`), case-insensitively.
///
/// Finder order (`localizedStandardCompare`), like every other name listing here, so the notice
/// the store posts names files the way the board would sort them.
///
/// Failure is silent (`[]`): a permissions race here must never be the reason a board refuses
/// to open, and "nothing to relocate" is the safe reading of "cannot tell".
static func looseFileNames(in cardFolder: URL) -> [String] {
guard let entries = try? FileManager.default.contentsOfDirectory(
at: cardFolder,
includingPropertiesForKeys: [.isRegularFileKey, .isSymbolicLinkKey],
options: [.skipsHiddenFiles]
) else {
return []
}
return entries
.filter { url in
guard !reservedCardChildNames.contains(url.lastPathComponent.lowercased()),
let values = try? url.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey])
else {
return false
}
return values.isRegularFile == true && values.isSymbolicLink != true
}
.map(\.lastPathComponent)
.sorted { $0.localizedStandardCompare($1) == .orderedAscending }
}
/// 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
/// **shape-only**: lowercase v4 is the app's emission rule, not the gate.
///
/// - **Any case.** `uuidgen(1)` and Swift's own `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: `BoardWriter` still writes only lowercase v4 and never renames an
/// existing folder to canonicalize it.
/// - **Any version.** The version (13th hex digit) and variant (17th hex digit) nibbles are
/// **not** validated: they protect no invariant here — an agent's v7 is exactly as unique
/// as a v4 — and the loader's job is recognizing the folder-naming *convention*, not
/// re-deriving RFC 4122 conformance every load.
///
/// Equivalent to "does `UUID(uuidString:)` parse it", which is how 01-storage-format.md
/// states the rule; kept as a manual scan because that is the cheaper answer on the hot path
/// (every folder of every load) and needs no bridging.
///
/// Recognizing a name is not the same as *comparing* two of them: identity comparison is
/// UUID-*value* equality, so two case-spellings of one UUID are one identity everywhere —
/// see `ItemID` (`BoardModel.swift`), which stores the folder's exact spelling but compares
/// canonically.
///
/// 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 {
IntegrityRules.isIdentityShaped(name)
}
/// Direct subdirectories of `folder`, in deterministic (folder-name) order, excluding
/// hidden entries (`.DS_Store`, `.git`, …) and symlinks — the loader's uniform stray
/// tolerance (01-storage-format.md § Fractal layout ▸ Rules). Stray *files* are excluded
/// here too: only directories are level candidates at all, and the caller further narrows
/// those to actual lane/card candidates by name shape (`isUUIDShaped`) before doing
/// anything else with them.
///
/// **`.trash/` is hidden, so it never appears among a board root's candidates** — which is
/// exactly right: it is a container, not a level, and the walk reaches it by name instead
/// (`trashCandidates(in:)`). This same call then enumerates *inside* it, so the trash's own
/// children get identical stray tolerance.
///
/// An unreadable non-root folder (permission changed mid-walk, races) degrades to "no
/// candidates" rather than failing the whole load — fail-fast is reserved for the board
/// root and for malformed `index.md` content, not transient directory-listing races below
/// it.
/// Internal rather than `private`: `BoardWriter.renumberVisibleChildren` enumerates
/// siblings through this same door, so the writer's idea of "the children" can never drift
/// from the loader's. It is also why `BoardWriter`'s temp files are dot-prefixed — the
/// `.skipsHiddenFiles` here is what makes a crashed write's residue invisible to a load.
static func directoryCandidates(in folder: URL) throws(BoardLoadError) -> [URL] {
guard let entries = try? FileManager.default.contentsOfDirectory(
at: folder,
includingPropertiesForKeys: [.isDirectoryKey, .isSymbolicLinkKey],
options: [.skipsHiddenFiles]
) else {
return []
}
return entries
.filter { url in
guard let values = try? url.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey]) else {
return false
}
return values.isDirectory == true && values.isSymbolicLink != true
}
.sorted { $0.lastPathComponent < $1.lastPathComponent }
}
// MARK: - Document reading + field validation
/// **Strict, byte-faithful UTF-8** — the same decode `BoardWriter` uses, and for the same
/// reason: Foundation's NSString-backed `String(contentsOf:encoding:)` silently strips a
/// leading BOM, which would let a BOM'd file *load* here and then refuse every write over
/// in `BoardWriter` — a baffling split. 01-storage-format.md § Fractal layout ▸ Rules is
/// explicit that a BOM'd file is rejected at load (it fails the frontmatter delimiter);
/// decoding byte-faithfully is what makes that stated rejection actually happen.
private static func readDocument(at url: URL, path: String) throws(BoardLoadError) -> FrontmatterDocument {
let data: Data
do {
data = try Data(contentsOf: url)
} catch {
throw BoardLoadError(
path: path,
reason: .unparseableYAML(message: "could not read file: \(error.localizedDescription)", line: nil)
)
}
return try parseDocument(data, path: path)
}
/// The decode-and-parse half of `readDocument(at:path:)`, over bytes rather than a URL.
///
/// Split out for the raw-source outlet, which validates bytes that are **not on disk yet**
/// (`validateCardIndex`) — and split rather than copied on purpose: "Apply validates through the
/// same fail-fast parse the loader uses" (05-card-window.md ▸ Raw source outlet) is only true if
/// it is literally the same function. The strict UTF-8 decode is half of what that buys — a BOM'd
/// or non-UTF-8 proposal is rejected here by the same two lines that reject one on disk
/// (01-storage-format.md § Fractal layout ▸ Rules).
static func parseDocument(_ data: Data, path: String) throws(BoardLoadError) -> FrontmatterDocument {
guard let text = String(validating: data, as: UTF8.self) else {
throw BoardLoadError(path: path, reason: .unparseableYAML(message: "file is not UTF-8", line: nil))
}
do {
return try FrontmatterDocument.parse(text)
} catch {
let line: Int? = if case let .unparseableYAML(_, line) = error { line } else { nil }
throw BoardLoadError(path: path, reason: .unparseableYAML(message: error.description, line: line))
}
}
/// Whether `data` would load as a **card's** `index.md` — the raw-source Apply's gate
/// (05-card-window.md ▸ Raw source outlet: "Apply validates through the same fail-fast parse the
/// loader uses (detailed alert on error, stays in source mode) before writing byte-for-byte").
///
/// **Exactly the three checks `load(boardRoot:)` runs on a card**, in its order and through its
/// own functions: decode + parse (`parseDocument`), then `schema` (present, well-formed, not
/// newer than this app) and `order` (present, well-formed) — the two fields a card must carry.
/// Nothing card-shaped is checked beyond that, because nothing else *is*: `title` is optional,
/// unknown keys are the whole point of the outlet, and the body is free text.
///
/// It deliberately does **not** check `uneditableShape`: that refusal exists for surgical
/// span edits (`BoardWriter.updateIndex`), and raw source replaces the whole file — a flow-mapping
/// frontmatter is precisely one of the things the escape hatch exists to let a user rewrite.
///
/// The error is the loader's own, undiluted, so the alert can show the taxonomy's display text
/// (line numbers included) rather than a re-worded copy.
///
/// - 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 {
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 {
try IntegrityRules.validatedSchema(in: document, path: path, supportedSchema: supportedSchema)
}
private static func validatedOrder(in document: FrontmatterDocument, path: String) throws(BoardLoadError) -> Double {
try IntegrityRules.validatedOrder(in: document, path: path)
}
}
// MARK: - Result
/// 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 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`).
///
/// **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. Order is the walk's: the board root's claimed names, then, lane by
/// lane, each lane's cards and then the lane itself.
///
/// **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.
///
/// **The reading the snapshot's own split is made of**: `BoardModel.trash` holds the entries
/// this called a card and `BoardModel.trashedLanes` the ones it called a lane. It is carried out
/// of the load as well because it is the *verdict* rather than its consequence — a suite pins
/// the discriminator directly, and a consumer asking "what did this entry read as" gets the
/// answer without inferring it from which array the entry landed in. 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 on **cards** — the retired tombstone model's
/// surviving migration input, as a **view over `defects`** (01-storage-format.md § Deletion).
///
/// A lane's key is not here and never will be: it is inert, tolerated, and reported as a
/// `LoadWarning.laneLevelDeletedIgnored` (lane clause re-ruled 2026-07-29). Order is the walk's,
/// lane by lane.
public var legacyTombstones: [LegacyTombstone] {
defects.compactMap { if case let .legacyTombstone(work) = $0 { work } else { nil } }
}
/// 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 } }
}
/// The later occurrences this walk withheld from `model` — a **view over `defects`**
/// (01-storage-format.md § Fractal layout ▸ Rules; re-ruled 2026-07-29 — the silent remint).
///
/// **The one defect the snapshot is already missing.** Every other defect names something that is
/// both on disk and in the model; a withheld duplicate is on disk and deliberately *not* in the
/// model, which is what makes the one-item-per-id invariant hold by construction rather than by
/// hope. In traversal order — which is the order the remint writes them and the notice names them.
public var duplicateIdentities: [DuplicateIdentity] {
defects.compactMap { if case let .duplicateIdentity(work) = $0 { work } else { nil } }
}
/// Every file whose lenient fields fell back to their defaults — a **view over `defects`**
/// (01-storage-format.md § Frontmatter, ruled 2026-07-29).
///
/// **Nothing consumes it, and that is the point**: the coerce tier changes no behavior, so this is
/// the observability handle — a suite pins it, a developer reads the log lines it produced, and the
/// day a shape shows up often enough to deserve a heuristic heal, this is where the evidence
/// already is. In walk order: the board, then each lane and its cards, then the trash.
public var coercedFrontmatter: [CoercedFrontmatter] {
defects.compactMap { if case let .coercedFrontmatter(work) = $0 { work } else { nil } }
}
}
/// A tolerated anomaly the loader kept going past. Never blocks a load — see `BoardLoadError`
/// for what does.
public enum LoadWarning: Sendable, Equatable, CustomStringConvertible {
/// A **UUID-shaped** folder below the board root has no `index.md` — skipped, not
/// fail-fast (an interrupted two-step create must not brick the board). `path` is relative
/// to the board root. Only reachable for a folder that passed `isUUIDShaped`; a
/// non-UUID-shaped folder missing `index.md` gets `.nonUUIDFolderIgnored` instead, never
/// this case.
case missingIndex(path: String)
/// A lane/card-depth folder whose name doesn't have a UUID's shape (`isUUIDShaped` — hex,
/// `8-4-4-4-12`, any case, any version) —
/// skipped, not fail-fast, regardless of whether it holds a valid `index.md`, a broken one,
/// or none at all (01-storage-format.md § Fractal layout ▸ Rules, "Name shape gates level
/// detection"). Preserved verbatim on disk, never descended into. `path` is relative to the
/// board root.
case nonUUIDFolderIgnored(path: String)
/// A board-level `deleted:` key is legal per the frontmatter table but meaningless
/// (01-storage-format.md § Deletion) — ignored, never tombstones the board.
case boardLevelDeletedIgnored
/// A **lane** carrying a legacy `deleted:` key (01-storage-format.md § Deletion, lane clause
/// re-ruled 2026-07-29): the lane loads live and the key is ignored — "no migration machinery,
/// no key-strip write, no notice", preserved verbatim like any unhandled key.
///
/// Its home is here rather than in the defect stream because that is exactly the tolerate tier's
/// verdict on it: this was ignored, it is staying exactly where it is, there is nothing to do.
/// A *card*'s key is still work and still a `LegacyTombstone` defect — the two halves of the old
/// migration parted company with the ruling. `path` is relative to the board root.
case laneLevelDeletedIgnored(path: String)
/// A folder whose name is a **case-spelled twin** of another occurrence of the same identity —
/// one item typed two ways (01-storage-format.md § Fractal layout ▸ Rules): a spelling some *live*
/// occurrence carries wins over one only trash ghosts carry (the container preference, stated
/// 2026-07-29), and among the candidates the canonical all-lowercase spelling wins where present,
/// else the lexicographically first. This is the loser. Skipped, preserved verbatim, never
/// rendered — **and never reminted**: it is a spelling artifact of the item that rendered, not a
/// copy, so there is nothing to heal.
///
/// Its home is here rather than in the defect stream precisely because of that: a warning says
/// "this was ignored, it is staying exactly where it is, there is nothing to do", which is the
/// whole of the tolerate tier's verdict on it. Both paths are root-relative.
case caseTwinIgnored(path: String, winner: String)
public var description: String {
switch self {
case let .missingIndex(path):
"\(path): folder has no index.md, skipped"
case let .nonUUIDFolderIgnored(path):
"\(path): folder name is not UUID-shaped, ignored as a stray"
case .boardLevelDeletedIgnored:
"index.md: board-level 'deleted' key is meaningless, ignored"
case let .laneLevelDeletedIgnored(path):
"\(path): lane-level 'deleted' key is inert, ignored — the lane loads live"
case let .caseTwinIgnored(path, winner):
"\(path): case-spelled twin of \(winner), ignored as a spelling artifact"
}
}
}
// MARK: - Error
/// A fail-fast structural failure loading a board — loud and specific: `path` (relative to
/// the board root where one exists) plus `reason` says exactly what's wrong. No partial
/// loads: throwing this means `BoardLoader.load` produced nothing at all.
public struct BoardLoadError: Error, Sendable, Equatable, CustomStringConvertible {
public let path: String
public let reason: Reason
public var description: String { "\(path): \(reason.description)" }
public enum Reason: Sendable, Equatable, CustomStringConvertible {
/// The board root itself has no `index.md` — unlike every level below it, this is not
/// skip-and-warn: there is no board without one.
case boardRootMissingIndex
/// Wraps any `FrontmatterError` from parsing — bad delimiters, bad YAML, a
/// frontmatter block that isn't a mapping. `line` is 1-based within the file when the
/// underlying error carries one.
case unparseableYAML(message: String, line: Int?)
case missingSchema
case malformedSchema(raw: String)
/// `schema` is present, valid, and greater than this app's `supportedSchema`.
case schemaNewerThanApp(found: Int)
/// `order` is required on lanes and cards, never on the board itself.
case missingOrder
case malformedOrder(raw: String)
/// The board root exists but is a file, not a directory.
case notADirectory
/// The board root doesn't exist, or its contents couldn't be listed.
case unreadableRoot(message: String)
public var description: String {
switch self {
case .boardRootMissingIndex:
"board root is missing index.md"
case let .unparseableYAML(message, line):
if let line {
"unparseable YAML at line \(line): \(message)"
} else {
"unparseable YAML: \(message)"
}
case .missingSchema:
"missing required 'schema' field"
case let .malformedSchema(raw):
"malformed 'schema' field: \(raw)"
case let .schemaNewerThanApp(found):
"schema \(found) is newer than this app supports (schema \(BoardLoader.supportedSchema))"
case .missingOrder:
"missing required 'order' field"
case let .malformedOrder(raw):
"malformed 'order' field: \(raw)"
case .notADirectory:
"board root is not a directory"
case let .unreadableRoot(message):
"board root is unreadable: \(message)"
}
}
}
}