Realign code with the 2026-07-31 findings-resolution rulings

The full bullet list from Implementation card bf080d9a — both ruling
batches, including the three appended mid-session by 16ef377:

- Restore subjects compose the inverse, never nest: crossing "Undo: S"
  emits "Redo: S" and vice versa; parity, not stack depth, reads a
  legacy double prefix (GitHistoryProvider.restoreSubject).
- Git-operation failures join the one-shot failure banner tier:
  BannerCenter.GitFailureBanner (undo/redo/branchSwitch/addGit), error
  tone at failure rank merged with write one-shots by recency; the
  postLoss compromise is retired at both AppModel wirings.
- order/schema optional below the board root: append-at-end reading
  (ordered siblings first, folder-name tie-break among the order-less),
  schema reads 1, both coerce-tier logged; the root keeps its
  requirements. Ranks.resolvedOrders materializes finite ranks so
  models and placement math stay untouched; first Writer rewrite
  stamps a real rank on touch, placement against an order-less sibling
  stamps that sibling inline in the same bracket. Agent guide v10
  teaches optional keys and zero-read filing. Hostile-YAML order
  shapes become coercion tests; Fixtures/Valid/optional-keys.kanban
  replaces the four retired Malformed boards.
- .gitignore is the relocation-heal noise gate: GitignoreRules pure
  matcher (standard semantics, board-root file only), loader consults
  it once per walk so matched loose files keep the stray posture;
  seeded (.DS_Store + .*.lanework-*) at board creation and template
  instantiation, healed in when missing at open — repo-nested
  included; empty file honored, existing files never edited; the
  committer's obedience via libgit2 status is pinned by test.
- Comments crash-residue sweep gates on step ownership: HistoryStep
  derives backing from its own undo expectations, backedContent unions
  both stacks, the sweep purges per-entry only what no live step owns.
- Skip-purge decoupled (16ef377): a stale-skipped coarse step strands
  whole in NativeHistoryProvider.strandedSteps — still backing, retired
  only at session end; clean exits purge as before.
- Coarse close step named "Changes to '<card>'"; the fine body-edit
  wording never leaks onto the board menu.
- Branch-switch settle clears every open card window's fine stack on
  Save All and Discard alike; the empty fold registers no coarse step.
- Close flush awaits its covering snapshot (quiesce + one generation
  bump, 1s bound), and an explicit flush now queues behind an
  in-flight one instead of skipping — the audit-caught interleaving
  could lose a close flush permanently when the debounce fired inside
  the close sequence; regression tests force both races.
- Commit comment bullets sort chronologically by created, not UUID.
- The production-unwired CardBodyEditSession.editSessionDidChange seam
  is deleted with its seam-only tests.
- Composition-root pins: beginSession composes the committer with the
  store's own EchoLedger and binds the announcer (the miswire class).
