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
+50 -18
View File
@@ -76,8 +76,15 @@ enum AgentGuide {
/// defect was a missing second line, and the parser's complaint pointed at the *last* key it
/// swallowed rather than the title. The guide now teaches long values as one long line, says
/// why the wrapped shape is the dangerous one to copy, and names the unterminated quote beside
/// the unquoted colon in Hard rules.
static let version = 9
/// the unquoted colon in Hard rules. **v10 makes `order` and `schema` optional below the board
/// root** (01-storage-format.md § Frontmatter and § Ordering, re-ruled 2026-07-31): the guide's
/// whole point is that filing a card must need nothing but the schema
/// (08-agent-integration.md's masterplan requirement), and until now that was untrue a card
/// needed a rank, and a rank needed a scan of every sibling in the lane. The guide now teaches
/// the zero-read minimum (`mkdir` plus one `index.md`, no `order`, no `schema`; it lands at the
/// lane's bottom and the app stamps a real rank on its first touch) while still teaching
/// *writing* `order` as the way to control position, which is the only way to control it.
static let version = 10
// MARK: - The version marker
@@ -367,7 +374,8 @@ enum AgentGuide {
## Reading the board
- Lanes run left→right by ascending `order`; cards top→bottom by ascending
`order` within their lane. Ties break by folder name.
`order` within their lane. Ties break by folder name. An item with no
`order` sorts after every item that has one — see Creating a card.
- Lane titles carry the workflow semantics (e.g. To Do → In Progress →
Done). Read the board's and lanes' index.md bodies for descriptions and
per-lane policy before deciding where a card belongs.
@@ -376,13 +384,15 @@ enum AgentGuide {
## Frontmatter
All levels: `schema` (required, always `1`), `title` (optional — an item
without one renders as untitled, so give cards real titles), `created` and
`modified` (ISO-8601 with timezone, e.g. `2026-07-24T18:00:00Z`),
`background` (color), `icon` (SF Symbol name), `iconColor` (color, tints
`icon`). Lanes and cards additionally require `order` (a number; floats are
fine). Lanes may set `width` (integer ≥ 1, multiplier of the standard lane
width).
All levels: `schema` (always `1`; **required at the board's own `index.md`**,
optional below it — a lane or card without one is read as schema 1), `title`
(optional — an item without one renders as untitled, so give cards real
titles), `created` and `modified` (ISO-8601 with timezone, e.g.
`2026-07-24T18:00:00Z`), `background` (color), `icon` (SF Symbol name),
`iconColor` (color, tints `icon`). Lanes and cards may set `order` (a number;
floats are fine) — **optional, and the way to control position**: an item
without one goes last. Lanes may set `width` (integer ≥ 1, multiplier of the
standard lane width).
**Quote any `title` containing a colon** — `title: Fix: the thing` is
invalid YAML; write `title: "Fix: the thing"`. The same goes for any value
@@ -418,9 +428,7 @@ enum AgentGuide {
## Creating a card
1. Pick the lane folder. Compute `order`: bottom of the lane = max existing
card `order` + 1024; top = min 1024; between two cards = their
midpoint. (Empty lane: any number, conventionally 1024.)
1. Pick the lane folder.
2. Create a folder named a fresh lowercase UUID:
`id=$(uuidgen | tr 'A-Z' 'a-z')`.
3. Write `<lane>/$id/index.md` (timestamp: `date -u +%FT%TZ`):
@@ -438,8 +446,31 @@ enum AgentGuide {
The card's content — any Markdown.
```
**`order` is what places the card, and computing it means reading the
lane**: bottom of the lane = max existing card `order` + 1024; top =
min 1024; between two cards = their midpoint. (Empty lane: any number,
conventionally 1024.) Write it whenever the position matters.
**You can also file a card without reading the lane at all.** The minimum
legal card is a `mkdir` and one `index.md` containing nothing but a title —
no `order`, no `schema`:
```markdown
---
title: Short imperative card title
---
The card's content.
```
It lands at the bottom of the lane (an item with no `order` sorts after
every item that has one; two such items sort by folder name), and the app
writes a real `order` into it the next time it rewrites that file. Prefer
the full frontmatter above — `kind`, the timestamps and `modified-by` are
all worth having — but when you are filing into a 200-card lane and the
position doesn't matter, the short form costs one write and no reads.
Creating a lane is the same one level up (body optional; `kind: lane`;
`order` ranks lanes left→right).
`order` ranks lanes left→right, and is optional in the same way).
**Always write `kind`** at creation — `kind: card`, `kind: lane`,
`kind: board` at board root. Depth already says what an item is on the
@@ -508,10 +539,11 @@ enum AgentGuide {
## Hard rules (the app fails loudly on violations)
- Frontmatter must parse as YAML; `schema` (plus `order` on lanes and
cards) is required. Keep `schema: 1`. The classic violations are an
unquoted colon in a title and a quoted value left unclosed across a
line break (see Frontmatter above).
- Frontmatter must parse as YAML. The board's own `index.md` must carry
`schema: 1`; everywhere else `schema` and `order` are optional and a
missing one is read, never refused. Never write a `schema` other than
`1`. The classic violations are an unquoted colon in a title and a
quoted value left unclosed across a line break (see Frontmatter above).
- Files must be UTF-8 without BOM.
- Never create a card folder without an `index.md`.
- Never rename UUID folders.
+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.
+21 -9
View File
@@ -169,11 +169,19 @@ public struct Lane: Identifiable, Sendable, Equatable {
public let icon: FieldValue<String>
public let iconColor: FieldValue<String>
/// Rank among lanes, ascending = left-to-right. Strict, per the frontmatter table's
/// required field: `BoardLoader` fails the whole load (`missingOrder`/`malformedOrder`)
/// rather than construct a `Lane` with a bad `order` by the time one exists here it is
/// always valid. Validity is the loader's job, not this type's; that is why it is a plain
/// `Double` and not `FieldValue<Double>`.
/// Rank among lanes, ascending = left-to-right **the reading, not necessarily the key**
/// (01-storage-format.md § Ordering, re-ruled 2026-07-31: `order` is optional below the board
/// root, and "missing or unusable reads as append-at-end").
///
/// A lane whose `index.md` carries a usable `order` reads as that number. One that carries none
/// no key, an explicit null, a non-numeric or non-finite value reads as a rank past every
/// ordered sibling, materialized by `Ranks.resolvedOrders(of:stored:name:)` and tie-broken among
/// the other order-less lanes by folder name. Either way this is a plain, finite `Double` by the
/// time a `Lane` exists, which is why it is not a `FieldValue<Double>`: resolving the reading is
/// the loader's job, and the snapshot carries answers rather than shapes.
///
/// The value is real enough to write down, and the Writer does exactly that on the file's first
/// touch (`IntegrityRules.OnTouchHeal.rankStamped`) so the stamp changes nothing on screen.
public let order: Double
/// Width multiplier 1 (default 1 when missing or malformed). Lenient it styles layout,
@@ -222,8 +230,10 @@ public struct Card: Identifiable, Sendable, Equatable {
public let icon: FieldValue<String>
public let iconColor: FieldValue<String>
/// Rank within its lane, ascending = top-to-bottom. Strict see `Lane.order`'s doc
/// comment; the same reasoning applies here.
/// Rank within its lane, ascending = top-to-bottom the reading, not necessarily the key. See
/// `Lane.order`'s doc comment; the same reasoning applies here, and a card is where it matters
/// most: the minimum legal agent card is a `mkdir` plus one `index.md` with no `order` at all
/// (08-agent-integration.md), and it lands at the bottom of its lane.
public let order: Double
/// The card's attachment file names **flat: top-level regular files only, in Finder
@@ -291,8 +301,10 @@ public struct TrashedLane: Identifiable, Sendable, Equatable {
/// The lane's rank **among the board's lanes**, riding along untouched the trash move rewrites
/// no `order` at all, so this is still the strip position a restore would want and the value the
/// undo of a restore puts back. It is deliberately *not* what orders this row in the column
/// (`modified` is), and the same required, strictly validated field a live lane carries
/// (`Lane.order`).
/// (`modified` is), and it is the same optional-but-always-read field a live lane carries
/// (`Lane.order`): an entry that reaches the trash without one reads as append-at-end over the
/// container's own entries, which is inert here by construction a restore computes a fresh
/// rank at its destination.
public let order: Double
/// **How many cards the lane is holding** the row's whole other half ("Doing 5 cards").
+303 -30
View File
@@ -116,10 +116,33 @@ public enum BoardWriter: Sendable {
let operation = operation.withTitle(document.title.value)
try checkEditable(document, at: indexURL, operation: operation)
let orderBefore = document.order
edits(&document)
// After `edits`, so a caller that wrote its own `kind` is left alone, and before the stamps,
// which outrank everything for their own reason.
IntegrityRules.healOnTouch(&document, kind: kind ?? derivedKind(ofItemFolder: folder))
let itemKind = kind ?? derivedKind(ofItemFolder: folder)
// **The inline sibling stamp** (01-storage-format.md § Ordering, re-ruled 2026-07-31): a
// write that rewrites this item's *rank* is placement math landing, and placement math ranks
// against the append-at-end reading of any order-less sibling so those siblings are made to
// hold that reading, here, inside the caller's own bracket and commit. Sequence-preserving
// and a no-op on every board this app wrote; see `stampAppendAtEndOrders`.
//
// Two exclusions. `.renumberChildren` *is* the rescale it rewrites every sibling with a
// fresh rank already, and re-entering here would recurse. Anything that is not a lane or a
// card has no rank ladder to be placed in: a board root's siblings are other people's
// folders, and a comment's are chronology.
if document.order != orderBefore, operation != .renumberChildren, itemKind == .lane || itemKind == .card {
try stampAppendAtEndOrders(
of: folder.deletingLastPathComponent(), excluding: folder, operation: operation)
}
// After `edits`, so a caller that wrote its own `kind` or `order` is left alone, and before
// the stamps, which outrank everything for their own reason.
IntegrityRules.healOnTouch(
&document,
kind: itemKind,
rank: appendAtEndRank(ofItemFolder: folder, kind: itemKind)
)
// The reorders-don't-stamp predicate, read off the operation. An order-only rewrite restates
// the container's own arrangement and leaves both provenance keys exactly as it found them
// a standing `modified-by` survives a reorder, which is the pairing 01 spells out.
@@ -166,6 +189,51 @@ public enum BoardWriter: Sendable {
}
}
/// **The rank an order-less file reads as** the on-touch stamp's value (01-storage-format.md
/// § Ordering, re-ruled 2026-07-31: "The rank materializes on touch: the first Writer rewrite of
/// the file stamps a real rank").
///
/// It is `Ranks.resolvedOrders`' answer for this folder among its siblings the very rank the
/// board is already rendering it at so the stamp writes down what the reading already said and
/// nothing moves on screen. The loader and this share the function, which is what makes that
/// true rather than merely intended.
///
/// `nil` stamp nothing in four cases, each of them "this file has no rank to have":
///
/// - **The kind has no ladder**: a board root (no siblings), a comment ("Ordering is chronology,
/// not ranks"), or a folder whose position has no answer at all.
/// - **The sibling scan failed** (an unreadable neighbour, a parse error). An on-touch heal is
/// opportunistic by construction it rides a host write that has its own reason to succeed
/// so a failure here declines to stamp rather than failing that write. The next touch tries
/// again.
/// - **The item is not among its own visible siblings**: it carries a legacy `deleted:` key, and
/// a tombstone is inert to ordering (§ Deletion). The migration relocates it; a rank invented
/// on the way would be a fact about a position it no longer has.
///
/// Never called on a file that already carries a usable `order` `healOnTouch` takes it as an
/// `@autoclosure`, so the directory of reads below is paid for only where it is the answer.
private static func appendAtEndRank(
ofItemFolder folder: URL,
kind: IntegrityRules.ObjectKind?
) -> Double? {
switch kind {
case .lane, .card: break
case .board, .comment, nil: return nil
}
// The operation is only what a failure would be *named*, and this one discards failures.
guard let siblings = try? visibleSiblings(
of: folder.deletingLastPathComponent(),
operation: .renumberChildren,
requireEditable: false
) else {
return nil
}
let identity = IntegrityRules.canonicalIdentity(folder.lastPathComponent)
return siblings
.first { IntegrityRules.canonicalIdentity($0.folder.lastPathComponent) == identity }?
.order
}
// MARK: - Atomic replace
/// Writes `text` over `fileURL` atomically: a hidden temp file in the **same directory**,
@@ -241,6 +309,11 @@ public enum BoardWriter: Sendable {
/// - Extension-less board folders are exactly as legal a target as a `.kanban`-suffixed one
/// (§ Document packaging, "Extension-less board folders still open") this call never
/// looks at `rootURL`'s extension.
/// - **The board is born with its noise definition** (06-history-undo.md Repository hygiene,
/// re-ruled 2026-07-31: "Board creation writes the minimal seed"): `.gitignore` lands beside
/// `index.md`, after it, so the file that makes a folder a board is written first and a failure
/// to seed can never leave a half-made board. Seeding is `seedGitignoreIfAbsent`'s, so a
/// creation into a folder that somehow already carries one leaves it alone.
public static func createBoard(at rootURL: URL, title: String?) throws(BoardWriteError) {
let operation = WriteOperation.createBoard
let indexURL = rootURL.appendingPathComponent(BoardLoader.indexFileName)
@@ -264,6 +337,7 @@ public enum BoardWriter: Sendable {
at: indexURL,
operation: operation
)
try seedGitignoreIfAbsent(atBoardRoot: rootURL)
}
/// Creates a lane in a board: mints a fresh lowercase-UUIDv4 folder directly under
@@ -308,7 +382,16 @@ public enum BoardWriter: Sendable {
) throws(BoardWriteError) -> ItemID {
try checkIsDirectory(parentFolder, describedAs: "parent folder", operation: operation)
let siblings = try visibleSiblings(of: parentFolder, operation: operation, requireEditable: false)
// **The inline sibling stamp, before the append** (01-storage-format.md § Ordering, re-ruled
// 2026-07-31). A create is placement math like any other "after the current visible
// siblings" is a rank *relative to* the last of them so an order-less sibling has to hold
// the rank this append is computed against, or the new item would file itself above a card
// that re-materializes past it on the next load. It is the one placement that cannot ride
// `updateIndex`'s hook: this path mints a file rather than rewriting one.
//
// `excluding: nil` the new identity does not exist yet, so there is nothing to skip, and
// the container the stamp reads is exactly the one the append is about to be computed over.
let siblings = try stampAppendAtEndOrders(of: parentFolder, excluding: nil, operation: operation)
let order = Ranks.append(toVisible: siblings.map(\.order))
let folder = try mintUUIDFolder(in: parentFolder, operation: operation)
@@ -428,6 +511,66 @@ public enum BoardWriter: Sendable {
}
}
// MARK: - The board's noise definition
/// **The whole of the seeded `.gitignore`** (06-history-undo.md Repository hygiene, re-ruled
/// 2026-07-31: "Board creation writes the minimal seed `.DS_Store` plus the writer's temp
/// pattern (`.*.lanework-*`)").
///
/// Two lines, and each one earns its place:
///
/// - **`.DS_Store`** the Finder writes one into every folder a user looks at, and on a board
/// that means one per lane and one per card, each churning as icons and window positions move.
/// On a git board it would be committed by the whole-tree stage, forever, under the user's own
/// name; on every board it is now also the plainest thing the loose-file heal must not treat as
/// a card's attachment.
/// - **`.*.lanework-*`** this file's own temp pattern, and the one entry the app is entitled to
/// have an opinion about: `atomicReplace` writes `.<name>.lanework-<uuid>` beside its target,
/// so a crashed write's residue is the app's litter rather than the user's file. The two must
/// stay in step; changing the temp name means changing this line.
///
/// Nothing else. "Fine-tuning what counts as noise over time means fine-tuning the seed" and
/// after the seed the file is the user's, never edited by the app again.
///
/// Files the app creates end with LF (01-storage-format.md § Encoding and line endings).
public static let gitignoreSeed = ".DS_Store\n.*.lanework-*\n"
/// **Puts the seed on a board that has no `.gitignore`, and does nothing to one that has**
/// (06-history-undo.md Repository hygiene, re-ruled 2026-07-31: "`.gitignore` seeded on every
/// board, never touched after").
///
/// The three properties are the feature:
///
/// - **Only when the name is free.** `IntegrityRules.node(at:)` `lstat`, never `fileExists`
/// so a *directory* or a **dangling symlink** wearing the name is left alone too, rather than
/// written through. A board that already carries a `.gitignore` (from a template, a clone, the
/// user, or an earlier open) is left byte for byte alone, and an **empty** one is honored
/// exactly as it stands: it is the ruling's own escape hatch for wanting no exclusions.
/// - **Never an edit.** There is no merge, no append, no "and while we're here". The app writes
/// this file at most once per board and then reads it forever.
/// - **Universal.** Board creation calls it, template instantiation calls it, and a scheduled
/// heal calls it at every open for boards born before the ruling repo-nested boards included,
/// because the file serves the heal there, not any app-managed git.
///
/// A foreign deletion is answered by re-seeding on the next open, which is what makes the
/// scheduled heal's memo (armed before the attempt, cleared on success) load-bearing rather than
/// tidy: the picture "missing" must stay healable.
///
/// - Returns: whether this call wrote the file `false` when the name was already taken, which
/// is a success with nothing to do (the heal's disk re-verify, and the reason a second open
/// costs no write).
@discardableResult
public static func seedGitignoreIfAbsent(atBoardRoot root: URL) throws(BoardWriteError) -> Bool {
let url = root.appendingPathComponent(IntegrityRules.gitignoreFileName)
guard IntegrityRules.node(at: url) == nil else { return false }
try atomicReplace(text: gitignoreSeed, at: url, operation: .seedGitignore)
// Heal-marked, on the agent guide's reasoning exactly: this is app-initiated work on a file
// the user did not create, and its commit is its own (06-history-undo.md Commit messages
// a non-snapshot path composes "Update '.gitignore'").
EchoLedger.current?.markHeal(at: url)
return true
}
// MARK: - Renumber
/// Renumbers a parent's visible children to whole multiples of 1024 the renumber fallback
@@ -442,7 +585,10 @@ public enum BoardWriter: Sendable {
/// rewriting the lane.
/// - **Display order is the assignment order** (`Ranks.isOrderedForDisplay`: `order`
/// ascending, folder name breaking ties) the same rule the loader sorts by, so a
/// renumber is guaranteed to be sequence-preserving: nothing visibly moves.
/// renumber is guaranteed to be sequence-preserving: nothing visibly moves. An order-less
/// sibling takes part on its append-at-end reading (`visibleSiblings`) and comes out of the
/// batch with a real rank, which makes the rescale the heavier sibling of
/// `stampAppendAtEndOrders` rather than a second rule.
/// - **Nothing is stamped.** A rescale is order-only, so no sibling's `modified` moves and no
/// sibling's `modified-by` is cleared (01-storage-format.md § Ordering, verbatim: "order-only
/// rewrites, so no `modified` stamp and no `modified-by` clear"). That falls out of
@@ -478,20 +624,27 @@ public enum BoardWriter: Sendable {
/// - **Strays are untouched**: non-UUID-shaped folders, and UUID-shaped folders without an
/// `index.md` (an interrupted two-step create the loader's own `.missingIndex` warning
/// tolerates exactly this), are skipped here for the same reasons `BoardLoader` skips them.
/// - A visible sibling's missing or malformed `order` fails the *whole* operation, naming
/// that sibling's file, before anything is written the same discover-before-you-write
/// guarantee `renumberVisibleChildren`'s batch depends on.
/// - **A missing or unusable `order` is no longer a failure** (01-storage-format.md § Ordering,
/// re-ruled 2026-07-31): the sibling reads as append-at-end, exactly as the loader reads it
/// (`Ranks.resolvedOrders(of:stored:name:)`), and `order` carries that reading while
/// `hasStoredOrder` records whether the file actually says so. The scan is still strict about
/// everything it was strict about before an unreadable or unparseable sibling still fails the
/// whole operation before anything is written, which is the discover-before-you-write guarantee
/// `renumberVisibleChildren`'s batch depends on.
/// - `requireEditable` scopes the readable-but-uneditable pre-flight to the caller that
/// will actually *rewrite* the siblings: renumber passes `true` (it must not discover an
/// unwritable sibling halfway through the batch), the creates pass `false` a create
/// only *reads* its siblings' orders, and a flow-mapping sibling that loads and renders
/// normally (01-storage-format.md § Frontmatter) must not block creating a new item
/// beside it.
///
/// The returned ranks are **in listing order, not display order**; every caller that needs the
/// latter sorts through `Ranks.sortedForDisplay`, as they always did.
private static func visibleSiblings(
of parentFolder: URL,
operation: WriteOperation,
requireEditable: Bool
) throws(BoardWriteError) -> [(folder: URL, order: Double)] {
) throws(BoardWriteError) -> [(folder: URL, order: Double, hasStoredOrder: Bool)] {
let candidates: [URL]
do {
candidates = try BoardLoader.directoryCandidates(in: parentFolder)
@@ -503,7 +656,7 @@ public enum BoardWriter: Sendable {
)
}
var visible: [(folder: URL, order: Double)] = []
var visible: [(folder: URL, storedOrder: Double?)] = []
for folder in candidates where BoardLoader.isUUIDShaped(folder.lastPathComponent) {
let indexURL = folder.appendingPathComponent(BoardLoader.indexFileName)
guard FileManager.default.fileExists(atPath: indexURL.path) else { continue }
@@ -513,25 +666,88 @@ public enum BoardWriter: Sendable {
if requireEditable {
try checkEditable(document, at: indexURL, operation: operation)
}
visible.append((folder: folder, storedOrder: IntegrityRules.resolvedOrder(in: document).order))
}
switch document.order {
case .missing:
throw BoardWriteError(
operation: operation,
path: indexURL.path,
reason: .unreadable(message: "missing required 'order' field")
)
case let .malformed(raw):
throw BoardWriteError(
operation: operation,
path: indexURL.path,
reason: .unreadable(message: "malformed 'order' field: \(raw)")
)
case let .valid(order):
visible.append((folder: folder, order: order))
// **The loader's own reading, computed from the same bytes** which is what lets a placement
// the app computed against the *snapshot* be made true on disk by simply stamping it
// (`stampAppendAtEndOrders(of:excluding:operation:)`).
let resolved = Ranks.resolvedOrders(
of: visible, stored: { $0.storedOrder }, name: { $0.folder.lastPathComponent })
return zip(visible, resolved).map {
(folder: $0.folder, order: $1, hasStoredOrder: $0.storedOrder != nil)
}
}
// MARK: - The append-at-end reading's inline stamp
/// **Writes down the append-at-end reading for every order-less child of `parentFolder`**
/// (01-storage-format.md § Ordering, re-ruled 2026-07-31: "placement math that must rank an item
/// *relative to* an order-less sibling stamps that sibling inline, inside the gesture's bracket
/// and commit the midpoint-renumber precedent").
///
/// ## Why a placement cannot skip it
///
/// An order-less file renders at a rank nobody wrote down. Placement math ranks the arriving item
/// against that rank a midpoint between two order-less siblings, an append past the last one
/// and the answer is only *true on disk* if the siblings hold the ranks the math assumed. Without
/// the stamp the arriving item would be filed against ranks that evaporate on the next load, and
/// the order-less siblings would re-materialize past it: the card lands in the wrong place,
/// silently. Stamping is what closes that gap, and it is the same shape as the renumber that
/// closes the midpoint-exhaustion gap inline, inside the caller's write bracket, composing no
/// event of its own.
///
/// ## Why nothing moves when it lands
///
/// The value written is `Ranks.resolvedOrders`' answer for that file the rank the board was
/// *already rendering it at*. So this is sequence-preserving by construction, exactly like a
/// renumber, and it is idempotent: run it twice and the second call finds nothing to do.
///
/// ## Cost, stated
///
/// One `index.md` read per visible sibling, on any write that rewrites a rank the same read the
/// reload following that write performs anyway. A board this app wrote has no order-less children
/// at all, so the pass writes nothing; it is the hand-filed and agent-filed cases that pay.
///
/// ## The reading is the container's, and the caller must not narrow it
///
/// `excluding` skips the **write**, never the reading: the ranks stamped are resolved over the
/// container exactly as the loader resolved them, placed item included. Narrowing the resolution
/// would re-base the materialized ranks against a different maximum than the snapshot the
/// placement was computed from, and the two would disagree about where the order-less siblings
/// sit. The corollary is that a placement whose item is **not yet in the container** a
/// cross-parent move, a copy, a create must stamp *before* the folder lands, so that what this
/// reads is what the loader read. Each of those call sites does.
///
/// - Parameter excluding: the item the caller is about to place, when it is already a child here.
/// It is skipped because its own rank is about to be written by the caller anyway, and stamping
/// it first would be two writes to one file inside one gesture.
/// - Returns: the container's visible siblings and their resolved ranks the same values a
/// re-scan would answer, so a caller that needs them next (the creates' append) can use these
/// rather than reading the directory twice.
@discardableResult
static func stampAppendAtEndOrders(
of parentFolder: URL,
excluding placed: URL?,
operation: WriteOperation
) throws(BoardWriteError) -> [(folder: URL, order: Double, hasStoredOrder: Bool)] {
// `requireEditable: false`: only the order-less siblings are rewritten, so only *they* owe
// the pre-flight a flow-mapping sibling that already carries a rank must not block a
// placement it has nothing to do with. `updateIndex` refuses an uneditable one loudly, which
// is the honest answer for a file the placement genuinely depends on.
let siblings = try visibleSiblings(of: parentFolder, operation: operation, requireEditable: false)
let excluded = placed.map { IntegrityRules.canonicalIdentity($0.lastPathComponent) }
for sibling in siblings where !sibling.hasStoredOrder {
guard IntegrityRules.canonicalIdentity(sibling.folder.lastPathComponent) != excluded else { continue }
// `.renumberChildren`: this *is* a rescale of the container's own arrangement order-only,
// so no `modified` stamp and no `modified-by` clear (the reorders-don't-stamp rule), and
// the one operation `updateIndex` does not re-enter this pass for.
try updateIndex(inItemFolder: sibling.folder, operation: .renumberChildren) { document in
document.set(FrontmatterKeys.order, to: .double(sibling.order))
}
}
return visible
return siblings
}
// MARK: - Move
@@ -655,6 +871,14 @@ public enum BoardWriter: Sendable {
return MoveResult(id: ItemID(rawValue: sourceName), reminted: [])
}
// **Before the folder lands** (01-storage-format.md § Ordering, re-ruled 2026-07-31): the
// destination's order-less children are stamped with the reading they had *without* this
// item in the container which is the reading the caller's placement math was computed
// against, since the snapshot it read did not hold this item here either. Doing it after the
// move would re-base those ranks against the arriving item's own (foreign-lane) rank.
// The same-parent degenerate reorder above needs none of this: it never leaves the container,
// so `updateIndex`'s own hook stamps against the unchanged reading.
try stampAppendAtEndOrders(of: destinationParent, excluding: nil, operation: operation)
let rank = try destinationOrder(order, inParent: destinationParent, operation: operation)
let isImport = !isSameLocation(sourceBoardRoot, destinationBoardRoot)
@@ -870,6 +1094,10 @@ public enum BoardWriter: Sendable {
// contract refuses here, where nothing has been materialized and there is nothing to undo.
try checkCopiedDescendantsAreStampable(of: sourceFolder, operation: operation)
// `moveItem`'s pre-arrival stamp, for the same reason: the copy is not in the destination's
// reading yet, and the placement it is about to take was computed against a reading that did
// not hold it (01-storage-format.md § Ordering, re-ruled 2026-07-31).
try stampAppendAtEndOrders(of: destinationParent, excluding: nil, operation: operation)
let rank = try destinationOrder(order, inParent: destinationParent, operation: operation)
let rootName = freshUUIDName(in: destinationParent, avoiding: [])
@@ -2064,13 +2292,33 @@ public enum BoardWriter: Sendable {
/// user's own paste made and the app immediately cleaned up.
///
/// A card with nothing loose is one directory listing and no write at all.
///
/// **The destination board's noise gate applies here too** (01-storage-format.md § Fractal layout
/// Rules, ruled 2026-07-31): the arriving card is already at its final path, so its files are
/// the destination board's to have an opinion about and a paste that swept a file the very next
/// walk would have left alone would be the app disagreeing with itself one gesture apart. The
/// rules are read from the board root the card's own path implies (`checkIsCardFolder` proves the
/// `<root>/<lane>/<card>` shape before anything moves), which for a batch means one small file
/// read per card; the lane-level face below reads it once for the whole lane.
@discardableResult
public static func normalizeLooseFiles(inCard cardFolder: URL) throws(BoardWriteError) -> [ImportedAttachment] {
try normalizeLooseFiles(
inCard: cardFolder,
ignoring: BoardLoader.ignoreRules(atBoardRoot: boardRoot(ofCard: cardFolder))
)
}
/// The rules-in-hand form, so a batch reads the board's `.gitignore` once rather than per card.
@discardableResult
static func normalizeLooseFiles(
inCard cardFolder: URL,
ignoring rules: GitignoreRules?
) throws(BoardWriteError) -> [ImportedAttachment] {
// `healMarked: false` an **inline** heal batches with the gesture that triggered it
// (01-storage-format.md § Validation and healing), so its paths are the paste's, not a
// heal's, and splitting them out would name a commit for work the user asked for.
try relocateLooseFiles(
BoardLoader.looseFileNames(in: cardFolder),
BoardLoader.looseFileNames(in: cardFolder, ignoring: rules),
inCard: cardFolder,
healMarked: false
)
@@ -2084,13 +2332,23 @@ public enum BoardWriter: Sendable {
/// reached: the carve-out is card-level and one level deep, exactly as 01 states it.
@discardableResult
public static func normalizeLooseFiles(inLane laneFolder: URL) throws(BoardWriteError) -> [ImportedAttachment] {
let rules = BoardLoader.ignoreRules(atBoardRoot: laneFolder.deletingLastPathComponent())
var moved: [ImportedAttachment] = []
for card in childCandidates(of: laneFolder) {
moved.append(contentsOf: try normalizeLooseFiles(inCard: card))
moved.append(contentsOf: try normalizeLooseFiles(inCard: card, ignoring: rules))
}
return moved
}
/// The board root a card folder implies `<root>/<lane>/<card>` with two components taken off.
///
/// Purely arithmetic on the path, and deliberately not a validation: the callers that reach it
/// run `checkIsCardFolder` on the same URL, and a folder that is not a card has no board root to
/// derive anyway the rules read from wherever this points would simply find no file.
private static func boardRoot(ofCard cardFolder: URL) -> URL {
cardFolder.deletingLastPathComponent().deletingLastPathComponent()
}
/// Refuses any folder that is not a **card**: UUID-shaped, *under* a UUID-shaped parent.
///
/// `checkIsUUIDShaped` is the guard every other item write leans on, and it is the wrong one
@@ -2681,6 +2939,19 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
/// second phrasing for "couldn't move a file you have never seen" would explain nothing.
case agentGuide
/// The board-root `.gitignore` being seeded at creation, at instantiation, and by the
/// scheduled heal that gives a board born before the ruling the file it now needs
/// (06-history-undo.md Repository hygiene, re-ruled 2026-07-31; `seedGitignoreIfAbsent`).
///
/// **No payload**, for `.agentGuide`'s reason: there is one such file per board, its name is
/// fixed, and it is not an item with a title. Its own case beside `.agentGuide` rather than a
/// fold into it, on the vocabulary's standing reasoning one is the agent guide and the other is
/// the board's noise definition, and a banner saying the app "couldn't update the agent guide"
/// after a failed seed would name a file that was never touched.
///
/// It never describes an *edit*: the app writes this file only when nothing holds the name.
case seedGitignore
/// A wrong-kinded node being moved off a board-root name the app claims a file or symlink
/// squatting `.trash` (01-storage-format.md § Fractal layout Rules, ruled 2026-07-29: "moved
/// aside by a scheduled heal via the Finder-style rename ladder").
@@ -2804,7 +3075,7 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
// `index.md` carries no `title` to enrich from, and the title these five hold is the *card's*,
// filled in by the caller from the window the gesture came from.
case .createBoard, .createLane, .createCard, .importAttachment, .listAttachments,
.removeAttachment, .renumberChildren, .relocateLooseFile, .agentGuide,
.removeAttachment, .renumberChildren, .relocateLooseFile, .agentGuide, .seedGitignore,
.displaceClaimedName, .repairDuplicateID, .saveCommentDraft, .postComment,
.editComment, .deleteComment, .purgeCommentTrash:
self
@@ -2867,7 +3138,8 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
// plain container reason its board-level twin does.
case .createBoard, .createLane, .createCard, .move, .copy, .delete, .purge, .migrateTombstone,
.style, .resize, .rename, .duplicateBoard, .saveAsTemplate, .paste, .importAttachment,
.listAttachments, .removeAttachment, .relocateLooseFile, .agentGuide, .displaceClaimedName,
.listAttachments, .removeAttachment, .relocateLooseFile, .agentGuide, .seedGitignore,
.displaceClaimedName,
.repairDuplicateID, .toggleTask, .editBody, .rawSource, .saveCommentDraft, .postComment,
.editComment, .deleteComment, .purgeCommentTrash:
false
@@ -2903,6 +3175,7 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
case .renumberChildren: "renumber children"
case let .relocateLooseFile(filename): "relocate loose file '\(filename)'"
case .agentGuide: "update the agent guide"
case .seedGitignore: "seed the board's .gitignore"
case let .displaceClaimedName(name): "move a stray '\(name)' aside"
case let .repairDuplicateID(title): Self.phrase("repair the duplicate id of", title)
case let .toggleTask(title): Self.phrase("toggle a checkbox in", title)
+26 -14
View File
@@ -8,10 +8,14 @@ public struct FrontmatterField: Sendable, Equatable {
public let rawValue: String
}
/// The result of reading a typed field. `malformed` is what keeps strict fields (`schema`,
/// `order`) from being silently coerced they fail the load instead. Lenient fields (colors,
/// icons, `width`) coerce where a sensible reading exists (01-storage-format.md § Frontmatter)
/// and only fall back to `.malformed` rendered as the field's default when none does.
/// The result of reading a typed field **the document's reading, before any policy**. Lenient
/// fields (colors, icons, `width`) coerce where a sensible reading exists (01-storage-format.md
/// § Frontmatter) and only fall back to `.malformed` rendered as the field's default when none
/// does. Structural fields (`schema`, `order`) have no coercion at all at this layer: a value that
/// is not an integer, or not a finite number, is `.malformed` and nothing here decides what that
/// costs. **What it costs is the rulebook's** (`IntegrityRules`): a malformed `schema` refuses the
/// load, while a malformed or missing `order` below the board root reads as append-at-end
/// (re-ruled 2026-07-31) one shape, two policies, and this type carries neither.
public enum FieldValue<Value: Sendable & Equatable>: Sendable, Equatable {
case missing
case valid(Value)
@@ -76,11 +80,16 @@ extension FrontmatterDocument {
/// "the one place where an observed-in-the-wild shape can later be promoted to a heuristic heal or
/// a notice".
///
/// **The strict fields are absent, and so is `deleted`.** `schema` and `order` are the *refuse*
/// tier a malformed one fails the load loudly (`IntegrityRules.validatedSchema`/`validatedOrder`),
/// so there is no silent recovery to leave a trace of. `deleted` is the odd one out on purpose: its
/// rule is *presence, not validity* (a malformed `deleted` still deletes `Lane`/`Card.isDeleted`),
/// so nothing falls back to a default and the migration reports it under its own defect anyway.
/// **The structural fields are absent, and so is `deleted`.** `schema` and `order` are read
/// through the rulebook rather than here (`IntegrityRules.resolvedSchema`/`resolvedOrder`),
/// because their readings depend on something this layer cannot see *which level the file sits
/// at*: the root's `schema` refuses, a lane's or card's defaults to 1, and `order` below the root
/// reads as append-at-end (re-ruled 2026-07-31). Those two make their own `CoercedField`s, which
/// the loader concatenates with this list; they also cover the shape a pure document reading
/// cannot produce at all an **absent** key, which has no `rawText` to record. `deleted` is the
/// odd one out on purpose: its rule is *presence, not validity* (a malformed `deleted` still
/// deletes `Lane`/`Card.isDeleted`), so nothing falls back to a default and the migration
/// reports it under its own defect anyway.
///
/// A document whose fields all read cleanly answers `[]`, which is the overwhelmingly common case
/// and costs one pass over the lenient fields.
@@ -103,17 +112,20 @@ extension FrontmatterDocument {
return found
}
// MARK: - Strict (structure the loader fails fast on `.malformed`)
// MARK: - Structural (no coercion here `IntegrityRules` decides what `.malformed` costs)
public var schema: FieldValue<Int> {
read(FrontmatterKeys.schema) { value, _ in if case let .int(value) = value { value } else { nil } }
}
/// A non-finite reading (`.nan`, `.inf`) has no place in the total order the tie-break and
/// midpoint math assume (01-storage-format.md § Frontmatter, settled) it is the same loud
/// malformed-input rejection as a non-numeric value, not a `.valid(Double.nan)` silently
/// poisoning every comparison downstream. An `Int` reading is always finite, so only the
/// `.double` case needs the check.
/// midpoint math assume (01-storage-format.md § Frontmatter, settled) it is `.malformed` here,
/// never a `.valid(Double.nan)` silently poisoning every comparison downstream. An `Int` reading
/// is always finite, so only the `.double` case needs the check.
///
/// Below the board root `.malformed` and `.missing` are one reading append-at-end, coerce-tier
/// (re-ruled 2026-07-31, `IntegrityRules.resolvedOrder`) but they stay two shapes here, because
/// the coerce record wants the text as written and only one of them has any.
public var order: FieldValue<Double> {
read(FrontmatterKeys.order) { value, _ in
switch value {
+419
View File
@@ -0,0 +1,419 @@
import Foundation
/// **The board's noise definition, parsed** a board-root `.gitignore` read as git itself reads
/// one, and asked one question: is this board-relative path ignored?
///
/// The file outgrew git on 2026-07-31 (01-storage-format.md § Fractal layout Rules, "`.gitignore`
/// is the noise gate"; 06-history-undo.md Repository hygiene): it is now the one definition of
/// noise the **loose-file relocation heal** obeys, on every board, git or not. A file matching it
/// keeps the ordinary stray posture "skipped, preserved verbatim, logged, never relocated, never
/// announced" and "the exclusion list is exactly that file, nothing hardcoded". On a Pro board the
/// same file governs the committer through libgit2, so ignored noise neither relocates nor commits:
/// one definition, two consumers. This type is the app's half of that, because the load path cannot
/// reach for libgit2 the free tier opens a board without so much as a `fileExists` under `.git`
/// (12-editions.md The free tier and `.git`), and the heal runs on boards that have no repository
/// at all.
///
/// **Pure: no I/O, no filesystem, no `URL`.** It parses text and answers about paths. Reading the
/// bytes is `BoardLoader.ignoreRules(atBoardRoot:)`' job, once per walk the loader stays a pure
/// function of the tree, and this stays a pure function of the file.
///
/// ## The semantics, which are git's
///
/// Implemented from `gitignore(5)`, in its own order:
///
/// - A **blank line** matches nothing; it exists to space the file out.
/// - A line beginning with `#` is a **comment**. `\#` begins a pattern with a literal `#`.
/// - **Trailing spaces are ignored** unless escaped (`\ `).
/// - A leading `!` **negates**, re-including a path an earlier pattern excluded. `\!` is a literal.
/// - A trailing `/` makes the pattern **directory-only**.
/// - A `/` at the beginning or in the middle **anchors** the pattern to the board root; a pattern
/// with no interior separator matches **at any depth** (`.DS_Store` matches every one of them).
/// - `*` matches any run of characters but never `/`; `?` matches exactly one non-`/` character;
/// `[]` is a character class, negatable with `!` or `^` and carrying ranges (`[0-9]`).
/// - A path segment that is exactly `**` matches **zero or more** segments: leading `**/` matches at
/// any depth, a trailing `/**` matches everything inside, and `a/**/b` matches `a/b` as well as
/// `a/x/y/b`. Asterisks anywhere else are ordinary `*`s, exactly as git says.
/// - **Last match wins.** The verdict is the last pattern in file order that matched, negations
/// included which is why the rules are kept in file order and evaluated in it.
/// - **An ignored directory is final.** A path under an excluded directory is excluded whatever a
/// later negation says about it git does not descend into an ignored directory, so a rule about
/// something inside it is never consulted. `isIgnored(relativePath:isDirectory:)` walks the
/// ancestors for exactly this reason.
///
/// ## The deliberate divergences, and they are three
///
/// - **Only this file.** Nested `.gitignore` files are never consulted, `.git/info/exclude` and the
/// user's global excludes are never read, and `core.excludesFile` does not exist here. That is the
/// ruling's own wording "nested `.gitignore` files are ordinary strays the heal never consults"
/// and it is what makes the noise definition something the user can see in one place.
/// - **Case-sensitive matching**, always. Git's is `core.ignorecase`'s to decide, which is a
/// repository setting on a file this app reads on boards that have no repository. The board's own
/// name comparisons go the other way (`IntegrityRules.reservedCardChildNames` is lowercased
/// "because the filesystem this runs on usually is"), but a *pattern* is the user's text and
/// folding it would silently widen what they wrote.
/// - **No POSIX bracket expressions** (`[[:digit:]]`): a `[` that does not close is a literal `[`,
/// and `[:digit:]` reads as the ordinary class it looks like. Nothing realistic in a board's noise
/// file uses them, and inventing a second character-class grammar to hold them would be the
/// over-engineering this type exists to avoid.
public struct GitignoreRules: Sendable, Equatable {
/// The file's patterns, **in file order** which is the whole of last-match-wins.
private let patterns: [Pattern]
/// Parses a whole `.gitignore` body.
///
/// **An empty file parses to no patterns and ignores nothing** the escape hatch 06 names by
/// hand ("the escape hatch for wanting no exclusions is an *empty* file, which the app honors and
/// never rewrites"), and the same answer a board carrying no `.gitignore` at all gets. The two
/// are deliberately indistinguishable to every consumer: one of them is a statement and the other
/// is a silence, but neither excludes anything.
///
/// Lines are split on **any** newline (`Character.isNewline`), which is git's own reading of a
/// CRLF file `dir.c` terminates each pattern before the `\r` arrived at from the other
/// direction: Swift treats `\r\n` as a single grapheme, so splitting on newline-ness drops the
/// carriage return by construction rather than by trimming it afterwards. A board hand-edited on
/// Windows must not end up carrying patterns nobody can match. A leading UTF-8 BOM is skipped for
/// the reason git skips one: it is a byte-order mark, not the first character of a pattern.
public init(parsing text: String) {
var body = Substring(text)
if body.hasPrefix("\u{FEFF}") { body = body.dropFirst() }
patterns = body.split(whereSeparator: \.isNewline).compactMap(Pattern.init(line:))
}
/// Whether the file said anything at all. `true` for a board with no `.gitignore` and for one
/// whose `.gitignore` is empty or all comments the honored-empty state.
public var isEmpty: Bool { patterns.isEmpty }
/// Whether `relativePath` **board-relative**, `/`-separated, no leading slash (`<lane>/<card>/notes.txt`)
/// is ignored by this file.
///
/// `isDirectory` decides the directory-only patterns (`build/`) and nothing else. It describes
/// what is on disk at that path, which is the caller's to know: the loose-file gate always passes
/// `false`, because the carve-out is exactly *files*.
///
/// **Ancestors are consulted first.** A path inside an excluded directory is excluded, and no
/// negation reaches it git never descends into an ignored directory, so it never sees the rule
/// that would have re-included the file. Everything below `build/` is ignored even where the file
/// goes on to say `!build/keep.txt`.
public func isIgnored(relativePath: String, isDirectory: Bool = false) -> Bool {
guard !patterns.isEmpty else { return false }
let segments = relativePath.split(separator: "/").map(String.init)
guard !segments.isEmpty else { return false }
for depth in 1 ..< segments.count where verdict(for: Array(segments.prefix(depth)), isDirectory: true) == true {
return true
}
return verdict(for: segments, isDirectory: isDirectory) == true
}
/// The last-match-wins verdict for one path: `true` ignored, `false` explicitly re-included,
/// `nil` matched by nothing.
///
/// `nil` and `false` behave identically at every call site today; they are kept apart because the
/// file's grammar keeps them apart "this was never mentioned" and "this was mentioned and then
/// taken back" are different statements, and a future consumer (a listing that shows *why*) would
/// need the difference the moment it existed.
private func verdict(for segments: [String], isDirectory: Bool) -> Bool? {
var answer: Bool?
for pattern in patterns {
guard !pattern.directoryOnly || isDirectory else { continue }
guard pattern.matches(segments) else { continue }
answer = !pattern.isNegated
}
return answer
}
}
// MARK: - One pattern
extension GitignoreRules {
/// One line of the file, compiled: what it matches, whether it re-includes, and whether it only
/// speaks about directories.
///
/// **Anchoring is baked into `segments`** rather than carried as a flag: an unanchored pattern is
/// exactly its anchored self with a leading `**`, which is what `gitignore(5)` says in prose
/// ("the pattern may also match at any level below") and what removes a branch from the matcher.
fileprivate struct Pattern: Sendable, Equatable {
let segments: [Segment]
let isNegated: Bool
let directoryOnly: Bool
/// `nil` for a line that is not a pattern at all blank, or a comment.
init?(line: Substring) {
var text = Self.trimmingTrailingSpaces(line)
guard !text.isEmpty, text.first != "#" else { return nil }
if text.first == "!" {
isNegated = true
text = text.dropFirst()
} else {
isNegated = false
}
// The trailing separator is the directory-only marker, and it is *not* an interior
// separator for the anchoring question below: `foo/` matches a directory named `foo` at
// any depth, while `a/b/` is anchored.
if text.hasSuffix("/") {
directoryOnly = true
text = text.dropLast()
} else {
directoryOnly = false
}
guard !text.isEmpty else { return nil }
let anchored = text.contains("/")
let parsed = text
.split(separator: "/", omittingEmptySubsequences: true)
.map(Segment.init(text:))
guard !parsed.isEmpty else { return nil }
segments = anchored ? parsed : [.globstar] + parsed
}
/// Whether this pattern matches the whole of `path`.
func matches(_ path: [String]) -> Bool {
Self.match(segments[...], path[...])
}
/// The segment walk, with `**`'s zero-or-more the only place it backtracks.
private static func match(_ pattern: ArraySlice<Segment>, _ path: ArraySlice<String>) -> Bool {
guard let head = pattern.first else { return path.isEmpty }
let tail = pattern.dropFirst()
if case .globstar = head {
// **A trailing `**` matches everything *inside*** (`gitignore(5)`), so it needs a
// segment to consume: `a/**` matches `a/b`, never the bare `a`. Where `a` itself is
// ignored, some other pattern said so.
guard !tail.isEmpty else { return !path.isEmpty }
var remaining = path
while true {
if match(tail, remaining) { return true }
guard !remaining.isEmpty else { return false }
remaining = remaining.dropFirst()
}
}
guard case let .literalOrWildcards(tokens) = head,
let name = path.first,
Segment.match(tokens[...], Array(name)[...])
else {
return false
}
return match(tail, path.dropFirst())
}
/// Drops the trailing spaces git drops every one that is not `\`-escaped.
///
/// Only spaces, and only trailing: git trims exactly this (`trim_trailing_spaces`), so a
/// pattern ending in a tab keeps it, and a filename that really does end in a space stays
/// reachable by writing `foo\ `.
private static func trimmingTrailingSpaces(_ line: Substring) -> Substring {
var end = line.endIndex
while end > line.startIndex {
let previous = line.index(before: end)
guard line[previous] == " " else { break }
// Escaped when preceded by an odd number of backslashes.
var backslashes = 0
var scan = previous
while scan > line.startIndex {
scan = line.index(before: scan)
guard line[scan] == "\\" else { break }
backslashes += 1
}
guard backslashes.isMultiple(of: 2) else { break }
end = previous
}
return line[line.startIndex ..< end]
}
}
/// One `/`-separated piece of a pattern.
fileprivate enum Segment: Sendable, Equatable {
/// Exactly `**` zero or more path segments. "Other consecutive asterisks are considered
/// regular asterisks" (`gitignore(5)`), which is why this case is reserved for the whole
/// segment and never for a `**` sitting inside one.
case globstar
/// Everything else, tokenized once at parse time.
case literalOrWildcards([Token])
init(text: Substring) {
self = text == "**" ? .globstar : .literalOrWildcards(Token.tokenize(text))
}
/// Matches one path segment against one pattern segment `*`'s backtracking, iteratively,
/// because a pattern is small and a recursion per `*` is not worth the stack.
static func match(_ tokens: ArraySlice<Token>, _ name: ArraySlice<Character>) -> Bool {
var t = tokens.startIndex
var n = name.startIndex
// Where to resume from if a `*` guessed short: the star itself and the character it was
// last asked to swallow up to.
var starToken: Int?
var starName = name.startIndex
while n < name.endIndex {
if t < tokens.endIndex {
switch tokens[t] {
case .anyRun:
starToken = t
starName = n
t += 1
continue
case .anyCharacter:
t += 1
n += 1
continue
case let .literal(character) where character == name[n]:
t += 1
n += 1
continue
case let .characterClass(group) where group.matches(name[n]):
t += 1
n += 1
continue
case .literal, .characterClass:
break
}
}
guard let star = starToken else { return false }
starName += 1
n = starName
t = star + 1
}
// Trailing `*`s can still match nothing at all.
while t < tokens.endIndex, tokens[t] == .anyRun { t += 1 }
return t == tokens.endIndex
}
}
/// One element of a pattern segment.
fileprivate enum Token: Sendable, Equatable {
case literal(Character)
/// `?`
case anyCharacter
/// `*` any run, `/` excluded by construction (a token never sees a separator).
case anyRun
/// `[]`
case characterClass(CharacterClass)
/// Compiles one segment's characters. A `\` escapes whatever follows it; a trailing `\` is a
/// literal backslash (there is nothing left for it to escape); an unterminated `[` is a
/// literal `[`, which is git's own reading and the only one that cannot lose a character.
static func tokenize(_ text: Substring) -> [Token] {
var tokens: [Token] = []
var index = text.startIndex
while index < text.endIndex {
let character = text[index]
switch character {
case "\\":
let next = text.index(after: index)
guard next < text.endIndex else {
tokens.append(.literal("\\"))
index = next
continue
}
tokens.append(.literal(text[next]))
index = text.index(after: next)
case "?":
tokens.append(.anyCharacter)
index = text.index(after: index)
case "*":
// Consecutive asterisks inside a segment are one ordinary `*`.
if tokens.last != .anyRun { tokens.append(.anyRun) }
index = text.index(after: index)
case "[":
if let (group, end) = CharacterClass.parse(text, from: index) {
tokens.append(.characterClass(group))
index = end
} else {
tokens.append(.literal("["))
index = text.index(after: index)
}
default:
tokens.append(.literal(character))
index = text.index(after: index)
}
}
return tokens
}
}
/// A `[]` group: members, ranges, and the leading `!`/`^` negation.
fileprivate struct CharacterClass: Sendable, Equatable {
enum Member: Sendable, Equatable {
case single(Character)
case range(ClosedRange<Character>)
}
let isNegated: Bool
let members: [Member]
func matches(_ character: Character) -> Bool {
let hit = members.contains { member in
switch member {
case let .single(value): value == character
case let .range(range): range.contains(character)
}
}
return hit != isNegated
}
/// Parses from the `[` at `start`, answering the group and the index just past its `]`, or
/// `nil` when the group never closes.
///
/// A `]` **immediately after** the opening bracket (or its negation mark) is a literal member
/// rather than the terminator, which is the POSIX rule git inherits `[]]` matches a bracket.
static func parse(_ text: Substring, from start: Substring.Index) -> (CharacterClass, Substring.Index)? {
var index = text.index(after: start)
var negated = false
if index < text.endIndex, text[index] == "!" || text[index] == "^" {
negated = true
index = text.index(after: index)
}
var members: [Member] = []
var first = true
while index < text.endIndex {
let character = text[index]
if character == "]", !first {
return (CharacterClass(isNegated: negated, members: members), text.index(after: index))
}
first = false
var value = character
if character == "\\" {
let next = text.index(after: index)
guard next < text.endIndex else { break }
value = text[next]
index = next
}
// A `-` between two members is a range; one at either end of the group is a literal.
let afterValue = text.index(after: index)
if afterValue < text.endIndex, text[afterValue] == "-" {
let upperIndex = text.index(after: afterValue)
if upperIndex < text.endIndex, text[upperIndex] != "]" {
var upper = text[upperIndex]
var end = upperIndex
if upper == "\\" {
let escaped = text.index(after: upperIndex)
guard escaped < text.endIndex else { break }
upper = text[escaped]
end = escaped
}
// An inverted range (`z-a`) is nonsense; git's matcher never matches one, and
// `ClosedRange` would trap on it.
if value <= upper { members.append(.range(value ... upper)) }
index = text.index(after: end)
continue
}
}
members.append(.single(value))
index = text.index(after: index)
}
return nil
}
}
}
+182 -55
View File
@@ -116,6 +116,13 @@ public enum IntegrityRules: Sendable {
/// The file every level's content lives in.
public static let indexFileName = "index.md"
/// **The board's noise definition** (01-storage-format.md § Fractal layout Rules, ruled
/// 2026-07-31; 06-history-undo.md Repository hygiene) a board-root **file** name, claimed
/// because the app seeds it on every board and then reads it on every walk: it is the one list of
/// files the loose-file relocation heal leaves alone, and on a Pro board the same file is what
/// keeps that noise out of history. Seeded once, never edited afterwards.
public static let gitignoreFileName = ".gitignore"
/// A card's **comment thread** (01-storage-format.md § Enhanced schema) a plain reserved
/// child, never a level and never identity; the identities are the UUID folders inside it.
public static let commentsFolderName = "comments"
@@ -180,11 +187,24 @@ public enum IntegrityRules: Sendable {
/// Whether a wrong-kinded node on this name is displaced by the scheduled heal (ruled
/// 2026-07-29 "Lanework owns the board"), or left exactly where it is.
///
/// `false` for the two names that are *destinations* or not the app's to police:
/// `false` for the two names that are *destinations* or that the app can do without:
/// `CLAUDE.user.md` is where a markerless `CLAUDE.md` is rescued **to**, and freeing a
/// destination by a second displacement would cascade renames (the settled skip stands
/// 08-agent-integration.md); `.gitignore` is seeded once and then the user's to edit
/// (06-history-undo.md Repository hygiene), and nothing in the app reads it.
/// (06-history-undo.md Repository hygiene).
///
/// **`.gitignore`'s `false` survived the 2026-07-31 re-ruling deliberately**, and the reason
/// changed underneath it. It used to be "nothing in the app reads it", which stopped being
/// true the day the file became the loose-file heal's noise gate (01-storage-format.md
/// § Fractal layout Rules) by the level-uniform timing principle (a name "joins the
/// scheduled class the day the name becomes load-bearing", which is how `comments` graduated)
/// a folder squatting it would now be a candidate. It stays undisplaced because **the app
/// degrades to correctness without the file**: an unreadable noise definition means no
/// exclusions, which is exactly the behaviour boards had before the ruling and exactly what an
/// empty file the ruling's own escape hatch asks for. Nothing breaks while the name is
/// held, so nothing is displaced; that is the "proactive only when the defect is load-bearing
/// *now*" half of § Validation and healing. Neither 01 nor 06 asks for a displacement here,
/// and inventing one would move a node of the user's to buy a courtesy file.
public let displacesSquatters: Bool
}
@@ -199,7 +219,7 @@ public enum IntegrityRules: Sendable {
ClaimedName(name: trashFolderName, expected: .directory, displacesSquatters: true),
ClaimedName(name: "CLAUDE.md", expected: .file, displacesSquatters: true),
ClaimedName(name: "CLAUDE.user.md", expected: .file, displacesSquatters: false),
ClaimedName(name: ".gitignore", expected: .file, displacesSquatters: false),
ClaimedName(name: gitignoreFileName, expected: .file, displacesSquatters: false),
]
/// **The card-level claimed names** the same table one level down (01-storage-format.md § Fractal
@@ -451,9 +471,15 @@ public enum IntegrityRules: Sendable {
// MARK: - Per-field validation (the rulebook)
/// `schema`, validated: present, well-formed, not newer than this app (01-storage-format.md
/// § Malformed input). Required at every level.
public static func validatedSchema(
/// `schema` **at the board root**, validated: present, well-formed, not newer than this app
/// (01-storage-format.md § Malformed input).
///
/// **The root keeps its requirement** (re-ruled 2026-07-31): the root's stamp is the board's
/// identity claim the this-really-is-a-board gate so a root without one is the loud
/// rejection it always was, and the decision surface's minted repair (`stamp schema: 1`) is
/// what fixes it. Below the root the key is optional and defaults instead
/// (`resolvedSchema(in:path:supportedSchema:)`).
public static func validatedRootSchema(
in document: FrontmatterDocument,
path: String,
supportedSchema: Int
@@ -464,51 +490,111 @@ public enum IntegrityRules: Sendable {
case let .malformed(raw):
throw BoardLoadError(path: path, reason: .malformedSchema(raw: raw))
case let .valid(value):
guard value <= supportedSchema else {
throw BoardLoadError(path: path, reason: .schemaNewerThanApp(found: value))
}
try checkNotNewerThanApp(value, path: path, supportedSchema: supportedSchema)
return value
}
}
/// `order`, validated: present and well-formed. Required on lanes and cards, **never** on the
/// board itself which is the whole of the per-kind difference in the tables today.
public static func validatedOrder(
in document: FrontmatterDocument,
path: String
) throws(BoardLoadError) -> Double {
switch document.order {
case .missing:
throw BoardLoadError(path: path, reason: .missingOrder)
case let .malformed(raw):
throw BoardLoadError(path: path, reason: .malformedOrder(raw: raw))
case let .valid(value):
return value
}
}
/// Whether an object of `kind` must carry `order` the per-kind field table, as a rule rather
/// than as two hand-written call sites in the loader's walk.
/// `schema` **below the board root**, read: a missing (or explicitly null) key reads as **1**
/// (01-storage-format.md § Frontmatter, re-ruled 2026-07-31 "below the root a missing `schema`
/// reads as 1 coerce-tier, logged, bytes preserved because the walk validating the file
/// against schema 1 is exactly what makes the reading reliable").
///
/// **A comment carries none, and never gains one** (01-storage-format.md § Enhanced schema:
/// "**No `title`, no `order`**" "Ordering is chronology, not ranks", because a conversation's
/// semantics *are* chronology and tracker-synced comments carry independent clocks where minted
/// ranks would interleave arbitrarily).
public static func requiresOrder(_ kind: ObjectKind) -> Bool {
switch kind {
case .board, .comment: false
case .lane, .card: true
/// Two things stay fail-fast here, and both are the same rule they always were:
///
/// - **A schema newer than the app**, at any level: there is no honest reading of a file written
/// by a Lanework that knows keys this one does not, and defaulting would be guessing at content
/// rather than recovering it.
/// - **A malformed `schema`** `schema: one`, `schema: [1]`. The ruling made the *absent* key
/// optional, not the unreadable one: absence is what a hand-filed item legitimately looks like,
/// while a value that is there and unreadable is a claim the app cannot check, and reading it
/// as 1 would be inventing agreement.
///
/// - Returns: the reading, plus the coerce-tier record when one was made the field, and the
/// text as written (`nil` when the key was present and valid, which is every file this app
/// ever wrote). The *path* is the loader's to attach (`CoercedFrontmatter`).
public static func resolvedSchema(
in document: FrontmatterDocument,
path: String,
supportedSchema: Int
) throws(BoardLoadError) -> (schema: Int, coerced: CoercedField?) {
switch document.schema {
case .missing:
return (defaultSchema, coercedRecord(for: FrontmatterKeys.schema, in: document, raw: nil))
case let .malformed(raw):
throw BoardLoadError(path: path, reason: .malformedSchema(raw: raw))
case let .valid(value):
try checkNotNewerThanApp(value, path: path, supportedSchema: supportedSchema)
return (value, nil)
}
}
/// The reading a `schema`-less file below the root gets the schema this document *is* being
/// validated against, which is the argument for the default rather than a coincidence.
public static let defaultSchema = 1
private static func checkNotNewerThanApp(
_ value: Int,
path: String,
supportedSchema: Int
) throws(BoardLoadError) {
guard value <= supportedSchema else {
throw BoardLoadError(path: path, reason: .schemaNewerThanApp(found: value))
}
}
/// `order` **below the board root**, read: `nil` where the file carries no rank this app can
/// use which the caller reads as **append-at-end** (`Ranks.resolvedOrders(of:stored:name:)`).
///
/// **Four shapes, one reading** (01-storage-format.md § Ordering, re-ruled 2026-07-31
/// "Missing or unusable `order` reads as append-at-end"): a missing key, an explicitly null one
/// (`order:` bare, `order: null` the null-as-missing rule), a non-numeric value, and a
/// non-finite one (`.nan`, `.inf`, which has no place in the total order the tie-break and
/// midpoint math assume). None of them is a load failure any more; all of them are coerce-tier
/// bytes preserved verbatim, the reading logged, the rank materializing on the file's first
/// touch (`healOnTouch`).
///
/// It never throws, and has no `path`: with nothing left to refuse, there is nothing to name.
///
/// **The board root never asks.** A board carries no rank it is not among siblings so the
/// key is meaningless there and this is simply not called on it, exactly as it never was.
public static func resolvedOrder(
in document: FrontmatterDocument
) -> (order: Double?, coerced: CoercedField?) {
switch document.order {
case .valid(let value):
return (value, nil)
case .malformed(let raw):
return (nil, coercedRecord(for: FrontmatterKeys.order, in: document, raw: raw))
case .missing:
return (nil, coercedRecord(for: FrontmatterKeys.order, in: document, raw: nil))
}
}
/// One coerce-tier record: the field and **the value exactly as written**, which for an absent
/// key is the empty string there is no text to record, and inventing a rendering of one would
/// defeat the record's whole purpose (a shape observed in the wild, promotable later to a
/// heuristic heal). An explicitly-null key answers with its own (empty) source span, so
/// "started the key and never gave it a value" is preserved as exactly that.
private static func coercedRecord(
for key: String,
in document: FrontmatterDocument,
raw: String?
) -> CoercedField {
CoercedField(key: key, raw: raw ?? document.rawValue(for: key) ?? "")
}
/// **Per-kind index validation** the loader's own checks, in its own order, over bytes that
/// need not be on disk yet (02-architecture.md Components: "the card validator generalized per
/// kind board, lane, card, the enhanced schema's comment when it lands").
///
/// Exactly the checks `BoardLoader.load` runs on an object of that kind, through its own
/// functions: decode + parse, then `schema`, then `order` where the kind requires it. Nothing
/// further is checked, because nothing else *is*: `title` is optional, unknown keys are the
/// point of the outlet the card validator serves, and the body is free text.
/// functions: decode + parse, then `schema` at the root's strictness for a board, at the
/// below-the-root reading for everything else. Nothing further is checked, because nothing else
/// *is*: `title` is optional, `order` is optional below the root since 2026-07-31 (a rankless
/// card applied through the raw-source outlet lands at its lane's bottom and gains a rank on its
/// next touch, exactly as a hand-filed one does), unknown keys are the point of the outlet the
/// card validator serves, and the body is free text.
///
/// It deliberately does **not** check `uneditableShape`: that refusal exists for surgical span
/// edits, and the raw-source Apply this serves replaces the whole file a flow-mapping
@@ -520,9 +606,11 @@ public enum IntegrityRules: Sendable {
supportedSchema: Int
) throws(BoardLoadError) -> FrontmatterDocument {
let document = try BoardLoader.parseDocument(data, path: path)
_ = try validatedSchema(in: document, path: path, supportedSchema: supportedSchema)
if requiresOrder(kind) {
_ = try validatedOrder(in: document, path: path)
switch kind {
case .board:
_ = try validatedRootSchema(in: document, path: path, supportedSchema: supportedSchema)
case .lane, .card, .comment:
_ = try resolvedSchema(in: document, path: path, supportedSchema: supportedSchema)
}
return document
}
@@ -551,6 +639,23 @@ public enum IntegrityRules: Sendable {
/// add a key that is redundant with position everywhere else.
case kindBackfilled(ObjectKind)
/// `order` was missing or unusable and has been stamped with the rank the board was
/// **already rendering it at** the append-at-end reading, materialized
/// (01-storage-format.md § Ordering, re-ruled 2026-07-31: "The rank materializes on touch:
/// the first Writer rewrite of the file stamps a real rank").
///
/// Nothing moves on screen when it lands, and that is the design rather than a happy
/// accident: the stamped value is `Ranks.resolvedOrders`' own answer for this file, so the
/// heal writes down what the reading already said. It is on-touch for the reason the whole
/// class is the defect is latent, the file is being rewritten anyway, and a sweep that
/// rewrote a board to add ranks nobody asked for would be churn.
///
/// **Unlike `kind`, an unusable *present* value is replaced.** `order: banana` and
/// `order: .nan` are read as order-less (the value-names-the-kind posture has no twin here:
/// a rank must be a number for the midpoint math to mean anything), so the stamp is the same
/// heal in both cases and the unreadable text does not survive the rewrite.
case rankStamped(Double)
/// A key written twice collapsed to its winning (last) occurrence the span editor's
/// duplicate-key twin removal (`FrontmatterDocument.set`). Named here because it *is* an
/// on-touch heal and was only ever documented as an editing detail: last-wins is the read
@@ -569,28 +674,43 @@ public enum IntegrityRules: Sendable {
/// `updateIndex` seam, which consults IntegrityRules for pending on-touch work on the file it is
/// rewriting").
///
/// Only the `kind` backfill is applied *here*; the other two members of the class are applied by
/// `FrontmatterDocument`'s own editor on every key it writes, and are named in `OnTouchHeal`
/// rather than re-implemented. That is the honest shape of "the same class, named, no behavior
/// change".
/// Two of the four members are applied *here* the `kind` backfill and the rank stamp; the
/// other two are applied by `FrontmatterDocument`'s own editor on every key it writes, and are
/// named in `OnTouchHeal` rather than re-implemented. That is the honest shape of "the same
/// class, named, no behavior change".
///
/// - **Missing only.** A present `kind` is never rewritten, never corroborated, and never
/// stripped the value names the kind and consumers trust it outright. An explicit `kind:`
/// with nothing after it reads as missing, like every other null (the null-as-missing rule),
/// and so backfills.
/// - **`kind`: missing only.** A present `kind` is never rewritten, never corroborated, and
/// never stripped the value names the kind and consumers trust it outright. An explicit
/// `kind:` with nothing after it reads as missing, like every other null (the null-as-missing
/// rule), and so backfills.
/// - **`kind == nil` stamps nothing.** Position cannot always answer (`Placement.unknown`), and
/// a guessed kind written to disk would be worse than an absent one: the trash discriminator
/// trusts what it finds.
/// - Parameter kind: `@autoclosure` so a caller whose answer costs a directory listing (the
/// trash's shape fallback) pays for it only on a file that actually needs the backfill.
/// - **`order`: missing *or unusable*.** The reading is stated over usability
/// (`resolvedOrder(in:)`), so the stamp is too.
/// - **`rank == nil` stamps nothing** the board root (which has no siblings and no rank), a
/// comment (which never gains one "Ordering is chronology, not ranks"), and any folder whose
/// position has no answer. The same posture as `kind`'s: a rank invented for something that is
/// not a lane or a card would be a fact nobody asked for.
/// - Parameters kind, rank: `@autoclosure` so a caller whose answer costs a directory listing
/// (the trash's shape fallback; the rank's own sibling scan) pays for it only on a file that
/// actually needs the heal which, on a board this app wrote, is never.
@discardableResult
public static func healOnTouch(
_ document: inout FrontmatterDocument,
kind: @autoclosure () -> ObjectKind?
kind: @autoclosure () -> ObjectKind?,
rank: @autoclosure () -> Double? = nil
) -> [OnTouchHeal] {
guard document.kind.isMissing, let kind = kind() else { return [] }
document.set(FrontmatterKeys.kind, to: .string(kind.rawValue))
return [.kindBackfilled(kind)]
var applied: [OnTouchHeal] = []
if document.kind.isMissing, let kind = kind() {
document.set(FrontmatterKeys.kind, to: .string(kind.rawValue))
applied.append(.kindBackfilled(kind))
}
if document.order.value == nil, let rank = rank() {
document.set(FrontmatterKeys.order, to: .double(rank))
applied.append(.rankStamped(rank))
}
return applied
}
// MARK: - The typed defect stream
@@ -640,6 +760,12 @@ public enum IntegrityRules: Sendable {
/// It is a class here because the engine treats it exactly like the others same gates,
/// same memo, same clear-on-success.
///
/// `missingGitignore` is the third, and the guide's twin in every respect: the board's noise
/// definition is one board-root file's presence, read at the moment of healing
/// (`BoardWriter.seedGitignoreIfAbsent`) rather than reported by a tree walk "a board
/// missing the file gains it by scheduled heal at open (the guide-refresh cadence)"
/// (06-history-undo.md Repository hygiene, re-ruled 2026-07-31).
///
/// `commentTrashResidue` is the second such class, for the same reason one level down: the
/// residue is whatever a crashed session left in one card's `comments/.trash/`, read at the
/// moment the card window opens (01-storage-format.md § Enhanced schema "purged when the
@@ -652,6 +778,7 @@ public enum IntegrityRules: Sendable {
case claimedNameSquatted
case duplicateIdentity
case staleAgentGuide
case missingGitignore
case commentTrashResidue
}
+52
View File
@@ -119,11 +119,63 @@ enum Ranks: Sendable {
return (1...count).map { Double($0) * gap }
}
// MARK: - The append-at-end reading
/// **Every sibling's rank, with the order-less ones read as append-at-end** the whole of the
/// optional-`order` ruling's read side (01-storage-format.md § Ordering, re-ruled 2026-07-31:
/// "Missing or unusable `order` reads as append-at-end order-less items sort after every
/// ordered sibling, among themselves by the folder-name tie-break deterministic with zero
/// sibling reads, which is what makes the minimum agent card legal").
///
/// - Parameter stored: the `order` the file actually carries, or `nil` where it carries none the
/// loader can use missing, explicitly null, non-numeric, non-finite. The four shapes are one
/// answer here on purpose: the reading is stated over *usability*, not over which way a value
/// failed to be usable (`IntegrityRules.resolvedOrder` is where the four are told apart, for
/// the coerce-tier record).
/// - Parameter name: the folder name the tie-break the design states this ordering in.
/// - Returns: one rank per sibling, **positionally aligned with `siblings`** (never reordered:
/// callers sort afterwards, through `sortedForDisplay`, exactly as they always did).
///
/// **The materialized ranks are `append`'s own arithmetic**, and that is the load-bearing
/// property rather than a convenience: the k-th order-less sibling reads as `max + 1024·k` over
/// the ranks actually written down, which is exactly where `append(toVisible:)` would have put it
/// had it been filed by the app. So the reading a board *renders* is a rank ladder the Writer can
/// stamp verbatim which is what the on-touch and inline stamps do
/// (`BoardWriter.stampAppendAtEndOrders`), and why stamping one changes nothing on screen.
///
/// An empty ranked set bases at `0`, so a container of nothing but order-less items reads
/// `1024, 2048, ` the same board convention `append` gives an empty container's first child.
///
/// **Accepted cost, stated by the ruling**: two order-less siblings sort by folder name rather
/// than by intent until something touches them.
static func resolvedOrders<T>(
of siblings: [T],
stored: (T) -> Double?,
name: (T) -> String
) -> [Double] {
let storedOrders = siblings.map(stored)
var resolved = storedOrders.map { $0 ?? 0 }
let orderless = storedOrders.indices.filter { storedOrders[$0] == nil }
guard !orderless.isEmpty else { return resolved }
let base = storedOrders.compactMap { $0 }.max() ?? 0
let queue = orderless.sorted { name(siblings[$0]) < name(siblings[$1]) }
for (step, index) in queue.enumerated() {
resolved[index] = base + gap * Double(step + 1)
}
return resolved
}
// MARK: - Display order
/// Ascending display order: primary key `order`, ties broken by folder
/// name (lexicographic) for deterministic rendering. Shared by the
/// loader and the writer so both apply the same tie-break rule.
///
/// An order-less sibling reaches here already carrying its append-at-end
/// reading (`resolvedOrders(of:stored:name:)`), so this comparator needs no
/// case for one: "after every ordered sibling, then by folder name" *is*
/// this rule applied to the materialized ranks.
static func isOrderedForDisplay<T>(
_ lhs: T, before rhs: T,
order: (T) -> Double, name: (T) -> String