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:ignoring:)` — 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. /// /// **A third read, once per walk rather than per card**: the board root's `.gitignore`, which is the /// noise gate the detection above obeys (§ Rules, ruled 2026-07-31 — `ignoreRules(atBoardRoot:)`). A /// file it matches is not a defect at all; it keeps the ordinary stray posture. /// /// 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 /// /// `/.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 through the same `schema`/`order` rulebook 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 = 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 = IntegrityRules.reservedCardChildNames /// The board's noise definition, at board root (01-storage-format.md § Fractal layout ▸ Rules, /// ruled 2026-07-31). The name is `IntegrityRules`', with the rest of the claimed names. static let gitignoreFileName = IntegrityRules.gitignoreFileName private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "loader") // MARK: - The noise gate /// **The board-root `.gitignore`, parsed** — the one file the loose-file carve-out consults /// before calling anything relocatable (01-storage-format.md § Fractal layout ▸ Rules, ruled /// 2026-07-31: "`.gitignore` is the noise gate"). /// /// Read **once per walk**, at the top of `load`, and handed down to every card: a board with 400 /// cards reads this file once, not 400 times. `nil` when the board has none — and `nil` and an /// empty file mean exactly the same thing to every caller (nothing is excluded), which is the /// ruling's own escape hatch working by construction rather than by a branch. /// /// **Board root only.** "Nested `.gitignore` files are ordinary strays the heal never consults" — /// a `.gitignore` inside a lane or a card is a file like any other, and one inside a card is /// itself a hidden entry the carve-out never touches. /// /// Reading it here does not make the loader impure: this is a read like `index.md`'s, on a file /// the walk's result depends on. Nothing is written, and an unreadable or non-UTF-8 file answers /// `nil` — "cannot tell" reads as "excludes nothing", which is the direction that loses no file /// (an unrelocated stray stays exactly where it is; the alternative would silently move things /// the user meant to be noise). static func ignoreRules(atBoardRoot root: URL) -> GitignoreRules? { guard let data = try? Data(contentsOf: root.appendingPathComponent(gitignoreFileName)), let text = String(data: data, encoding: .utf8) else { return nil } return GitignoreRules(parsing: text) } // 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) // **The root's own `schema` stays required** (01-storage-format.md § Malformed input, // re-ruled 2026-07-31): it is the this-really-is-a-board gate, and the one `schema` on the // board that does not read as 1 when absent. let boardSchema = try validatedRootSchema(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"). // // `plus` carries the records only the rulebook can make: `order` and `schema` below the root // (re-ruled 2026-07-31 — the optional-key readings). They cannot come from // `document.coercedFields`, which reads *present* fields that had no sensible reading and by // construction knows nothing about a key that is simply absent, nor about which level the // file sits at. func noteCoercions( in document: FrontmatterDocument, at path: String, plus extra: [CoercedField] = [] ) { let fields = extra + 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) // **The noise gate, read once for the whole walk** (01-storage-format.md § Fractal layout ▸ // Rules, ruled 2026-07-31): the board's `.gitignore` is what decides which loose files are // work and which are ordinary strays, and it is one file per board — not one per card. let ignoreRules = ignoreRules(atBoardRoot: boardRoot) // 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) // Below the root both keys are optional (re-ruled 2026-07-31): a missing `schema` reads // as 1, a missing or unusable `order` as append-at-end. Both readings are coerce-tier — // recorded here, logged, and acted on by nothing until the file's next Writer touch. let laneSchema = try resolvedSchema(in: laneDocument, path: lanePath) let laneOrder = IntegrityRules.resolvedOrder(in: laneDocument) noteCoercions( in: laneDocument, at: lanePath, plus: [laneSchema.coerced, laneOrder.coerced].compactMap { $0 } ) var walkedCards: [WalkedCard] = [] 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, plus: card.coercions) // **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. What // the board's `.gitignore` excludes never becomes a defect at all — it keeps the // stray posture, so there is nothing here for the store to schedule and nothing for // a banner to announce. let loose = looseFileNames(in: cardURL, ignoring: ignoreRules) 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)/") } walkedCards.append(card) } // **The append-at-end reading, settled per container** (01-storage-format.md § Ordering, // re-ruled 2026-07-31): every card's rank is the one it wrote down, or — where it wrote // none — a materialized rank past every ordered sibling, ordered among the other // order-less ones by folder name. Settled here, over the lane's whole card list, because // that is the smallest scope in which "past every ordered sibling" is a fact. let cardOrders = Ranks.resolvedOrders( of: walkedCards, stored: \.storedOrder, name: { $0.id.rawValue }) let cards = zip(walkedCards, cardOrders).map { $0.rendered(order: $1) } // **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.schema, storedOrder: laneOrder.order, document: laneDocument, cards: Ranks.sortedForDisplay(cards, order: \.order, name: { $0.id.rawValue }) )) } // The strip's own append-at-end reading — the card rule one level up, and the reason the // lane loop above carried a *stored* order: a lane with no rank sorts past every ranked one, // which cannot be known until the last lane folder has been read. for (index, order) in Ranks.resolvedOrders( of: walkedLanes, stored: \.storedOrder, name: \.name ).enumerated() { walkedLanes[index].order = order } /// Every trash entry as the walk read it, kind decided but rank not yet — the `WalkedCard` /// intermediate's job in the flat container, in a tuple because the split into the snapshot's /// two arrays happens on the far side of the append-at-end reading. var walkedTrash: [( id: ItemID, kind: IntegrityRules.ObjectKind, schema: Int, storedOrder: Double?, heldCards: Int, attachments: [String], document: FrontmatterDocument )] = [] 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 read identically — the same optional-`schema`/optional-`order` // rulebook below the root — so the parse happens once, before the discriminator, and a // schema newer than this app fails fast whichever kind the entry turns out to be. let entryPath = entryRelPath + "/" + indexFileName let document = try readDocument(at: entryURL.appendingPathComponent(indexFileName), path: entryPath) let schema = try resolvedSchema(in: document, path: entryPath) let order = IntegrityRules.resolvedOrder(in: document) noteCoercions( in: document, at: entryPath, plus: [schema.coerced, order.coerced].compactMap { $0 } ) // **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 // **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 — and a card pays for its attachment listing only, // which is why each side is read under its own arm rather than unconditionally. // // Neither `kind: board` nor `kind: comment` reaches here as itself — `trashKind` treats // both as unrecognized and answers by shape — so the non-lane arm is the card answer and // nothing else. let isLane = kind == .lane walkedTrash.append(( id: id, kind: kind, schema: schema.schema, storedOrder: order.order, heldCards: isLane ? children().count : 0, attachments: isLane ? [] : attachmentNames(in: entryURL), document: document )) } // The trash's own append-at-end reading, over the container as one flat list. `order` decides // nothing about where a trash row *sits* — that is `modified`'s job since 2026-07-31 — but // every entry carries a rank for its eventual restore, and an entry that carries none reads // like every other order-less file rather than getting a container-specific rule of its own. 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. Carried with the two keys the /// container's order is stated in (`Ranks.isOrderedForTrash`), never `order`: the trash is /// sorted by `modified` descending since 2026-07-31. var trashEntries: [(id: ItemID, title: String?, modified: Date?)] = [] let trashOrders = Ranks.resolvedOrders( of: walkedTrash, stored: { $0.storedOrder }, name: { $0.id.rawValue }) for (entry, order) in zip(walkedTrash, trashOrders) { let document = entry.document trashEntries.append(( id: entry.id, title: document.title.value, modified: document.modified.value)) switch entry.kind { case .lane: trashedLanes.append(TrashedLane( id: entry.id, schema: entry.schema, title: document.title, modified: document.modified, order: order, heldCards: entry.heldCards, document: document )) case .card, .board, .comment: trash.append(Card( id: entry.id, schema: entry.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: entry.attachments, 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) // **The trash's arrays are sorted by `modified` descending**, not by `order` (01-storage-format.md // § Deletion, re-ruled 2026-07-31): the trash move rewrites no rank, so the stamp *is* the // position. Each kind is sorted by the same comparator the merged `BoardModel.trashEntries` // applies, which is what makes a kind-narrowed slice of the column agree with the column. let orderedTrash = Ranks.sortedForTrash( trash, modified: { $0.modified.value }, title: { $0.title.value }, name: { $0.id.rawValue }) let orderedTrashedLanes = Ranks.sortedForTrash( trashedLanes, modified: { $0.modified.value }, title: { $0.title.value }, name: { $0.id.rawValue }) // The trash's own display order, **both kinds at once** — the column interleaves them // (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.sortedForTrash( trashEntries, modified: \.modified, title: \.title, 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?, modified: Date?)], 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 = [] 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 /// The rank the file actually carries, `nil` where it carries none this app can use. let storedOrder: Double? /// The append-at-end reading, filled in once every sibling lane has been read /// (`Ranks.resolvedOrders(of:stored:name:)` — 01-storage-format.md § Ordering, re-ruled /// 2026-07-31). A `var` on a private walk value for the same reason the type exists at all: /// the container settles it, and the `Lane` is built on the far side of that. var order: Double = 0 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) -> 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 unreachable /// without git: the free tier runs no git machinery at all (12-editions.md ▸ The inert posture), /// so the loader consults an injected ranker and falls through to birth date and traversal order /// when there is none — which is every free-tier 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 provider 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 as the walk read it, before its container's ranks resolved — a `Card` minus the /// append-at-end reading, which cannot be settled until every sibling's stored `order` is known /// (01-storage-format.md § Ordering, re-ruled 2026-07-31). `WalkedLane`'s shape, one level down /// and for the same kind of reason: a value the container decides is not a value the item can /// carry while it is still being read. private struct WalkedCard { let id: ItemID let schema: Int /// The rank the file actually carries, `nil` where it carries none this app can use — the /// input to `Ranks.resolvedOrders(of:stored:name:)`. let storedOrder: Double? let attachments: [String] let document: FrontmatterDocument /// This card's coerce-tier records for the strict fields, which only the rulebook can make /// (a missing key leaves no trace in `document.coercedFields`). let coercions: [CoercedField] var title: FieldValue { document.title } var isDeleted: Bool { !document.deleted.isMissing } func rendered(order: Double) -> Card { 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: attachments, document: document ) } } /// One card folder read into a `WalkedCard` — 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 /// `schema`/`order` rulebook (`IntegrityRules`, 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) -> WalkedCard { let cardPath = path + "/" + indexFileName let document = try readDocument(at: cardURL.appendingPathComponent(indexFileName), path: cardPath) let schema = try resolvedSchema(in: document, path: cardPath) let order = IntegrityRules.resolvedOrder(in: document) return WalkedCard( id: ItemID(rawValue: cardURL.lastPathComponent), schema: schema.schema, storedOrder: order.order, attachments: attachmentNames(in: cardURL), document: document, coercions: [schema.coerced, order.coerced].compactMap { $0 } ) } /// The candidate entry folders inside `/.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 `/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. /// /// Five 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. /// /// **This survived the noise-gate ruling** (2026-07-31), which retired "nothing hardcoded" /// exclusions in favour of the seeded `.gitignore`, because it is not a noise heuristic and /// never was a `.DS_Store` special case: `attachmentNames(in:)` skips hidden entries too, so a /// relocated hidden file would land in a folder whose listing can never show it — the move /// would take a file the user can see in Finder beside `index.md` and put it somewhere the app /// is structurally unable to surface. The carve-out exists to put a card's files where the app /// *shows* them; a hidden file has no such destination. What the ruling retires is the app /// having a second opinion about *which* visible files are noise, and it never had one. /// - **The reserved card-level names** (`reservedCardChildNames`), case-insensitively. /// - **Whatever the board's `.gitignore` excludes** (§ Rules, ruled 2026-07-31 — the noise /// gate): "a file matching the board-root `.gitignore` … keeps the ordinary stray posture: /// skipped, preserved verbatim, logged, never relocated, never announced". Matched against the /// file's **board-relative** path (`//`), because that is the path git would /// match and because an anchored pattern (`/notes.txt`) has to mean the board root rather than /// every card in it. `rules` is `nil` on a board that carries no such file, which excludes /// nothing — the pre-ruling behaviour, and the same answer an empty file gives. /// /// 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". /// `rules` has no default on purpose: the gate is the ruling, and a caller that could forget to /// pass it would be a second, quieter definition of what counts as noise. static func looseFileNames(in cardFolder: URL, ignoring rules: GitignoreRules?) -> [String] { guard let entries = try? FileManager.default.contentsOfDirectory( at: cardFolder, includingPropertiesForKeys: [.isRegularFileKey, .isSymbolicLinkKey], options: [.skipsHiddenFiles] ) else { return [] } let cardPath = boardRelativeCardPath(of: cardFolder) return entries .filter { url in guard !reservedCardChildNames.contains(url.lastPathComponent.lowercased()), let values = try? url.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey]), values.isRegularFile == true, values.isSymbolicLink != true else { return false } // The noise gate. Logged rather than silent, because "skipped, preserved verbatim, // logged" is the stray posture this file is being *given*, and a file that stays put // for a reason nobody can see is the one the user files a bug about. let name = url.lastPathComponent guard rules?.isIgnored(relativePath: cardPath + "/" + name) == true else { return true } logger.info( "\(cardPath, privacy: .public)/\(name, privacy: .public): matched by the board's \(gitignoreFileName, privacy: .public) — left where it is, not relocated" ) return false } .map(\.lastPathComponent) .sorted { $0.localizedStandardCompare($1) == .orderedAscending } } /// A card folder's board-relative path — `/` — read off the URL rather than passed /// in, so the one caller that has no walk behind it (the Writer's import-boundary normalization) /// gets the same answer the walk would have given. /// /// The depth is fixed by the schema: a card is `//` and nothing else /// (`BoardWriter.checkIsCardFolder` enforces exactly this shape before any relocation), so two /// components are the whole of it. private static func boardRelativeCardPath(of cardFolder: URL) -> String { cardFolder.deletingLastPathComponent().lastPathComponent + "/" + cardFolder.lastPathComponent } /// 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 checks `load(boardRoot:)` runs on a card**, in its order and through its own /// functions: decode + parse (`parseDocument`), then `schema` at the below-the-root reading /// (missing reads as 1; malformed, or newer than this app, still refuses). Nothing card-shaped /// is checked beyond that, because nothing else *is*: `title` is optional, **`order` is optional /// too** since 2026-07-31 — a card applied without one lands at its lane's bottom and gains a /// rank on its next touch — 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 forward so the walk above reads as it always did. private static func validatedRootSchema( in document: FrontmatterDocument, path: String ) throws(BoardLoadError) -> Int { try IntegrityRules.validatedRootSchema(in: document, path: path, supportedSchema: supportedSchema) } /// `schema` below the root — missing reads as 1, coerce-tier (re-ruled 2026-07-31). private static func resolvedSchema( in document: FrontmatterDocument, path: String ) throws(BoardLoadError) -> (schema: Int, coerced: CoercedField?) { try IntegrityRules.resolvedSchema(in: document, path: path, supportedSchema: supportedSchema) } } // 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?) /// **The board root's own missing `schema`** — the this-really-is-a-board gate (re-ruled /// 2026-07-31). Below the root a missing `schema` reads as 1 instead, coerce-tier. case missingSchema case malformedSchema(raw: String) /// `schema` is present, valid, and greater than this app's `supportedSchema`. case schemaNewerThanApp(found: Int) /// **Retired, and nothing throws these any more** (01-storage-format.md § Ordering and /// § Malformed input, re-ruled 2026-07-31): below the board root a missing, null, non-numeric /// or non-finite `order` reads as append-at-end — coerce-tier, logged, bytes preserved /// (`IntegrityRules.resolvedOrder`) — and the board root never carried a rank to begin with. /// /// They stay in the vocabulary rather than being deleted because this enum *is* the /// load-failure surface every banner, announcement and decision-surface row is written /// against, and a reason that can no longer occur costs those surfaces nothing while removing /// one would rewrite them for a rule that changed underneath, not for a shape they render /// differently. 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)" } } } }