- Deliberate 06 conformance pass over every 2026-07-31-tagged
  sentence: fixed Change-custom-key subjects (the retired named
  generic was the only producer), the unbuilt Replace attachment
  vocabulary, heal commits now authored Lanework Integrity, the config
  reader scopes identity to plain [user] sections, add-git re-runs
  detection at create (a stale mode-none could initialize inside the
  user's repo), and add-git failures answer at the form or the banner.
  Structural residue filed on the Redesign board.

2554 tests / 439 suites green.

Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
This commit is contained in:
2026-08-01 07:43:45 -04:00
parent 16ef3779e8
commit 274ccd9ff5
75 changed files with 5619 additions and 791 deletions
+298 -80
View File
@@ -30,9 +30,9 @@ import os
/// - `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
/// - `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
@@ -40,6 +40,10 @@ import os
///
/// 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.
@@ -49,7 +53,7 @@ import os
/// `<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
/// 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.
///
@@ -142,8 +146,41 @@ public enum BoardLoader: Sendable {
/// table itself is `IntegrityRules`', with every other reserved name.
static let reservedCardChildNames: Set<String> = 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(
@@ -157,7 +194,10 @@ public enum BoardLoader: Sendable {
throw BoardLoadError(path: indexFileName, reason: .boardRootMissingIndex)
}
let boardDocument = try readDocument(at: boardIndexURL, path: indexFileName)
let boardSchema = try validatedSchema(in: boardDocument, 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) {
@@ -182,8 +222,18 @@ public enum BoardLoader: Sendable {
// `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
//
// `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 {
@@ -214,6 +264,11 @@ public enum BoardLoader: Sendable {
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
@@ -236,11 +291,18 @@ public enum BoardLoader: Sendable {
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)
// 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 cards: [Card] = []
var walkedCards: [WalkedCard] = []
for cardURL in try directoryCandidates(in: laneURL) {
let cardName = cardURL.lastPathComponent
let cardRelPath = laneName + "/" + cardName
@@ -254,7 +316,7 @@ public enum BoardLoader: Sendable {
}
let card = try parseCard(at: cardURL, path: cardRelPath)
noteCoercions(in: card.document, at: cardRelPath + "/" + indexFileName)
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
@@ -269,8 +331,11 @@ public enum BoardLoader: Sendable {
)
}
// Noticed, never acted on: the relocation is the store's, through the Writer.
let loose = looseFileNames(in: cardURL)
// 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),
@@ -292,9 +357,18 @@ public enum BoardLoader: Sendable {
logger.info("\(cardRelPath, privacy: .public): legacy 'deleted' key — card to be relocated into \(trashFolderName, privacy: .public)/")
}
cards.append(card)
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
@@ -307,20 +381,34 @@ public enum BoardLoader: Sendable {
walkedLanes.append(WalkedLane(
name: laneName,
schema: laneSchema,
order: laneOrder,
schema: laneSchema.schema,
storedOrder: laneOrder.order,
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. 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?)] = []
// 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
@@ -333,14 +421,18 @@ public enum BoardLoader: Sendable {
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.
// 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 validatedSchema(in: document, path: entryPath)
let order = try validatedOrder(in: document, path: entryPath)
noteCoercions(in: document, at: 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
@@ -362,30 +454,60 @@ public enum BoardLoader: Sendable {
)
let id = ItemID(rawValue: entryName)
trashKinds[id] = kind
trashEntries.append((id: id, title: document.title.value, modified: document.modified.value))
switch 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:
// **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,
id: entry.id,
schema: entry.schema,
title: document.title,
modified: document.modified,
order: order,
heldCards: children().count,
heldCards: entry.heldCards,
document: document
))
case .card, .board, .comment:
// Neither `kind: board` nor `kind: comment` reaches here as itself `trashKind`
// treats both as unrecognized and answers by shape so this arm is the card answer
// and nothing else.
trash.append(Card(
id: id,
schema: schema,
id: entry.id,
schema: entry.schema,
title: document.title,
created: document.created,
modified: document.modified,
@@ -395,7 +517,7 @@ public enum BoardLoader: Sendable {
icon: document.icon,
iconColor: document.iconColor,
order: order,
attachments: attachmentNames(in: entryURL),
attachments: entry.attachments,
document: document
))
}
@@ -579,7 +701,13 @@ public enum BoardLoader: Sendable {
private struct WalkedLane {
let name: String
let schema: Int
let order: Double
/// 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]
@@ -648,35 +776,67 @@ public enum BoardLoader: Sendable {
return children.filter { isUUIDShaped($0.lastPathComponent) && hasIndex($0) }
}
/// One card folder read into a `Card` the lane walk's card parse.
/// 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<String> { 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 strict
/// `schema`/`order` validation (`IntegrityRules`' rulebook, one rule) rather than sharing a
/// function that has already decided what it is reading.
/// 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) -> Card {
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 validatedSchema(in: document, path: cardPath)
let order = try validatedOrder(in: document, path: cardPath)
let schema = try resolvedSchema(in: document, path: cardPath)
let order = IntegrityRules.resolvedOrder(in: document)
return Card(
return WalkedCard(
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,
schema: schema.schema,
storedOrder: order.order,
attachments: attachmentNames(in: cardURL),
document: document
document: document,
coercions: [schema.coerced, order.coerced].compactMap { $0 }
)
}
@@ -782,7 +942,7 @@ public enum BoardLoader: Sendable {
/// 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:
/// 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
@@ -795,14 +955,32 @@ public enum BoardLoader: Sendable {
/// 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 (`<lane>/<card>/<name>`), 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".
static func looseFileNames(in cardFolder: URL) -> [String] {
/// `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],
@@ -811,19 +989,40 @@ public enum BoardLoader: Sendable {
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])
let values = try? url.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey]),
values.isRegularFile == true, values.isSymbolicLink != true
else {
return false
}
return values.isRegularFile == true && values.isSymbolicLink != true
// 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 `<lane>/<card>` 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 `<root>/<lane>/<card>` 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
@@ -943,11 +1142,13 @@ public enum BoardLoader: Sendable {
/// (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.
/// **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
@@ -968,13 +1169,18 @@ public enum BoardLoader: Sendable {
}
/// 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)
/// 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)
}
private static func validatedOrder(in document: FrontmatterDocument, path: String) throws(BoardLoadError) -> Double {
try IntegrityRules.validatedOrder(in: document, path: path)
/// `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)
}
}
@@ -1153,11 +1359,23 @@ public struct BoardLoadError: Error, Sendable, Equatable, CustomStringConvertibl
/// 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)
/// `order` is required on lanes and cards, never on the board itself.
/// **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.