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
@@ -1,7 +0,0 @@
---
schema: 1
order:
title: Lane With Null Order
---
The hand-editor started the 'order' key and never gave it a value — reads
as missing, not malformed.
@@ -1,4 +0,0 @@
---
schema: 1
title: Board Is Fine
---
@@ -1,5 +0,0 @@
---
schema: 1
title: Card Missing Order
---
No 'order' key — required on every card.
@@ -1,5 +0,0 @@
---
schema: 1
order: 1024
title: Lane Is Fine
---
@@ -1,4 +0,0 @@
---
schema: 1
title: Board Is Fine
---
@@ -1,5 +0,0 @@
---
schema: 1
title: Lane Missing Order
---
No 'order' key — required on every lane.
@@ -1,4 +0,0 @@
---
schema: 1
title: Board Is Fine
---
@@ -1,7 +0,0 @@
---
schema: 1
order: banana
title: Card With Non-Numeric Order
---
'order' is present but not a number — malformed, not missing; the same
loud rejection as a missing value.
@@ -1,4 +0,0 @@
---
schema: 1
title: Board Is Fine
---
+4 -5
View File
@@ -22,6 +22,7 @@ Lane/card folder names are fixed literal lowercase-UUIDv4-shaped strings (never
| `coercion.kanban` | Lenient-field coercion and fallback: wrong-type scalars that coerce (`title: 2048`, `iconColor: 42`, `background: 12345`, `width: "3"`) versus ones with no sensible reading that fall back to the default (`title: [a, b]`, `background: {x: 1}`, `width: 1.5`), plus a `deleted` with an unusable timestamp that still tombstones. |
| `duplicate-top-level-keys.kanban` | A top-level key written twice — at board, lane (the strict `order` field), and card level. Last occurrence wins; **not** a fail-fast case (settled, newer than the original card text). Round-tripped to prove the earlier occurrence survives on disk, invisible only to reads. |
| `board-level-deleted.kanban` | A board-level `deleted:` key — legal per the frontmatter table but meaningless; ignored + warned, rest of the board loads normally. |
| `optional-keys.kanban` | `order` and `schema` optional below the board root (re-ruled 2026-07-31). One lane holds a ranked card plus every order-less shape — no key, an explicit null, `order: banana`, `order: .nan` — which all read as append-at-end in folder-name order; the strip holds a ranked lane, a `schema`-less one, and an order-less one. Also the golden case for the minimum agent card: a card whose whole frontmatter is a title. |
## Malformed/ — one board per fail-fast case
@@ -30,10 +31,8 @@ Each board is minimal: one broken thing.
| Board | Case |
|---|---|
| `unparseable-yaml.kanban` | An unterminated flow sequence in the board's frontmatter. |
| `missing-schema.kanban` | Board root `index.md` has no `schema` key. |
| `missing-schema.kanban` | Board root `index.md` has no `schema` key — the **root only**; below it a missing `schema` reads as 1 (`Valid/optional-keys.kanban`). |
| `schema-newer-than-app.kanban` | Board root `schema: 2`, newer than `BoardLoader.supportedSchema`. |
| `missing-order-lane.kanban` | A UUID-shaped lane `index.md` with no `order` key. |
| `missing-order-card.kanban` | A UUID-shaped card `index.md` with no `order` key. |
| `explicit-null-order.kanban` | A lane's `order:` key present with no value — reads as missing, not malformed. |
| `non-numeric-order.kanban` | A card's `order: banana` — present but not a number. |
| `board-root-missing-index.kanban` | The board root folder itself has no `index.md`. |
The four `order` boards that used to live here — `missing-order-lane`, `missing-order-card`, `explicit-null-order`, `non-numeric-order` — were retired on 2026-07-31, when `order` became optional below the board root. Their shapes all live on in `Valid/optional-keys.kanban` as coercion cases.
@@ -0,0 +1,8 @@
---
schema: 1
kind: card
order: 1024
title: Ranked Card
---
The only card in this lane that says where it goes. Every sibling below reads as
append-at-end, in folder-name order.
@@ -0,0 +1,6 @@
---
title: Minimum Agent Card
---
The whole legal minimum: one `mkdir` and one write, no `order` and no `schema`
(08-agent-integration.md — "filing a card must need nothing but the schema").
Reads as schema 1, at the bottom of the lane.
@@ -0,0 +1,8 @@
---
schema: 1
kind: card
order:
title: Null Order Card
---
The hand-editor started the key and never gave it a value — reads as missing,
which below the root is the append-at-end reading.
@@ -0,0 +1,8 @@
---
schema: 1
kind: card
order: banana
title: Non-Numeric Order Card
---
Present but unusable — the same reading a missing key gets, recorded as a
coercion with the text as written.
@@ -0,0 +1,9 @@
---
schema: 1
kind: card
order: .nan
title: Non-Finite Order Card
---
NaN has no place in the total order the tie-break and midpoint math assume, so
it is unusable exactly like `banana` — a coercion since 2026-07-31, not a
rejection.
@@ -1,5 +1,6 @@
---
schema: 1
kind: lane
order: 1024
title: Lane Is Fine
title: Ranked Lane
---
@@ -0,0 +1,6 @@
---
schema: 1
kind: card
order: 1024
title: Card In The Orderless Lane
---
@@ -0,0 +1,6 @@
---
schema: 1
kind: lane
title: Orderless Lane
---
No rank at all: sorts right of every ranked lane in the strip.
@@ -0,0 +1,7 @@
---
kind: lane
order: 2048
title: Schemaless Lane
---
No `schema` below the root reads as 1 — the walk validating this file against
schema 1 is what makes the reading reliable.
@@ -0,0 +1,8 @@
---
schema: 1
kind: board
title: Optional Keys
---
The board root keeps its `schema` — the this-really-is-a-board gate. Everything
below it may leave `order` and `schema` out entirely (01-storage-format.md
§ Frontmatter and § Ordering, re-ruled 2026-07-31).
+102 -15
View File
@@ -812,6 +812,15 @@ public final class AppModel {
git.activateAutoCommit { [weak store] committer in
guard let store else { return }
committer.currentSnapshot = { [weak store] in store?.snapshot }
// **The flush awaits the snapshot that covers it** (06 Rules Auto-commit, ruled
// 2026-07-31): "the composer diffs `store.snapshot` against HEAD, so the close flush
// awaits a snapshot generation covering its changed paths before the committer runs
// the commit's subject can never be outrun by its own reload". Both halves of that
// await are reads of the store the composer is already diffing, which is why they are
// wired here rather than reached for: the engine holds the *policy* (when to wait, how
// long), the session supplies the two facts (`GitAutoCommitter.awaitCoveringSnapshot`).
committer.awaitReloadQuiescence = { [weak store] in await store?.awaitQuiescence() }
committer.snapshotGeneration = { [weak store] in store?.snapshotGeneration }
// 02-architecture.md Write-failure surfacing, through the strip the board window
// already renders: a genuine commit failure means "your edits are saved, history has
// stopped advancing", which is exactly what the standing suspension row says. Lock
@@ -839,6 +848,14 @@ public final class AppModel {
git.didAddGit = { [weak self] in
self?.bindHistoryProvider(for: ref)
}
// **The form-anchored posture's fallback half** (06 Interaction with external writers,
// ruled 2026-07-31): add-git answers inline in the form that asked, and lands here instead
// when that form has been dismissed before the answer arrived "inline is the primary
// surface, never a silence trap". The banner enumeration is the same one branch switch and
// undo restore post into, one row per failure.
git.reportFailure = { [weak store] failure in
store?.banners.postGitFailure(.addGit, reason: failure.message)
}
}
// **The binding 13-native-undo.md Rules' "registration at the Writer boundary" needs**: the
// store is that boundary every app-mediated mutation goes out through one of its write
@@ -900,17 +917,16 @@ public final class AppModel {
self?.resumeCardSessionStaging(for: ref)
}
// **A restore that failed cleanly** (06 Interaction with external writers: "surfaces as a
// one-shot banner failure naming the operation and the error, the tree left as it was").
// one-shot banner failure naming the operation and the error, the tree left as it was")
// now literally that, at the failure rank in the error tone (02 The banner surface, settled
// 2026-07-31: the one-shot class's second, message-carrying shape). The loss-row compromise
// this line used to carry is retired: a Z that didn't happen is an action that didn't
// happen, not content that didn't arrive.
//
// Posted as a **loss row**, and the compromise is recorded rather than hidden: the true
// failure class (`OneShotBanner`) carries a `BoardWriteError`, whose `operation` is the closed
// `WriteOperation` vocabulary and a git operation is deliberately not one of those
// (`BoardStore.performWholesale`'s own note says so). The loss row is the nearest honest
// class: warning tone, one-shot lifecycle, never auto-expires, and a free-form message that
// can name both halves 06 asks for. A message-carrying failure class is the right fix and is
// a banner-surface change, not this card's.
provider.reportFailure = { [weak store] failure in
store?.banners.postLoss(failure.description)
// The closure passes the *direction* and libgit2's own message and stops there "Undo
// failed " is BannerCenter's sentence, from the closed `GitOperation` vocabulary.
provider.reportFailure = { [weak store] direction, failure in
store?.banners.postGitFailure(.restore(direction), reason: failure.message)
}
provider.runBracketed = { [weak store] subject, work in
guard let store else { return await work() }
@@ -978,11 +994,16 @@ public final class AppModel {
store?.banners.updateOperation(id, label: label)
}
switcher.endProgress = { [weak store] id in store?.banners.endOperation(id) }
// The loss row, on `GitHistoryProvider.reportFailure`'s recorded compromise see it for why
// a git failure cannot be a `OneShotBanner` today.
// The failure rank's git shape, as on the restore path above: a switch that didn't happen is
// an action that didn't happen ("Couldn't switch branches ", BannerCenter's words from
// the operation alone).
switcher.reportFailure = { [weak store] failure in
store?.banners.postLoss(failure.description)
store?.banners.postGitFailure(.branchSwitch, reason: failure.message)
}
// **The recovery notice stays a loss row**, and the ruling is explicit about why (02 The
// banner surface): "recovery notices report a success, not a failure, and stay warning-tone".
// "A branch switch was interrupted the previous state is restored" is the app tidying up
// after itself, with nothing for the user to do the loss class's own register.
switcher.reportRecovery = { [weak store] message in
store?.banners.postLoss(message)
}
@@ -1009,7 +1030,16 @@ public final class AppModel {
let outcome = await gate.settleAll()
// The switch's flush runs next and must find a tree it can settle whole see
// `releaseCardSessionStaging(for:)` for why the modal's own predicate is not enough.
if outcome == .proceed { self.releaseCardSessionStaging(for: ref) }
//
// **And the fine undo stacks go with it** (06 Branch switching, ruled 2026-07-31): the
// same `.proceed`, the same seam, for the same reason one rung up what a window is
// holding describes the branch being left. "Cancel keeps the current branch and the
// sessions" is this `if`, unchanged: a cancelled or failed settle clears nothing, exactly
// as it releases nothing.
if outcome == .proceed {
self.releaseCardSessionStaging(for: ref)
self.discardCardWindowUndoStacks(for: ref)
}
return outcome
}
@@ -1019,6 +1049,23 @@ public final class AppModel {
Task { await switcher.recoverInterruptedOperation() }
}
/// **The three buttons, as a seam** `SessionSettleStep.ask(message:)` in production.
///
/// `SessionSettleGate` already keeps the presentation behind a closure for its own reason
/// ("presenting three buttons is AppKit's job and cannot be asserted without a display the
/// presentation is a seam and the decision is testable"), and every gate this model builds pointed
/// that closure straight at the alert so the *composition* around the gate, which is what
/// `releaseCardSessionStaging(for:)` and `discardCardWindowUndoStacks(for:)` hang off, could only
/// be exercised by a board with nothing to settle. Lifting the ask one level up is what lets a test
/// answer Save All, Discard and Cancel over real card windows without a modal on screen.
///
/// `@ObservationIgnored` because nothing renders from it, and internal because it is a test seam
/// rather than API: production never assigns it.
@ObservationIgnored
var settleAsk: @MainActor (String) async -> SessionSettleChoice = {
await SessionSettleStep.ask(message: $0)
}
/// **The save-or-discard step for one board**, built from its open card windows
/// (06-history-undo.md Rules Undo restore vs open Edit sessions; Branch switching).
///
@@ -1055,7 +1102,10 @@ public final class AppModel {
)
}
},
ask: { await SessionSettleStep.ask(message: message) },
ask: { [weak self] in
guard let self else { return await SessionSettleStep.ask(message: message) }
return await self.settleAsk(message)
},
focus: { [weak self] id in
guard let self, let session = self.sessions[ref] else { return }
guard let cardRef = session.cardRefs.first(where: { $0.cardID == id }) else { return }
@@ -1212,6 +1262,43 @@ public final class AppModel {
}
}
/// **The branch switch's settle empties every open card window's fine undo stack**
/// (06-history-undo.md Branch switching, ruled 2026-07-31).
///
/// > "The settle also clears each open card window's fine undo stack: pre-switch steps describe
/// > the branch being left Save All and Discard alike end with every window's stack empty, the
/// > board-stack discard-and-reseed precedent one level down; the windows stay open, following
/// > their cards onto the new branch with fresh stacks."
///
/// ### Why here, beside the staging release
///
/// Because it is the same fact about the same moment. `releaseCardSessionStaging(for:)` lets go of
/// what a window is holding *on disk*; this lets go of what it is holding *in memory*, and both
/// are true of a session whose branch is about to be replaced under it. Running them from one
/// `.proceed` is also what makes "Cancel clears nothing" a property of one `if` rather than a rule
/// two call sites have to keep in step (`wireBranchSwitching`).
///
/// **The board stack is not touched**, and it is not an omission: the switch discards and reseeds
/// it from the new HEAD's first-parent ancestry a few steps later, inside the bracket
/// (`GitBranchSwitcher.reseedUndo` `GitHistoryProvider.reseed`). Doing it here would be the same
/// discard, one level up, at the wrong moment before the checkout that decides what to reseed
/// *from*.
///
/// **The restore path deliberately does not call this.** An undo restore materializes a diff and
/// leaves the branch where it is, so a window's steps still describe the branch they were made on;
/// what protects them there is 13-native-undo.md's field-level staleness predicate, which is a
/// per-step question rather than a wholesale one.
///
/// The downcast is the honest shape rather than a shortcut: `CardSessionFlushing` is the *close
/// flush's* seam end the session, say whether it holds unsaved content, offer the settle's two
/// writes and a fine undo stack is none of those things. The one type that has one is the card
/// window's own session, which is what every registration passes.
func discardCardWindowUndoStacks(for ref: BoardWindowRef) {
for cardRef in sessions[ref]?.cardRefs ?? [] {
(cardSessions[cardRef] as? CardWindowSession)?.undo.discardSteps()
}
}
/// **The next session begins** the other half of `releaseCardSessionStaging(for:)`, run when the
/// operation behind the settle has finished with the tree.
///
+9
View File
@@ -428,6 +428,15 @@ enum TemplateEngine {
// The lane list is the loader's own level detection `directoryCandidates` (hidden
// entries and symlinks already out) narrowed by the identity predicate so a stray
// folder at board level is never descended into here either.
// **The seed, before the normalization it governs** (01-storage-format.md § Fractal
// layout Rules and 06-history-undo.md Repository hygiene, ruled 2026-07-31: every
// board carries a `.gitignore`, and an instantiated board is a board created today). A
// template that carries one of its own copies through and is honored the seed writes
// only into a free name which is 09-templates.md's "strays copy through" doing the
// right thing by itself; a template written before the ruling gains the seed here rather
// than a reload later, so the new board's very first walk has its noise gate.
try BoardWriter.seedGitignoreIfAbsent(atBoardRoot: root)
let lanes = ((try? BoardLoader.directoryCandidates(in: root)) ?? [])
.filter { BoardLoader.isUUIDShaped($0.lastPathComponent) }
for lane in lanes {
+20
View File
@@ -108,6 +108,19 @@ public enum CommitAttribution {
/// and the generic external author".
public static let agentEmailDomain = "agents.lanework.invalid"
/// **Who a heal commit is by** (06 Commit messages Healing mutations commit separately, ruled
/// 2026-07-31 "the third pinned synthetic, joining Lanework External and the agent-slug family;
/// strings are API"):
///
/// > a heal is a third origin not the user's gesture, not a foreign writer and the separation
/// > exists for audit, so the trail filters by author like every origin; the committer stays the
/// > user (the recorded-by convention above).
///
/// It replaced authoring heals as the user, which made the separate commit filterable only by
/// message shape and the shape vocabulary deliberately never says "healed".
public static let integrityAuthorName = "Lanework Integrity"
public static let integrityAuthorEmail = "[email protected]"
/// The frontmatter key a foreign writer refines its own attribution with
/// (01-storage-format.md; 08-agent-integration.md teaches it).
static let modifiedByKey = "modified-by"
@@ -116,6 +129,13 @@ public enum CommitAttribution {
GitIdentity(name: externalAuthorName, email: externalAuthorEmail)
}
/// The heal class's author (`integrityAuthorName`). The *committer* beside it is still the user's
/// identity, every time "every commit the app makes, foreign-authored included, records the
/// user's app as its committer" (06).
public static var integrityIdentity: GitIdentity {
GitIdentity(name: integrityAuthorName, email: integrityAuthorEmail)
}
/// **A `modified-by` stamp, as an author** (06): "that commit is authored as **X** with the
/// synthetic email `<slug>@agents.lanework.invalid` (display name verbatim, email local part
/// slugified)".
+20 -1
View File
@@ -75,6 +75,23 @@ public struct CommitMessageRequest: Sendable {
/// be read, or has been deleted.
public let agentGuideText: String?
/// **When each of this commit's comments was created** keyed by the comment folder's
/// board-root-relative path, as `CommitMessageEngine.commentFolder(of:)` spells it.
///
/// The second value on this struct that a *file* has to be read for, and it is here for
/// `agentGuideText`'s reason exactly: "a commit's comment bullets sort chronologically by the
/// comments' own `created`, folder name on ties" (06 Rules Auto-commit, blessed 2026-07-31),
/// and `created` lives in a comment's own `index.md` because comments are window-scoped and the
/// board snapshot never carries them (01-storage-format.md § Enhanced schema). The flush resolves
/// it once (`GitAutoCommitter.commentTimestamps(for:boardRoot:)`) and the engine stays a pure
/// function of values.
///
/// **Missing is normal, not a defect.** A comment whose folder left the tree in this very commit
/// (the close purge), one whose `index.md` does not parse, one written by hand with no `created`
/// at all each is simply absent here and sorts after its dated siblings in folder-name order,
/// which is `CommentThread.sorted`'s own fallback for the same field.
public let commentTimestamps: [String: Date]
public init(
boardRoot: URL,
changedPaths: [GitChangedPath],
@@ -82,7 +99,8 @@ public struct CommitMessageRequest: Sendable {
isRootCommit: Bool,
snapshot: BoardModel?,
previousSnapshot: BoardModel? = nil,
agentGuideText: String? = nil
agentGuideText: String? = nil,
commentTimestamps: [String: Date] = [:]
) {
self.boardRoot = boardRoot
self.changedPaths = changedPaths
@@ -91,6 +109,7 @@ public struct CommitMessageRequest: Sendable {
self.snapshot = snapshot
self.previousSnapshot = previousSnapshot
self.agentGuideText = agentGuideText
self.commentTimestamps = commentTimestamps
}
}
+187 -23
View File
@@ -83,6 +83,11 @@ enum CommitMessageEngine {
/// container with no identity of its own (`BoardModel.trash`).
static let trashDestination = "the trash"
/// What a key's old (or new) side reads as when the frontmatter simply did not carry it the
/// `(untitled)` convention applied to a value rather than to a name, so "sprint: (none) 42"
/// says *added* without a second sentence shape for it.
static let absentValuePlaceholder = "(none)"
// MARK: - Entry point
/// One request in, one whole message out a subject, and a body when there is more to say.
@@ -242,8 +247,12 @@ enum CommitMessageEngine {
previous: previous.document,
current: current.document,
noun: "board",
// The trio's board phrasing is count-less and title-less ("Relabel board") there is one
// board and naming it adds nothing. The custom-key event is the one place 06 spells the
// board's title out ("Change custom key on board 'title'"), so it gets it separately.
itemTitle: nil,
kinds: (.relabelBoard, .assignBoard, .dueBoard, .updateBoard),
customKeyItemTitle: title(current.title),
kinds: (.relabelBoard, .assignBoard, .dueBoard, .changeKeyBoard),
paths: paths
)
return events
@@ -380,7 +389,8 @@ enum CommitMessageEngine {
current: new.document,
noun: "lane",
itemTitle: new.displayTitle,
kinds: (.relabelLane, .assignLane, .dueLane, .updateLane),
customKeyItemTitle: new.displayTitle,
kinds: (.relabelLane, .assignLane, .dueLane, .changeKeyLane),
paths: paths
)
}
@@ -637,7 +647,8 @@ enum CommitMessageEngine {
current: new.card.document,
noun: "card",
itemTitle: new.displayTitle,
kinds: (.relabelCard, .assignCard, .dueCard, .updateCard),
customKeyItemTitle: new.displayTitle,
kinds: (.relabelCard, .assignCard, .dueCard, .changeKeyCard),
paths: paths
)
return events
@@ -684,9 +695,19 @@ enum CommitMessageEngine {
/// **The full schema-1 surface, plus the reserved trio, deliberately** (06 The external gap,
/// closed): "label, assignee, and due changes compose even though 01-storage-format.md reserves
/// those keys out of this version's UI external writers are exactly who touches them. A change
/// to any other unmodeled or custom key composes a named generic ('Update card 'X'') **never a
/// board-level shrug when the touched item is identifiable**."
/// those keys out of this version's UI external writers are exactly who touches them."
///
/// **A change to any other unmodeled or custom key says what it is** (06, re-ruled 2026-07-31
/// "first lines self-describe; generics are a last resort, kept very rare"):
///
/// > **"Change custom key on card 'X'"** (board and lane likewise "Change custom key on board
/// > 'title'"; several keys fold plural), the body naming each key with its old new values.
///
/// The named generic it retired ("Update card 'X'") is 06's own last resort and survives only
/// "for a change in a known file that is neither a vocabulary event nor a key change a shape
/// that should almost never occur". Nothing composes that shape today: a known file whose diff is
/// neither is the *bookkeeping* rule's silence, which composes nothing at all. So the vocabulary
/// keeps no case for it, and the zero-event floor stays `unnamedSubject`'s.
///
/// Read off `unknownFields`, which is exactly "every key the schema does not own" so the
/// bookkeeping keys are excluded by construction rather than by a list kept in step: `modified`,
@@ -701,7 +722,8 @@ enum CommitMessageEngine {
current: FrontmatterDocument,
noun: String,
itemTitle: String?,
kinds: (label: Kind, assignee: Kind, due: Kind, generic: Kind),
customKeyItemTitle: String,
kinds: (label: Kind, assignee: Kind, due: Kind, customKey: Kind),
paths: [String]
) -> [Event] {
let before = Dictionary(previous.unknownFields.map { ($0.key, $0.rawValue) }, uniquingKeysWith: { _, last in last })
@@ -726,11 +748,24 @@ enum CommitMessageEngine {
if changed(Keys.labels) { events.append(event(kinds.label, "Relabel")) }
if changed(Keys.assignees) { events.append(event(kinds.assignee, "Assign")) }
if changed(Keys.due) { events.append(event(kinds.due, "Set due date on")) }
// Everything else the schema does not own one named generic for the item, however many
// custom keys an agent touched in the same window.
// **Everything else the schema does not own says what it is** (re-ruled 2026-07-31). The
// *item* is still what the subject names one event however many keys moved but the verb
// now describes the change instead of shrugging at it, and the keys themselves ride the body.
let trio: Set<String> = [Keys.labels, Keys.assignees, Keys.due]
if Set(before.keys).union(after.keys).subtracting(trio).contains(where: changed) {
events.append(event(kinds.generic, "Update"))
let keys = Set(before.keys).union(after.keys).subtracting(trio).filter(changed).sorted()
if !keys.isEmpty {
let subject = keys.count == 1 ? "custom key" : "\(keys.count) custom keys"
events.append(Event(
kind: kinds.customKey,
subject: "Change \(subject) on \(noun) \(quotedSubject(customKeyItemTitle))",
bullet: "Change \(subject) on \(noun) \(quoted(customKeyItemTitle))",
detail: keys
.map { "\($0): \(before[$0] ?? absentValuePlaceholder)\(after[$0] ?? absentValuePlaceholder)" }
.joined(separator: "\n"),
destination: customKeyItemTitle,
paths: paths
))
}
return events
}
@@ -750,10 +785,17 @@ enum CommitMessageEngine {
/// lane is one event, not one line per file inside it);
/// - the departing end of a **rename**, which its arrival already speaks for the loose-file
/// relocation, the remint, a displaced squatter.
///
/// **One path shape is not a generic** (06 Commit messages Vocabulary, "Replace added
/// 2026-07-31"): "a changed file under a card's `attachments/` with an unchanged listing is a
/// content replacement, named from the path alone: 'Replace attachment 'photo.png' card 'X'',
/// never the anonymous path generic". An unchanged listing is exactly "no model event claimed
/// this path", so the rule needs no second question of the snapshot see `replacedAttachment`.
private static func pathEvents(for request: CommitMessageRequest, claimedBy model: [Event]) -> [Event] {
let claimed = Set(model.flatMap(\.paths))
var comments: [String: CommentGroup] = [:]
var events: [Event] = []
lazy var cardTitles = cardTitlesByPath(request)
for changed in request.changedPaths.sorted(by: { $0.path < $1.path }) {
let path = changed.path
@@ -763,7 +805,11 @@ enum CommitMessageEngine {
// schema): a trashed card's thread lives under `.trash/`, which the model-silence rule
// would otherwise swallow whole.
if let comment = CommentPath.classify(path) {
comments[CommentGroup.key(comment), default: CommentGroup(comment: comment)].add(changed)
let folder = commentFolder(of: path) ?? comment.cardPath
comments[
CommentGroup.key(comment),
default: CommentGroup(comment: comment, folder: folder)
].add(changed)
continue
}
guard !Paths.isModelSilent(path) else { continue }
@@ -778,6 +824,21 @@ enum CommitMessageEngine {
events.append(Event(kind: .agentGuide, subject: "Update agent guide (v\(version))", paths: [path]))
continue
}
// **Replace**, before the anonymous generic the file is still listed, so the model had
// nothing to say and the path says it instead.
if let replaced = replacedAttachment(at: path) {
let card = cardTitles[replaced.cardFolder] ?? untitledPlaceholder
events.append(Event(
kind: .replaceFile,
subject: "Replace attachment \(quotedSubject(replaced.file)) — card \(quotedSubject(card))",
bullet: "Replace attachment \(quoted(replaced.file)) — card \(quoted(card))",
destination: card,
paths: [path]
))
continue
}
events.append(Event(
kind: .updatePath,
subject: "Update \(quotedSubject(path))",
@@ -788,12 +849,33 @@ enum CommitMessageEngine {
return commentEvents(comments, model: model, request: request) + events
}
/// **A card attachment rewritten in place** the `Replace` shape, read from the path alone.
///
/// `<lane>/<card>/attachments/<file>`, and nothing else: an attachment one folder deeper is not a
/// shape this app writes, and a trashed card's is unreachable here (everything under `.trash/` is
/// model-silent and never gets this far). Whether the *listing* changed is the caller's question,
/// already answered an added or removed file was claimed by its own Attach/Remove event before
/// this path was ever reached.
private static func replacedAttachment(at path: String) -> (cardFolder: String, file: String)? {
let components = path.split(separator: "/", omittingEmptySubsequences: true).map(String.init)
guard components.count == 4,
components[2] == Paths.attachmentsFolder,
let folder = cardFolder(of: path)
else { return nil }
return (folder, components[3])
}
// MARK: - The comment verb family
/// Every changed path inside **one comment folder**, gathered so a comment that had its
/// `index.md` and two attachments rewritten is one event rather than three.
private struct CommentGroup {
let comment: CommentPath
/// This comment's own folder, board-root-relative the key its `created` is looked up under
/// (`CommitMessageRequest.commentTimestamps`) and the name the chronology's tie-break reads.
let folder: String
var paths: [String] = []
var hasArrival = false
var hasSurvivor = false
@@ -806,6 +888,11 @@ enum CommitMessageEngine {
}
}
/// The folder's last component the UUID a comment is named by, or `.draft`.
var folderName: String {
folder.split(separator: "/", omittingEmptySubsequences: true).last.map(String.init) ?? folder
}
mutating func add(_ changed: GitChangedPath) {
paths.append(changed.path)
if changed.isArrival { hasArrival = true }
@@ -813,6 +900,29 @@ enum CommitMessageEngine {
}
}
/// **The comment folder a changed path sits in**, board-root-relative `<card>/comments/<uuid>`,
/// `<card>/comments/.trash/<uuid>` or `<card>/comments/.draft` or `nil` for a path that is not
/// inside a thread at all.
///
/// Shared with the flush that reads each of those folders' `created`
/// (`GitAutoCommitter.commentTimestamps(for:boardRoot:)`), so the key a timestamp is *filed*
/// under and the key it is *looked up* by have one definition. Everything about where a thread
/// lives is still `CommentPath.classify`'s; this only says how many of the path's components that
/// classification consumed.
static func commentFolder(of path: String) -> String? {
guard let comment = CommentPath.classify(path) else { return nil }
let components = path.split(separator: "/", omittingEmptySubsequences: true).map(String.init)
// `classify` has already guaranteed these counts `<lane>/<card>/comments/<entry>` for a
// comment or the draft, one deeper for `comments/.trash/<uuid>`.
let depth: Int
switch comment.kind {
case .comment, .draft: depth = 4
case .trashed: depth = 5
}
guard components.count >= depth else { return nil }
return components.prefix(depth).joined(separator: "/")
}
/// **The comment verb family** (01-storage-format.md § Enhanced schema, the `kind: comment` block:
/// "foreign comment changes are described by **path shape** the 'Update agent guide (vN)'
/// mechanism: a changed path under `/comments/<uuid>/` composes 'Comment on card title' / 'Edit
@@ -850,7 +960,7 @@ enum CommitMessageEngine {
})
var events: [Event] = []
for key in groups.keys.sorted() {
for key in chronological(groups, timestamps: request.commentTimestamps) {
guard let group = groups[key], !group.paths.isEmpty else { continue }
guard !relocated.contains(group.comment.cardPath) else { continue }
let card = titles[group.comment.cardPath] ?? untitledPlaceholder
@@ -891,6 +1001,47 @@ enum CommitMessageEngine {
return events
}
/// **A commit's comment bullets sort chronologically** (06-history-undo.md Rules Auto-commit,
/// blessed 2026-07-31):
///
/// > "by the comments' own `created`, folder name on ties event order reads as the conversation
/// > did, never UUID-arbitrary."
///
/// The ladder is `CommentThread.sorted`'s, one layer up and over folders rather than over parsed
/// comments: `created` ascending, **the undated after the dated**, folder name on ties compared
/// as the canonical lowercase spelling, the corpus-wide rule for every folder-name tie-break
/// (01-storage-format.md § Ordering). The thread the bullets describe is sorted by that ladder on
/// screen; a commit describing the same thread in a different order would be the same events told
/// out of sequence.
///
/// **Chronology is global rather than per card**, recorded as a judgment call. A window's comment
/// events almost always belong to one card the card-window session flush is that window by
/// construction and where they do not, "the order the events happened in" is still the honest
/// reading of a list of events; grouping by card first would sort a conversation by which lane its
/// card sits in. The group key breaks the last tie so the order is total, which keeps a message
/// reproducible rather than hash-ordered.
private static func chronological(
_ groups: [String: CommentGroup],
timestamps: [String: Date]
) -> [String] {
groups.keys.sorted { lhs, rhs in
guard let left = groups[lhs], let right = groups[rhs] else { return lhs < rhs }
switch (timestamps[left.folder], timestamps[right.folder]) {
case let (leftDate?, rightDate?) where leftDate != rightDate:
return leftDate < rightDate
case (.some, .none):
return true
case (.none, .some):
return false
default:
break
}
let leftName = IntegrityRules.canonicalIdentity(left.folderName)
let rightName = IntegrityRules.canonicalIdentity(right.folderName)
return leftName == rightName ? lhs < rhs : leftName < rightName
}
}
/// The card folders this commit says moved, arrived or went the set a comment path checks
/// itself against before speaking.
private static func relocatedCardFolders(in model: [Event]) -> Set<String> {
@@ -962,17 +1113,17 @@ enum CommitMessageEngine {
/// The verb-plus-noun grouping that decides what folds with what **06's vocabulary, one case
/// each**: Add / Delete / Move / Rename / Edit / Restyle / Resize / Reorder over cards, lanes and
/// the board, Attach / Remove for attachment files, Repair for the remint, the trash pair, the
/// reserved metadata trio, and the two path shapes.
/// the board, Attach / Remove / **Replace** for attachment files, Repair for the remint, the trash
/// pair, the reserved metadata trio, the custom-key change, and the two path shapes.
enum Kind: Hashable {
case addCard, deleteCard, restoreCard, purgeCard, moveCard, renameCard, editCard, restyleCard
case relabelCard, assignCard, dueCard, updateCard
case attachFile, removeFile, reorderCards, repairDuplicate
case relabelCard, assignCard, dueCard, changeKeyCard
case attachFile, removeFile, replaceFile, reorderCards, repairDuplicate
case addLane, deleteLane, restoreLane, purgeLane, renameLane, editLane, restyleLane, resizeLane
case relabelLane, assignLane, dueLane, updateLane, reorderLanes
case relabelLane, assignLane, dueLane, changeKeyLane, reorderLanes
case renameBoard, editBoard, restyleBoard, relabelBoard, assignBoard, dueBoard, updateBoard
case renameBoard, editBoard, restyleBoard, relabelBoard, assignBoard, dueBoard, changeKeyBoard
case agentGuide, updatePath
@@ -1014,13 +1165,18 @@ enum CommitMessageEngine {
case .relabelCard: return "Relabel \(count) cards"
case .assignCard: return "Assign \(count) cards"
case .dueCard: return "Set due date on \(count) cards"
case .updateCard: return "Update \(count) cards"
case .changeKeyCard:
guard let destination else { return "Change custom keys on \(count) cards" }
return "Change custom keys on card \(CommitMessageEngine.quotedSubject(destination))"
case .attachFile:
guard let destination else { return "Attach \(count) files" }
return "Attach \(count) files to card \(CommitMessageEngine.quotedSubject(destination))"
case .removeFile:
guard let destination else { return "Remove \(count) files" }
return "Remove \(count) files from card \(CommitMessageEngine.quotedSubject(destination))"
case .replaceFile:
guard let destination else { return "Replace \(count) attachments" }
return "Replace \(count) attachments — card \(CommitMessageEngine.quotedSubject(destination))"
case .reorderCards:
guard let destination else { return "Reorder cards in \(count) lanes" }
return "Reorder cards in \(CommitMessageEngine.truncated(destination))"
@@ -1037,7 +1193,9 @@ enum CommitMessageEngine {
case .relabelLane: return "Relabel \(count) lanes"
case .assignLane: return "Assign \(count) lanes"
case .dueLane: return "Set due date on \(count) lanes"
case .updateLane: return "Update \(count) lanes"
case .changeKeyLane:
guard let destination else { return "Change custom keys on \(count) lanes" }
return "Change custom keys on lane \(CommitMessageEngine.quotedSubject(destination))"
// A board has one title, one description, one style and a lane reorder is a single
// whole-board event. None of these can actually recur; the switch stays exhaustive.
@@ -1048,7 +1206,11 @@ enum CommitMessageEngine {
case .relabelBoard: return "Relabel board"
case .assignBoard: return "Assign board"
case .dueBoard: return "Set due date on board"
case .updateBoard: return CommitMessageEngine.unnamedSubject
// One board, so a plural of *events* here is a plural of keys already folded into one
// event unreachable in practice, and count-less if it ever is.
case .changeKeyBoard:
guard let destination else { return "Change custom keys on board" }
return "Change custom keys on board \(CommitMessageEngine.quotedSubject(destination))"
case .agentGuide: return "Update agent guide"
case .updatePath: return "Update \(count) files"
@@ -1108,7 +1270,9 @@ enum CommitMessageEngine {
/// Every `index.md` in the board's fractal layout, and everything inside `.trash/`. An
/// attachment is deliberately **not** here: the model carries attachment *names*, so an added
/// or removed file composes its own event, while a rewritten one same name, new bytes has
/// nothing in the snapshot to show for it and rightly composes "Update 'path'".
/// nothing in the snapshot to show for it and composes **Replace** off its path instead
/// (`replacedAttachment(at:)`, 06 Commit messages Vocabulary, added 2026-07-31 "never
/// the anonymous path generic").
/// Whether a path could make the *snapshot* differ at all every path the model speaks for,
/// plus attachments, whose names it carries.
///
+228 -9
View File
@@ -134,6 +134,45 @@ public final class GitAutoCommitter {
@ObservationIgnored
public var currentSnapshot: (@MainActor () -> BoardModel?)?
/// **The reload pipeline settling** `BoardStore.awaitQuiescence()`, and `nil` on a storeless
/// committer.
///
/// Read only by `awaitCoveringSnapshot()`, whose whole correctness rests on it: it is what makes
/// the *next* walk a walk that started after this flush's changes were on disk.
@ObservationIgnored
public var awaitReloadQuiescence: (@MainActor () async -> Void)?
/// **Which generation the board `currentSnapshot` answers with is at**
/// `BoardStore.snapshotGeneration`, incremented by every landed reload.
///
/// `nil` the closure absent, or answering `nil` because the store has gone means there is no
/// snapshot to be outrun by, and the covering await becomes the no-op it is on every storeless
/// committer.
@ObservationIgnored
public var snapshotGeneration: (@MainActor () -> Int?)?
/// **How long an explicit flush waits for its covering reload** before composing from the snapshot
/// it already has.
///
/// A bound rather than an open-ended wait, and recorded as a judgment call: 06 rules that the
/// flush awaits its covering snapshot and does not say what happens if that reload never lands. It
/// normally lands within the watcher's ~200 ms debounce, and it is *scheduled unconditionally* by
/// the write bracket that closed (`FolderWatcher.endBracket`, "the mandatory single post-bracket
/// reload even if not one filesystem event was seen"), so the wait is short and certain in every
/// ordinary case. What it must not be is unbounded: this flush runs on the close and quit paths,
/// and a board whose watcher stream failed to start (`BoardStoreRegistry.acquire` logs and carries
/// on) would otherwise make the app unquittable. So the wait ends, generously, and the commit is
/// composed from the snapshot in hand one stale subject in a degraded configuration, against a
/// hang.
@ObservationIgnored
public var coveringSnapshotDeadline: Duration = .seconds(1)
/// How often the wait re-reads the generation. Polled rather than signalled for
/// `CloseFlushCoordinator.drainCardWindows`' reason: the point of this wait is that it *ends*, and
/// a continuation resumed by a reload that never lands has no way to.
@ObservationIgnored
public var coveringSnapshotPollInterval: Duration = .milliseconds(10)
/// **A genuine commit failure** disk full, repo corruption (06: "files stay safe on disk but
/// history stops advancing; surfaced per 02-architecture.md Write-failure surfacing, retried
/// on the next debounce"). Wired to `BannerCenter.suspendHistory(reason:)`.
@@ -208,6 +247,23 @@ public final class GitAutoCommitter {
@ObservationIgnored
private var holdsForeignChanges = false
/// **Whether an app write has closed with no reload landed since** the covering await's entry
/// gate (`awaitCoveringSnapshot()`).
///
/// Set at every write-bracket close and cleared by every landing, so it answers exactly "is
/// `currentSnapshot` known to be behind the tree". Without it an explicit flush on a quiet board
/// would wait out the whole deadline for a reload nothing has any reason to schedule.
///
/// **The one corner it does not cover, recorded rather than discovered**: a reload that was
/// already *in flight* when the write bracket closed walked the pre-write tree, and its landing
/// clears this flag all the same the store's landing signal carries no such distinction
/// (`HistoryCommitSeam.reloadDidLand`). A flush inside that gap composes from a snapshot one walk
/// behind, which is the pre-ruling behaviour for a window narrower than it used to be: the write
/// bracket's own mandatory post-bracket reload is already scheduled and lands ~200 ms later, and
/// closing the gap properly needs a fact only `BoardStore` has (whether a walk was running).
@ObservationIgnored
private var holdsUncoveredWrites = false
/// Open **card-window sessions**, each answering with the folder to stage around *right now*.
///
/// A closure per session rather than a stored URL, because a card can move lane, or into the
@@ -222,6 +278,10 @@ public final class GitAutoCommitter {
@ObservationIgnored
private var isFlushing = false
/// Explicit flushes suspended behind the one in flight, resumed together by `endFlushing()`.
@ObservationIgnored
private var flushWaiters: [CheckedContinuation<Void, Never>] = []
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "git")
init(boardRoot: URL, ledger: EchoLedger) {
@@ -259,6 +319,9 @@ public final class GitAutoCommitter {
/// this is the only moment at which the committer can still see them (`HarvestedReceipt`).
public func noteWriteBracketClosed() {
harvest()
// The snapshot the composer diffs is now known to be behind the tree until a reload lands
// see `holdsUncoveredWrites` and `awaitCoveringSnapshot()`.
holdsUncoveredWrites = true
arm()
}
@@ -269,6 +332,7 @@ public final class GitAutoCommitter {
/// *file* at flush time, because this is one bit about a whole reload.
public func noteReloadLanded(sawForeignChange: Bool) {
if sawForeignChange { holdsForeignChanges = true }
holdsUncoveredWrites = false
arm()
}
@@ -307,7 +371,7 @@ public final class GitAutoCommitter {
public func noteWillWrite() {
guard holdsForeignChanges, !isFlushing, let input = makeInput() else { return }
isFlushing = true
defer { isFlushing = false }
defer { endFlushing() }
pending?.cancel()
pending = nil
// One attempt, no lock backoff: this path cannot suspend, and a held lock here simply means
@@ -398,9 +462,110 @@ public final class GitAutoCommitter {
/// store tears down". `CloseFlushCoordinator.committerFlush` is this, and by the time it runs the
/// sessions have ended, so nothing is staged around any more.
public func flushNow() async {
await awaitCoveringSnapshot()
// **Queued behind an in-flight flush, never skipped** see `awaitFlushInFlight()`.
await awaitFlushInFlight()
await flush()
}
/// **Suspends until no flush is running** what makes `flushNow()` a promise rather than an
/// attempt (06-history-undo.md Rules Auto-commit: "nothing settled is ever left unsaved or
/// uncommitted by closing").
///
/// ### The bug this exists for
///
/// `flush()` skips when one is already running, which is exactly right for the **debounce** a
/// timer firing into a commit already in progress has nothing to add, and coalescing is the
/// cadence rule. It was catastrophically wrong for the **explicit** flush, which is the close
/// flush, the quit flush, the branch switch's pre-checkout flush and File Duplicate's: those
/// callers are not asking for a commit *soon*, they are asking to be told when the pipeline is
/// empty, and a `return` gave them that answer while it was still full.
///
/// It was reachable, and by a *narrow* margin in one direction and a wide one in the other. The
/// close sequence releases each session's stage-around and then nudges the committer
/// (`endCardSession`), which arms a fresh debounce; `CloseFlushCoordinator` then spends up to its
/// card-drain deadline before reaching `committerFlush`. With the two intervals both at two
/// seconds the debounce fired *into* the drain's last moments about half the time and the flush
/// it started had, in the worst case, planned its commit while the session's folder was still
/// staged around. So the in-flight flush committed nothing of the session, the close flush skipped
/// behind it, and teardown stopped the committer: the window's whole session was left uncommitted,
/// permanently, with no later flush anywhere that could have picked it up. Even in the benign
/// interleaving `closeBoard` returned and at quit, `applicationShouldTerminate` replied while
/// the commit was still detached work in flight.
///
/// ### The shape
///
/// A queue of waiters rather than a lock, `BoardStore.awaitQuiescence()`'s own shape and for its
/// reason: this type is `@MainActor`, so there is no data race to exclude only a *suspension* to
/// wait out and the thing a caller wants is "tell me when it is over", which is what a resumed
/// continuation is. The loop re-checks rather than trusting one resumption, so a flush that armed
/// another on its way out cannot slip between the resume and the caller's own attempt.
private func awaitFlushInFlight() async {
while isFlushing {
await withCheckedContinuation { flushWaiters.append($0) }
}
}
/// Ends one flush and releases whoever was queued behind it. The single exit for both flushing
/// paths the debounced one and the synchronous flush-before-overwrite so a waiter can never be
/// left suspended by a path that forgot it.
private func endFlushing() {
isFlushing = false
let waiters = flushWaiters
flushWaiters.removeAll()
for waiter in waiters { waiter.resume() }
}
/// **The flush awaits the snapshot that covers it** (06-history-undo.md Rules Auto-commit,
/// ruled 2026-07-31).
///
/// > "The composer diffs `store.snapshot` against HEAD, so the close flush awaits a snapshot
/// > generation covering its changed paths before the committer runs the commit's subject can
/// > never be outrun by its own reload; the cadence margin (2 s debounce vs 200 ms watcher) is the
/// > practical cushion, never the guarantee."
///
/// ### What "covering its changed paths" means to this store
///
/// A reload is a **whole tree walk** the store has no changed-path channel at all
/// (02-architecture.md; `BoardStore.refreshCommentIndex`'s own note) so a walk that *started*
/// after this flush's writes were on disk covers every path they touched, by construction. There
/// is nothing narrower to ask for and nothing narrower to wait on, and that is what makes the
/// generation counter a sufficient answer rather than an approximation of one.
///
/// Two steps, in this order, are what turn it into a guarantee:
///
/// 1. **Quiesce.** A walk already in flight may have started *before* the writes, so its landing
/// proves nothing. `BoardStore.awaitQuiescence()` returns when none is running and none is
/// owed, which is the moment after which every walk is a walk that started later.
/// 2. **Wait for one generation.** The write bracket that produced these changes already
/// scheduled the reload that will supply it unconditionally, whether or not FSEvents said
/// anything (`FolderWatcher.endBracket`) so this is a bounded wait on work already in the
/// pipeline, not a hope.
///
/// ### Why only the explicit flush
///
/// This is `flushNow()`'s alone: the close and quit paths, the branch switch's pre-checkout flush,
/// File Duplicate's pending-work step, and the undo restore's. Those are the flushes that run
/// *because* something just finished, which is exactly when the snapshot can still be one walk
/// behind. The debounced flush is re-armed by both the write and the reload and fires two seconds
/// after the later of them 06's own "practical cushion", doing the job it is enough for and
/// `noteWillWrite()` cannot await at all, being the synchronous flush-before-overwrite.
private func awaitCoveringSnapshot() async {
guard holdsUncoveredWrites, let read = snapshotGeneration else { return }
await awaitReloadQuiescence?()
// Re-read the gate: the quiescence may itself have been the covering landing.
guard holdsUncoveredWrites, let base = read() else { return }
let started = ContinuousClock.now
while let current = read(), current == base {
guard ContinuousClock.now - started < coveringSnapshotDeadline else {
Self.logger.notice("the covering reload did not land in time; composing from the snapshot in hand")
return
}
try? await Task.sleep(for: coveringSnapshotPollInterval)
}
}
/// Arms (or re-arms) the debounce. Every signal funnels through here, so "debounced past drag and
/// typing churn" is one timer rather than a rule each call site remembers.
private func arm(after interval: Duration? = nil) {
@@ -414,10 +579,13 @@ public final class GitAutoCommitter {
}
}
/// One flush. **Skipping when one is already running is the debounce's rule and only the
/// debounce's** an explicit `flushNow()` has already waited its turn (`awaitFlushInFlight()`)
/// before it gets here, so this guard can only ever coalesce a timer.
private func flush() async {
guard !isFlushing else { return }
isFlushing = true
defer { isFlushing = false }
defer { endFlushing() }
pending?.cancel()
pending = nil
@@ -512,6 +680,7 @@ public final class GitAutoCommitter {
var previous: BoardModel?
var current: BoardModel?
var agentGuideText: String?
var commentTimestamps: [String: Date] = [:]
}
/// Reads the two snapshots and the guide's bytes the only impure step in the message path, kept
@@ -537,6 +706,12 @@ public final class GitAutoCommitter {
// card's title. So a comment-only window loads the current board and skips the materialization.
let touchesModel = changed.contains { CommitMessageEngine.Paths.mightAffectSnapshot($0.path) }
let namesACard = changed.contains { CommentPath.classify($0.path) != nil }
// **The chronology the bullets sort by** (06 Rules Auto-commit, blessed 2026-07-31) the
// one field of a comment the composer needs and the board snapshot cannot carry. Read beside
// the guide's bytes, for the guide's reason, and only for a window that names a comment at all.
if namesACard {
composition.commentTimestamps = commentTimestamps(for: changed, boardRoot: input.boardRoot)
}
guard touchesModel || namesACard else { return composition }
// **The store's snapshot when there is one, disk when there is not.** A storeless committer is
@@ -550,6 +725,42 @@ public final class GitAutoCommitter {
return composition
}
/// **When each comment this window touched was created**, keyed by its folder the chronology
/// `CommitMessageEngine` sorts a commit's comment bullets by (06 Rules Auto-commit, blessed
/// 2026-07-31: "by the comments' own `created`, folder name on ties").
///
/// One `index.md` per touched comment folder, read off the **working tree** which is the state
/// this commit is about to stage, and the only place a comment's own fields exist at all. A folder
/// this window *removed* (the close purge) has nothing left to read, and a comment whose
/// frontmatter does not parse or carries no `created` answers nothing either: all three are
/// absent from the map and sort after their dated siblings, which is `CommentThread.sorted`'s own
/// fallback for the same field. Nothing here is a defect and nothing is reported a commit
/// message is the wrong place to discover one (`CommentThread.searchableBodies`' rule, kept).
///
/// Internal rather than private so the composer's own suite can resolve the chronology exactly the
/// way a flush does, instead of hand-assembling a map the flush could never produce
/// (`WriterFixture.snapshot()`'s reason, restated one field down).
nonisolated static func commentTimestamps(
for changed: [GitChangedPath],
boardRoot: URL
) -> [String: Date] {
var timestamps: [String: Date] = [:]
var seen: Set<String> = []
for path in changed {
guard let folder = CommitMessageEngine.commentFolder(of: path.path), seen.insert(folder).inserted
else { continue }
let index = boardRoot
.appendingPathComponent(folder)
.appendingPathComponent(IntegrityRules.indexFileName)
guard let data = try? Data(contentsOf: index),
let document = try? BoardLoader.parseDocument(data, path: folder),
let created = document.created.value
else { continue }
timestamps[folder] = created
}
return timestamps
}
/// The three-way split turned into commits or, on an unborn HEAD, the one commit 06 fixes.
private nonisolated static func plan(
_ changed: [GitChangedPath],
@@ -571,7 +782,8 @@ public final class GitAutoCommitter {
isRootCommit: isRootCommit,
snapshot: composition.current,
previousSnapshot: composition.previous,
agentGuideText: composition.agentGuideText
agentGuideText: composition.agentGuideText,
commentTimestamps: composition.commentTimestamps
)
}
@@ -600,16 +812,23 @@ public final class GitAutoCommitter {
authorship = .foreign(
CommitAttribution.foreignIdentity(for: group.paths, under: input.boardRoot)
)
// **A heal is authored by the user**, recorded as a judgment call: DESIGN fixes that a
// heal's paths commit *separately* and says nothing about who they are by. The healer is
// the app acting on the user's behalf its writes are app-mediated, receipt and all so
// authoring them as the user is the honest reading, and authoring them as `Lanework
// External` would blame the outside world for the app's own repair.
// **A heal is authored `Lanework Integrity <integrity@lanework.invalid>`** (06 Commit
// messages Healing mutations commit separately, ruled 2026-07-31): "a heal is a third
// origin not the user's gesture, not a foreign writer and the separation exists for
// audit, so the trail filters by author like every origin". This authored heals as the
// *user* until that ruling, which left the separate commit filterable only by message
// shape and the shape vocabulary deliberately never says "healed".
case .heal: authorship = .heal
case .user: authorship = .user
}
// The committer stays the user throughout 06's recorded-by convention, which is why
// only the author varies here.
let author: GitIdentity
if case let .foreign(identity) = authorship { author = identity } else { author = user }
switch authorship {
case let .foreign(identity): author = identity
case .heal: author = CommitAttribution.integrityIdentity
case .user: author = user
}
let kind: PlannedCommitKind
switch group.kind {
case .foreign: kind = .foreign
+12 -2
View File
@@ -90,15 +90,25 @@ public final class GitBranchSwitcher {
/// A clean failure "surfaces as a one-shot banner failure naming the operation and the error,
/// the tree left as it was" (06 Interaction with external writers).
///
/// The banner rather than the popover's inline caption, deliberately, and 06 draws the line: the
/// popover-anchored answer is for operations that answer *at the form* (add-git, verify-remote),
/// The banner rather than an inline caption, deliberately, and 06 draws the line: the
/// form-anchored answer is for operations that answer *at the form* (add-git, verify-remote
/// forms that live in the board settings sheet since the 2026-07-31 popover/sheet split),
/// while "the banner enumeration stays the posture for board-wholesale brackets that outlive any
/// one surface" which a branch switch is by construction, since its bracket locks the board and
/// its completion is announced.
///
/// **Which row that is, settled 2026-07-31** (02-architecture.md The banner surface): the
/// one-shot failure class's message-carrying git shape error tone, failure rank, dismissable
/// and untimed. What travels is the operation and the underlying message; the sentence
/// ("Couldn't switch branches ") is `BannerCenter`'s, which is why nothing here composes one.
@ObservationIgnored
public var reportFailure: (@MainActor (GitOperationFailure) -> Void)?
/// The own-leftovers recovery's banner (`GitOperationStamp.interruptionMessage`).
///
/// **A warning-tone loss row, not a failure** (02 The banner surface, settled 2026-07-31):
/// "recovery notices report a success, not a failure, and stay warning-tone" the abort put the
/// previous state back, and the row exists so the user learns that it happened.
@ObservationIgnored
public var reportRecovery: (@MainActor (String) -> Void)?
+127 -8
View File
@@ -93,8 +93,14 @@ public final class GitHistoryProvider: HistoryProviding {
public var runBracketed: (@MainActor (_ announcing: String, _ work: @escaping () async -> Void) async -> Void)?
/// A genuine restore failure surfaced as 02's one-shot banner by whoever wires it.
///
/// **The direction travels with the failure** (02-architecture.md The banner surface, settled
/// 2026-07-31): the one-shot failure class's second shape names the operation in the user's
/// words "Undo failed", "Redo failed" and this object is the only one that knows which key
/// was pressed. Everything past that boundary is the banner's: the closure receives the
/// direction and libgit2's own message, never a sentence composed here.
@ObservationIgnored
public var reportFailure: (@MainActor (GitOperationFailure) -> Void)?
public var reportFailure: (@MainActor (HistoryDirection, GitOperationFailure) -> Void)?
// MARK: - The cached stack
@@ -326,7 +332,11 @@ public final class GitHistoryProvider: HistoryProviding {
guard let index = crossableIndex() else { return }
let crossed = ancestry[index]
guard let target = crossed.parentOID else { return }
let landed = await restore(to: target, message: "Undo: \(crossed.subject)")
let landed = await restore(
.undo,
to: target,
message: Self.restoreSubject(.undo, crossing: crossed.subject)
)
guard landed else { return }
redoCommits.append(crossed)
pointerOID = target
@@ -334,7 +344,11 @@ public final class GitHistoryProvider: HistoryProviding {
case .redo:
guard let target = redoCommits.last else { return }
let landed = await restore(to: target.oid, message: "Redo: \(target.subject)")
let landed = await restore(
.redo,
to: target.oid,
message: Self.restoreSubject(.redo, crossing: target.subject)
)
guard landed else { return }
redoCommits.removeLast()
// The commit just restored *to* is the one the next Z crosses again the classic dance,
@@ -349,7 +363,11 @@ public final class GitHistoryProvider: HistoryProviding {
/// `message` is both the commit's subject and the bracket's completion announcement
/// (10-accessibility.md Live board announcements: "bracketed operations announce once, at
/// completion") one sentence, so the trail and the speech cannot disagree about what happened.
private func restore(to target: String, message: String) async -> Bool {
///
/// `direction` is carried for one reason: a failure here is the banner's git-operation shape,
/// and it is named by the key the user pressed rather than by the subject the restore would have
/// carried (`reportFailure`).
private func restore(_ direction: HistoryDirection, to target: String, message: String) async -> Bool {
let root = boardRoot
let excluded = healPaths
@@ -358,7 +376,7 @@ public final class GitHistoryProvider: HistoryProviding {
guard let preliminary = await Task.detached(priority: .userInitiated, operation: {
GitRestoreOperation.plan(at: root, target: target, excluding: excluded)
}).value else {
report("this board's repository could not be read")
report(direction, "this board's repository could not be read")
return false
}
@@ -429,7 +447,7 @@ public final class GitHistoryProvider: HistoryProviding {
case let .held(pause):
Self.logger.notice("restore held: \(pause.rawValue, privacy: .public)")
case let .failed(failure):
self.reportFailure?(failure)
self.reportFailure?(direction, failure)
}
}
@@ -478,10 +496,111 @@ public final class GitHistoryProvider: HistoryProviding {
return nil
}
private func report(_ message: String) {
reportFailure?(GitOperationFailure(
private func report(_ direction: HistoryDirection, _ message: String) {
reportFailure?(direction, GitOperationFailure(
operation: GitRestoreOperation.operationName,
message: message
))
}
// MARK: - The restore subject
/// **What a restore commit is called** a pure function of the crossed subject and the
/// direction, so the rule can be read (and pinned) without a repository.
///
/// The base rule is 06's oldest: a crossing commits the state it restored as "Undo: subject"
/// or "Redo: subject". **Subjects don't nest** (06 Commit messages, settled 2026-07-31):
/// when the crossed subject already carries a restore prefix the post-relaunch case, where the
/// reseed has made old restore commits ordinary steps the composer "emits the *inverse* label
/// instead of stacking: crossing 'Undo: S' yields 'Redo: S', crossing 'Redo: S' yields
/// 'Undo: S'", which "caps prefixes at one across any number of relaunches".
///
/// ### Why the two directions read the crossed subject differently
///
/// The label states what the new commit's tree *does* to the base subject S: "Undo: S" is the
/// state where S is out, "Redo: S" the state where S is in. An undo restores the crossed
/// commit's **parent** the state before it so it emits that commit's inverse; a redo
/// restores the target commit **itself**, so it emits that commit's own reading. That is what
/// makes 06's sentence true ("undoing the restore that undid a move *re-applies* the move") and
/// its mirror true with it: Z back across an "Undo: S" step lands on the tree where S is out,
/// and says "Undo: S" the truer label, rather than the "Redo: S" the Z that crossed it
/// already used for the opposite tree.
///
/// ### The legacy double prefix
///
/// "Undo: Undo: S" exists in the wild the shipped nesting build made them and the honest
/// reading is this same one applied twice: the inner "Undo:" took S out, the outer one took
/// *that* back, so the commit's tree is the one where S is in. Undoing across it therefore emits
/// **"Undo: S"** the tree it restores is the one without S, and saying "Redo: S" there would be
/// exactly the euphemism 06 rules out ("This is the truer label, not a euphemism"), while
/// "Redo: Undo: S" would keep the nesting the ruling caps at one. So each "Undo: " prefix flips
/// the reading, each "Redo: " prefix leaves it, and what comes out carries exactly one.
///
/// The sniff is on the subject string, deliberately (06), so "a foreign commit that happens to
/// open with a prefix gets the inverse label too; that's cosmetic the restore itself is
/// unaffected".
public nonisolated static func restoreSubject(
_ direction: HistoryDirection,
crossing subject: String
) -> String {
let reading = RestoreSubjectReading(of: subject)
let emitted = switch direction {
case .undo: reading.polarity.inverse
case .redo: reading.polarity
}
return "\(emitted.label): \(reading.base)"
}
}
// MARK: - Helpers
/// What a subject says about its own base subject: is that change *in* the tree the subject
/// describes, or has it been taken back out? Every restore label is one of these two readings, which
/// is why the composer can invert rather than stack (`GitHistoryProvider.restoreSubject(_:crossing:)`).
private enum RestorePolarity {
/// The base subject's change is in the tree every ordinary commit, and every "Redo: S".
case applied
/// The base subject's change has been taken back out "Undo: S".
case reverted
var inverse: RestorePolarity { self == .applied ? .reverted : .applied }
/// The word that states this reading in a subject.
var label: String { self == .applied ? "Redo" : "Undo" }
/// The same word as a prefix the only two this composer emits, and the only two it reads, so
/// that reading and writing can never drift apart.
var prefix: String { "\(label): " }
}
/// One subject read as "a base subject, plus what its restore prefixes say about it".
///
/// Stripping is greedy because the legacy nesting build's subjects are (`restoreSubject`), and a
/// prefix only counts while something is left for it to be *about*: a bare "Undo: " is somebody's
/// subject, not a label with nothing after it.
private struct RestoreSubjectReading {
let base: String
let polarity: RestorePolarity
init(of subject: String) {
var base = subject
var polarity = RestorePolarity.applied
while true {
let read: RestorePolarity
if base.hasPrefix(RestorePolarity.reverted.prefix) {
read = .reverted
} else if base.hasPrefix(RestorePolarity.applied.prefix) {
read = .applied
} else {
break
}
let rest = String(base.dropFirst(read.prefix.count))
guard !rest.isEmpty else { break }
base = rest
// "Undo: " flips what the rest of the subject was saying; "Redo: " restates it.
if read == .reverted { polarity = polarity.inverse }
}
self.base = base
self.polarity = polarity
}
}
+26 -15
View File
@@ -9,8 +9,10 @@ import Foundation
///
/// 1. **Repo-local `.git/config` wins when present.** "Standard git semantics, readable in-sandbox
/// because it lives under the board root, and the natural state of adopted/cloned boards." The
/// popover's name/email fields (a later card) write exactly that file: "the setting *is* the
/// file, portable to any git client, per-board by nature".
/// identity fields write exactly that file: "the setting *is* the file, portable to any git
/// client, per-board by nature". Their home is the **board settings sheet** since the 2026-07-31
/// popover/sheet split (03-board-ui.md); they are hosted in the popover's git section until that
/// sheet is built, which changes nothing about this file.
/// 2. **Absent repo config, the derived default**: "the macOS account's full name plus
/// `shortname@hostname` git's own no-config fallback shape, zero ceremony."
///
@@ -142,8 +144,19 @@ enum GitConfigFile {
}
/// The parse, over text the pure half, and where the format's edges are decided.
///
/// **Reads take the last plain-section value** (06-history-undo.md Interaction with external
/// writers, blessed 2026-07-31): "the reader like git itself takes the last plain-section
/// value, which is exactly what an append produces."
///
/// *Plain* is load-bearing and is the whole of the subsection rule. `[user "work"]` is a different
/// key in git's own model `user.work.name`, not `user.name` so its values are not answers to
/// this question at all, and reading one would sign the user's commits with an identity they
/// filed under a name this app never asked about. Last-wins still holds inside the plain
/// sections: a later `[user]` overrides an earlier one, which is how an appended section wins
/// without the writer ever touching what came before it.
static func identity(inConfigText text: String) -> (name: String?, email: String?) {
var section: String?
var isPlainUserSection = false
var name: String?
var email: String?
@@ -152,17 +165,16 @@ enum GitConfigFile {
if line.isEmpty || line.hasPrefix("#") || line.hasPrefix(";") { continue }
if line.hasPrefix("[") {
// `[user]`, and `[user "work"]` a subsection is somebody else's scope, so the
// header's first token is what names the section.
let header = line.drop(while: { $0 == "[" }).prefix(while: { $0 != "]" })
section = header
let section = header
.split(separator: " ", maxSplits: 1)
.first
.map { $0.trimmingCharacters(in: .whitespaces).lowercased() }
isPlainUserSection = section == "user" && !header.contains("\"")
continue
}
guard section == "user", let separator = line.firstIndex(of: "=") else { continue }
guard isPlainUserSection, let separator = line.firstIndex(of: "=") else { continue }
let key = line[line.startIndex..<separator].trimmingCharacters(in: .whitespaces).lowercased()
let value = unquoted(line[line.index(after: separator)...].trimmingCharacters(in: .whitespaces))
switch key {
@@ -177,10 +189,10 @@ enum GitConfigFile {
// MARK: Writing
/// **The popover's identity fields, landing in the file** (06-history-undo.md Interaction with
/// external writers: "The board popover's git section exposes name/email fields that **write that
/// repo-local config** the setting *is* the file, portable to any git client, per-board by
/// nature").
/// **The identity fields, landing in the file** (06-history-undo.md Interaction with external
/// writers: "The board settings sheet's identity section exposes name/email fields that **write
/// that repo-local config** the setting *is* the file, portable to any git client, per-board by
/// nature"; the fields are popover-hosted until that sheet is built).
///
/// This is the **only** thing in the app that writes `user.name`/`user.email` anywhere, and that
/// is the design's own line: the derived default "is passed as an explicit per-commit signature,
@@ -225,10 +237,9 @@ enum GitConfigFile {
/// `user.name`), and editing keys inside one would be this app rewriting a setting the user
/// aimed somewhere else much the worse error, whatever the read side does with it.
///
/// (The read side, `identity(inConfigText:)`, deliberately takes the last matching value it
/// meets whichever section it is in its own recorded call. The two agree in practice for
/// every file this writer has touched, because a plain section it *adds* goes at the end, so
/// its keys are the last ones the reader meets.)
/// (The read side, `identity(inConfigText:)`, scopes itself to plain sections for the same
/// reason and takes the last one's value, so the two halves agree by construction rather than
/// by coincidence.)
var isPlainUserSection = false
/// Where a key the file does not yet have would be inserted: just after the last line of the
/// plain `[user]` section, or `nil` while there is no such section.
+50 -44
View File
@@ -50,9 +50,10 @@ public struct GitOperationFailure: Error, Sendable, Equatable, CustomStringConve
///
/// This is the pathfinder's `GitSource` shape, kept because it was right, with the pathfinder's
/// *policy* deliberately left behind: nothing here auto-initializes anything and nothing commits on
/// its own schedule. The one seed it does write a `.gitignore`, at init and never again
/// (06 Repository hygiene) is the app's last word on that file rather than the start of a
/// relationship with it.
/// its own schedule. It writes no seed of its own any more: the `.gitignore` outgrew git on
/// 2026-07-31 and belongs to the board now (`BoardWriter.gitignoreSeed`, seeded at creation and
/// healed in at open), so all that survives here is a last-chance check that the file exists before
/// the initial commit freezes the tree see `seedGitignoreIfAbsent(at:)`.
enum GitRepository {
/// **The root commit's own subject** (06-history-undo.md Rules Abnormal repo states,
@@ -72,20 +73,11 @@ enum GitRepository {
/// writing it is exactly `git symbolic-ref HEAD refs/heads/main` before anything else touches
/// the repo.
///
/// DESIGN is silent on the name; `main` is git's own modern default and the pathfinder's choice.
/// **The initial branch is `main`** (06 Rules Opt-in init, blessed 2026-07-31): "the host's
/// `init.defaultBranch` lives in config layers the sandbox can't read, so add-git sets it
/// deterministically git's modern default, the pathfinder's choice."
static let initialBranchName = "main"
/// **The whole of the seeded `.gitignore`** (06-history-undo.md Repository hygiene: "Adding git
/// to a board writes a minimal `.gitignore` (`.DS_Store`) if none exists").
///
/// One line, because one line is what the rule says and because every additional entry would be
/// the app deciding something about a file it is about to stop having opinions on. `.DS_Store` is
/// the entry that earns its place: 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 noise that would otherwise be committed by the whole-tree stage, forever,
/// under the user's own name.
static let seededGitignore = ".DS_Store\n"
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "git")
// MARK: Opt-in init
@@ -97,24 +89,39 @@ enum GitRepository {
/// is protected from the moment git exists" so the two halves are one operation and a failure
/// in either is one failure.
///
/// Between them sits the one seed the app ever writes: a minimal `.gitignore`, if the board has
/// none, in the initial commit rather than after it (`seedGitignoreIfAbsent`).
/// Between them sits a last-chance `.gitignore` check the file is the board's rather than
/// git's since 2026-07-31, so it is almost always already there; when it is not, seeding it here
/// puts it *in* the initial commit rather than after it (`seedGitignoreIfAbsent`).
///
/// **It refuses a board that already has a `.git`.** The app "never mutates repo state it didn't
/// create" (06), and `git_repository_init` over an existing repository is a re-initialization
/// harmless in the common case and precisely the kind of thing that rule exists to forbid. The
/// caller (`HistoryStore.addGit`) has already established mode `none`; this is the check that
/// makes it impossible rather than merely unlikely.
/// **Create re-runs full detection and refuses anything but mode none** (06 Rules Detection,
/// ruled 2026-07-31): "as hardening, add-git's create re-runs full detection and refuses unless it
/// reads clean none, so the forbidden nested init is impossible even on a raced or stale read."
///
/// The caller (`HistoryStore.addGit`) has already established mode `none` from the mode it
/// detected at board open, which can be minutes old a `git init` in a terminal at the board root
/// *or anywhere above it* between the two would otherwise slip past a root-only check and
/// initialize a repository inside the user's, which is the one init 06 forbids outright. The whole
/// walk runs again here, at the moment of the write, so the refusal is structural rather than
/// probable. (Detection has no *unverifiable* answer yet 06's denial-is-not-absence distinction
/// is not built so "clean none" is spelled `.none` for now.)
///
/// Returns the branch the root commit landed on, which is the popover's display line.
nonisolated static func create(at boardRoot: URL) -> Result<String, GitOperationFailure> {
let operation = "Adding git to this board"
guard !BoardGitMode.hasGitEntry(at: boardRoot) else {
switch BoardGitMode.detect(boardRoot: boardRoot) {
case .none:
break
case .git:
return .failure(GitOperationFailure(
operation: operation,
message: "this board already has a git repository"
))
case .repoNested:
return .failure(GitOperationFailure(
operation: operation,
message: "this board lives inside a repository; Lanework leaves it to that repository"
))
}
let gitDirectory: URL
@@ -189,33 +196,32 @@ enum GitRepository {
}
}
/// **The `.gitignore` seed, written at init and never again** (06-history-undo.md Repository
/// hygiene: "the app never edits an existing one and never manages the file afterward it's the
/// user's from then on").
/// **The last-chance `.gitignore` seed, immediately before the initial commit.**
///
/// Three properties, and they are the feature:
/// The seed itself stopped being git's on 2026-07-31 (06-history-undo.md Repository hygiene,
/// re-ruled: "`.gitignore` seeded on every board, never touched after git or not"). Every board
/// the app creates is born with one, and every board it opens is healed into having one
/// (`BoardStore.seedGitignore`) and add-git can only run on a board that is *open* and writable,
/// so by the time this line is reached the file is essentially always already there and this call
/// writes nothing.
///
/// - **Only when absent.** A board that already carries a `.gitignore` from a template, from a
/// clone, from the user is left byte for byte alone. `fileExists` rather than a read, so a
/// *directory* wearing the name is left alone too (`IntegrityRules.claimedRootNames` marks
/// `.gitignore` as one of the two claimed names whose squatters are never displaced, precisely
/// because nothing in the app reads this file).
/// - **Only here.** This is the one call site, on the one path that creates a repository. Nothing
/// re-checks it, no heal restores it, no later version of the app appends to it: a user who
/// deletes the seeded line has deleted it.
/// - **Only on the app's own init.** Adoption seeds nothing an adopted repository is somebody
/// else's init, and 06's rule is about what the app writes when *it* creates one. A repo-nested
/// board seeds nothing either, and structurally cannot: `HistoryStore.addGit` refuses any mode
/// but `none`, so this function is unreachable from there.
/// **It stays anyway, and stays here before the stage below.** The one case it still answers is
/// the one that cannot be fixed afterwards: if the board's seed heal has not landed (a transient
/// failure that armed its memo, a picture that has not changed since), the initial commit would
/// otherwise capture every `.DS_Store` the Finder has left under the board *into history*, where
/// this app has no operation that could ever remove it (06 Deleting never forgets). One
/// `lstat` on the one path that mints a repository is a cheap insurance policy against a
/// permanent record.
///
/// Seeding is `BoardWriter.seedGitignoreIfAbsent`'s one seed text, one write-only-when-free
/// rule, `lstat` semantics so this cannot drift from what board creation and the heal write.
///
/// A write that fails is not a failure of add-git. The repository exists, the commit that follows
/// simply will not carry a `.gitignore`, and a board with none is an ordinary board surfacing a
/// banner about a courtesy file would be louder than the thing it reports.
/// simply will not carry a `.gitignore`, and the board's own heal will try again at the next
/// open surfacing a banner about a courtesy file would be louder than the thing it reports.
private static func seedGitignoreIfAbsent(at boardRoot: URL) {
let url = boardRoot.appendingPathComponent(".gitignore")
guard !FileManager.default.fileExists(atPath: url.path) else { return }
do {
try Data(seededGitignore.utf8).write(to: url, options: .atomic)
try BoardWriter.seedGitignoreIfAbsent(atBoardRoot: boardRoot)
} catch {
logger.notice("could not seed .gitignore at \(boardRoot.path, privacy: .public): \(String(describing: error), privacy: .public)")
}
+42 -6
View File
@@ -58,14 +58,26 @@ public final class HistoryStore {
/// click from running `git_repository_init` twice.
public private(set) var isAddingGit = false
/// The last add-git failure, or `nil` if the last attempt succeeded (or there hasn't been one).
/// The last add-git failure while the form that asked is still on screen, or `nil`.
///
/// Surfaced inline in the popover rather than as a banner: the popover is where the operation
/// was asked for and is still open when it answers, and 02-architecture.md's one-shot banner
/// vocabulary is for failures of writes the user made *elsewhere*. DESIGN does not settle
/// add-git's failure surface either way.
/// **Form-anchored operations answer at the form first** (06 Interaction with external writers,
/// ruled 2026-07-31): "add-git and later sheet-asked operations like verify-remote fail into
/// an inline caption in the sheet's relevant section while the sheet is up if the sheet has been
/// dismissed before the answer arrives, the failure falls back to the one-shot banner above
/// inline is the primary surface, never a silence trap."
///
/// So this property is exactly the *inline* half: it is set only while `isFormVisible`, and
/// dismissing the form clears it ("dismissing the sheet dismisses the stale error"). The other
/// half is `reportFailure`, which posts the banner when the answer arrives to an empty room.
///
/// The form is the popover's git section today and the board settings sheet once that exists
/// the ruling's container moved in the 2026-07-31 popover/sheet split, its substance did not, and
/// `noteFormVisible(_:)` is the one line the sheet will re-point.
public private(set) var lastFailure: GitOperationFailure?
/// Whether the form add-git was asked from is on screen right now (`noteFormVisible(_:)`).
public private(set) var isFormVisible = false
/// **The auto-commit engine** (06-history-undo.md Rules Auto-commit), or `nil` on a board
/// there is no repository to commit into.
///
@@ -152,6 +164,22 @@ public final class HistoryStore {
@ObservationIgnored
public var didAddGit: (@MainActor () -> Void)?
/// **The banner half of the form-anchored posture** where a form-asked failure goes when the
/// form is gone (`BannerCenter.postGitFailure`). `nil` on a storeless `HistoryStore`, which has no
/// strip to post to; the inline half still works there.
@ObservationIgnored
public var reportFailure: (@MainActor (GitOperationFailure) -> Void)?
/// **The form appeared or was dismissed.** Dismissal clears the stale inline error, which is the
/// ruling's own sentence ("dismissing the sheet dismisses the stale error, retry is right there").
///
/// A `Bool` rather than a count because there is one such form per board at a time: the popover is
/// built fresh on each open and the settings sheet is modal to its board window.
public func noteFormVisible(_ visible: Bool) {
isFormVisible = visible
if !visible { lastFailure = nil }
}
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "git")
init(boardRoot: URL, mode: BoardGitMode, ledger: EchoLedger) {
@@ -283,7 +311,15 @@ public final class HistoryStore {
Self.logger.notice("add-git initialized a repository at \(root.path, privacy: .public)")
return true
case .failure(let failure):
lastFailure = failure
// **Inline while the form is up, the banner when it is not** (06, ruled 2026-07-31) the
// answer can outlive the surface that asked for it, and a failure with nowhere to land
// would be the silence trap the ruling names.
if isFormVisible {
lastFailure = failure
} else {
lastFailure = nil
reportFailure?(failure)
}
Self.logger.error("add-git failed: \(failure.description, privacy: .public)")
return false
}
+24 -9
View File
@@ -107,6 +107,11 @@ extension BoardStore {
let named = subject ?? name
let step = HistoryStep(
name: name,
// **What this step holds in a trash, read off what it already declares** the sweep's
// gate (`HistoryStep.backing`, ruled 2026-07-31). Derived here rather than passed by each
// call site because the expectations *are* the claim: a step whose undo needs a folder
// present under `comments/.trash/` is a step whose undo is the move back out of it.
backing: HistoryStep.backing(declaredBy: undoExpects),
retirement: retirement,
undo: { [weak self] direction in
BoardStore.cross(self, direction, named, undoExpects, undo)
@@ -130,9 +135,9 @@ extension BoardStore {
// MARK: The window close's coarse step
/// **Registers one card-window session as one board step** 13-native-undo.md Rules' window
/// close ("the session's net effect registers on the board stack as one coarse step, 'Edit card
/// title', values-based, whose undo restores the card subtree to its session-start state
/// deleted comments included and whose redo reapplies the net effect").
/// close ("the session's net effect registers on the board stack as one coarse step named
/// 'Changes to card', values-based, whose undo restores the card subtree to its session-start
/// state deleted comments included and whose redo reapplies the net effect").
///
/// Everything about *what* the step does is `CardWindowUndo.netEffect()`'s; everything about
/// whether there is a board to register it on is this method's:
@@ -152,10 +157,11 @@ extension BoardStore {
/// - **A session with no net change registers nothing**, which is `netEffect()`'s `nil`.
///
/// - Parameter retiring: the deferred `comments/.trash/` purge (13 Interaction with the trash).
/// - Returns: whether the purge now has an owner a live step holding it until the step leaves
/// the board stack, or a substrate that declined to keep the step and therefore ran it already
/// (`GitHistoryProvider.register`). `false` means nothing was registered and the caller still
/// owes the purge.
/// - Returns: whether the purge now has an owner a live step holding it until the substrate
/// lets the step go (which a stale skip deliberately does not do; 13's skip-purge decoupling,
/// ruled 2026-07-31), or a substrate that declined to keep the step and therefore ran it
/// already (`GitHistoryProvider.register`). `false` means nothing was registered and the caller
/// still owes the purge.
@discardableResult
func registerCardSession(
_ window: CardWindowUndo,
@@ -164,9 +170,18 @@ extension BoardStore {
) -> Bool {
guard let target = Self.cardBodyTarget(cardID, in: snapshot) else { return false }
guard let net = window.netEffect() else { return false }
// **The title the step is registered with, not the one the session opened on** (the phrase is
// 13's "Changes to 'card'", ruled 2026-07-31). Read once and spent twice, which is the
// reason: the menu row and the skip banner must quote *one* name for one step, and a step
// whose row said "Changes to 'Fix login'" while its banner said "'Fix log-in' changed outside
// Lanework" would be two names for one card. Registration time is also the honest instant
// the row appears in the Edit menu the moment this returns, beside a board already showing
// this title, and a session that renamed the card is a session whose rename is *in* the net
// effect the row is offering to walk back.
let title = Self.cardTitle(at: target, in: snapshot)
registerStep(
HistoryPhrase.cardSession,
subject: Self.cardTitle(at: target, in: snapshot),
HistoryPhrase.cardSession(title),
subject: title,
retiring: retiring,
undoExpects: net.undoExpects,
redoExpects: net.redoExpects,
+5 -2
View File
@@ -167,8 +167,11 @@ public final class BoardUndoManager: UndoManager {
/// `NSSearchField` vends no manager of its own to the window, so `undo:` falls through to the
/// window's, and a reflexive Z over a typo in the search field or the board-rename field would
/// cross a *board* step. 06 rules that out by name: "Control-class text fields route the same way
/// (settled): the search field ... and the popover's text fields ... own Z/Z as field-local text
/// undo while focused a reflexive undo over a typo must never become a tree checkout." So the
/// (settled): the search field ..., the popover's rename field, and the settings sheet's text fields
/// (commit identity, credentials, remote URL) own Z/Z as field-local text undo while focused a
/// reflexive undo over a typo must never become a tree checkout." (That list is 06's own since the
/// 2026-07-31 popover/sheet split; the rule is per *responder class*, so it covers each of them
/// wherever it is hosted, and the sheet's arrival adds nothing here.) So the
/// window-level answer is the board's stack **only when no text-editing surface holds the
/// keyboard**, and a per-window text manager otherwise which is also exactly what AppKit would
/// have created for such a window on its own, so nothing about typing in a field changes.
+38 -1
View File
@@ -101,6 +101,39 @@ public final class CardWindowUndo {
writes[id] = write
}
/// **Empties this window's fine stack, registering nothing** the branch switch's settle step
/// (06-history-undo.md Branch switching, ruled 2026-07-31).
///
/// > "The settle also clears each open card window's fine undo stack: pre-switch steps describe
/// > the branch being left Save All and Discard alike end with every window's stack empty, the
/// > board-stack discard-and-reseed precedent one level down; the windows stay open, following
/// > their cards onto the new branch with fresh stacks."
///
/// ### Closing the stack is not closing the window
///
/// This is the whole reason it is a method rather than a call to `stack.clear()` at the call site.
/// A window *close* owes the board one coarse step folded from this stack
/// (`BoardStore.registerCardSession`, `netEffect()`); a settle clear owes it nothing, because the
/// window is not closing it stays open on the other side of the checkout and its next gesture
/// starts a fresh stack. So nothing here registers, nothing folds, and the fold that runs at the
/// window's eventual close sees only what the *new* branch's session did: `netEffect()` reads
/// `stack.pendingSteps`, which this empties, and `registerCardSession` answers `false` over an
/// empty fold exactly as it does for a session with no net change.
///
/// The raw writes go with the steps. They are keyed by step id and nothing on either stack names
/// them any more, so keeping them would be a table that only grows and a redone step's half of
/// the fold, which is the one reason the table outlives the undo stack, has nothing left to be
/// half of once both stacks are empty.
///
/// **Nothing retires here that would not have retired anyway.** `NativeHistoryProvider.clear()`
/// runs each dropped step's retirement, and a window's fine steps carry none: the deferred
/// `comments/.trash/` purge is the *coarse* step's (13-native-undo.md Interaction with the
/// trash), registered on the board's stack at close and never on this one.
func discardSteps() {
stack.clear()
writes.removeAll()
}
// MARK: - The fold
/// **The session's net effect, or `nil` when there is none** what the window's close registers
@@ -111,7 +144,11 @@ public final class CardWindowUndo {
///
/// - **undo** every live step's undo, newest first. Replaying the session backwards lands on the
/// state it started from, deleted comments included: their backing is still in
/// `comments/.trash/` because this step's own existence is what defers the purge.
/// `comments/.trash/` because this step's own existence is what defers the purge and, since
/// the sweep gate (13 Interaction with the trash, ruled 2026-07-31), what keeps the *next*
/// window open from sweeping it as residue. That claim needs nothing of its own here: the fold
/// below carries each delete's `.present(.trashedComment())` into the coarse step's
/// expectations, and `HistoryStep.backing(declaredBy:)` reads it straight off them.
/// - **redo** every live step's redo, oldest first. The session, replayed.
/// - **the undo's expectations** the state the session's writes left, folded **last-write-wins**
/// per field: what must still be true for the whole step to be safe to cross.
+34 -8
View File
@@ -25,6 +25,10 @@ import Foundation
/// destination clause a commit subject carries has no place in a title that has to stay short enough
/// for a menu row.
///
/// **One deliberate exception, and it is the only one**: `cardSession(_:)` names its card
/// ("Changes to 'Fix login'"), because the coarse close step is the one phrase whose *scope* is what
/// distinguishes it see that member.
///
/// Pure, and its own type rather than a `String` built at each call site, because a phrase composed
/// in eleven places is a vocabulary that drifts in eleven places.
public enum HistoryPhrase {
@@ -105,15 +109,37 @@ public enum HistoryPhrase {
// MARK: The card-window session
/// **The coarse close step's phrase** one card window's whole session, as the board's stack sees
/// it: 13-native-undo.md Rules names it "Edit card 'Fix login'", so the menu title is the same
/// verb and noun every other card edit uses, dropping the item clause a menu row has no space for.
/// it: **"Changes to 'card'"** (13-native-undo.md Rules, ruled 2026-07-31).
///
/// Deliberately *not* a new verb. The session is an edit to a card the fine-grained things
/// inside it (a comment posted, a colour chosen, a paragraph rewritten) are the window stack's
/// story, and a board-level row that tried to enumerate them would be the "Mixed update" problem
/// in a menu (06-history-undo.md Commit messages). It reads identically to a body-edit step
/// because on the board's stack it *is* the card's edit.
public static let cardSession = name(.edit, kind: .card)
/// > "the session's net effect registers on the board stack as **one coarse step named
/// > "Changes to 'card'"** the board row reads "Undo Changes to 'Fix login'": plural and
/// > scope-flavoured, distinct from every fine verb, honest about folding many kinds; the fine
/// > body-edit wording never leaks onto the board menu."
///
/// ### Why this one phrase carries its item
///
/// Every other phrase here drops the item clause a commit subject carries, because a verb plus a
/// noun already says what the row is (the type's note). This step has no such verb: what it folds
/// is a comment posted, a colour chosen, a paragraph rewritten, all at once, and a row that named
/// any one of them would be lying about the other two while enumerating them would be the
/// "Mixed update" problem in a menu (06-history-undo.md Commit messages). So the *scope* is the
/// phrase, and a scope is only legible when it names what it is the scope **of**.
///
/// ### It is not `Edit Card`, and that is the point
///
/// This member read `name(.edit, kind: .card)` until the ruling, and the fine body-edit step still
/// does (`registerBodyEdit`) two different steps on two different stacks, one row apart in the
/// Edit menu, saying the same six characters. The board menu now says "Changes to 'Fix login'" and
/// the window menu says "Edit Card": the coarse row names the session, the fine row names the
/// gesture, and neither can be mistaken for the other.
///
/// - Parameter title: the card's title **as the step is registered** `nil` renders the same
/// placeholder the card's own window title bar renders ("Untitled" is a rendering, never a
/// value 03-board-ui.md § Card face), so a menu row and the window it came from name the card
/// the same way.
public static func cardSession(_ title: String?) -> String {
"Changes to '\(title ?? "Untitled")'"
}
// MARK: Composition
+120 -15
View File
@@ -111,10 +111,18 @@ public struct HistoryStep {
/// It exists for one consumer, and the design names it precisely: the window-close coarse step
/// defers a card's `comments/.trash/` purge, because "the coarse close step's undo restores
/// deleted comments, so their backing lives as long as the step does the purge runs when the
/// coarse step leaves the board stack (undone-and-superseded, dropped off the end, or gone
/// stale) or the board session ends" (13-native-undo.md Interaction with the trash, re-ruled
/// 2026-07-31). A step is the only object that knows all three of those moments, and it knows
/// none of them itself so the *provider* reports them, through this.
/// coarse step leaves the board stack **cleanly** undone-and-superseded, or dropped off the end
/// or when the board session ends" (13-native-undo.md Interaction with the trash, re-ruled
/// 2026-07-31). A step is the only object that knows those moments, and it knows none of them
/// itself so the *provider* reports them, through this.
///
/// **A stale skip is deliberately not one of them** (the skip-purge decoupling, ruled
/// 2026-07-31): "a stale-skipped step's backing instead survives to board-session end ... the
/// skip banner says nothing was applied, and an irreversible purge riding that gesture would be
/// surprise loss". Leaving the stack and leaving history for good stopped being the same event
/// there, and this latch marks the second which is why a substrate that keeps a skipped step's
/// hold alive holds the *step* (`NativeHistoryProvider.strandedSteps`) rather than running this
/// early and re-arming the sweep from somewhere else.
///
/// **A reference type inside a value type, deliberately.** `reversed` copies the step every time
/// it crosses, and the two copies must not each run the work: sharing one latch is what makes
@@ -150,6 +158,26 @@ public struct HistoryStep {
/// The menu phrase, unprefixed see the type's note.
public let name: String
/// **What this step holds as undo backing** deleted content that exists on disk only because
/// *this step's undo would move it back out*, and which is therefore not residue for as long as
/// the step is crossable (13-native-undo.md Interaction with the trash, ruled 2026-07-31).
///
/// > "`comments/.trash/` content referenced by a live coarse step on the board stack is a step's
/// > **backing, not residue** the open-time sweep consults the stack and skips owned content,
/// > re-arming when the owning step leaves the stack (which is exactly when the deferred purge
/// > wanted to run; one condition, two consumers). Reopening a window can therefore never destroy
/// > its prior session's undo backing."
///
/// The pair with `retirement` is the whole mechanism: this says *what* is being held, that says
/// *when the hold ends*, and the two are read by the two consumers the ruling names the
/// crash-residue sweep (`BoardStore.sweepCommentTrashResidue(inCard:)`, through
/// `HistoryProviding.backedContent`) and the deferred purge itself.
///
/// **Declared by the step's own expectations rather than by hand** see `backing(declaredBy:)`.
/// Empty for every board gesture, which is all but a comment delete and the coarse step folding
/// one.
public let backing: Set<HistoryAnchor>
/// What this step owes when it leaves history see `Retirement`. `nil` for every step that owes
/// nothing, which is all but the card-window close step.
public let retirement: Retirement?
@@ -165,26 +193,58 @@ public struct HistoryStep {
public init(
id: UUID = UUID(),
name: String,
backing: Set<HistoryAnchor> = [],
retirement: Retirement? = nil,
undo: @escaping @MainActor (HistoryDirection) -> HistoryStepOutcome,
redo: @escaping @MainActor (HistoryDirection) -> HistoryStepOutcome
) {
self.id = id
self.name = name
self.backing = backing
self.retirement = retirement
self.undo = undo
self.redo = redo
}
/// **The backing a step's own registration already names**: every target its **undo** expects to
/// find *in a trash*.
///
/// Derived rather than passed, so no call site can forget it and no call site can say something
/// its expectations contradict. The reading is exact: a step whose undo requires a folder to be
/// present under `comments/.trash/` is a step whose undo *is* the move back out of it which is
/// the definition of backing, spelled in the currency the step already carries.
///
/// **`.trashedComment` is the whole vocabulary**, deliberately. The board's own `.trash/` holds
/// the same relationship (a delete step's undo restores from it) and is deliberately absent: the
/// board trash is a UI surface the user empties on purpose, with a confirm, and nothing sweeps it
/// behind their back so there is no consumer for the answer. `comments/.trash/` is the one
/// trash the app purges on its own schedule (01-storage-format.md § Enhanced schema), which is
/// exactly why it is the one that needs asking.
public static func backing(declaredBy undoExpects: [HistoryExpectation]) -> Set<HistoryAnchor> {
var backing: Set<HistoryAnchor> = []
for expectation in undoExpects where expectation.presence == .present {
guard case .trashedComment = expectation.anchor else { continue }
backing.insert(expectation.anchor)
}
return backing
}
/// The same step read backwards what a provider puts on the opposite stack once this one has
/// applied. The name does not change, which is the whole of "Undo Move Card" becoming "Redo Move
/// Card": the phrase names the *gesture*, not the direction.
///
/// **The identity and the retirement travel with it**, both for the same reason: a step that has
/// crossed is the same step, so the fold that keyed state on it must still find that state, and
/// the purge it defers must still be owed exactly once.
/// **The identity, the backing and the retirement travel with it**, all three for one reason: a
/// step that has crossed is the same step, so the fold that keyed state on it must still find that
/// state, the content it holds must still be held, and the purge it defers must still be owed
/// exactly once.
///
/// The backing claim is constant across the crossing rather than swapped with the closures, and
/// that is the honest reading: an undone coarse step has *already* moved its comments back out of
/// `comments/.trash/`, so there is nothing there to sweep and its redo will put them back,
/// after which its undo needs them again. A claim that lapsed while the step sat on the redo stack
/// would be a claim that lapsed exactly when the step was still crossable.
public var reversed: HistoryStep {
HistoryStep(id: id, name: name, retirement: retirement, undo: redo, redo: undo)
HistoryStep(id: id, name: name, backing: backing, retirement: retirement, undo: redo, redo: undo)
}
}
@@ -220,14 +280,31 @@ public struct HistoryStep {
/// - **No routing.** Which surface Z reaches is focus's answer, not the substrate's
/// (06 Undo routing, tier-independent) `BoardUndoRouting`.
///
/// ### One obligation every implementation shares: retire what you drop
/// ### One obligation every implementation shares: retire what you let go of
///
/// A step may owe work for as long as it is crossable and no longer (`HistoryStep.Retirement` the
/// deferred `comments/.trash/` purge). Only the substrate knows when a step stops being crossable, so
/// **every implementation must call `retirement?.run()` on every step it lets go**: the redo stack it
/// clears on a `register`, a step it drops as stale, everything in `clear()`, and for a substrate
/// that keeps no steps at all the step handed to `register` itself. Nothing else in the app can
/// observe that moment, and a step dropped in silence would defer its work forever.
/// A step may owe work for as long as the substrate holds it and no longer (`HistoryStep.Retirement`
/// the deferred `comments/.trash/` purge). Only the substrate knows when it has stopped holding
/// one, so **every implementation must call `retirement?.run()` on every step it lets go**: the redo
/// stack it clears on a `register`, everything in `clear()`, and for a substrate that keeps no
/// steps at all the step handed to `register` itself. Nothing else in the app can observe that
/// moment, and a step dropped in silence would defer its work forever.
///
/// **Held is not the same as crossable**, since the skip-purge decoupling (13 Interaction with the
/// trash, ruled 2026-07-31): a step a stale skip popped is crossable by nothing, and its hold
/// nonetheless stands to the session's end, because "the skip banner says nothing was applied, and an
/// irreversible purge riding that gesture would be surprise loss". A substrate that pops stale steps
/// therefore has a place to put them (`NativeHistoryProvider.strandedSteps`); one that never keeps a
/// step is untouched by the distinction, which is why the git provider needed no change.
///
/// ### And its mirror: say what you are still holding
///
/// The same fact, asked the other way round `backedContent`. Retirement is the *moment* a hold
/// ends; that is the *inventory* of holds standing right now, which is what a sweep needs before it
/// removes anything (13 Interaction with the trash, ruled 2026-07-31: "one condition, two
/// consumers"). The two must be answered off the same steps or the pair stops being one condition
/// which is the whole reason a stranded step is kept whole rather than filleted into a retirement
/// here and a set of anchors there. It defaults to nothing, so a substrate that keeps no steps needs
/// no line of code.
@MainActor
public protocol HistoryProviding: AnyObject {
@@ -262,4 +339,32 @@ public protocol HistoryProviding: AnyObject {
/// Drops every step in both directions session-only persistence (13 Rules), run at the board
/// session's teardown. Also what a substrate that must re-seed (a branch switch, 06) calls first.
func clear()
/// **The undo backing every step this substrate still holds** the union of `HistoryStep.backing`
/// over everything it has not yet let go of.
///
/// Read by the crash-residue sweep before it removes anything
/// (`BoardStore.sweepCommentTrashResidue(inCard:)`): "content referenced by a live coarse step on
/// the board stack is a step's backing, not residue" (13 Interaction with the trash, ruled
/// 2026-07-31). Both directions count, because both are live: a step sitting on the redo stack has
/// not retired, and 13's own release condition "undone-and-superseded" is the moment it does.
/// So does a step a stale skip stranded, which is crossable in *neither* direction and holding all
/// the same: the skip-purge decoupling put its backing's release at the session's end, and a sweep
/// that could not see it would take the release back (`NativeHistoryProvider.strandedSteps`).
///
/// A *point-in-time* answer, computed on demand rather than cached: the stacks are the truth, and
/// a second copy of this could only ever be a stale one.
var backedContent: Set<HistoryAnchor> { get }
}
extension HistoryProviding {
/// **A substrate that keeps no steps holds no backing** the git provider's honest answer
/// (`GitHistoryProvider.register` retires every step on arrival, which is what makes "purge rides
/// the close flush" true on Pro), and a test fake's.
///
/// A default on the requirement rather than a free function, so the dispatch is the substrate's:
/// an implementation that *does* keep steps overrides it and every caller through `any
/// HistoryProviding` sees the override.
public var backedContent: Set<HistoryAnchor> { [] }
}
+90 -9
View File
@@ -39,8 +39,16 @@ import Foundation
/// A step that applies is pushed onto the opposite stack **reversed** its two halves swapped
/// (`HistoryStep.reversed`) which gives the whole classic dance (undo redo undo ) with one
/// rule. Both stacks therefore hold steps oriented so that *crossing them means calling `undo`*, and
/// a skipped step leaves nothing behind at all: it is popped and never re-pushed, which is 13's
/// a skipped step leaves nothing crossable behind: it is popped and never re-pushed, which is 13's
/// "popped from the stack ... and Z falls through to the next step".
///
/// ### A third collection, which is not a stack
///
/// What a stale skip leaves behind is not *nothing*, though, and that is the one place this type
/// keeps state a stack cannot express: a skipped step that was holding undo backing goes on holding
/// it until the session ends (`strandedSteps`; 13 Interaction with the trash, ruled 2026-07-31
/// the skip-purge decoupling). Nothing can cross a stranded step and nothing can see one in the Edit
/// menu; it is a hold outliving the history it belonged to.
@MainActor
public final class NativeHistoryProvider: HistoryProviding {
@@ -48,6 +56,26 @@ public final class NativeHistoryProvider: HistoryProviding {
private var undoSteps: [HistoryStep] = []
private var redoSteps: [HistoryStep] = []
/// **Steps a stale skip popped that were still holding something** off both stacks, uncrossable
/// forever, and *not yet retired* (13-native-undo.md Interaction with the trash, ruled
/// 2026-07-31):
///
/// > "the purge runs when the coarse step leaves the board stack **cleanly** undone-and-superseded,
/// > or dropped off the end or when the board session ends; **a stale-skipped step's backing
/// > instead survives to board-session end** ... the skip banner says nothing was applied, and an
/// > irreversible purge riding that gesture would be surprise loss the skip is exactly when the
/// > user may want to inspect what the collision left."
///
/// **The whole step is kept, not a copy of what it was holding**, and that is the ruling's "one
/// condition, two consumers" taken literally: the sweep's ownership question (`backedContent`) and
/// the deferred purge's moment (`HistoryStep.Retirement`) are two readings of one object's hold,
/// so they must not become two facts able to disagree. A skip that ran the retirement and re-armed
/// the sweep from a side-table would be saying *released* to one consumer and *still held* to the
/// other, for the same instant. Deferring the latch itself says it once.
///
/// Only steps that owe something are stranded see `strand(_:)`.
private var strandedSteps: [HistoryStep] = []
public init() {}
// MARK: - HistoryProviding
@@ -73,16 +101,44 @@ public final class NativeHistoryProvider: HistoryProviding {
/// membership question, and the two arrays already answer it.
public var pendingSteps: [HistoryStep] { undoSteps }
/// **What every step this substrate still holds is holding** the sweep's gate
/// (`HistoryProviding.backedContent`; 13-native-undo.md Interaction with the trash, ruled
/// 2026-07-31).
///
/// All *three* collections, unlike `pendingSteps` above, and the difference is the question: that
/// one asks what Z would cross *now*, this one asks what has not been released. A step on the
/// redo stack has not retired "undone-and-superseded" is a release condition precisely because
/// being undone is not one so its backing is still owed; and a stranded step is the case where
/// the two questions come furthest apart, holding backing it will never again cross for
/// (`strandedSteps`, the skip-purge decoupling ruled 2026-07-31). The readings differing is what
/// makes them two members.
///
/// Leaving the stranded out would defeat the ruling by the back door: the step is off the stack,
/// so the next window open's sweep would call its backing residue and purge exactly the content
/// the skip was meant to leave standing.
public var backedContent: Set<HistoryAnchor> {
var backing: Set<HistoryAnchor> = []
for step in undoSteps { backing.formUnion(step.backing) }
for step in redoSteps { backing.formUnion(step.backing) }
for step in strandedSteps { backing.formUnion(step.backing) }
return backing
}
/// Records one undoable step and clears the redo stack the classic rule, and the one every
/// substrate shares.
///
/// The cleared steps are **retired** on the way out (`HistoryStep.Retirement`): this is the
/// "undone-and-superseded" half of the deferred purge's release condition, and it is the only
/// moment the app can see it.
/// "undone-and-superseded" half of the deferred purge's release condition a *clean* exit in the
/// ruling's own word and it is the only moment the app can see it.
///
/// `strandedSteps` is deliberately untouched: superseding is something that happens to a step on
/// the redo stack, and a stranded step is on no stack to be superseded from. Its one exit is the
/// session's end.
public func register(_ step: HistoryStep) {
undoSteps.append(step)
retire(redoSteps)
let dropped = redoSteps
redoSteps.removeAll()
retire(dropped)
}
public func undo() { cross(.undo) }
@@ -92,17 +148,38 @@ public final class NativeHistoryProvider: HistoryProviding {
/// Session teardown, the add-git substrate swap, a branch reseed every step goes, so every step
/// retires: "the purge runs when the coarse step leaves the board stack ... or the board session
/// ends" (13 Interaction with the trash).
///
/// **This is where the stranded finally pay**, and the only place they can: "a stale-skipped
/// step's backing instead survives to board-session end" (ruled 2026-07-31) names exactly this
/// moment, and `AppModel`'s teardown reaches it with the store still alive the `clear()` is a
/// line above the store's release, for the reason the deferred purge needs it to be.
public func clear() {
retire(undoSteps)
retire(redoSteps)
let dropped = undoSteps + redoSteps + strandedSteps
undoSteps.removeAll()
redoSteps.removeAll()
strandedSteps.removeAll()
retire(dropped)
}
/// **Off the stack first, then retired** the order every caller above keeps, and the one
/// `backedContent` depends on: a retirement is where the deferred purge runs, and a step that
/// retired while still listed would be answering that it still holds the very content it is
/// releasing.
private func retire(_ steps: [HistoryStep]) {
for step in steps { step.retirement?.run() }
}
/// Keeps a skipped step's *hold* alive without keeping the step crossable see `strandedSteps`.
///
/// **A step holding nothing is dropped outright**, which is every board gesture: with no backing
/// to spare and no retirement to defer there is no consumer for the entry, and stranding it would
/// be bookkeeping that only grows. The predicate is the pair the ruling's two consumers read, so
/// it can never spare a step neither of them would ask about.
private func strand(_ step: HistoryStep) {
guard step.retirement != nil || !step.backing.isEmpty else { return }
strandedSteps.append(step)
}
// MARK: - The crossing
/// Crosses one step, and keeps going while the steps it crosses decline as **stale** 13's
@@ -119,9 +196,13 @@ public final class NativeHistoryProvider: HistoryProviding {
push(step.reversed, onto: direction.opposite)
return
case .skipped:
// Dropped for good the third of the deferred purge's release conditions ("gone
// stale"), and the reason a skip is reported here rather than merely counted.
step.retirement?.run()
// **Off the stack, but not released** the skip-purge decoupling (13 Interaction
// with the trash, ruled 2026-07-31). A stale skip is the one exit that is not clean:
// "the skip banner says nothing was applied, and an irreversible purge riding that
// gesture would be surprise loss the skip is exactly when the user may want to
// inspect what the collision left". So the step stops being crossable here and its
// hold outlives it, to the session's end (`strandedSteps`).
strand(step)
continue
case .failed:
push(step, onto: direction)
+228 -44
View File
@@ -36,6 +36,79 @@ public struct OneShotBanner: Identifiable, Sendable, Equatable {
}
}
/// **The one-shot failure class's second shape** (02-architecture.md § The banner surface, settled
/// 2026-07-31): "the `BoardWriteError`-shaped write failure, and a message-carrying **git-operation
/// failure** the operation named in the user's words plus the underlying error, phrasing still
/// BannerCenter's because failures rank by what they are, not by which error vocabulary threw
/// them".
///
/// It is a row of its own rather than a `BoardWriteError` with a git-shaped case because "git
/// operations stay off the closed `WriteOperation` vocabulary only the banner tier learns the
/// second shape": a `WriteOperation` is something the Writer does to a file the model knows, and a
/// checkout is not one. What the two shapes *do* share is everything the strip cares about the
/// error tone, the failure rank, and the one-shot's dismissable, untimed lifecycle which is why
/// they sit in one precedence class rather than in two (`BannerCenter.rows(...)`).
///
/// It replaces the shipped compromise, and the compromise is worth naming because its reasoning is
/// the mirror of this one's: a failed restore used to post a `LossBanner` the nearest class with a
/// free-form message which read as warning-tone content-didn't-arrive when what actually happened
/// was an action that didn't happen at all. "A failed undo restore, branch switch, or (pro-m2)
/// pull/push presents in the error tone at the failure rank, never as a warning-tone loss row."
public struct GitFailureBanner: Identifiable, Sendable, Equatable {
public let id: UUID
/// Which operation didn't happen a closed vocabulary, so the sentence is composed here rather
/// than carried in from the git layer (`GitOperation`).
public let operation: GitOperation
/// The underlying error, verbatim libgit2's own message, the free-form tail the phrasing rules
/// allow ("could not write to '': Permission denied"). Never the banner's verb.
public let reason: String
/// When it failed the sort key for "newest first within a class", shared with the write
/// failures it ranks beside.
public let occurredAt: Date
public init(id: UUID = UUID(), operation: GitOperation, reason: String, occurredAt: Date = Date()) {
self.id = id
self.operation = operation
self.reason = reason
self.occurredAt = occurredAt
}
}
/// **The git operations the banner can name** the closed vocabulary behind `GitFailureBanner`,
/// and the git half of the rule the `WriteOperation` enum already carries for writes: "the banner
/// owns all user-facing phrasing and localization from that vocabulary", and an operation added
/// without a sentence to say about it is a compile-time hole rather than a silent default
/// (`BannerCenter.actionPhrase(for:)` switches over it exhaustively).
///
/// Deliberately small. 02 names the members: "a failed undo restore, branch switch, or (pro-m2)
/// pull/push" the first three are here because they have producers today
/// (`GitHistoryProvider`, `GitBranchSwitcher`), and pull/push join by adding a case and a sentence
/// when the remote half ships.
///
/// No associated values: a branch switch's target is in the in-progress row the failure replaces
/// ("Switching to 'main'"), and a failure that named it again would be the only member of this
/// vocabulary carrying a payload a case can grow one the day a sentence genuinely needs it.
public enum GitOperation: Sendable, Equatable, CaseIterable {
case undo
case redo
case branchSwitch
/// **Add-git, when the form that asked has been dismissed** (06-history-undo.md Interaction with
/// external writers, ruled 2026-07-31): "if the sheet has been dismissed before the answer
/// arrives, the failure falls back to the one-shot banner above inline is the primary surface,
/// never a silence trap." Inline is `HistoryStore.lastFailure`; this is the fallback.
case addGit
/// The pair Z and Z map to so a caller that knows a `HistoryDirection` (the provider's
/// crossing) never has to spell the mapping itself, and never gets it backwards.
public static func restore(_ direction: HistoryDirection) -> GitOperation {
switch direction {
case .undo: .undo
case .redo: .redo
}
}
}
/// A loss row: content that didn't arrive though nothing failed (02-architecture.md § The banner
/// surface, "Loss rows are the warning-tone class for non-failure losses", settled 2026-07-28)
/// folders skipped from a Finder drop, the app's own relocation and repair notices, their future kin.
@@ -144,7 +217,7 @@ public struct InProgressOperation: Identifiable, Sendable {
/// One row in a window's banner strip.
///
/// The seven cases are the whole vocabulary of 02-architecture.md § The banner surface, and they
/// The eight cases are the whole vocabulary of 02-architecture.md § The banner surface, and they
/// divide into three lifecycles that the view renders differently and that the ordering rule
/// treats as classes:
///
@@ -152,11 +225,13 @@ public struct InProgressOperation: Identifiable, Sendable {
/// ongoing state and carry no dismiss control "an error never evaporates unread" has a twin,
/// "a condition is never dismissed while it is still true". Each leaves when the thing it
/// describes stops being true.
/// - **One-shots dismiss**: `oneShot`, `loss`, and `signpost`. Each reports something that already
/// happened, so only the user can clear it. `loss` shares this lifecycle deliberately (settled
/// 2026-07-28) even though it reports no failure: "a loss the user didn't notice is the harm, so
/// it never auto-expires" is the same reasoning that keeps a one-shot from evaporating unread,
/// aimed at a row that isn't an error at all.
/// - **One-shots dismiss**: `oneShot`, `gitFailure`, `loss`, and `signpost`. Each reports something
/// that already happened, so only the user can clear it. `gitFailure` is the same failure class as
/// `oneShot` in a second shape (settled 2026-07-31) same tone, same rank, same lifecycle, a
/// message where the other carries a `BoardWriteError`. `loss` shares the lifecycle deliberately
/// (settled 2026-07-28) even though it reports no failure: "a loss the user didn't notice is the
/// harm, so it never auto-expires" is the same reasoning that keeps a one-shot from evaporating
/// unread, aimed at a row that isn't an error at all.
/// - **In-progress rows complete or fail**: `inProgress`. Completion clears the row; failure swaps
/// it for a one-shot (`BannerCenter.endOperation(_:)` + `post(_:)`).
///
@@ -173,6 +248,10 @@ public enum BannerRow: Identifiable, Sendable {
case reloadBreakage(BoardLoadError)
/// A write that did not happen. Dismissable, error tone.
case oneShot(OneShotBanner)
/// A git operation that did not happen an undo restore, a branch switch, and (pro-m2) a pull
/// or a push. Dismissable, error tone, and it ranks *with* `oneShot`: the one-shot failure
/// class's second shape (settled 2026-07-31, see `GitFailureBanner`).
case gitFailure(GitFailureBanner)
/// Content that didn't arrive though nothing failed folders skipped from a Finder drop, the
/// app's own relocation and repair notices. Dismissable, warning tone: below the true failures above it,
/// above the ambient notices below it (settled 2026-07-28, see `LossBanner`).
@@ -192,6 +271,7 @@ public enum BannerRow: Identifiable, Sendable {
case .readOnlyLock: "read-only-lock"
case .reloadBreakage: "reload-breakage"
case let .oneShot(banner): "one-shot:\(banner.id.uuidString)"
case let .gitFailure(banner): "git-failure:\(banner.id.uuidString)"
case let .loss(loss): "loss:\(loss.id.uuidString)"
case .historySuspended: "history-suspension"
case let .inProgress(operation): "operation:\(operation.id.uuidString)"
@@ -201,7 +281,10 @@ public enum BannerRow: Identifiable, Sendable {
public var tone: BannerTone {
switch self {
case .readOnlyLock, .reloadBreakage, .oneShot: .error
// A git operation that failed is an action that didn't happen, so it takes the failure
// tone with the write failures it ranks beside "never as a warning-tone loss row"
// (02-architecture.md § The banner surface, settled 2026-07-31).
case .readOnlyLock, .reloadBreakage, .oneShot, .gitFailure: .error
case .historySuspended, .loss: .warning
case .inProgress, .signpost: .info
}
@@ -221,6 +304,7 @@ public enum BannerRow: Identifiable, Sendable {
case let .readOnlyLock(reason): BannerCenter.headline(for: reason)
case let .reloadBreakage(error): BannerCenter.headline(for: error)
case let .oneShot(banner): BannerCenter.headline(for: banner.error)
case let .gitFailure(banner): BannerCenter.headline(for: banner)
case let .loss(loss): loss.message
case let .historySuspended(suspension): BannerCenter.headline(for: suspension)
case let .inProgress(operation): operation.label
@@ -234,6 +318,7 @@ public enum BannerRow: Identifiable, Sendable {
public var dismissID: UUID? {
switch self {
case let .oneShot(banner): banner.id
case let .gitFailure(banner): banner.id
case let .loss(loss): loss.id
case let .signpost(signpost): signpost.id
case .readOnlyLock, .reloadBreakage, .historySuspended, .inProgress: nil
@@ -300,11 +385,12 @@ public enum BannerRowControl: Identifiable, Sendable {
///
/// ### What lives here and what does not
///
/// A center holds the state nothing else does: dismissable one-shot write failures, loss rows, the
/// history suspension, in-progress operations, and passive signposts. It deliberately does **not**
/// hold the read-only lock or the reload breakage those are `BoardStore`'s truths, and copying
/// them here would create a second place for them to be stale. `BoardStore.bannerRows` composes both
/// halves through `rows(lock:breakage:oneShots:losses:suspension:operations:signposts:)`, which is a
/// A center holds the state nothing else does: dismissable one-shot failures in both shapes (write
/// and git-operation), loss rows, the history suspension, in-progress operations, and passive
/// signposts. It deliberately does **not** hold the read-only lock or the reload breakage those
/// are `BoardStore`'s truths, and copying them here would create a second place for them to be
/// stale. `BoardStore.bannerRows` composes both halves through
/// `rows(lock:breakage:oneShots:losses:suspension:operations:signposts:gitFailures:)`, which is a
/// *pure function* precisely so the precedence rule can be tested without a store, a window, or a
/// filesystem.
///
@@ -315,7 +401,10 @@ public enum BannerRowControl: Identifiable, Sendable {
/// diagnostic `reason`; `headline(for:)` switches over that enum **exhaustively, with no
/// `default`**, so a Writer operation added without a sentence to say about it is a compile-time
/// hole rather than a silent fallback. The same rule covers locks, breakage, and the history
/// suspension: their user-facing lines are here, not on the error types.
/// suspension: their user-facing lines are here, not on the error types. **And the git operations
/// too** (settled 2026-07-31): `GitOperation` is that vocabulary's git-side twin "the operation
/// named in the user's words plus the underlying error, phrasing still BannerCenter's" which is
/// why `GitOperationFailure.operation`, a developer-facing string, never reaches a row.
///
/// ### One center per window, not per board
///
@@ -334,6 +423,17 @@ public final class BannerCenter {
/// turn still order deterministically.
public private(set) var oneShots: [OneShotBanner] = []
/// The failure class's git-shaped half, newest first like `oneShots` a failed undo restore,
/// a failed branch switch, and (pro-m2) a failed pull or push (settled 2026-07-31, see
/// `GitFailureBanner`).
///
/// **A second array rather than a second case inside `OneShotBanner`**: the two shapes have no
/// payload in common one carries the closed `WriteOperation` vocabulary and a path, the other
/// a git operation and libgit2's message and every reader of a write failure would have to
/// start asking whether it was one. They meet where the design says they meet, in the strip's
/// order: `rows(...)` merges them into one precedence class, newest first across both.
public private(set) var gitFailures: [GitFailureBanner] = []
/// Newest first, like `oneShots` content that didn't arrive though nothing failed,
/// dismissable and untimed for the same reason a one-shot failure is (settled 2026-07-28, see
/// `LossBanner`).
@@ -361,6 +461,19 @@ public final class BannerCenter {
oneShots.insert(OneShotBanner(error: error), at: 0)
}
/// **Records a git operation that didn't happen** the failure class's second shape (settled
/// 2026-07-31, see `GitFailureBanner`): an undo or redo restore that failed cleanly
/// (06-history-undo.md Interaction with external writers), a branch switch that could not run
/// (06 Branch switching), and the remote pair when pro-m2 wires them.
///
/// `reason` is the underlying error and nothing else libgit2's own message, or the app's own
/// diagnosis of a repository it could not read. **Every user-facing word is composed here**
/// (`headline(for:)`), which is what keeps a caller from inventing a verb: the git layer's
/// `GitOperationFailure.operation` is a developer-facing string and never reaches the strip.
public func postGitFailure(_ operation: GitOperation, reason: String) {
gitFailures.insert(GitFailureBanner(operation: operation, reason: reason), at: 0)
}
/// Posts a loss row content that didn't arrive though nothing failed (settled 2026-07-28, see
/// `LossBanner`). Newest first, like the one-shots it shares a lifecycle with.
public func postLoss(_ message: String) {
@@ -575,20 +688,23 @@ public final class BannerCenter {
nonisolated static let mixedTrashDragMessage =
"Cards and lanes leave the trash separately \u{2014} restore one kind at a time"
/// Removes a dismissable row: a one-shot failure, a loss row, or a signpost. **An id that names
/// an in-progress operation is ignored** rather than ending it, because "dismiss" and "cancel"
/// are different promises and a row that offers one must never quietly do the other.
/// Removes a dismissable row: a one-shot failure in either shape, a loss row, or a signpost.
/// **An id that names an in-progress operation is ignored** rather than ending it, because
/// "dismiss" and "cancel" are different promises and a row that offers one must never quietly do
/// the other.
public func dismiss(_ id: UUID) {
oneShots.removeAll { $0.id == id }
gitFailures.removeAll { $0.id == id }
losses.removeAll { $0.id == id }
signposts.removeAll { $0.id == id }
}
/// Removes every dismissable row one-shots, losses, and signposts alike. The strip's own
/// "clear all" affordance later; today it is what a window uses when it re-homes its rows
/// Removes every dismissable row both failure shapes, losses, and signposts alike. The strip's
/// own "clear all" affordance later; today it is what a window uses when it re-homes its rows
/// elsewhere (m6).
public func dismissAllDismissableRows() {
oneShots.removeAll()
gitFailures.removeAll()
losses.removeAll()
signposts.removeAll()
}
@@ -684,10 +800,17 @@ public final class BannerCenter {
/// a condition and a one-shot are not comparable by recency in any way a user would read as
/// order the condition's `since` is when it *started* being true, not when it happened
/// so they are ordered by kind, and recency orders only the one-shots among themselves.
/// - **The failure rank holds both shapes, interleaved by recency** (settled 2026-07-31):
/// "failures rank by what they are, not by which error vocabulary threw them", so a failed
/// undo posted a second ago sits above a failed move from a minute ago and below one from a
/// second before it. Ties two rows sharing a `Date` to the microsecond put the write
/// failure first; the two shapes are posted from different call sites, so a tie is an
/// accident of the clock rather than an order anyone can read.
///
/// `signposts` carries a default because its producer is m6's card window and nothing posts one
/// today; every other class has a live producer and is spelled out at every call site `losses`
/// included, since a Finder drop that skipped folders already posts one (`postSkippedFolders`).
/// `signposts` and `gitFailures` carry defaults: the first because its producer is m6's card
/// window, the second because a center that hosts no git surface (a card window's own) can never
/// hold one. Every other class is spelled out at every call site `losses` included, since a
/// Finder drop that skipped folders already posts one (`postSkippedFolders`).
public nonisolated static func rows(
lock: ReadOnlyLockReason?,
breakage: BoardLoadError?,
@@ -695,7 +818,8 @@ public final class BannerCenter {
losses: [LossBanner],
suspension: HistorySuspension?,
operations: [InProgressOperation],
signposts: [InfoSignpost] = []
signposts: [InfoSignpost] = [],
gitFailures: [GitFailureBanner] = []
) -> [BannerRow] {
var rows: [BannerRow] = []
@@ -708,10 +832,13 @@ public final class BannerCenter {
rows.append(.reloadBreakage(breakage))
}
let ordered = newestFirst(oneShots)
rows.append(contentsOf: ordered.lazy.filter { !$0.isAttachmentImport }.map(BannerRow.oneShot))
let ordered = newestFirst(oneShots, by: \.occurredAt)
rows.append(contentsOf: failureRank(
writes: ordered.filter { !$0.isAttachmentImport },
git: newestFirst(gitFailures, by: \.occurredAt)
))
rows.append(contentsOf: newestFirst(losses).map(BannerRow.loss))
rows.append(contentsOf: newestFirst(losses, by: \.occurredAt).map(BannerRow.loss))
if let suspension {
rows.append(.historySuspended(suspension))
@@ -722,31 +849,47 @@ public final class BannerCenter {
return rows
}
/// Newest first, and **stable**: `sorted(by:)` is not, and two failures posted in the same run
/// loop turn can share a `Date` to the microsecond. Ties fall back to the input order, which
/// `post(_:)` already maintains newest-first so a tie renders in the order it was posted
/// rather than in whatever order the sort happened to leave.
private nonisolated static func newestFirst(_ banners: [OneShotBanner]) -> [OneShotBanner] {
banners
.enumerated()
.sorted { lhs, rhs in
lhs.element.occurredAt == rhs.element.occurredAt
? lhs.offset < rhs.offset
: lhs.element.occurredAt > rhs.element.occurredAt
/// **The failure rank, both shapes** (settled 2026-07-31): the non-attachment write failures and
/// the git-operation failures, merged into the one precedence class they share "failures rank
/// by what they are, not by which error vocabulary threw them".
///
/// A merge rather than a re-sort, because both inputs arrive newest-first already; ties keep the
/// write failure first, which is arbitrary and says so (`rows(...)`).
private nonisolated static func failureRank(
writes: [OneShotBanner],
git: [GitFailureBanner]
) -> [BannerRow] {
var merged: [BannerRow] = []
var writes = writes[...]
var git = git[...]
while let write = writes.first, let failure = git.first {
if failure.occurredAt > write.occurredAt {
merged.append(.gitFailure(failure))
git = git.dropFirst()
} else {
merged.append(.oneShot(write))
writes = writes.dropFirst()
}
.map(\.element)
}
merged.append(contentsOf: writes.map(BannerRow.oneShot))
merged.append(contentsOf: git.map(BannerRow.gitFailure))
return merged
}
/// The same stable newest-first ordering as the overload above, for loss rows the two classes
/// share a lifecycle, and `postLoss` already maintains newest-first on insertion the way
/// `post(_:)` does.
private nonisolated static func newestFirst(_ losses: [LossBanner]) -> [LossBanner] {
losses
/// Newest first, and **stable**: `sorted(by:)` is not, and two rows posted in the same run loop
/// turn can share a `Date` to the microsecond. Ties fall back to the input order, which every
/// `post` maintains newest-first on insertion so a tie renders in the order it was posted
/// rather than in whatever order the sort happened to leave.
///
/// One function over a date key rather than one per class: the three dismissable classes order
/// by exactly the same rule, and three copies of it were three places for it to drift.
private nonisolated static func newestFirst<Row>(_ rows: [Row], by occurredAt: KeyPath<Row, Date>) -> [Row] {
rows
.enumerated()
.sorted { lhs, rhs in
lhs.element.occurredAt == rhs.element.occurredAt
lhs.element[keyPath: occurredAt] == rhs.element[keyPath: occurredAt]
? lhs.offset < rhs.offset
: lhs.element.occurredAt > rhs.element.occurredAt
: lhs.element[keyPath: occurredAt] > rhs.element[keyPath: occurredAt]
}
.map(\.element)
}
@@ -764,6 +907,39 @@ public final class BannerCenter {
return cause.isEmpty ? action : "\(action)\(cause)"
}
/// The user-facing line for a git operation that didn't happen: what the app could not do, then
/// why the write failure's shape exactly (settled 2026-07-31), because the two are one class.
///
/// The cause is the underlying error verbatim, on `causePhrase(for:)`'s reasoning read one layer
/// down: libgit2's messages are specific in a way no re-phrasing of ours would be, and the
/// alternative to showing one is a shrug. An empty or whitespace-only reason leaves the action
/// clause alone rather than trailing a dash into nothing.
public nonisolated static func headline(for failure: GitFailureBanner) -> String {
let action = actionPhrase(for: failure.operation)
let cause = trimmed(failure.reason)
return cause.isEmpty ? action : "\(action)\(cause)"
}
/// **Exhaustive by construction no `default`**, the `WriteOperation` rule applied to the git
/// vocabulary: an operation added to `GitOperation` without a sentence here fails to compile.
///
/// The undo pair is named by **the command the user pressed** the skipped-step row's rule
/// (`skippedStepMessage`), and for its reason: Z is the gesture, and "Couldn't restore an
/// earlier state" would describe machinery the user has no model of. The branch switch is named
/// by the control they used (the popover's branch picker) and stays plural "branches" rather
/// than naming the target, which the in-progress row this failure replaces already said
/// ("Switching to 'main'") and which `GitOperation` deliberately doesn't carry.
private nonisolated static func actionPhrase(for operation: GitOperation) -> String {
switch operation {
case .undo: "Undo failed"
case .redo: "Redo failed"
case .branchSwitch: "Couldn't switch branches"
// Named by the control the user pressed, like the pair above "Add Git" is the button, and
// the row is only ever seen by someone who pressed it and looked away.
case .addGit: "Couldn't add git to this board"
}
}
/// **Exhaustive by construction no `default`.** A `WriteOperation` case added without a
/// sentence here fails to compile, which is the settled contract ("a new Writer operation
/// without a banner rendering is a compile-time hole, not a silent default").
@@ -853,6 +1029,14 @@ public final class BannerCenter {
// touched, and nothing is lost: the board works exactly as well without the guide, which
// is why every *refusal* to write it is a log line and only a real I/O failure gets here.
"Couldn't update the agent guide"
case .seedGitignore:
// **The agent guide's sentence, one file over**, and for its reasons: `.gitignore` is a
// courtesy the app writes once, most users will never open it, and nothing about the
// board's own files is at stake a board without one simply excludes nothing, which is
// how every board behaved until 2026-07-31. It names the file rather than a role because
// this one *does* have a name users know from git, and "the ignore list" would be the
// app inventing a word for something already called something.
"Couldn't write this board's .gitignore"
case let .displaceClaimedName(name):
// **The name, quoted, and what the app wanted with it** the failure's mirror of the
// success row ("Renamed '.trash' to '.trash 2' Lanework needs that name"). It names
+53 -1
View File
@@ -370,7 +370,8 @@ public final class BoardStore: HealHost {
losses: banners.losses,
suspension: banners.historySuspension,
operations: banners.operations,
signposts: banners.signposts
signposts: banners.signposts,
gitFailures: banners.gitFailures
)
}
@@ -4055,10 +4056,60 @@ public final class BoardStore: HealHost {
}
}
// MARK: - The board's noise definition
/// Puts the seeded `.gitignore` on a board that has none, and does nothing at all to a board
/// that has one 06-history-undo.md Repository hygiene's whole scheduling ("a board missing
/// the file gains it by scheduled heal at open (the guide-refresh cadence)", re-ruled
/// 2026-07-31).
///
/// **`refreshAgentGuide()`'s twin, deliberately**, down to the shape of this method: the file is
/// a board-root courtesy the app owns, its defect is *presence* rather than anything a tree walk
/// could report, and the decision is one `lstat`. What differs is that there is no version to
/// compare and no displacement to make a name already held by anything at all is left exactly
/// as it stands (`BoardWriter.seedGitignoreIfAbsent`), including by a directory, because an
/// unreadable noise definition costs the board nothing but the exclusions it never had.
///
/// **Silent.** No banner row, on the guide's reasoning exactly: a courtesy file the user did not
/// create and may not know exists, whose absence changed nothing they can see. Only a genuine I/O
/// failure reaches the strip, through `performWrite`.
///
/// **The memo is armed before the attempt and cleared on success** (`HealScheduler`'s steps 4 and
/// 6), which is what makes a foreign deletion healable: the picture "missing" is restored by the
/// delete, and a standing memo would make that deletion the one thing this could not answer
/// while a failing write still gets exactly one attempt per changed picture rather than one per
/// reload. "Deletion is answered by re-seeding" is 06's own wording.
///
/// **It registers no undo step**, like every heal: nobody asked for it.
public func seedGitignore() {
let root = rootURL
// An empty signature is the engine's resting state and costs no bracket which matters here
// more than anywhere, because this heal runs at every open and reload tail of every board,
// and a bracket schedules a reload whether or not anything was written.
let signature: Set<String> = IntegrityRules
.node(at: root.appendingPathComponent(IntegrityRules.gitignoreFileName)) == nil
? ["gitignore:missing"]
: []
heals.run(.missingGitignore, signature: signature, on: self) { () throws(BoardWriteError) -> Void in
// The disk re-verify is the Writer's own: a file that appeared under us another window,
// an agent, a clone finishing makes this a no-op rather than an overwrite.
try BoardWriter.seedGitignoreIfAbsent(atBoardRoot: root)
}
}
/// **Every scheduled heal, in order** the engine's two seams call exactly this
/// (02-architecture.md Components HealScheduler: "fires uniformly at the reload tail and at
/// registry acquire, closing today's asymmetry where tombstone migration never fires at open").
///
/// **The `.gitignore` seed goes before the relocation, and that ordering is load-bearing in the
/// same way** (01-storage-format.md § Fractal layout Rules, ruled 2026-07-31): the seed *is*
/// the noise gate the relocation obeys, so a pass that relocated first would act, once, on a
/// board whose noise definition it was about to write. The window it closes is narrow by
/// construction the walk that produced this pass's work list ran before either heal, so the
/// seed's own patterns cannot filter it until the next load, and both of them name hidden files
/// the carve-out never touches anyway but the order costs nothing and states the dependency.
///
/// **The claimed-name displacement goes first, and that ordering is load-bearing**: a card's
/// migration mints `<root>/.trash/`, which cannot be created while a file or symlink holds that
/// name so a migration attempted ahead of the displacement fails *and arms its memo against an
@@ -4084,6 +4135,7 @@ public final class BoardStore: HealHost {
/// re-armed by the reload the others' writes produce, so none can see another's work half-done.
public func runScheduledHeals() {
displaceClaimedNames()
seedGitignore()
relocateLooseCardFiles()
migrateLegacyTombstones()
remintDuplicateIdentities()
+69 -10
View File
@@ -261,6 +261,12 @@ extension BoardStore {
/// old stale-after-close skip scenario cannot arise". What reaches the board is the close step
/// that folds it, whose undo restores the comment from a `comments/.trash/` the same step is
/// keeping alive (`registerCardSession`).
///
/// The `.present(trashed)` half of the undo's expectations is also this step's **backing claim**,
/// read off it by `HistoryStep.backing(declaredBy:)` and carried into the coarse step by the fold:
/// it is what tells the next window open's residue sweep that the folder in `comments/.trash/` is
/// a live step's, not a dead session's leftovers (13 Interaction with the trash, ruled
/// 2026-07-31).
@discardableResult
public func deleteComment(_ commentID: ItemID, inCard id: ItemID, on window: CardWindowUndo? = nil) -> Bool {
guard let card = commentSubject(id) else { return false }
@@ -309,10 +315,16 @@ extension BoardStore {
/// The call site moved with that re-ruling and this method did not change: the window's close no
/// longer purges on its own, it hands this work to the coarse close step as that step's
/// **retirement** (`HistoryStep.Retirement`), and the purge runs when the step leaves the board
/// stack undone-and-superseded, dropped, gone stale or when the board session ends. On a git
/// board the step is never kept, so the retirement fires at the close flush, which is where the
/// purge always ran there ("purge rides the close flush"). The crash-residue sweep at the next
/// card-window open is unchanged.
/// stack **cleanly** undone-and-superseded, or dropped off the end or when the board session
/// ends. A **stale skip is not a clean exit** and does not purge (the decoupling ruled
/// 2026-07-31): that step's backing survives to session end instead, where `clear()` retires it
/// and this method finally runs. On a git board the step is never kept, so the retirement fires at
/// the close flush, which is where the purge always ran there ("purge rides the close flush").
///
/// **It empties the folder whole, and its two callers are exactly the moments that is right**: a
/// close that registered no step (nothing took the hold), and the retirement of the step that
/// did (the hold has just ended). The *sweep* cannot assume either, which is why it purges per
/// entry behind the ownership gate `sweepCommentTrashResidue(inCard:)`.
///
/// One bracket, no step. Leftover comment steps on a stack are not pruned here and must not be:
/// invalidation is lazy (13 Rules), so they stay on the stack, look full, and skip with the
@@ -327,10 +339,49 @@ extension BoardStore {
/// **The crash-residue sweep**, run when a card window opens (§ Enhanced schema: "crash residue
/// sweeps at the next card-window open, armed-then-cleared like every heal memo").
///
/// The same six steps every scheduled heal gets, through the same engine: **rest** when the trash
/// is empty (which is every open on a board that closed cleanly, and costs no bracket at all),
/// defer under a read-only lock, compare the signature, arm before attempting, one bracket, clear
/// on success.
/// ### Residue is what no live step owns the stack is asked first
///
/// The ruling of 2026-07-31 (13-native-undo.md Interaction with the trash; § Enhanced schema's
/// "residue defined as content no live coarse step owns"):
///
/// > "residue is defined by the purge-deferral condition itself: `comments/.trash/` content
/// > referenced by a live coarse step on the board stack is a step's **backing, not residue**
/// > the open-time sweep consults the stack and skips owned content, re-arming when the owning
/// > step leaves the stack (which is exactly when the deferred purge wanted to run; one condition,
/// > two consumers). **Reopening a window can therefore never destroy its prior session's undo
/// > backing.** Unowned content sweeps as before."
///
/// The defect it closes is the reopen: close a window that deleted a comment (the coarse step
/// registers, its undo restores from `comments/.trash/`), open the same card again, and this sweep
/// used to empty the trash out from under a step still sitting on the board's stack leaving a
/// Z that could only skip.
///
/// So the gate is `HistoryProviding.backedContent`, and it is the same fact the deferred purge
/// waits on, asked as an inventory instead of as a moment. Nothing here has to *re-arm* by hand:
/// when the owning step retires, its retirement runs the deferred purge and the content is gone;
/// and a sweep that found nothing left to do rests, which clears the memo (`HealScheduler` step 1)
/// so a later picture is a fresh one.
///
/// **A step a stale skip popped still counts as an owner** (the skip-purge decoupling, ruled
/// 2026-07-31): "a stale-skipped step's backing instead survives to board-session end ... the skip
/// is exactly when the user may want to inspect what the collision left". Nothing here says so
/// that is `backedContent`'s answer, and keeping it there is what makes the pair one condition
/// read twice rather than two conditions kept in step by hand.
///
/// ### Entries, not the container
///
/// Which is why this no longer calls `BoardWriter.purgeCommentTrash(inCard:)` that empties the
/// folder whole, and the whole folder is exactly what this may not assume it owns. The per-entry
/// primitive is the same one an undone create removes its folder with, over the entries the
/// listing already narrowed to identity shape; a hand-editor's stray in there keeps the verbatim
/// posture either way.
///
/// The rest is the same six steps every scheduled heal gets, through the same engine: **rest**
/// when there is nothing unowned (which is every open on a board that closed cleanly, and costs no
/// bracket at all), defer under a read-only lock, compare the signature, arm before attempting,
/// one bracket, clear on success. The signature is computed from **the entries actually purged**,
/// so the picture the memo remembers is the work that was attempted rather than everything the
/// trash happened to hold.
///
/// **Silent** `HealNotice.none`. `comments/.trash/` is "never a UI surface", and the residue is
/// the app's own leftovers from a session that died; there is nothing here a user could act on.
@@ -341,13 +392,21 @@ extension BoardStore {
public func sweepCommentTrashResidue(inCard id: ItemID) {
guard let card = commentSubject(id) else { return }
let folder = card.folder
let residue = CommentThread.trashedCommentIDs(inCard: folder)
// The board's stack, never a window's: a window's own fine comment steps die with the window
// that owns them, and the window this sweep runs for has not made a gesture yet. What can
// outlive a close is the coarse step the close folded the session into, and that is here.
let backed = history?.backedContent ?? []
let residue = CommentThread.trashedCommentIDs(inCard: folder).filter { commentID in
!backed.contains(.trashedComment(commentID, inCard: id))
}
heals.run(
.commentTrashResidue,
signature: Set(residue.map { "comment-trash:\(card.path)/\($0.rawValue)" }),
on: self
) { () throws(BoardWriteError) -> Void in
_ = try BoardWriter.purgeCommentTrash(inCard: folder)
for commentID in residue {
try BoardWriter.purgeItem(at: CommentThread.trashedCommentFolder(commentID, inCard: folder))
}
}
}
+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 leftright by ascending `order`; cards topbottom 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 leftright).
`order` ranks lanes leftright, 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
+14 -4
View File
@@ -235,8 +235,15 @@ struct BoardGitControls: View {
// MARK: - Commit identity
/// **The name and email that repo-local `.git/config` carries** (06-history-undo.md Interaction
/// with external writers: "The board popover's git section exposes name/email fields that write that
/// repo-local config the setting *is* the file, portable to any git client, per-board by nature").
/// with external writers: "The board settings sheet's identity section exposes name/email fields
/// that write that repo-local config the setting *is* the file, portable to any git client,
/// per-board by nature").
///
/// **Its home is the sheet, and the sheet is not built yet.** The 2026-07-31 popover/sheet split moved
/// every setup-shaped control add-git, branch creation, these fields, remote and credentials out
/// of the popover and into a board settings sheet (03-board-ui.md Board settings sheet), leaving the
/// popover the daily face. This view is the fields, hosted where they were; the sheet's card moves
/// them, along with the visibility-scoped poll below, which 06 says "rides with the fields".
///
/// ### The placeholder is the whole of the identity rule made visible
///
@@ -270,8 +277,11 @@ private struct BoardGitIdentityFields: View {
case email
}
/// How often an open popover re-reads the config file. Slow enough to be free, fast enough that a
/// terminal `git config user.email ` shows up while the user is still looking at the popover.
/// **The fields re-read the config at 2 s while the surface is visible** (06 Interaction with
/// external writers, blessed 2026-07-31): "the watcher never delivers `.git`, so no board event
/// can carry a terminal-side config edit the unfocused-resync courtesy needs its own signal, and
/// a visibility-scoped poll is the 15 s paused-state re-read's shape at sheet cadence (a focused
/// field keeps its keystrokes; dismissing the sheet stops the poll)."
private static let pollInterval: Duration = .seconds(2)
var body: some View {
+7
View File
@@ -424,6 +424,13 @@ private struct BoardGitAddAction: View {
.fixedSize(horizontal: false, vertical: true)
}
}
// **The form add-git answers at** (06 Interaction with external writers, ruled 2026-07-31
// "Form-anchored operations answer at the form first"; its container moved to the board
// settings sheet in the 2026-07-31 split, and these two lines are what that sheet re-points).
// Appearing claims the inline surface; disappearing gives it up, which both dismisses the
// stale error and sends any answer still in flight to the banner instead of to nobody.
.onAppear { git.noteFormVisible(true) }
.onDisappear { git.noteFormVisible(false) }
}
}
+20 -39
View File
@@ -43,12 +43,18 @@ import Observation
/// `NSUndoManager` of its own, which is what makes it session-scoped). What lives here is the other
/// half of 05 Edit's undo sentence: the **session**, whose end is the effective Save.
/// `endEditSession()` is that moment the EditPreview flip, raw-source entry, or the window
/// closing and it is deliberately a named call rather than a side effect of `flush()`, because
/// pro-m1's auto-commit coalesces exactly here: every debounced tick inside one session rides its
/// own `performWrite` bracket, and the committer's rule is one commit per *session*, "never per save
/// tick" (06-history-undo.md Rules Auto-commit). In the free tier there is no committer, so
/// the two calls do the same work today; the seam is what keeps them from having to be pulled apart
/// later.
/// closing and it is deliberately a named call rather than a side effect of `flush()`, because it
/// is where this session's **one undo step** is registered: every debounced tick inside a session
/// rides its own `performWrite` bracket, and 13-native-undo.md's rule is one step per session, never
/// per save tick (`registerUndo`).
///
/// **The commit boundary is no longer here** (06-history-undo.md Rules Auto-commit, widened
/// 2026-07-31 "Board history sees card-window sessions, not gestures"): the committer's
/// stage-around used to open and close with this flip, and it now opens with the *window* and
/// releases when the window's session ends, so a comment posted with the body in Preview is inside
/// the same one commit as the body. What this type still owes that model is `isEditing`, read as
/// unsaved-content state rather than announced (`CardWindowHost`); the announcement seam it used to
/// wire went with the widening.
@MainActor
@Observable
public final class CardBodyEditSession {
@@ -110,23 +116,6 @@ public final class CardBodyEditSession {
@ObservationIgnored
public var registerUndo: ((_ priorBody: String, _ newBody: String) -> Void)?
/// **The session boundary, announced** called with `true` when an Edit session opens and
/// `false` when it ends, and with nothing in between.
///
/// **No longer the committer's stage-around boundary** (06-history-undo.md Rules Auto-commit,
/// widened 2026-07-31 "Board history sees card-window sessions, not gestures"): the exclusion
/// used to open and close with this flip, and it now opens with the *window* and releases when its
/// session ends, so a comment posted with the body in Preview is inside the same one commit as the
/// body. `CardWindowHost` therefore no longer wires this to anything, and the EditPreview flip is
/// a save point rather than a commit point.
///
/// The seam stays, unwired, because it is the only announcement of the boundary this type makes
/// and the ordering it carries that the flush precedes the announcement is a property worth
/// keeping proved (`AutoCommitTests`). A closure for `save`'s reason exactly: this type is a
/// buffer and a clock, and it stays testable by having no idea what a repository is.
@ObservationIgnored
public var editSessionDidChange: ((_ isEditing: Bool) -> Void)?
/// What disk said before this session's **first** landed save the step's before-value, held
/// from the first write until the session ends.
///
@@ -194,16 +183,16 @@ public final class CardBodyEditSession {
/// because its card's body was empty (`CardBodyMode.opening(body:)`).
///
/// Idempotent, because the mode can be re-asserted by a menu validation pass or a re-published
/// focus value, and a second announcement would register a session that is already registered.
/// focus value, and `isEditing` is observed: assigning `true` over `true` would still notify, and
/// the notification is a view rebuild for a session that never changed (`adopt(diskBody:)`'s
/// equal-assignment note, one property along).
public func beginEditSession() {
guard !isEditing else { return }
isEditing = true
editSessionDidChange?(true)
}
/// The end of one Edit session the flip back to Preview, raw-source entry, or the window
/// closing. Flushes, and marks the boundary pro-m1's auto-commit coalesces on (see the type's
/// doc comment).
/// closing. Flushes, then registers the session's one undo step (see the type's doc comment).
@discardableResult
public func endEditSession() -> CardBodyWriteOutcome {
let outcome = flush()
@@ -216,14 +205,9 @@ public final class CardBodyEditSession {
registerUndo?(origin, disk)
}
sessionOriginBody = nil
// **Last**, after the flush and after the undo step: the committer's nudge must find the
// session's final bytes already on disk, or the commit it arms would carry the file as it
// stood one keystroke ago. Guarded on `isEditing` so a window closing from Preview which
// calls this too, and should announces nothing.
if isEditing {
isEditing = false
editSessionDidChange?(false)
}
// Guarded on `isEditing` for `beginEditSession`'s reason a window closing from Preview
// calls this too, and should, but has no session flag to move.
if isEditing { isEditing = false }
return outcome
}
@@ -243,10 +227,7 @@ public final class CardBodyEditSession {
cancelPending()
text = disk
sessionOriginBody = nil
if isEditing {
isEditing = false
editSessionDidChange?(false)
}
if isEditing { isEditing = false }
}
/// `DirtyBufferGuard`'s `attemptSave`: the same flush, with a real failure raised instead of
+8
View File
@@ -105,6 +105,14 @@ public final class CardComments {
/// The crash-residue sweep, run once when the window opens
/// `BoardStore.sweepCommentTrashResidue(inCard:)`.
///
/// **It is a sweep of *residue*, not of the trash** (13-native-undo.md Interaction with the
/// trash, ruled 2026-07-31): content a live board step still restores from is that step's backing
/// and is left standing, so reopening a card can never destroy the last session's undo. **Nor the
/// bytes a collision left**, since the same day's skip-purge decoupling: a coarse step popped as
/// stale applied nothing and destroys nothing, and what it was holding stands until the board
/// session ends. The gate lives store-side, where the stack is; this seam is unchanged and still
/// called exactly once, by `open()`.
@ObservationIgnored
public var sweepTrashResidue: (() -> Void)?
+33
View File
@@ -22,10 +22,18 @@ import Testing
// MARK: - Shared fixtures
/// A one-lane board enough tree that a reload has something to walk.
///
/// It carries the **seeded `.gitignore`**, which is what any board the app has opened once looks
/// like (06-history-undo.md Repository hygiene, re-ruled 2026-07-31). Without it the store's own
/// seeding heal which runs on every successful reload beside this file's guide refresh would
/// write that file on the first reload and open a bracket of its own, and the bracket counts below
/// would stop being claims about the guide. (`LooseFileRelocationTests`' fixture carries the guide
/// for the mirror-image reason.)
@MainActor
private func makeBoard() throws -> WriterFixture {
let fixture = try WriterFixture()
try fixture.item("", Item.board)
try fixture.file(IntegrityRules.gitignoreFileName, Data(BoardWriter.gitignoreSeed.utf8))
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
return fixture
}
@@ -721,6 +729,31 @@ struct AgentGuideContentTests {
#expect(content.contains("quoted value left unclosed across a"))
}
/// **v10: the zero-read minimum** (01-storage-format.md § Frontmatter and § Ordering, re-ruled
/// 2026-07-31). 08-agent-integration.md's masterplan requirement "filing a card must need
/// nothing but the schema" was untrue while a card needed a rank, because a rank needed a scan
/// of every sibling in the lane. The guide has to teach both halves: the short form is legal and
/// lands at the bottom, and *writing* `order` is still the only way to choose a position.
@Test("v10 teaches optional keys and the zero-read minimum")
func v10OptionalKeyVocabularyIsPresent() {
let content = AgentGuide.content
// The keys are optional below the root, and the root's `schema` is not.
#expect(content.contains("**required at the board's own `index.md`**"))
#expect(content.contains("optional below it — a lane or card without one is read as schema 1"))
#expect(content.contains("**optional, and the way to control position**"))
// The minimum card, and where it lands.
#expect(content.contains("**You can also file a card without reading the lane at all.**"))
#expect(content.contains("no `order`, no `schema`"))
#expect(content.contains("It lands at the bottom of the lane"))
#expect(content.contains("the app\nwrites a real `order` into it"))
// Reading order names the rule the minimum card depends on.
#expect(content.contains("An item with no\n `order` sorts after every item that has one"))
// Hard rules no longer calls either key required below the root.
#expect(content.contains("`schema` and `order` are optional and a\n missing one is read, never refused"))
#expect(!content.contains("Lanes and cards additionally require `order`"))
#expect(!content.contains("plus `order` on lanes and"))
}
/// The pathfinder's guide taught `media/` and tombstone deletes; both are retired
/// (01-storage-format.md Changes from the pathfinder schema; Deletion). The one legitimate
/// mention of `deleted:` is the warning never to write it.
+138
View File
@@ -62,6 +62,49 @@ private func openBoard(_ model: AppModel, at url: URL) throws -> BoardWindowRef
return ref
}
/// The same board with a **real repository** at its root, root commit and all what a Pro session
/// detects as mode `git`, and therefore the only shape that composes a branch switcher to test the
/// settle step's seams through.
@MainActor
private func makeProGitBoard() throws -> WriterFixture {
let fixture = try makeMixedBoard()
guard case .success = GitRepository.create(at: fixture.root) else {
fixture.tearDown()
Issue.record("could not initialize a repository for the fixture board")
throw CocoaError(.fileWriteUnknown)
}
return fixture
}
/// Opens a card window the way its host does registered with the board's session and leaves it
/// holding one fine step and an open Edit session, so it both *has* a stack to lose and answers the
/// save-or-discard step's `needsSettling` with `true`.
///
/// The step is registered through `BoardStore.registerStep` rather than pushed onto the provider, so
/// it is routed by the same line production routes a card-window gesture with (`on:` the window's
/// stack) and carries the raw write the close fold would look for.
@MainActor
@discardableResult
private func openCardWindow(
_ model: AppModel,
board: BoardWindowRef,
card id: String,
store: BoardStore
) -> CardWindowSession {
let window = CardWindowSession()
model.registerCardWindow(CardWindowRef(board: board, cardID: ItemID(rawValue: id)), session: window)
window.body.beginEditSession()
store.registerStep(
"Edit Card",
on: window.undo,
undoExpects: [.present(.card(ItemID(rawValue: id)), .body("after\n"))],
redoExpects: [.present(.card(ItemID(rawValue: id)), .body("before\n"))],
undo: { _ in },
redo: { _ in }
)
return window
}
// MARK: - Tests
@MainActor
@@ -276,6 +319,101 @@ struct AppModelTests {
model.storeRegistry.release(try #require(model.session(for: ref)?.store))
}
// MARK: The branch switch's settle
/// **"The settle also clears each open card window's fine undo stack"** (06-history-undo.md
/// Branch switching, ruled 2026-07-31): "pre-switch steps describe the branch being left Save
/// All and Discard alike end with every window's stack empty the windows stay open, following
/// their cards onto the new branch with fresh stacks."
///
/// This is an `AppModel` test rather than a `GitBranchSwitcher` one because the clear is a fact
/// about the **composition**: the switcher's settle seam, the card-window registry and the stacks
/// themselves only meet in `wireBranchSwitching`, and a switcher wired by hand would be a test
/// asserting its own wiring (the `a381fac` lesson, applied one card later).
@Test(
"The branch switch's settle empties every open card window's fine stack",
arguments: [SessionSettleChoice.saveAll, .discard]
)
func theSettleClearsEveryFineStack(answering choice: SessionSettleChoice) async throws {
let board = try makeProGitBoard()
defer { board.tearDown() }
let (model, tearDown) = try makeModel()
defer { tearDown() }
model.currentTier = { .pro }
let ref = try openBoard(model, at: board.root)
let session = try #require(model.session(for: ref))
let switcher = try #require(session.git?.switcher)
let windows = [Ident.card1, Ident.card2].map { id in
openCardWindow(model, board: ref, card: id, store: session.store)
}
#expect(windows.allSatisfy { $0.undo.stack.canUndo })
#expect(windows.allSatisfy { $0.undo.netEffect() != nil }, "a session with a net effect to fold")
model.settleAsk = { _ in choice }
#expect(await switcher.settleSessions?() == .proceed)
for window in windows {
#expect(!window.undo.stack.canUndo, "the stack describes the branch being left")
#expect(!window.undo.manager.canUndo, "and ⌘Z in that window answers with it")
}
// "The windows stay open, following their cards onto the new branch with fresh stacks."
#expect(model.session(for: ref)?.cardRefs.count == 2)
}
/// **Closing the stack is not closing the window.** The coarse step a card window owes its board is
/// registered at *close*, folded from this stack (13-native-undo.md Rules "Window close
/// coarsens"); a settle clear registers nothing at all, which is exactly what
/// `registerCardSession` answering `false` and the deferred purge staying the caller's says.
@Test("A settle clear registers no coarse step — the fold that would have run finds nothing")
func theClearRegistersNoCoarseStep() async throws {
let board = try makeProGitBoard()
defer { board.tearDown() }
let (model, tearDown) = try makeModel()
defer { tearDown() }
model.currentTier = { .pro }
let ref = try openBoard(model, at: board.root)
let session = try #require(model.session(for: ref))
let switcher = try #require(session.git?.switcher)
let window = openCardWindow(model, board: ref, card: Ident.card1, store: session.store)
model.settleAsk = { _ in .saveAll }
#expect(await switcher.settleSessions?() == .proceed)
#expect(window.undo.netEffect() == nil, "nothing left to fold")
var purged = false
let registered = session.store.registerCardSession(
window.undo,
inCard: ItemID(rawValue: Ident.card1),
retiring: { purged = true }
)
#expect(!registered, "a close arriving right after the switch registers nothing")
#expect(!purged, "and the deferred purge is still the caller's, not a step's")
}
/// "**Cancel** keeps the current branch and the sessions" and now their stacks with them. The
/// same `if` that withholds the staging release withholds this.
@Test("Cancel clears nothing")
func cancelKeepsTheFineStacks() async throws {
let board = try makeProGitBoard()
defer { board.tearDown() }
let (model, tearDown) = try makeModel()
defer { tearDown() }
model.currentTier = { .pro }
let ref = try openBoard(model, at: board.root)
let session = try #require(model.session(for: ref))
let switcher = try #require(session.git?.switcher)
let window = openCardWindow(model, board: ref, card: Ident.card1, store: session.store)
model.settleAsk = { _ in .cancel }
#expect(await switcher.settleSessions?() == .cancelled)
#expect(window.undo.stack.canUndo)
#expect(window.undo.netEffect() != nil)
}
// MARK: Launch restoration
/// App Settings's "Restore open boards at launch" (11-command-nexus.md) gates the flagged set
+289 -44
View File
@@ -414,6 +414,17 @@ struct AutoCommitAttributionTests {
#expect(committer.commitCount == 2, "the heal's paths commit separately — the split's third class")
#expect(isClean(at: fixture.root))
// **And it is authored by the third pinned synthetic** (06 Commit messages Healing
// mutations commit separately, ruled 2026-07-31): "a heal is a third origin not the user's
// gesture, not a foreign writer and the separation exists for audit, so the trail filters by
// author like every origin; the committer stays the user."
let log = try history(at: fixture.root)
let user = GitCommitOperation.userIdentity(at: fixture.root)
#expect(log[0].authorEmail == user.email, "the user's own write stays the user's")
#expect(log[1].authorName == CommitAttribution.integrityAuthorName)
#expect(log[1].authorEmail == CommitAttribution.integrityAuthorEmail)
#expect(log[1].committerName == user.name, "the committer is always the user")
}
@Test("The app's own delete is the user's, not an agent's")
@@ -553,6 +564,70 @@ struct AutoCommitStageAroundTests {
#expect(committer.commitCount == 0)
#expect(committer.lastFailure == nil, "an empty window is a no-op, never a failure")
}
/// **A close flush queues behind an in-flight flush rather than skipping it** (06 Rules
/// Auto-commit: "nothing settled is ever left unsaved or uncommitted by closing").
///
/// The interleaving is the close sequence's own, forced rather than waited for. `endCardSession`
/// releases the stage-around **and arms a fresh debounce**, and `CloseFlushCoordinator` then
/// spends its card-drain deadline before reaching `committerFlush` two intervals that are both
/// two seconds, so in practice the debounce fired into the drain's last moments about half the
/// time. What made that a defect rather than a coin toss is what the debounced flush had already
/// planned: a commit whose exclusion list still held the session's folder. Skipping behind it left
/// the session uncommitted *permanently* teardown stops the committer, and there is no later
/// flush anywhere.
///
/// So the flush in flight here is deliberately one that planned **with** the exclusion, and the
/// release happens while it is still running. Before the fix this test's `flushNow()` returned
/// having done nothing and the card's body stayed dirty forever.
@Test("A flush asked for while one is in flight waits for it, and commits what it was asked to")
func anExplicitFlushIsNeverDroppedBehindAnInFlightOne() async throws {
let (fixture, git, _) = try await makeGitBoard()
defer { fixture.tearDown() }
let committer = try quickCommitter(git)
// The one point inside a flush that is both off the main actor and injectable: composing.
// It holds the flush open long enough for the close to arrive underneath it.
committer.composer = SlowComposer(delay: 0.4)
let token = UUID()
committer.beginCardSession(token) { fixture.url("\(Ident.lane1)/\(Ident.card1)") }
// The session's uncommitted work, held by the stage-around
try fixture.item("\(Ident.lane1)/\(Ident.card1)",
plain(order: "1024", title: "First", body: "typed and never committed"))
// and a board change beside it, so the debounced flush has something to compose slowly about
// rather than answering `nothingToCommit` before it ever reaches the composer.
try fixture.item(Ident.lane2, plain(order: "2048", title: "Doing"))
// Arm the debounce and let it fire: from here until the composer returns, a flush is in
// flight, and it planned its commit while the session folder was still excluded.
committer.noteReloadLanded(sawForeignChange: true)
try await waitUntil { committer.isCommitInFlight }
// The close sequence, arriving underneath it: the session ends, its folder is released, and
// the coordinator asks for the flush that must not be lost.
committer.endCardSession(token)
await committer.flushNow()
#expect(isClean(at: fixture.root),
"the close flush waited its turn and committed the session it was asked to")
#expect(GitRepository.trackedPaths(at: fixture.root)
.contains("\(Ident.lane1)/\(Ident.card1)/\(BoardLoader.indexFileName)"))
}
}
/// A composer that takes its time, so a test can hold a flush open and drive the close sequence into
/// the gap. Everything else about it is the real one this suite asserts *when* a commit exists, and
/// a fake message would make the commits it reads back unrecognisable.
private struct SlowComposer: CommitMessageComposing {
let delay: TimeInterval
func message(for request: CommitMessageRequest) -> String {
// Blocking, deliberately: this runs on the flush's own detached task, and what the test needs
// held open is that task rather than the actor the close sequence is running on.
Thread.sleep(forTimeInterval: delay)
return CommitMessageEngine.message(for: request)
}
}
// MARK: - Contention, holds, and failure
@@ -823,29 +898,155 @@ struct AutoCommitCompositionTests {
}
}
// MARK: - The Edit-session boundary
// MARK: - The composition root
/// **The wired-at-`beginSession` seams, pinned where they are wired** (02-architecture.md Layering;
/// 12-editions.md The provider seam).
///
/// Every suite above composes its own committer by hand, which is what makes them readable and is
/// exactly why they cannot see the defect this suite exists for: `AppModel.beginSession` once composed
/// the committer *without* the store's `EchoLedger` (`HistoryStore.compose`'s default is a fresh one,
/// for the store-less callers), so every unit layer passed while every production commit misattributed
/// the app's own writes arriving unvouched-for and authored `Lanework External`. It was fixed in
/// `a381fac` by passing `store.echoes`, and nothing but a test that opens a board *through the model*
/// could have caught it or can keep it caught.
///
/// So the assertions here are about the **composition** and never about the units: not "the ledger
/// classifies" (`AutoCommitAttributionTests`) and not "a bracket announces at completion"
/// (`BoardAnnouncerTests`), but that a board opened the way a window opens one has those two wires in
/// it.
@MainActor
@Suite("Auto-commit ▸ the Edit-session boundary")
@Suite("Auto-commit ▸ the composition root")
struct AutoCommitCompositionRootTests {
/// An `AppModel` whose app-side state lives in temp rather than in the app's real Application
/// Support home `AppModelTests`' own fixture, for its reason.
private func makeModel() throws -> (model: AppModel, tearDown: () -> Void) {
let folder = FileManager.default.temporaryDirectory
.appendingPathComponent("AutoCommitCompositionTests-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
let model = AppModel(
registryStorageURL: folder.appendingPathComponent("board-registry.json"),
clipboardStagingRoot: folder.appendingPathComponent("Clipboard", isDirectory: true)
)
model.currentTier = { .pro }
return (model, { try? FileManager.default.removeItem(at: folder) })
}
/// Opens a board the way `BoardWindowHost` does record, acquire, flag, begin so what is under
/// test is the real `beginSession` and not a hand-assembled session.
private func openBoard(_ model: AppModel, at url: URL) throws -> AppModel.BoardSession {
let ref = BoardWindowRef(url: url)
let recordID = model.boardRegistry.recordOpen(of: url)
let store = try model.storeRegistry.acquire(url)
model.boardRegistry.setOpenNow(id: recordID)
model.beginSession(ref: ref, store: store, recordID: recordID, access: nil)
return try #require(model.session(for: ref))
}
/// **(a) The committer is composed with the session store's own ledger** the same instance the
/// store's writes drop receipts into (`BoardStore.echoes`).
///
/// Asserted through the one thing the ledger decides: **authorship**. An ordinary app-mediated
/// write through the store, committed by the session's own committer, is authored by this
/// machine's user. Composed with any *other* ledger it would be authored `Lanework External`
/// which is not a hypothetical shape, it is what `AutoCommitAttributionTests`'
/// `foreignIsLaneworkExternal` pins for a write nobody vouched for, and what this board's every
/// commit did before `a381fac`.
@Test("beginSession composes the committer with the store's own EchoLedger")
func theCommitterIsComposedWithTheStoresLedger() async throws {
let (fixture, _, _) = try await makeGitBoard()
defer { fixture.tearDown() }
let (model, tearDown) = try makeModel()
defer { tearDown() }
let session = try openBoard(model, at: fixture.root)
let committer = try #require(session.git?.committer)
// Only the explicit flush commits, and it does not sit out a watcher that a temp directory may
// or may not deliver events for: this test is about *who* the commit is by.
committer.stop()
committer.debounceInterval = .seconds(30)
committer.coveringSnapshotDeadline = .milliseconds(50)
committer.coveringSnapshotPollInterval = .milliseconds(5)
// An ordinary write through the store the Writer boundary, receipt and all. Nothing here
// touches the ledger by hand, which is the whole point: the receipt has to travel from the
// store's own ledger to the committer's, and there is only one way for that to be true.
let outcome = session.store.writeCardBody(inCard: ItemID(rawValue: Ident.card1), body: "By the app.\n")
#expect(outcome == .written)
await committer.flushNow()
let head = try #require(try history(at: fixture.root).first)
#expect(head.authorEmail == GitCommitOperation.userIdentity(at: fixture.root).email)
#expect(
head.authorEmail != CommitAttribution.externalAuthorEmail,
"a committer composed over any other ledger would blame the outside world for this write"
)
}
/// **(b) The announcer outlet is bound** the undo restore's bracket runs through the store's
/// `performWholesale(announcing:)`, so its subject reaches `BoardStore.announce`.
///
/// `GitHistoryProvider.runBracketed` is optional and "`nil` runs the work bare, which is what a
/// repository-level test wants" so an unwired seam is silent rather than broken, and every
/// repository-level suite in this file would keep passing over one. What a session owes it is the
/// store's bracket: the watcher suspension, the reload floor that locks the board if the closing
/// reload fails, and 10-accessibility.md's one sentence at completion.
@Test("beginSession binds the restore's bracket to the board's announcer outlet")
func theRestoreBracketReachesTheAnnouncer() async throws {
let (fixture, _, _) = try await makeGitBoard()
defer { fixture.tearDown() }
let (model, tearDown) = try makeModel()
defer { tearDown() }
let session = try openBoard(model, at: fixture.root)
session.git?.committer?.stop()
let store = session.store
let provider = try #require(session.history as? GitHistoryProvider, "a git board binds the git provider")
var spoken: [String] = []
store.announce = { if let phrase = $0 { spoken.append(phrase) } }
let bracket = try #require(provider.runBracketed, "the restore has a bracket to run inside")
await bracket("Undid 'Add card'") {
try? fixture.item("\(Ident.lane1)/\(Ident.card2)", plain(order: "2048", title: "Restored"))
}
// The bracket's closing reload the one it armed, whichever way the operation went.
store.handleWatcherEvent(.treeChanged(.appMediated))
await store.awaitQuiescence()
#expect(spoken == ["Undid 'Add card'"], "the trail's sentence and the spoken one are the same one")
#expect(store.readOnlyLock == nil, "the closing reload succeeded, so nothing is locked")
}
}
// MARK: - The Edit-session flag
/// What the card body still owes the commit model after the stage-around widened to the whole window
/// (06 Rules Auto-commit, 2026-07-31): the **flag**, not an announcement.
///
/// `CardBodyEditSession.editSessionDidChange` was the boundary's announcement, and it went with the
/// widening the exclusion now opens with the window and releases when the window's session ends,
/// so nothing in production ever wired it (`CardWindowHost.configureSession`). What survives is
/// `isEditing`, which the close path reads as part of "does this window hold unsaved content".
@MainActor
@Suite("Auto-commit ▸ the Edit-session flag")
struct EditSessionBoundaryTests {
@Test("Entering and leaving Edit announces the session exactly once each way")
func theBoundaryIsAnnouncedOnce() {
@Test("Entering and leaving Edit moves the flag, and a re-assertion of the mode does not")
func theBoundaryMovesTheFlagOnce() {
let session = CardBodyEditSession()
let presentation = CardBodyPresentation()
presentation.beginEdits = { session.beginEditSession() }
presentation.flushEdits = { session.endEditSession() }
var events: [Bool] = []
session.editSessionDidChange = { events.append($0) }
presentation.setMode(.edit)
presentation.setMode(.edit) // a re-published focus value, a menu validation pass
session.beginEditSession() // idempotent
presentation.setMode(.preview)
presentation.setMode(.preview)
#expect(session.isEditing)
#expect(events == [true, false])
presentation.setMode(.preview)
presentation.setMode(.preview)
#expect(!session.isEditing)
}
@@ -859,40 +1060,6 @@ struct EditSessionBoundaryTests {
#expect(presentation.openIfNeeded(body: "") == .edit)
#expect(session.isEditing)
}
@Test("A window closing from Preview announces nothing")
func closingFromPreviewIsSilent() {
let session = CardBodyEditSession()
var events: [Bool] = []
session.editSessionDidChange = { events.append($0) }
// `CardWindowSession.endSession()` calls this on every close, in Edit or not.
session.endEditSession()
#expect(events.isEmpty)
}
@Test("The session's last keystrokes are on disk before the committer is nudged")
func theFlushPrecedesTheNudge() {
let session = CardBodyEditSession()
var landed: [String] = []
var textAtNudge: String?
session.save = { text in
landed.append(text)
return .written
}
session.editSessionDidChange = { isEditing in
if !isEditing { textAtNudge = landed.last }
}
session.beginEditSession()
session.adopt(diskBody: "before")
session.edited("after")
session.endEditSession()
// A nudge that arrived before the flush would arm a commit carrying the file as it stood one
// keystroke ago.
#expect(textAtNudge == "after")
}
}
// MARK: - Semantic messages, through the whole engine
@@ -1038,6 +1205,84 @@ struct AutoCommitMessageTests {
#expect(try headSubject(at: fixture.root) == "Update 'notes.txt'")
}
// MARK: The covering snapshot
/// One card-window session's worth of state, as the close flush meets it: a change on disk that
/// the app vouched for, and a `store.snapshot` that has not caught up yet.
///
/// The board's two store reads are faked rather than driven through a real `BoardStore`, and
/// deliberately: what is being pinned is *the order the flush reads them in*, which a real
/// watcher would settle by racing rather than by rule. `landsAfterReads` is the reload landing
/// the generation asked for the nth time is the walk that finally covers the write.
private func flushRacingItsReload(
awaitsCoverage: Bool,
landsAfterReads: Int = 3
) async throws -> String? {
let (fixture, git, ledger) = try await makeGitBoard()
defer { fixture.tearDown() }
let committer = try quickCommitter(git)
// Only the explicit flush runs: a debounce firing mid-wait would be a second flush answering
// the question this test is asking of the first.
committer.debounceInterval = .seconds(30)
committer.coveringSnapshotPollInterval = .milliseconds(1)
committer.coveringSnapshotDeadline = .milliseconds(500)
// The board as the app last read it one card, which is what HEAD's tree also says.
var current = try fixture.snapshot()
committer.currentSnapshot = { current }
// The session's write lands on disk, vouched for, with no reload behind it yet.
let text = plain(order: "2048", title: "Second")
try fixture.item("\(Ident.lane1)/\(Ident.card2)", text)
ledger.recordWrite(
at: fixture.url("\(Ident.lane1)/\(Ident.card2)").appendingPathComponent(BoardLoader.indexFileName),
text: text
)
committer.noteWriteBracketClosed()
if awaitsCoverage {
var generation = 0
var reads = 0
committer.awaitReloadQuiescence = {}
committer.snapshotGeneration = {
reads += 1
if reads == landsAfterReads {
current = (try? fixture.snapshot()) ?? current
generation += 1
committer.noteReloadLanded(sawForeignChange: false)
}
return generation
}
}
await committer.flushNow()
return try headSubject(at: fixture.root)
}
/// **"The flush awaits the snapshot that covers it"** (06 Rules Auto-commit, ruled
/// 2026-07-31): "the commit's subject can never be outrun by its own reload".
@Test("A close flush racing a stale snapshot composes from the covering one")
func theFlushAwaitsItsCoveringSnapshot() async throws {
#expect(try await flushRacingItsReload(awaitsCoverage: true) == "Add card 'Second'")
}
/// The same race with the store's two reads unwired the storeless configuration, and what the
/// close flush did before the ruling. The commit still lands (the condition is the *tree*), but
/// its subject describes a board that has not heard about the card it is committing.
@Test("Without the await the subject is the one the stale snapshot could compose — the defect, pinned")
func aStaleSnapshotComposesTheShrug() async throws {
#expect(try await flushRacingItsReload(awaitsCoverage: false) == CommitMessageEngine.unnamedSubject)
}
/// The bound is a bound: a board whose watcher stream never came up has no reload to wait for, and
/// the close path may not hang on one. The commit lands from the snapshot in hand.
@Test("A covering reload that never lands ends the wait rather than the app")
func theWaitIsBounded() async throws {
// The generation never moves, so the wait runs to its (millisecond) deadline and composes.
#expect(try await flushRacingItsReload(awaitsCoverage: true, landsAfterReads: .max)
== CommitMessageEngine.unnamedSubject)
}
}
// MARK: - Attribution, as a pure function
+155 -5
View File
@@ -87,6 +87,13 @@ struct BannerCenterOrderingTests {
)
let operation = InProgressOperation(label: "Pulling…")
let signpost = InfoSignpost(message: "This card changed on the remote")
// The failure class's second shape (settled 2026-07-31) newer than the failed move, so it
// leads the rank the two of them share.
let restore = GitFailureBanner(
operation: .undo,
reason: "could not write to 'index.md': Permission denied",
occurredAt: Date(timeIntervalSince1970: 120)
)
let rows = BannerCenter.rows(
lock: .vanishedRoot,
@@ -95,27 +102,87 @@ struct BannerCenterOrderingTests {
losses: [loss],
suspension: HistorySuspension(reason: "disk full", since: Date(timeIntervalSince1970: 50)),
operations: [operation],
signposts: [signpost]
signposts: [signpost],
gitFailures: [restore]
)
// in-progress (pinned) > read-only lock > reload breakage > one-shot write failures >
// in-progress (pinned) > read-only lock > reload breakage > one-shot failures, both shapes >
// loss rows > commit and attachment failures > passive info rows. The two info classes
// sit at opposite ends of the strip.
#expect(rows.map(\.id) == [
"operation:\(operation.id.uuidString)",
"read-only-lock",
"reload-breakage",
"git-failure:\(restore.id.uuidString)",
"one-shot:\(move.id.uuidString)",
"loss:\(loss.id.uuidString)",
"history-suspension",
"one-shot:\(attachment.id.uuidString)",
"signpost:\(signpost.id.uuidString)",
])
#expect(rows.map(\.tone) == [.info, .error, .error, .error, .warning, .warning, .error, .info])
#expect(rows.map(\.isPinned) == [true, false, false, false, false, false, false, false],
#expect(rows.map(\.tone) == [.info, .error, .error, .error, .error, .warning, .warning, .error, .info])
#expect(rows.map(\.isPinned) == [true, false, false, false, false, false, false, false, false],
"a spinner may never hide behind '+N more' — nothing else is pinned")
}
@Test("Both failure shapes share one rank, interleaved by recency")
func theFailureRankHoldsBothShapes() {
// "Failures rank by what they are, not by which error vocabulary threw them" (02 § The
// banner surface, settled 2026-07-31): the two shapes are one precedence class, so recency
// not vocabulary decides which of them a user reads first.
let oldMove = OneShotBanner(error: error(.move(title: "Old")), occurredAt: Date(timeIntervalSince1970: 1))
let newMove = OneShotBanner(error: error(.move(title: "New")), occurredAt: Date(timeIntervalSince1970: 3))
let oldSwitch = GitFailureBanner(
operation: .branchSwitch,
reason: "your local changes would be overwritten",
occurredAt: Date(timeIntervalSince1970: 2)
)
let newUndo = GitFailureBanner(
operation: .undo,
reason: "the repository is locked",
occurredAt: Date(timeIntervalSince1970: 4)
)
let rows = BannerCenter.rows(
lock: nil,
breakage: nil,
oneShots: [oldMove, newMove],
losses: [LossBanner(message: "Folders can't be attached — 1 skipped")],
suspension: nil,
operations: [],
gitFailures: [oldSwitch, newUndo]
)
#expect(rows.map(\.id).prefix(4) == [
"git-failure:\(newUndo.id.uuidString)",
"one-shot:\(newMove.id.uuidString)",
"git-failure:\(oldSwitch.id.uuidString)",
"one-shot:\(oldMove.id.uuidString)",
])
#expect(rows.map(\.tone) == [.error, .error, .error, .error, .warning],
"and every one of them is a failure, above the warning-tone loss row")
}
@Test("A git failure outranks a loss row however much older it is — the compromise is retired")
func aGitFailureOutranksALossRow() {
// The shipped build posted these as loss rows, which put a failed Z *below* a folder-drop
// notice and painted it warning-tone. Both halves of that are retired (settled 2026-07-31).
let ancient = GitFailureBanner(
operation: .redo,
reason: "the repository is locked",
occurredAt: Date(timeIntervalSince1970: 1)
)
let fresh = LossBanner(message: "Folders can't be attached — 2 skipped", occurredAt: Date(timeIntervalSince1970: 900))
let rows = BannerCenter.rows(
lock: nil, breakage: nil, oneShots: [], losses: [fresh], suspension: nil, operations: [],
gitFailures: [ancient]
)
#expect(rows.map(\.id) == ["git-failure:\(ancient.id.uuidString)", "loss:\(fresh.id.uuidString)"])
#expect(rows.map(\.tone) == [.error, .warning])
}
@Test("An attachment failure ranks below other one-shots even when it is newer")
func attachmentFailuresRankLast() {
let attachment = OneShotBanner(
@@ -294,12 +361,40 @@ struct BannerCenterLifecycleTests {
#expect(center.losses.count == 1, "a loss survives everything except its own dismissal")
}
@Test("Dismissing all dismissable rows clears losses along with one-shots and signposts")
@Test("A git failure dismisses individually and is untimed — the one-shot's lifecycle exactly")
func gitFailuresDismissByIDAndNeverExpire() throws {
let center = BannerCenter()
center.postGitFailure(.undo, reason: "the repository is locked")
center.postGitFailure(.branchSwitch, reason: "your local changes would be overwritten")
#expect(center.gitFailures.count == 2)
#expect(center.gitFailures.map(\.operation) == [.branchSwitch, .undo], "newest first on insertion")
let doomed = try #require(center.gitFailures.first)
center.dismiss(doomed.id)
#expect(center.gitFailures.count == 1)
#expect(center.gitFailures.first?.id != doomed.id, "dismissing one must not take its neighbour")
// No timer, no auto-expiry: an error never evaporates unread, whichever vocabulary raised it.
let id = center.beginOperation(label: "Switching to 'main'…", cancel: nil)
center.endOperation(id)
center.suspendHistory(reason: "disk full")
center.clearHistorySuspension()
#expect(center.gitFailures.count == 1, "a failure survives everything except its own dismissal")
// And it is a *failure*, so nothing about it lands in the loss class.
#expect(center.losses.isEmpty)
#expect(center.oneShots.isEmpty)
}
@Test("Dismissing all dismissable rows clears losses along with both failure shapes and signposts")
func dismissAllClearsLosses() {
let center = BannerCenter()
center.postLoss("Pasted 'Fix login' without its 3 attachments")
center.postGitFailure(.redo, reason: "the repository is locked")
center.dismissAllDismissableRows()
#expect(center.losses.isEmpty)
#expect(center.gitFailures.isEmpty)
}
@Test("postSkippedFolders no-ops when nothing was skipped")
@@ -485,8 +580,11 @@ struct BannerRowControlsTests {
let loss = LossBanner(message: "Pasted 'Fix login' without its 3 attachments")
let signpost = InfoSignpost(message: "This card changed on the remote — your edits still win")
let gitFailure = GitFailureBanner(operation: .undo, reason: "the repository is locked")
for (row, id) in [
(BannerRow.oneShot(banner), banner.id),
(BannerRow.gitFailure(gitFailure), gitFailure.id),
(BannerRow.loss(loss), loss.id),
(BannerRow.signpost(signpost), signpost.id),
] {
@@ -522,6 +620,7 @@ struct BannerRowControlsTests {
.readOnlyLock(.vanishedRoot),
.reloadBreakage(BoardLoadError(path: "Todo/index.md", reason: .missingOrder)),
.oneShot(OneShotBanner(error: error(.move(title: "Fix login")))),
.gitFailure(GitFailureBanner(operation: .branchSwitch, reason: "the repository is locked")),
.loss(LossBanner(message: "Pasted 'Fix login' without its 3 attachments")),
.historySuspended(HistorySuspension(reason: "disk full")),
.inProgress(InProgressOperation(label: "Pulling…")),
@@ -612,6 +711,53 @@ struct BannerCenterPhrasingTests {
#expect(uneditable.contains("frontmatter"))
}
@Test("Every git operation names itself in the user's words, with the error as the tail")
func everyGitOperationSaysSomethingDistinct() {
// The vocabulary is closed and the sentences are here, not at the call sites (02 § The
// banner surface, settled 2026-07-31: "the operation named in the user's words plus the
// underlying error, phrasing still BannerCenter's"). `CaseIterable` is what keeps this test
// honest when pro-m2 adds pull and push.
let headlines = GitOperation.allCases.map {
BannerCenter.headline(for: GitFailureBanner(operation: $0, reason: "the repository is locked"))
}
for (operation, headline) in zip(GitOperation.allCases, headlines) {
#expect(!headline.isEmpty, "\(operation) has no headline")
#expect(headline.hasSuffix(" — the repository is locked"), "\(operation) drops the underlying error")
#expect(!headline.contains("nil"), "\(operation) leaked an optional into the product's voice")
}
#expect(Set(headlines).count == headlines.count, "two operations share a sentence — one of them is wrong")
}
@Test("The git failure's sentences are the ruling's own")
func gitFailureSentencesArePinned() {
// Pinned as literals, unlike most phrasing here, because 02 wrote these two shapes by hand
// and the third is their mirror: the undo pair names the command the user pressed, the
// switch names the control they used.
#expect(BannerCenter.headline(for: GitFailureBanner(operation: .undo, reason: "the repository is locked"))
== "Undo failed — the repository is locked")
#expect(BannerCenter.headline(for: GitFailureBanner(operation: .redo, reason: "the repository is locked"))
== "Redo failed — the repository is locked")
#expect(BannerCenter.headline(for: GitFailureBanner(
operation: .branchSwitch,
reason: "your local changes would be overwritten"
)) == "Couldn't switch branches — your local changes would be overwritten")
// The tail is trimmed like every other diagnostic tail, and an absent one leaves the action
// clause alone rather than trailing a dash into nothing.
#expect(BannerCenter.headline(for: GitFailureBanner(operation: .undo, reason: " the disk is full. "))
== "Undo failed — the disk is full")
#expect(BannerCenter.headline(for: GitFailureBanner(operation: .undo, reason: " ")) == "Undo failed")
}
@Test("The restore pair maps from the direction the provider crossed in")
func restoreOperationsMapFromDirection() {
// The provider knows which key was pressed and nothing else about banners; this is the whole
// of the translation, kept in one place so no wiring can get it backwards.
#expect(GitOperation.restore(.undo) == .undo)
#expect(GitOperation.restore(.redo) == .redo)
}
@Test("Every lock reason says what is wrong and that the view is still the last good one")
func lockHeadlinesReassure() {
let reasons: [ReadOnlyLockReason] = [.bracketedReloadFailed, .vanishedRoot]
@@ -742,16 +888,20 @@ struct BannerCenterStoreTests {
let store = try BoardStore(rootURL: fixture.root)
store.enterUnwritableLock(.permissionDenied)
store.banners.postGitFailure(.undo, reason: "the working tree is locked")
store.banners.post(BoardWriteError(operation: .createCard, path: "/x", reason: .io(message: "the disk is full")))
store.banners.postLoss("Pasted 'Fix login' without its 3 attachments")
store.banners.suspendHistory(reason: "the disk is full")
store.banners.beginOperation(label: "Duplicating…", cancel: nil)
store.banners.postSignpost("This card changed on the remote")
// The git failure posts before the write failure, so recency (and the tie rule alike)
// puts the write one-shot first within the shared failure rank.
#expect(store.bannerRows.map(\.id) == [
"operation:\(store.banners.operations[0].id.uuidString)",
"read-only-lock",
"one-shot:\(store.banners.oneShots[0].id.uuidString)",
"git-failure:\(store.banners.gitFailures[0].id.uuidString)",
"loss:\(store.banners.losses[0].id.uuidString)",
"history-suspension",
"signpost:\(store.banners.signposts[0].id.uuidString)",
+4 -3
View File
@@ -780,8 +780,9 @@ struct BoardAnnouncerStoreTests {
let log = listen(to: store)
try store.performWholesale(announcing: "Pulled 3 commits") {
// A lane with no `order` fails the whole load (01-storage-format.md § Malformed input).
try fixture.item(lane1, "---\nschema: 1\ntitle: Todo\n---\n\n")
// A lane written by a newer Lanework fails the whole load (01-storage-format.md
// § Malformed input) the fail-fast that survived the 2026-07-31 optional-key ruling.
try fixture.item(lane1, "---\nschema: 99\norder: 1024\ntitle: Todo\n---\n\n")
}
await reload(store, .appMediated)
@@ -800,7 +801,7 @@ struct BoardAnnouncerStoreTests {
let store = try BoardStore(rootURL: fixture.root)
try store.performWholesale {
try fixture.item(lane1, "---\nschema: 1\ntitle: Todo\n---\n\n")
try fixture.item(lane1, "---\nschema: 99\norder: 1024\ntitle: Todo\n---\n\n")
}
await reload(store, .appMediated)
#expect(store.readOnlyLock == .bracketedReloadFailed)
+160 -40
View File
@@ -303,7 +303,8 @@ struct BoardLoaderNonUUIDStrayTests {
try fixture.index("", "schema: 1\n")
try fixture.index(realLane, "schema: 1\norder: 1024\n")
// Missing 'order' would be a fail-fast .missingOrder if this were UUID-shaped.
// A hand-authored lane with a name that isn't identity-shaped: never a candidate, so its
// contents are never read at all.
try fixture.index("todo", "schema: 1\ntitle: Broken hand-authored lane\n")
let result = try BoardLoader.load(boardRoot: fixture.root)
@@ -725,54 +726,25 @@ struct BoardLoaderFailFastTests {
}
}
/// A UUID-shaped folder still fails fast on structurally-bad content the name shape only
/// gates *candidacy*, never the validity of a folder that qualifies.
@Test func missingOrderOnUUIDLaneThrows() throws {
/// A schema newer than the app fails fast **below** the root too the one `schema` rule the
/// optional-key ruling left alone (01-storage-format.md § Malformed input, re-ruled 2026-07-31).
@Test func schemaNewerThanAppOnALaneThrows() throws {
let fixture = try BoardFixture()
defer { fixture.tearDown() }
let lane = uuidFolderName()
try fixture.index("", "schema: 1\n")
try fixture.index(lane, "schema: 1\n")
try fixture.index(lane, "schema: 2\norder: 1024\n")
expectFailure(.missingOrder, path: "\(lane)/index.md") {
expectFailure(.schemaNewerThanApp(found: 2), path: "\(lane)/index.md") {
_ = try BoardLoader.load(boardRoot: fixture.root)
}
}
@Test func malformedOrderOnUUIDLaneThrows() throws {
let fixture = try BoardFixture()
defer { fixture.tearDown() }
let lane = uuidFolderName()
try fixture.index("", "schema: 1\n")
try fixture.index(lane, "schema: 1\norder: not-a-number\n")
expectFailure(.malformedOrder(raw: "not-a-number"), path: "\(lane)/index.md") {
_ = try BoardLoader.load(boardRoot: fixture.root)
}
}
/// A non-finite `order` (`.nan`, `.inf`) is the same loud rejection as a non-numeric one
/// (01-storage-format.md § Frontmatter, settled) NaN has no place in the total order the
/// tie-break and midpoint math assume.
@Test func nonFiniteOrderOnUUIDLaneThrows() throws {
let fixture = try BoardFixture()
defer { fixture.tearDown() }
let lane = uuidFolderName()
try fixture.index("", "schema: 1\n")
try fixture.index(lane, "schema: 1\norder: .nan\n")
expectFailure(.malformedOrder(raw: ".nan"), path: "\(lane)/index.md") {
_ = try BoardLoader.load(boardRoot: fixture.root)
}
}
@Test func missingOrderOnUUIDCardThrows() throws {
/// A malformed `schema` below the root still refuses: the ruling made the *absent* key optional,
/// not the unreadable one reading `schema: one` as 1 would be inventing agreement.
@Test func malformedSchemaOnACardThrows() throws {
let fixture = try BoardFixture()
defer { fixture.tearDown() }
@@ -781,14 +753,162 @@ struct BoardLoaderFailFastTests {
try fixture.index("", "schema: 1\n")
try fixture.index(lane, "schema: 1\norder: 1024\n")
try fixture.index("\(lane)/\(card)", "schema: 1\n")
try fixture.index("\(lane)/\(card)", "schema: one\norder: 1024\n")
expectFailure(.missingOrder, path: "\(lane)/\(card)/index.md") {
expectFailure(.malformedSchema(raw: "one"), path: "\(lane)/\(card)/index.md") {
_ = try BoardLoader.load(boardRoot: fixture.root)
}
}
}
// MARK: - `order` and `schema` optional below the board root
/// **The append-at-end reading** (01-storage-format.md § Ordering, re-ruled 2026-07-31): below the
/// board root a missing, null, non-numeric or non-finite `order` is no longer a fail-fast it reads
/// as a rank past every ordered sibling, tie-broken by folder name, and the reading is coerce-tier
/// (logged, bytes preserved). `Fixtures/Valid/optional-keys.kanban` is the disk-backed golden case;
/// these are the synthetic edges.
struct BoardLoaderOptionalOrderTests {
/// The zero-read minimum the ruling exists for: a lane with one ranked card and one card whose
/// whole frontmatter is a title.
@Test func orderlessCardAppendsAfterEveryRankedSibling() throws {
let fixture = try BoardFixture()
defer { fixture.tearDown() }
let lane = "10000000-0000-4000-8000-000000000001"
let ranked = "20000000-0000-4000-8000-000000000001"
// Deliberately the *lower* folder name, so folder order alone would put it first.
let orderless = "10000000-0000-4000-8000-000000000009"
try fixture.index("", "schema: 1\n")
try fixture.index(lane, "schema: 1\norder: 1024\n")
try fixture.index("\(lane)/\(ranked)", "schema: 1\norder: 4096\n")
try fixture.index("\(lane)/\(orderless)", "title: Minimum\n")
let model = try BoardLoader.load(boardRoot: fixture.root).model
#expect(model.lanes[0].cards.map(\.id.rawValue) == [ranked, orderless])
#expect(model.lanes[0].cards.map(\.order) == [4096, 5120])
}
/// Two order-less siblings: folder name decides, and the ranks they read as are `append`'s own
/// ladder which is what lets the Writer stamp them without anything moving.
@Test func twoOrderlessSiblingsSortByFolderName() throws {
let fixture = try BoardFixture()
defer { fixture.tearDown() }
let lane = "10000000-0000-4000-8000-000000000001"
let second = "30000000-0000-4000-8000-000000000002"
let first = "20000000-0000-4000-8000-000000000001"
try fixture.index("", "schema: 1\n")
try fixture.index(lane, "schema: 1\norder: 1024\n")
try fixture.index("\(lane)/\(second)", "schema: 1\n")
try fixture.index("\(lane)/\(first)", "schema: 1\n")
let model = try BoardLoader.load(boardRoot: fixture.root).model
#expect(model.lanes[0].cards.map(\.id.rawValue) == [first, second])
// No ranked sibling at all, so the ladder bases at 0 the empty-container convention.
#expect(model.lanes[0].cards.map(\.order) == [1024, 2048])
}
/// The four unusable shapes are one reading. Each is a coercion carrying the text as written.
@Test func everyUnusableOrderShapeReadsAsAppendAtEnd() throws {
let fixture = try BoardFixture()
defer { fixture.tearDown() }
let lane = "10000000-0000-4000-8000-000000000001"
let anchor = "20000000-0000-4000-8000-000000000000"
let shapes: [(id: String, frontmatter: String, raw: String)] = [
("20000000-0000-4000-8000-000000000001", "schema: 1\n", ""),
("20000000-0000-4000-8000-000000000002", "schema: 1\norder:\n", ""),
("20000000-0000-4000-8000-000000000003", "schema: 1\norder: null\n", "null"),
("20000000-0000-4000-8000-000000000004", "schema: 1\norder: banana\n", "banana"),
("20000000-0000-4000-8000-000000000005", "schema: 1\norder: .nan\n", ".nan"),
("20000000-0000-4000-8000-000000000006", "schema: 1\norder: .inf\n", ".inf"),
]
try fixture.index("", "schema: 1\n")
try fixture.index(lane, "schema: 1\norder: 1024\n")
try fixture.index("\(lane)/\(anchor)", "schema: 1\norder: 2048\n")
for shape in shapes {
try fixture.index("\(lane)/\(shape.id)", shape.frontmatter)
}
let result = try BoardLoader.load(boardRoot: fixture.root)
#expect(result.model.lanes[0].cards.map(\.id.rawValue) == [anchor] + shapes.map(\.id))
#expect(result.model.lanes[0].cards.map(\.order) == [2048, 3072, 4096, 5120, 6144, 7168, 8192])
let coerced = Dictionary(
uniqueKeysWithValues: result.coercedFrontmatter.map { ($0.path, $0.fields) })
for shape in shapes {
#expect(
coerced["\(lane)/\(shape.id)/index.md"] == [CoercedField(key: "order", raw: shape.raw)],
"\(shape.frontmatter) should coerce with raw '\(shape.raw)'"
)
}
}
/// The rule holds one level up: an order-less lane sits right of every ranked one.
@Test func orderlessLaneAppendsAtTheEndOfTheStrip() throws {
let fixture = try BoardFixture()
defer { fixture.tearDown() }
let ranked = "90000000-0000-4000-8000-000000000001"
let orderless = "10000000-0000-4000-8000-000000000002"
try fixture.index("", "schema: 1\n")
try fixture.index(ranked, "schema: 1\norder: 1024\n")
try fixture.index(orderless, "schema: 1\n")
let model = try BoardLoader.load(boardRoot: fixture.root).model
#expect(model.lanes.map(\.id.rawValue) == [ranked, orderless])
#expect(model.lanes.map(\.order) == [1024, 2048])
}
/// A missing `schema` below the root reads as 1 and records a coercion; the **root's** own
/// missing `schema` is still the loud rejection (`missingSchemaThrows` above).
@Test func missingSchemaBelowRootReadsAsOne() throws {
let fixture = try BoardFixture()
defer { fixture.tearDown() }
let lane = uuidFolderName()
let card = uuidFolderName()
try fixture.index("", "schema: 1\n")
try fixture.index(lane, "order: 1024\ntitle: No Schema Lane\n")
try fixture.index("\(lane)/\(card)", "order: 1024\ntitle: No Schema Card\n")
let result = try BoardLoader.load(boardRoot: fixture.root)
#expect(result.model.lanes[0].schema == 1)
#expect(result.model.lanes[0].cards[0].schema == 1)
let paths = Set(result.coercedFrontmatter.map(\.path))
#expect(paths == ["\(lane)/index.md", "\(lane)/\(card)/index.md"])
#expect(result.coercedFrontmatter.allSatisfy { $0.fields == [CoercedField(key: "schema", raw: "")] })
}
/// A trash entry without a rank reads like every other order-less file. `order` decides nothing
/// about where a trash row sits `modified` does so this is only about the rank it carries
/// back out on a restore.
@Test func orderlessTrashEntryReadsAsAppendAtEnd() throws {
let fixture = try BoardFixture()
defer { fixture.tearDown() }
let ranked = "20000000-0000-4000-8000-000000000001"
let orderless = "10000000-0000-4000-8000-000000000002"
try fixture.index("", "schema: 1\n")
try fixture.index(".trash/\(ranked)", "schema: 1\nkind: card\norder: 1024\n")
try fixture.index(".trash/\(orderless)", "schema: 1\nkind: card\n")
let model = try BoardLoader.load(boardRoot: fixture.root).model
let byID = Dictionary(uniqueKeysWithValues: model.trash.map { ($0.id.rawValue, $0.order) })
#expect(byID[ranked] == 1024)
#expect(byID[orderless] == 2048)
}
}
// MARK: - Encoding strictness
/// The loader decodes byte-faithfully (no NSString BOM-stripping) so the settled encoding
+202 -27
View File
@@ -465,32 +465,54 @@ struct BoardWriterRenumberTests {
#expect(try fixture.indexData("lane/notes") == stray)
}
/// A renumber runs over loaded, valid children: one broken sibling fails the whole
/// operation, and it fails before anything has been rewritten.
@Test func aChildWithAMalformedOrderFailsTheWholeRenumber() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("lane/\(Child.a)", child(order: "1.0000001", title: "A"))
try fixture.item("lane/\(Child.b)", "---\nschema: 1\norder: banana\ntitle: B\n---\nbody\n")
let untouched = try fixture.indexData("lane/\(Child.a)")
/// **An order-less sibling takes part in the rescale rather than stopping it**
/// (01-storage-format.md § Ordering, re-ruled 2026-07-31): it joins the batch on its
/// append-at-end reading, so it comes out of the renumber holding a real rank and sitting exactly
/// where the board was already drawing it last.
@Test func aChildWithAnUnusableOrderJoinsTheRenumberAtTheEnd() throws {
for unusable in ["order: banana", "order: .nan", "order:"] {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
// `b` sorts after `a` by folder name, but it is the *rank* that puts it last here: `a`
// carries one and `b` does not.
try fixture.item("lane/\(Child.a)", child(order: "1.0000001", title: "A"))
try fixture.item("lane/\(Child.b)", "---\nschema: 1\n\(unusable)\ntitle: B\n---\nbody\n")
let error = writeFailure { try BoardWriter.renumberVisibleChildren(of: fixture.url("lane")) }
#expect(error?.reason == .unreadable(message: "malformed 'order' field: banana"))
#expect(error?.path.contains(Child.b) == true)
#expect(error?.operation == .renumberChildren)
#expect(try fixture.indexData("lane/\(Child.a)") == untouched)
try BoardWriter.renumberVisibleChildren(of: fixture.url("lane"))
#expect(try FrontmatterDocument.parse(fixture.indexText("lane/\(Child.a)")).order == .valid(1024), "\(unusable)")
#expect(try FrontmatterDocument.parse(fixture.indexText("lane/\(Child.b)")).order == .valid(2048), "\(unusable)")
}
}
@Test func aChildWithNoOrderFailsTheWholeRenumber() throws {
/// The order-less sibling sorts *after* every ranked one even when its folder name would put it
/// first the whole of the append-at-end reading, seen through the rescale.
@Test func anOrderlessChildRenumbersLastRegardlessOfFolderName() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("lane/\(Child.a)", "---\nschema: 1\ntitle: A\n---\nbody\n")
try fixture.item("lane/\(Child.b)", child(order: "512", title: "B"))
try fixture.item("lane/\(Child.c)", child(order: "1536", title: "C"))
try BoardWriter.renumberVisibleChildren(of: fixture.url("lane"))
#expect(try FrontmatterDocument.parse(fixture.indexText("lane/\(Child.b)")).order == .valid(1024))
#expect(try FrontmatterDocument.parse(fixture.indexText("lane/\(Child.c)")).order == .valid(2048))
#expect(try FrontmatterDocument.parse(fixture.indexText("lane/\(Child.a)")).order == .valid(3072))
}
/// A renumber still runs over *readable* children: a sibling that refuses writes fails the whole
/// operation before anything has been rewritten.
@Test func aChildWithAnUnreadableIndexFailsTheWholeRenumber() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("lane/\(Child.a)", child(order: "1.0000001", title: "A"))
try fixture.item("lane/\(Child.b)", "---\nschema: 1\ntitle: B\n---\nbody\n")
try fixture.item("lane/\(Child.b)", bytes: Data([0xFF, 0xFE, 0x00]))
let untouched = try fixture.indexData("lane/\(Child.a)")
let error = writeFailure { try BoardWriter.renumberVisibleChildren(of: fixture.url("lane")) }
#expect(error?.reason == .unreadable(message: "missing required 'order' field"))
#expect(error?.path.contains(Child.b) == true)
#expect(error?.operation == .renumberChildren)
#expect(try fixture.indexData("lane/\(Child.a)") == untouched)
}
@@ -515,6 +537,155 @@ struct BoardWriterRenumberTests {
}
}
// MARK: - The rank stamps
/// **The write side of the optional-`order` ruling** (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, and placement math that must rank an item *relative to* an order-less sibling stamps that
/// sibling inline, inside the gesture's bracket and commit."
///
/// Both stamps write `Ranks.resolvedOrders`' own answer the rank the loader was already rendering
/// the file at so every assertion here is also an assertion that nothing moved.
struct BoardWriterRankStampTests {
private func order(_ fixture: WriterFixture, _ path: String) throws -> FieldValue<Double> {
try FrontmatterDocument.parse(fixture.indexText(path)).order
}
/// The on-touch half: any rewrite at all here an ordinary title edit stamps the rank.
@Test func aRewriteOfAnOrderlessFileStampsItsRank() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("\(Ident.lane1)/\(Child.a)", "---\nschema: 1\nkind: card\norder: 1024\ntitle: A\n---\nbody\n")
let folder = try fixture.item("\(Ident.lane1)/\(Child.b)", "---\ntitle: Minimum\n---\nbody\n")
try BoardWriter.updateIndex(inItemFolder: folder, operation: .style(title: nil)) { document in
document.set(FrontmatterKeys.title, to: .string("Renamed"))
}
// 1024 (the ranked sibling) + one gap where the board was already drawing it.
#expect(try order(fixture, "\(Ident.lane1)/\(Child.b)") == .valid(2048))
// The `kind` backfill rides the same write, and the ranked sibling is untouched.
#expect(try FrontmatterDocument.parse(fixture.indexText("\(Ident.lane1)/\(Child.b)")).kind == .valid("card"))
#expect(try order(fixture, "\(Ident.lane1)/\(Child.a)") == .valid(1024))
}
/// An unusable value heals the same way the reading is stated over usability, so the stamp is.
@Test func aRewriteOfAnUnusableOrderStampsOverIt() throws {
for unusable in ["order: banana", "order: .nan"] {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let folder = try fixture.item(
"\(Ident.lane1)/\(Child.a)", "---\nschema: 1\nkind: card\n\(unusable)\n---\nbody\n")
try BoardWriter.updateIndex(inItemFolder: folder, operation: .style(title: nil)) { document in
document.set(FrontmatterKeys.title, to: .string("Renamed"))
}
#expect(try order(fixture, "\(Ident.lane1)/\(Child.a)") == .valid(1024), "\(unusable)")
}
}
/// **The board root never gains a rank** it has no siblings to sit among, and `order` is
/// meaningless there.
@Test func theBoardRootIsNeverStamped() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let root = try fixture.item("board", "---\nschema: 1\nkind: board\ntitle: Board\n---\nbody\n")
try BoardWriter.updateIndex(inItemFolder: root, kind: .board, operation: .style(title: nil)) { document in
document.set(FrontmatterKeys.title, to: .string("Renamed"))
}
#expect(try order(fixture, "board") == .missing)
}
/// **The inline half**: a rank write is placement math landing, so the container's order-less
/// siblings are stamped with the reading the placement was computed against inside the same
/// call, which is inside the caller's bracket and commit.
@Test func aRankWriteStampsItsOrderlessSiblingsInline() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("\(Ident.lane1)/\(Child.a)", "---\nschema: 1\nkind: card\norder: 1024\ntitle: A\n---\nbody\n")
try fixture.item("\(Ident.lane1)/\(Child.b)", "---\nschema: 1\nkind: card\ntitle: B\n---\nbody\n")
let c = try fixture.item("\(Ident.lane1)/\(Child.c)", "---\nschema: 1\nkind: card\ntitle: C\n---\nbody\n")
// The reading is A=1024, B=2048, C=3072. A drop between B and C is their midpoint an
// answer that is only true on disk if B and C hold those ranks.
try BoardWriter.updateIndex(inItemFolder: c, operation: .reorder(title: nil)) { document in
document.set(FrontmatterKeys.order, to: .double(2560))
}
#expect(try order(fixture, "\(Ident.lane1)/\(Child.a)") == .valid(1024))
#expect(try order(fixture, "\(Ident.lane1)/\(Child.b)") == .valid(2048))
#expect(try order(fixture, "\(Ident.lane1)/\(Child.c)") == .valid(2560))
}
/// The pass is idempotent and silent on a board this app wrote: nothing order-less, nothing
/// written, and no sibling's bytes touched.
@Test func aRankWriteTouchesNoRankedSibling() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("\(Ident.lane1)/\(Child.a)", "---\nschema: 1\nkind: card\norder: 1024\ntitle: A\n---\nbody\n")
let b = try fixture.item("\(Ident.lane1)/\(Child.b)", "---\nschema: 1\nkind: card\norder: 2048\ntitle: B\n---\nbody\n")
let untouched = try fixture.indexData("\(Ident.lane1)/\(Child.a)")
try BoardWriter.updateIndex(inItemFolder: b, operation: .reorder(title: nil)) { document in
document.set(FrontmatterKeys.order, to: .double(512))
}
#expect(try fixture.indexData("\(Ident.lane1)/\(Child.a)") == untouched)
}
/// A create appends *after* the order-less sibling rather than above it which needs that
/// sibling stamped first, since `createChild` mints a file instead of rewriting one.
@Test func aCreateStampsTheOrderlessSiblingItAppendsPast() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", "---\nschema: 1\nkind: board\n---\n")
let lane = try fixture.item(Ident.lane1, "---\nschema: 1\nkind: lane\norder: 1024\n---\n")
try fixture.item("\(Ident.lane1)/\(Child.a)", "---\nschema: 1\nkind: card\norder: 1024\ntitle: A\n---\nbody\n")
try fixture.item("\(Ident.lane1)/\(Child.b)", "---\nschema: 1\nkind: card\ntitle: B\n---\nbody\n")
let created = try BoardWriter.createCard(inLane: lane, title: "New")
#expect(try order(fixture, "\(Ident.lane1)/\(Child.b)") == .valid(2048))
#expect(try order(fixture, "\(Ident.lane1)/\(created.rawValue)") == .valid(3072))
let model = try BoardLoader.load(boardRoot: fixture.root).model
#expect(model.lanes[0].cards.map(\.title.value) == ["A", "B", "New"])
}
/// A move into a lane stamps the destination's order-less children **before the folder lands**,
/// so the reading it stamps is the one the caller's placement was computed against not one
/// re-based against the arriving item's own foreign rank.
@Test func aMoveStampsTheDestinationBeforeTheFolderArrives() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", "---\nschema: 1\nkind: board\n---\n")
try fixture.item("\(Ident.lane1)", "---\nschema: 1\nkind: lane\norder: 1024\n---\n")
try fixture.item("\(Ident.lane2)", "---\nschema: 1\nkind: lane\norder: 2048\n---\n")
try fixture.item("\(Ident.lane1)/\(Child.a)", "---\nschema: 1\nkind: card\norder: 1024\ntitle: A\n---\nbody\n")
try fixture.item("\(Ident.lane1)/\(Child.b)", "---\nschema: 1\nkind: card\ntitle: B\n---\nbody\n")
// The arriving card carries a rank far above anything in the destination the value that
// would poison the reading if the stamp ran after the move.
try fixture.item("\(Ident.lane2)/\(Child.c)", "---\nschema: 1\nkind: card\norder: 99999\ntitle: C\n---\nbody\n")
// The reading in lane1 is A=1024, B=2048; the drop between them is their midpoint.
_ = try BoardWriter.moveItem(
at: fixture.url("\(Ident.lane2)/\(Child.c)"),
toParent: fixture.url("\(Ident.lane1)"),
sourceBoardRoot: fixture.root,
destinationBoardRoot: fixture.root,
order: 1536
)
#expect(try order(fixture, "\(Ident.lane1)/\(Child.b)") == .valid(2048))
let model = try BoardLoader.load(boardRoot: fixture.root).model
#expect(model.lanes[0].cards.map(\.title.value) == ["A", "C", "B"])
}
}
// MARK: - Loader integration
struct BoardWriterLoaderIntegrationTests {
@@ -747,20 +918,24 @@ struct BoardWriterCreateChildTests {
#expect(Set(result.model.lanes.map(\.id.rawValue)) == Set([Child.a, newID.rawValue]))
}
@Test func aSiblingWithAMalformedOrderFailsTheCreateNamingTheSibling() throws {
/// **A sibling with an unusable `order` no longer fails the create** (01-storage-format.md
/// § Ordering, re-ruled 2026-07-31): it reads as append-at-end, gets stamped with that reading
/// inline, and the new lane appends past it which is what "append after the current visible
/// siblings" has to mean for the result to survive a reload.
@Test func aSiblingWithAnUnusableOrderIsStampedAndAppendedPast() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item(Child.a, "---\nschema: 1\norder: banana\ntitle: A\n---\nbody\n")
let before = try fixture.entryNames("")
try fixture.item("", "---\nschema: 1\nkind: board\ntitle: Board\n---\n")
try fixture.item(Ident.lane1, "---\nschema: 1\norder: 1024\ntitle: Ranked\n---\nbody\n")
try fixture.item(Ident.lane2, "---\nschema: 1\norder: banana\ntitle: Unusable\n---\nbody\n")
let error = writeFailure {
_ = try BoardWriter.createLane(inBoard: fixture.root, title: "New")
}
#expect(error?.reason == .unreadable(message: "malformed 'order' field: banana"))
#expect(error?.path.contains(Child.a) == true)
#expect(error?.operation == .createLane)
// Nothing was minted: the scan fails before the new folder is ever created.
#expect(try fixture.entryNames("") == before)
let newID = try BoardWriter.createLane(inBoard: fixture.root, title: "New")
#expect(try FrontmatterDocument.parse(fixture.indexText(Ident.lane2)).order == .valid(2048))
#expect(try FrontmatterDocument.parse(fixture.indexText(newID.rawValue)).order == .valid(3072))
let model = try BoardLoader.load(boardRoot: fixture.root).model
#expect(model.lanes.map(\.title.value) == ["Ranked", "Unusable", "New"])
}
@Test func aMissingParentFolderIsALoudUnreadableError() throws {
+191 -18
View File
@@ -33,13 +33,31 @@ private struct Window {
private let cardID = ItemID(rawValue: Ident.card1)
private let cardPath = "\(Ident.lane1)/\(Ident.card1)"
/// **The coarse close step's row, spelled once** "Changes to 'card'" (13-native-undo.md Rules,
/// ruled 2026-07-31), over the fixture card's own title. Pinned as a value here and asserted verbatim
/// in `theCoarseStepNamesItsCard` below, so a suite that reads the phrase eleven times still only
/// *decides* it once.
private let coarseStep = "Changes to 'Fix login'"
/// A card window over a board with one card, wired exactly as `CardWindowHost` wires one.
@MainActor
private func makeWindow(_ fixture: WriterFixture) throws -> Window {
let store = try BoardStore(rootURL: fixture.root)
let board = NativeHistoryProvider()
store.history = board
return try openWindow(fixture, store: store, board: board)
}
/// **The same card opened again, over the board that is already holding the last session's step**
/// the reopen 13's sweep gate is about (ruled 2026-07-31): a second window is a second session with
/// its own empty stack, and the *board's* stack is the one that survived the close.
@MainActor
private func reopen(_ window: Window, _ fixture: WriterFixture) throws -> Window {
try openWindow(fixture, store: window.store, board: window.board)
}
@MainActor
private func openWindow(_ fixture: WriterFixture, store: BoardStore, board: NativeHistoryProvider) throws -> Window {
let session = CardWindowSession()
CardWindowHost.configureUndo(session, store: store, cardID: cardID)
CardWindowHost.configureComments(session.comments, store: store, cardID: cardID, on: session.undo)
@@ -221,7 +239,7 @@ struct CardSessionCloseTests {
// One step, named for the session rather than for any gesture inside it.
#expect(window.board.canUndo)
#expect(window.board.undoActionName == "Edit Card")
#expect(window.board.undoActionName == coarseStep)
window.board.undo()
#expect(!window.board.canUndo, "exactly one")
@@ -238,6 +256,31 @@ struct CardSessionCloseTests {
#expect(fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)"))
}
@Test("The coarse step is named for its card — 'Changes to ⟨title⟩', never the fine 'Edit Card'")
func theCoarseStepNamesItsCard() async throws {
// 13-native-undo.md Rules, ruled 2026-07-31: "one coarse step named 'Changes to card'
// the board row reads 'Undo Changes to Fix login': plural and scope-flavoured, distinct from
// every fine verb the fine body-edit wording never leaks onto the board menu."
let fixture = try WriterFixture()
defer { fixture.tearDown() }
_ = try makeCommentBoard(fixture)
let window = try makeWindow(fixture)
editBody(window, to: "Edited in the window.\n")
#expect(window.window.stack.undoActionName == "Edit Card",
"the fine body edit keeps its verb — this is the collision the ruling resolves")
await window.session.endSession()
#expect(window.board.undoActionName == "Changes to 'Fix login'")
#expect(window.board.undoActionName == coarseStep)
// The "Undo " prefix is the platform's, composed over the bare phrase (`BoardUndoManager`).
#expect(BoardUndoManager(history: window.board).undoMenuItemTitle == "Undo Changes to 'Fix login'")
// An untitled card renders the placeholder its own window title bar renders "Untitled" is a
// rendering, never a value (03-board-ui.md § Card face).
#expect(HistoryPhrase.cardSession(nil) == "Changes to 'Untitled'")
}
@Test("A session with no net change registers nothing")
func noNetChangeRegistersNothing() async throws {
let fixture = try WriterFixture()
@@ -291,7 +334,7 @@ struct CardSessionCloseTests {
window.comments.commitEdit()
window.comments.delete(ItemID(rawValue: CommentIdent.one))
await window.session.endSession()
#expect(window.board.undoActionName == "Edit Card")
#expect(window.board.undoActionName == coarseStep)
window.board.undo()
#expect(window.store.banners.signposts.isEmpty, "the session's own step is never stale on arrival")
@@ -340,7 +383,7 @@ struct CardSessionCloseTests {
// "A tracked relocation a lane move mid-session or after close, **a trash move** never
// stales the step" (13 Rules, ruled 2026-07-31): the session resolves through the walk that
// spans both containers, so the step registers over the delete rather than being dropped.
#expect(window.board.undoActionName == "Edit Card")
#expect(window.board.undoActionName == coarseStep)
window.board.undo()
#expect(window.store.banners.signposts.isEmpty, "nothing about the card's content changed")
@@ -384,7 +427,7 @@ struct CardSessionCloseTests {
#expect(!window.board.canUndo)
await window.session.endSession()
#expect(window.board.undoActionName == "Edit Card")
#expect(window.board.undoActionName == coarseStep)
window.board.undo()
let document = try FrontmatterDocument.parse(fixture.indexText(cardPath))
@@ -500,7 +543,7 @@ struct CardSessionAnchorTests {
#expect(window.board.undoActionName == "Move Card")
await window.session.endSession()
#expect(window.board.undoActionName == "Edit Card", "the session registered over the move")
#expect(window.board.undoActionName == coarseStep, "the session registered over the move")
window.board.undo()
#expect(window.store.banners.signposts.isEmpty, "a relocation is not a collision")
@@ -544,8 +587,8 @@ struct CardSessionAnchorTests {
#expect(try body(fixture, cardPath) == "Somebody else.\n", "never applied over a newer write")
#expect(!fixture.exists("\(cardPath)/comments/\(CommentIdent.one)"),
"and the comment half did not half-happen either")
#expect(try fixture.entryNames("\(cardPath)/comments/.trash").isEmpty,
"the skipped step retired, so the backing it was holding was purged with it")
#expect(fixture.exists("\(cardPath)/comments/.trash/\(CommentIdent.one)"),
"the skip is not a clean exit — the backing it held survives to the session's end")
#expect(!window.board.canUndo, "both steps are gone — one skipped, one applied")
}
@@ -589,6 +632,34 @@ struct CardSessionPurgeTests {
return (window, card)
}
/// The same close, collided with **the coarse step popped as stale, having applied nothing**,
/// which is the state the skip-purge decoupling is about (13 Interaction with the trash, ruled
/// 2026-07-31).
///
/// **The body edit is what makes this a skip at all.** A session of nothing but the delete names
/// only the comment, and a foreign write to the *card* would leave it perfectly current the undo
/// would then apply, restore the comment, and empty the trash by moving its one entry out, which
/// is the same disk state for entirely the wrong reason. With the body in the step, a foreign body
/// rewrite stales it **without touching what the purge would remove**, which is the only way to
/// ask what the skip did to the backing.
@MainActor
private func skippedOnAForeignEdit(_ fixture: WriterFixture) async throws -> (Window, String) {
let card = try makeCommentBoard(fixture)
try fixture.item(commentPath(CommentIdent.one, inCard: card), commentText())
let window = try makeWindow(fixture)
window.comments.reload()
editBody(window, to: "Edited in the window.\n")
window.comments.delete(ItemID(rawValue: CommentIdent.one))
await window.session.endSession()
_ = try BoardWriter.writeBody(inItemFolder: fixture.url(cardPath), body: "Somebody else.\n")
window.board.undo()
#expect(window.store.banners.signposts.map(\.message)
== ["Undo skipped — 'Fix login' changed outside Lanework"])
#expect(!window.board.canUndo, "the stale step was popped")
return (window, card)
}
@Test("comments/.trash survives the close while the coarse step lives")
func theTrashOutlivesTheClose() async throws {
let fixture = try WriterFixture()
@@ -600,6 +671,64 @@ struct CardSessionPurgeTests {
"the step's undo restores from here — the purge waits for it")
}
// MARK: The reopen 13's sweep gate
@Test("Reopening the card window never destroys the last session's undo backing")
func theReopenSweepSparesTheCoarseStepsBacking() async throws {
// The ship-blocker this gate exists for (13 Interaction with the trash, ruled 2026-07-31):
// the open-time residue sweep used to empty `comments/.trash/` unconditionally, so opening the
// card again threw away the folder the board's own coarse step was about to restore from.
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let (window, card) = try await closedWithADeletedComment(fixture)
let path = commentPath(CommentIdent.one, inCard: card)
// The reopen. `CardComments.open()` runs the sweep before it reads the thread, exactly as the
// host wires it so this is the production sequence, not a re-typed copy of it.
_ = try reopen(window, fixture)
#expect(fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)"),
"the sweep asked the board's stack first: this is backing, not residue")
window.board.undo()
#expect(window.store.banners.signposts.isEmpty, "nothing was stale — the backing was still there")
#expect(fixture.exists(path), "the coarse step's undo restored the comment the reopen spared")
}
@Test("Residue no live step owns still sweeps at the open, beside backing that does")
func unownedResidueStillSweeps() async throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let (window, card) = try await closedWithADeletedComment(fixture)
// A crashed session's leftovers, landing beside the live step's backing: nothing on the stack
// names this one, so it is residue by the ruling's own definition.
try fixture.item("\(card)/comments/.trash/\(CommentIdent.two)", commentText())
_ = try reopen(window, fixture)
#expect(!fixture.exists("\(card)/comments/.trash/\(CommentIdent.two)"), "unowned content sweeps as before")
#expect(fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)"), "and owned content is left alone")
#expect(window.store.heals.memo(for: .commentTrashResidue) == nil,
"the signature is the entries actually purged, and it cleared on success")
}
@Test("Once the hold ends, the purge the sweep deferred to runs")
func theRetirementRunsThePurgeTheSweepDeferredTo() async throws {
// "One condition, two consumers" (13): the sweep spared this content because a step owned it,
// and the moment that ownership ends is the moment the deferred purge was always waiting for.
// The arc spared, then released, then purged is what this proves; *which* release ends it
// is the two tests below and the two below the divider.
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let (window, card) = try await closedWithADeletedComment(fixture)
_ = try reopen(window, fixture)
#expect(fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)"), "spared by the reopen's sweep")
window.board.clear()
#expect(try fixture.entryNames("\(card)/comments/.trash").isEmpty,
"and the purge the sweep had deferred to ran with it")
}
@Test("The board session's end purges what the step was holding")
func theBoardSessionsEndPurges() async throws {
let fixture = try WriterFixture()
@@ -611,19 +740,63 @@ struct CardSessionPurgeTests {
#expect(try fixture.entryNames("\(card)/comments/.trash").isEmpty)
}
@Test("A stale step's skip purges too — the step is gone, so its backing is not needed")
func aSkippedStepPurges() async throws {
// MARK: The skip the one exit that is not clean
@Test("A stale skip purges nothing — the backing it was holding survives")
func aSkippedStepsBackingSurvives() async throws {
// The decoupling ruled 2026-07-31 (13 Interaction with the trash), and its reason: "the skip
// banner says nothing was applied, and an irreversible purge riding that gesture would be
// surprise loss the skip is exactly when the user may want to inspect what the collision
// left". This is the pin that used to say the opposite.
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let (window, card) = try await closedWithADeletedComment(fixture)
let (_, card) = try await skippedOnAForeignEdit(fixture)
// A foreign delete of the trashed folder is not the interesting collision; a foreign body
// rewrite is it makes the step stale without touching what the purge would remove.
_ = try BoardWriter.writeBody(inItemFolder: fixture.url(cardPath), body: "Somebody else.\n")
// The session wrote the body too, so the step names it.
window.board.undo()
#expect(!fixture.exists(commentPath(CommentIdent.one, inCard: card)),
"the step skipped rather than applied — nothing was restored")
#expect(fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)"),
"and nothing was destroyed either: the bytes are still there to look at")
}
#expect(!window.board.canUndo)
@Test("Reopening the card after a skip spares the survivor — the same hold, read twice")
func theSweepSparesASkipSurvivor() async throws {
// The half that makes "survives to board-session end" true rather than merely intended: the
// step is off both stacks, so a sweep that read only those would call this residue and purge
// it at the very next open taking the ruling back one window later.
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let (window, card) = try await skippedOnAForeignEdit(fixture)
_ = try reopen(window, fixture)
#expect(fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)"),
"the sweep asked the same hold the purge is still waiting on")
}
@Test("Unowned residue beside a skip's survivor still sweeps")
func residueSweepsBesideASkipSurvivor() async throws {
// The gate did not become "spare everything in there": a stranded step's claim is exactly as
// narrow as a live one's, one anchor at a time.
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let (window, card) = try await skippedOnAForeignEdit(fixture)
try fixture.item("\(card)/comments/.trash/\(CommentIdent.two)", commentText())
_ = try reopen(window, fixture)
#expect(fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)"), "the survivor is owned")
#expect(!fixture.exists("\(card)/comments/.trash/\(CommentIdent.two)"), "the crash leftover is not")
}
@Test("The board session's end purges a skip's survivor too")
func theBoardSessionsEndPurgesASkipSurvivor() async throws {
// "A stale-skipped step's backing instead survives **to board-session end**" the deferral
// has a floor, and it is the same one every other hold has (`AppModel`'s teardown).
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let (window, card) = try await skippedOnAForeignEdit(fixture)
window.board.clear()
#expect(try fixture.entryNames("\(card)/comments/.trash").isEmpty)
}
@@ -668,7 +841,7 @@ struct CardSessionPurgeTests {
// gesture leaves history for good, and its retirement runs (13 Interaction with the trash).
let provider = NativeHistoryProvider()
let retirement = HistoryStep.Retirement {}
provider.register(HistoryStep(name: "Edit Card", retirement: retirement, undo: { _ in .applied }, redo: { _ in .applied }))
provider.register(HistoryStep(name: coarseStep, retirement: retirement, undo: { _ in .applied }, redo: { _ in .applied }))
#expect(retirement.isOwed)
provider.undo()
@@ -683,7 +856,7 @@ struct CardSessionPurgeTests {
var runs = 0
let retirement = HistoryStep.Retirement { runs += 1 }
let provider = NativeHistoryProvider()
provider.register(HistoryStep(name: "Edit Card", retirement: retirement, undo: { _ in .applied }, redo: { _ in .applied }))
provider.register(HistoryStep(name: coarseStep, retirement: retirement, undo: { _ in .applied }, redo: { _ in .applied }))
provider.clear()
provider.clear()
+14 -8
View File
@@ -273,17 +273,23 @@ struct CommentDefectToleranceTests {
@Suite("Comments ▸ the kind: comment field table")
struct CommentFieldTableTests {
@Test("schema is the only required field — never order, never title")
/// A comment carries no `order` and never gains one ("Ordering is chronology, not ranks"), and
/// since 2026-07-31 its `schema` is optional too it sits below the board root like everything
/// else, so an absent one reads as 1. What still refuses is a schema **newer than this app**,
/// which is the one `schema` rule that is level-blind.
@Test("No field is required — order is meaningless here, schema defaults to 1")
func requiredFields() throws {
#expect(IntegrityRules.requiresOrder(.comment) == false)
let withoutOrder = Data("---\nschema: 1\nkind: comment\n---\nbody\n".utf8)
#expect(throws: Never.self) {
try IntegrityRules.validateIndex(withoutOrder, path: "index.md", kind: .comment, supportedSchema: 1)
for frontmatter in ["schema: 1\nkind: comment", "kind: comment"] {
#expect(throws: Never.self) {
try IntegrityRules.validateIndex(
Data("---\n\(frontmatter)\n---\nbody\n".utf8),
path: "index.md", kind: .comment, supportedSchema: 1
)
}
}
let withoutSchema = Data("---\nkind: comment\n---\nbody\n".utf8)
let newer = Data("---\nschema: 99\nkind: comment\n---\nbody\n".utf8)
#expect(throws: BoardLoadError.self) {
try IntegrityRules.validateIndex(withoutSchema, path: "index.md", kind: .comment, supportedSchema: 1)
try IntegrityRules.validateIndex(newer, path: "index.md", kind: .comment, supportedSchema: 1)
}
}
+27
View File
@@ -402,6 +402,33 @@ struct CommentResidueTests {
#expect(store.heals.memo(for: .commentTrashResidue) == nil, "cleared on success")
}
@Test("Content a live step still backs is not residue — the sweep asks the stack first")
func aLiveStepsBackingIsNotResidue() throws {
// 13-native-undo.md Interaction with the trash, ruled 2026-07-31: "`comments/.trash/`
// content referenced by a live coarse step on the board stack is a step's backing, not
// residue the open-time sweep consults the stack and skips owned content". The gate is over
// *steps*, not over the coarse one by name, so the shortest way to hold a live step is the
// window-less delete (`deleteComment`'s `nil` window, which is the board's own stack).
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
try fixture.item(commentPath(CommentIdent.one, inCard: card), commentText())
try fixture.item("\(card)/comments/.trash/\(CommentIdent.two)", commentText())
let (store, history) = try makeStore(fixture)
let cardID = ItemID(rawValue: Ident.card1)
#expect(store.deleteComment(ItemID(rawValue: CommentIdent.one), inCard: cardID))
store.sweepCommentTrashResidue(inCard: cardID)
#expect(fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)"),
"the delete step's undo is the move back out — this is its backing")
#expect(!fixture.exists("\(card)/comments/.trash/\(CommentIdent.two)"),
"and the entry no step names swept as before")
history.undo()
#expect(fixture.exists(commentPath(CommentIdent.one, inCard: card)), "so ⌘Z still has something to restore")
}
@Test("An open with nothing to sweep rests — no bracket, no memo")
func cleanOpenRests() throws {
let fixture = try WriterFixture()
+178 -6
View File
@@ -109,7 +109,11 @@ private func compose(
isRootCommit: false,
snapshot: try after.snapshot(),
previousSnapshot: try before.snapshot(),
agentGuideText: guideText
agentGuideText: guideText,
// Resolved the way a flush resolves it off the "after" tree, through the committer's own
// reader rather than hand-assembled, for the same reason both snapshots are loaded rather
// than built: a map the flush could never produce would prove nothing about the flush.
commentTimestamps: GitAutoCommitter.commentTimestamps(for: paths, boardRoot: after.root)
))
}
@@ -201,6 +205,37 @@ struct CommitMessageSingleEventTests {
#expect(message == "Remove 'spec.pdf' from card 'Fix login'")
}
@Test("A rewritten attachment composes Replace — never the anonymous path generic")
func replacingAFile() throws {
// Added 2026-07-31: "a changed file under a card's `attachments/` with an unchanged listing is
// a content replacement, named from the path alone". The listing is unchanged here same
// name, new bytes so the snapshot diff has nothing to say and the path says it instead.
let message = try compose { fixture in
try baseBoard(fixture)
try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/photo.png", Data("old".utf8))
} change: { fixture in
try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/photo.png", Data("new".utf8))
}
#expect(message == "Replace attachment 'photo.png' — card 'Fix login'")
}
@Test("Two replaced attachments on one card fold plural, still naming the card")
func replacingSeveralFiles() throws {
let message = try compose { fixture in
try baseBoard(fixture)
try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/photo.png", Data("old".utf8))
try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/spec.pdf", Data("old".utf8))
} change: { fixture in
try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/photo.png", Data("new".utf8))
try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/spec.pdf", Data("new".utf8))
}
#expect(subject(of: message) == "Replace 2 attachments — card 'Fix login'")
#expect(body(of: message) == [
"- Replace attachment 'photo.png' — card 'Fix login'",
"- Replace attachment 'spec.pdf' — card 'Fix login'",
])
}
@Test("A repositioned card composes Reorder, naming its lane")
func reorderingCards() throws {
// A *foreign* single-file reorder: one card's rank crosses its sibling's, nothing else
@@ -378,16 +413,56 @@ struct CommitMessageExternalSurfaceTests {
#expect(try withKey("due: 2026-08-31") == "Set due date on card 'Fix login'")
}
@Test("An unmodeled custom key composes a named generic — never a board-level shrug")
func customKeysComposeANamedGeneric() throws {
@Test("An unmodeled custom key says what it is, with its values in the body")
func customKeysSayWhatTheyAre() throws {
// Re-ruled 2026-07-31: the named generic ("Update card 'X'") is retired here "first lines
// self-describe; generics are a last resort". One key, so the singular.
let message = try compose { fixture in
try fixture.item(
"\(Ident.lane1)/\(Ident.card1)",
"---\nschema: 1\ntitle: Fix login\norder: 1024\nsprint: 42\nestimate: 3\n---\n\n"
"---\nschema: 1\ntitle: Fix login\norder: 1024\nsprint: 42\n---\n\n"
)
}
// Two custom keys, one event: the item is what is named, not the keys.
#expect(message == "Update card 'Fix login'")
#expect(subject(of: message) == "Change custom key on card 'Fix login'")
#expect(body(of: message) == ["sprint: (none) → 42"])
}
@Test("Several custom keys fold plural on one item, each named with its old → new values")
func customKeysFoldPlural() throws {
let message = try compose { fixture in
try baseBoard(fixture)
try fixture.item(
"\(Ident.lane1)/\(Ident.card1)",
"---\nschema: 1\ntitle: Fix login\norder: 1024\nsprint: 41\nestimate: 3\n---\n\n"
)
} change: { fixture in
try fixture.item(
"\(Ident.lane1)/\(Ident.card1)",
"---\nschema: 1\ntitle: Fix login\norder: 1024\nsprint: 42\n---\n\n"
)
}
// Two keys, one event the item is still what the subject names and the body carries both
// sides of each, a removal reading as a move to absence.
#expect(subject(of: message) == "Change 2 custom keys on card 'Fix login'")
#expect(body(of: message) == ["estimate: 3 → (none)", "sprint: 41 → 42"])
}
@Test("A lane's and the board's custom keys name their item too — never a board-level shrug")
func customKeysNameLanesAndTheBoard() throws {
let lane = try compose { fixture in
try fixture.item(
Ident.lane1,
"---\nschema: 1\ntitle: Todo\norder: 1024\nwip-limit: 5\n---\n\n"
)
}
#expect(subject(of: lane) == "Change custom key on lane 'Todo'")
// The board is the case 06 calls out by name: "never a board-level shrug when the touched item
// is identifiable" and the board is identifiable, by its own title.
let board = try compose { fixture in
try fixture.item("", "---\nschema: 1\ntitle: Board\nsprint-length: 2w\n---\nBoard description.\n")
}
#expect(subject(of: board) == "Change custom key on board 'Board'")
}
@Test("A foreign change composes identically to an app-mediated one")
@@ -750,6 +825,103 @@ struct CommitMessageCommentTests {
}
#expect(message == "Delete card 'Fix login'")
}
// MARK: The chronology
/// A comment's `index.md` with a `created` stamp the one field the ordering below reads, written
/// in the YAML 1.1 timestamp grammar the loader accepts (`FrontmatterFields.date`).
private static func datedComment(_ body: String, created: String) -> Data {
Data("---\nschema: 1\nkind: comment\nauthor: Ada\ncreated: \(created)\n---\n\(body)\n".utf8)
}
/// **"A commit's comment bullets sort chronologically never UUID-arbitrary"** (06 Rules
/// Auto-commit, blessed 2026-07-31).
///
/// Three comments on one card, each with a *different verb* so the bullets are distinguishable,
/// and identities deliberately ordered against their chronology: the earliest comment carries the
/// middle UUID and the latest carries the smallest. Folder-name order would read Edit, Comment,
/// Delete; the conversation happened in the other order, and that is what the body says.
@Test("Comment bullets read in the order the conversation did, not in UUID order")
func commentBulletsSortByCreated() throws {
let card = "\(Ident.lane1)/\(Ident.card1)"
let message = try compose { fixture in
try baseBoard(fixture)
// Posted first, and edited in this window: the latest `created`, the smallest UUID.
try fixture.file(
Self.thread(Ident.card1, Self.commentA),
Self.datedComment("First draft.", created: "2026-07-31T12:00:00Z")
)
// Deleted in this window, so it exists before and moves into `comments/.trash/`.
try fixture.file(
Self.thread(Ident.card1, Self.commentC),
Self.datedComment("Regretted.", created: "2026-07-31T11:00:00Z")
)
} change: { fixture in
try fixture.file(
Self.thread(Ident.card1, Self.commentA),
Self.datedComment("Second thoughts.", created: "2026-07-31T12:00:00Z")
)
// Posted in this window the earliest `created`, the middle UUID.
try fixture.file(
Self.thread(Ident.card1, Self.commentB),
Self.datedComment("Said hours ago.", created: "2026-07-31T10:00:00Z")
)
try fixture.moveFolder(
"\(card)/comments/\(Self.commentC)",
to: "\(card)/comments/.trash/\(Self.commentC)"
)
}
#expect(body(of: message) == [
"- Comment on 'Fix login'",
"- Delete comment on 'Fix login'",
"- Edit comment on 'Fix login'",
])
}
/// "**folder name on ties**" and the name that breaks the tie is the *comment's* folder, not the
/// composite key the groups are gathered under. Two cards, one timestamp: the comment named
/// `0001` speaks first even though its card sorts second.
@Test("Comments created at the same moment fall back to folder name, never to the card's path")
func tiesFallBackToTheFolderName() throws {
let stamp = "2026-07-31T09:30:00Z"
let message = try compose { fixture in
// On the *first* card, the larger identity.
try fixture.file(
Self.thread(Ident.card1, Self.commentC),
Self.datedComment("On Fix login.", created: stamp)
)
// On the second card, the smaller one.
try fixture.file(
Self.thread(Ident.card2, Self.commentA),
Self.datedComment("On Ship it.", created: stamp)
)
}
#expect(body(of: message) == [
"- Comment on 'Ship it'",
"- Comment on 'Fix login'",
])
}
/// The undated sort **after** the dated `CommentThread.sorted`'s own fallback, applied one layer
/// up. The undated comment here carries the smallest identity, so folder-name order alone would
/// have put it first.
@Test("A comment with no readable created sorts after its dated siblings")
func undatedCommentsSortLast() throws {
let message = try compose { fixture in
try fixture.file(Self.thread(Ident.card1, Self.commentA), Self.commentText("No stamp at all."))
try fixture.file(
Self.thread(Ident.card2, Self.commentB),
Self.datedComment("Stamped.", created: "2026-07-31T08:00:00Z")
)
}
#expect(body(of: message) == [
"- Comment on 'Ship it'",
"- Comment on 'Fix login'",
])
}
}
// MARK: - Repair
+105 -42
View File
@@ -427,6 +427,108 @@ struct FixtureBoardLevelDeletedTests {
}
}
// MARK: - Valid/optional-keys.kanban
/// **The optional-key ruling's golden board** (01-storage-format.md § Frontmatter and § Ordering,
/// re-ruled 2026-07-31): below the board root `order` and `schema` are optional, a missing or
/// unusable `order` reads as append-at-end, and a missing `schema` reads as 1. Every shape that used
/// to have its own board under `Malformed/` lives here instead, as a coercion case.
private enum OptionalKeys {
static let rankedLane = "10000000-0000-4000-8000-000000000001"
static let schemalessLane = "40000000-0000-4000-8000-000000000002"
static let orderlessLane = "30000000-0000-4000-8000-000000000003"
static let rankedCard = "20000000-0000-4000-8000-000000000001"
static let minimumCard = "20000000-0000-4000-8000-000000000002"
static let nullOrderCard = "20000000-0000-4000-8000-000000000003"
static let nonNumericCard = "20000000-0000-4000-8000-000000000004"
static let nonFiniteCard = "20000000-0000-4000-8000-000000000005"
}
struct FixtureOptionalKeysTests {
/// The strip: ranked lanes first in ascending order, then the order-less one and the
/// `schema`-less lane is an ordinary ranked lane, since only its `schema` was absent.
@Test func orderlessLaneSortsAfterEveryRankedOne() throws {
let model = try loadFixture("Valid/optional-keys.kanban").model
#expect(model.lanes.map(\.id.rawValue) == [
OptionalKeys.rankedLane, OptionalKeys.schemalessLane, OptionalKeys.orderlessLane,
])
#expect(model.lanes.map(\.order) == [1024, 2048, 3072])
}
/// Four order-less cards behind one ranked one, in folder-name order the tie-break the ruling
/// states the reading in, and the accepted cost it names ("two order-less siblings sort by UUID
/// rather than by intent until touched").
@Test func orderlessCardsAppendInFolderNameOrder() throws {
let model = try loadFixture("Valid/optional-keys.kanban").model
let lane = try #require(model.lanes.first { $0.id.rawValue == OptionalKeys.rankedLane })
#expect(lane.cards.map(\.id.rawValue) == [
OptionalKeys.rankedCard,
OptionalKeys.minimumCard,
OptionalKeys.nullOrderCard,
OptionalKeys.nonNumericCard,
OptionalKeys.nonFiniteCard,
])
// `append`'s own arithmetic, which is what makes the reading stampable verbatim.
#expect(lane.cards.map(\.order) == [1024, 2048, 3072, 4096, 5120])
}
/// A missing `schema` below the root reads as 1 at both levels.
@Test func missingSchemaBelowTheRootReadsAsOne() throws {
let model = try loadFixture("Valid/optional-keys.kanban").model
let lane = try #require(model.lanes.first { $0.id.rawValue == OptionalKeys.schemalessLane })
#expect(lane.schema == 1)
let ranked = try #require(model.lanes.first { $0.id.rawValue == OptionalKeys.rankedLane })
let minimum = try #require(ranked.cards.first { $0.id.rawValue == OptionalKeys.minimumCard })
#expect(minimum.schema == 1)
#expect(minimum.title == .valid("Minimum Agent Card"))
}
/// Every reading leaves a coerce-tier trace: field, path, and the text as written an absent
/// key having none to record (01-storage-format.md § Frontmatter, the family posture).
@Test func everyReadingIsRecordedAsACoercion() throws {
let result = try loadFixture("Valid/optional-keys.kanban")
let byPath = Dictionary(
uniqueKeysWithValues: result.coercedFrontmatter.map { ($0.path, $0.fields) })
func fields(_ path: String) throws -> [CoercedField] {
try #require(byPath[path], "no coercion recorded for \(path)")
}
#expect(try fields("\(OptionalKeys.orderlessLane)/index.md") == [CoercedField(key: "order", raw: "")])
#expect(try fields("\(OptionalKeys.schemalessLane)/index.md") == [CoercedField(key: "schema", raw: "")])
let lane = OptionalKeys.rankedLane
#expect(try fields("\(lane)/\(OptionalKeys.minimumCard)/index.md") == [
CoercedField(key: "schema", raw: ""), CoercedField(key: "order", raw: ""),
])
#expect(try fields("\(lane)/\(OptionalKeys.nullOrderCard)/index.md")
== [CoercedField(key: "order", raw: "")])
#expect(try fields("\(lane)/\(OptionalKeys.nonNumericCard)/index.md")
== [CoercedField(key: "order", raw: "banana")])
#expect(try fields("\(lane)/\(OptionalKeys.nonFiniteCard)/index.md")
== [CoercedField(key: "order", raw: ".nan")])
// Coerce-tier means read-side only: nothing here is work, so nothing carries a heal class.
#expect(result.defects.allSatisfy { $0.healClass == nil })
}
/// The whole board loads clean no warnings, no fail-fast, and every file byte-identical after
/// a parse/serialize round-trip: the bytes are preserved verbatim, which is the coerce tier's
/// other half.
@Test func loadsWithoutWarningsAndRoundTrips() throws {
let result = try loadFixture("Valid/optional-keys.kanban")
#expect(result.warnings.isEmpty)
for file in try allIndexMdFiles(under: fixtureBoard("Valid/optional-keys.kanban")) {
let text = try String(contentsOf: file, encoding: .utf8)
#expect(try FrontmatterDocument.parse(text).serialized() == text)
}
}
}
// MARK: - Malformed/*.kanban fail-fast cases
struct FixtureMalformedTests {
@@ -436,6 +538,9 @@ struct FixtureMalformedTests {
}
}
/// **The board root's own `schema` is still required** (re-ruled 2026-07-31 the
/// this-really-is-a-board gate). Its below-the-root twin is `FixtureOptionalKeysTests`, where
/// the same absence reads as 1.
@Test func missingSchema() {
expectFixtureFailure("Malformed/missing-schema.kanban", path: "index.md", reasonDescription: "missingSchema") {
$0 == .missingSchema
@@ -450,48 +555,6 @@ struct FixtureMalformedTests {
}
}
@Test func missingOrderOnLane() {
let lane = "10000000-0000-4000-8000-000000000001"
expectFixtureFailure(
"Malformed/missing-order-lane.kanban", path: "\(lane)/index.md", reasonDescription: "missingOrder"
) {
$0 == .missingOrder
}
}
@Test func missingOrderOnCard() {
let lane = "10000000-0000-4000-8000-000000000001"
let card = "20000000-0000-4000-8000-000000000002"
expectFixtureFailure(
"Malformed/missing-order-card.kanban", path: "\(lane)/\(card)/index.md", reasonDescription: "missingOrder"
) {
$0 == .missingOrder
}
}
/// Explicit null reads as missing (01-storage-format.md § Malformed input): `order:` with
/// nothing after it fails the same way a missing key does, not as `.malformedOrder`.
@Test func explicitNullOrderReadsAsMissing() {
let lane = "10000000-0000-4000-8000-000000000001"
expectFixtureFailure(
"Malformed/explicit-null-order.kanban", path: "\(lane)/index.md", reasonDescription: "missingOrder"
) {
$0 == .missingOrder
}
}
@Test func presentButNonNumericOrder() {
let lane = "10000000-0000-4000-8000-000000000001"
let card = "20000000-0000-4000-8000-000000000002"
expectFixtureFailure(
"Malformed/non-numeric-order.kanban",
path: "\(lane)/\(card)/index.md",
reasonDescription: "malformedOrder(banana)"
) {
$0 == .malformedOrder(raw: "banana")
}
}
@Test func boardRootMissingIndex() {
expectFixtureFailure(
"Malformed/board-root-missing-index.kanban", path: "index.md", reasonDescription: "boardRootMissingIndex"
+7 -2
View File
@@ -527,8 +527,13 @@ struct FrontmatterStrictFieldTests {
}
/// NaN has no place in the total order the tie-break and midpoint math assume
/// (01-storage-format.md § Frontmatter, settled): a non-finite reading is the same loud
/// malformed-input rejection as a non-numeric one, never a silently `.valid(Double.nan)`.
/// (01-storage-format.md § Frontmatter, settled): a non-finite reading is `.malformed` exactly
/// like a non-numeric one, never a silently `.valid(Double.nan)`.
///
/// What that *costs* is the rulebook's, and it changed on 2026-07-31: below the board root both
/// shapes now read as append-at-end, coerce-tier, rather than failing the load
/// (`IntegrityRules.resolvedOrder`, `BoardLoaderOptionalOrderTests`). The document's reading is
/// unchanged, which is the point of the split.
@Test func nonFiniteOrderIsMalformedNotValid() throws {
#expect(try document("order: .nan").order == .malformed(raw: ".nan"))
#expect(try document("order: .inf").order == .malformed(raw: ".inf"))
+37 -6
View File
@@ -106,26 +106,57 @@ struct GitConfigFileTests {
#expect(neither == derived, "a blank value is not a value")
}
@Test("Comments, quoting and subsections are read the way git reads them")
@Test("Comments and quoting are read the way git reads them")
func theParseHandlesTheFormatsEdges() {
let text = """
# a comment
; another
[user "work"]
\tname = Wrong Section
[user]
\tname = "Ada # Lovelace"
\temail = ada@example.com # trailing comment
"""
let identity = GitConfigFile.identity(inConfigText: text)
// `[user "work"]` is a subsection but still the `user` section git reads its keys as
// `user.name` under a subsection name, and this parse deliberately takes the last value it
// meets rather than inventing subsection scoping for a file that has none in practice.
#expect(identity.name == "Ada # Lovelace", "a `#` inside quotes is content")
#expect(identity.email == "[email protected]", "an unquoted trailing comment is not")
}
@Test("Reads take the last plain-section value, and no subsection's")
func readsTakeTheLastPlainSectionValue() {
// **Writes append, reads take the last** (06 Interaction with external writers, blessed
// 2026-07-31): "the reader like git itself takes the last plain-section value, which is
// exactly what an append produces."
let appended = """
[user]
\tname = Old Ada
\temail = old@example.com
[user]
\tname = New Ada
\temail = new@example.com
"""
#expect(GitConfigFile.identity(inConfigText: appended).name == "New Ada")
#expect(GitConfigFile.identity(inConfigText: appended).email == "[email protected]")
// A subsection is a *different key* in git's model `user.work.name`, not `user.name` so
// it is not an answer to this question however late in the file it sits. Signing the user's
// commits with an identity they filed under a name this app never asked about would be the
// worse error, and 06 says plain-section for exactly that reason.
let subsectioned = """
[user]
\tname = Ada
\temail = ada@example.com
[user "work"]
\tname = Work Ada
\temail = ada@work.example
"""
#expect(GitConfigFile.identity(inConfigText: subsectioned).name == "Ada")
#expect(GitConfigFile.identity(inConfigText: subsectioned).email == "[email protected]")
// A file with *only* a subsection names nobody, and falls through to the derived default.
let onlySubsection = "[user \"work\"]\n\tname = Work Ada\n\temail = [email protected]\n"
#expect(GitConfigFile.identity(inConfigText: onlySubsection) == (nil, nil))
}
@Test("A config with no `[user]` section, or no config at all, names nobody")
func absentConfigNamesNobody() throws {
let empty = GitConfigFile.identity(inConfigText: "[core]\n\tbare = false\n")
+181
View File
@@ -458,6 +458,108 @@ struct GitUndoForwardTests {
}
}
// MARK: - Restore subjects compose the inverse
/// **"Subjects don't nest either crossing a restore composes the inverse"** (06-history-undo.md
/// Commit messages, settled 2026-07-31). The composer is a pure function of the crossed subject and
/// the direction (`GitHistoryProvider.restoreSubject(_:crossing:)`), so most of this suite needs no
/// repository at all and the one test that does is the case the rule exists for: the relaunch that
/// turns yesterday's restore commit into an ordinary step.
@MainActor
@Suite("Git undo ▸ restore subjects compose the inverse")
struct GitUndoRestoreSubjectTests {
@Test("An ordinary subject takes one prefix, per direction")
func anOrdinarySubjectNestsOnce() {
#expect(GitHistoryProvider.restoreSubject(.undo, crossing: "Move card 'Fix login' to Doing")
== "Undo: Move card 'Fix login' to Doing")
#expect(GitHistoryProvider.restoreSubject(.redo, crossing: "Move card 'Fix login' to Doing")
== "Redo: Move card 'Fix login' to Doing")
}
@Test("Undoing across a restore emits the inverse label, not a second prefix")
func undoingARestoreInverts() {
// 06's own two examples: "crossing 'Undo: S' yields 'Redo: S', crossing 'Redo: S' yields
// 'Undo: S'" because an undo restores the crossed commit's *parent*, the state that
// commit took away.
#expect(GitHistoryProvider.restoreSubject(.undo, crossing: "Undo: Move card 'X'")
== "Redo: Move card 'X'")
#expect(GitHistoryProvider.restoreSubject(.undo, crossing: "Redo: Move card 'X'")
== "Undo: Move card 'X'")
}
@Test("Redoing across a restore restates it — the mirror of the undo rule, not a copy of it")
func redoingARestoreRestates() {
// A redo restores the target commit *itself*, so the label the new commit carries is that
// commit's own reading: Z back across an "Undo: S" step lands on the tree where S is out.
// Emitting "Redo: S" there the label the Z that crossed it already used, for the opposite
// tree would be the euphemism 06 rules out.
#expect(GitHistoryProvider.restoreSubject(.redo, crossing: "Undo: Move card 'X'")
== "Undo: Move card 'X'")
#expect(GitHistoryProvider.restoreSubject(.redo, crossing: "Redo: Move card 'X'")
== "Redo: Move card 'X'")
}
@Test("A legacy double prefix reads as two flips, and comes out carrying one")
func theLegacyDoublePrefixReadsAsTwoFlips() {
// **The honest reading of a commit the shipped nesting build made.** "Undo: Undo: S" undid
// the commit that undid S, so its tree is the one where S is *in*. Undoing across it puts S
// back out "Undo: S" which is what 06's "the truer label, not a euphemism" asks for;
// "Redo: S" would claim the opposite tree, and "Redo: Undo: S" would keep the nesting the
// ruling caps at one ("it caps prefixes at one across any number of relaunches").
#expect(GitHistoryProvider.restoreSubject(.undo, crossing: "Undo: Undo: Move card 'X'")
== "Undo: Move card 'X'")
#expect(GitHistoryProvider.restoreSubject(.redo, crossing: "Undo: Undo: Move card 'X'")
== "Redo: Move card 'X'")
// The legacy redo's shape reads the same way: "Redo:" restates whatever follows it.
#expect(GitHistoryProvider.restoreSubject(.undo, crossing: "Redo: Undo: Move card 'X'")
== "Redo: Move card 'X'")
// And any depth caps at one, which is the property the ruling actually claims the reading
// is the parity of the "Undo:"s (two here, so the tree has the move in it) and never the
// depth of the stack.
#expect(GitHistoryProvider.restoreSubject(.undo, crossing: "Undo: Redo: Undo: Move card 'X'")
== "Undo: Move card 'X'")
}
@Test("A prefix with nothing after it is somebody's subject, not a label")
func aBarePrefixIsASubject() {
// The sniff is on the subject string (06), and a subject that is *only* a prefix has no base
// to talk about stripping it would compose "Undo: " with nothing, naming no change at all.
#expect(GitHistoryProvider.restoreSubject(.undo, crossing: "Undo: ") == "Undo: Undo: ")
// Foreign subjects that merely look like prefixes are unaffected the match is exact.
#expect(GitHistoryProvider.restoreSubject(.undo, crossing: "undo: fix the build")
== "Undo: undo: fix the build")
}
@Test("After a relaunch, ⌘Z over yesterday's restore commits the inverse — the trail never nests")
func theRelaunchCaseLandsTheInverse() async throws {
let (fixture, git, _) = try await makeGitBoard()
defer { fixture.tearDown() }
let committer = try quickCommitter(git)
let provider = await makeProvider(fixture, git, committer: committer)
try fixture.item("\(Ident.lane1)/\(Ident.card1)", plain(order: "1024", title: "Renamed"))
committer.noteReloadLanded(sawForeignChange: true)
await commitAndSettle(committer, provider)
await provider.cross(.undo)
#expect(try subjects(at: fixture.root).first == "Undo: Rename card 'First' → 'Renamed'")
// **The relaunch**, which is what `reseed()` is: the stack starts again at HEAD with an empty
// redo, so the restore commit above is now an ordinary step the pointer sits on.
await provider.reseed()
#expect(provider.undoActionName == "Undo: Rename card 'First' → 'Renamed'",
"the menu label is still the crossed commit's own subject — labels never nested")
await provider.cross(.undo)
let subjects = try subjects(at: fixture.root)
#expect(subjects.first == "Redo: Rename card 'First' → 'Renamed'",
"the trail says what the restore did: the rename is back")
#expect(title(ofCard: "\(Ident.lane1)/\(Ident.card1)", at: fixture.root) == "Renamed")
}
}
// MARK: - Heal transparency
@MainActor
@@ -1121,6 +1223,85 @@ struct GitUndoBindingTests {
}
}
// MARK: - Failures reach the strip as failures
/// **The one-shot failure class's second shape, wired** (02-architecture.md The banner surface,
/// settled 2026-07-31): "a failed undo restore, branch switch, or (pro-m2) pull/push is an action
/// that didn't happen: it presents in the error tone at the failure rank, never as a warning-tone
/// loss row (the shipped loss-row compromise is retired)".
///
/// These are wiring tests: what the session hands each seam, and which class of row comes out the
/// other side. The sentences themselves are `BannerCenterTests`' subject, and the precedence is
/// `BannerCenter.rows(...)`'.
@MainActor
@Suite("Git undo ▸ a failed git operation is a failure row")
struct GitOperationFailureBannerTests {
@Test("A failed restore posts the git failure shape, named by the key that was pressed")
func aFailedRestorePostsAFailureRow() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let seed = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
#expect(await seed.addGit())
let (model, tearDown) = try makeModel()
defer { tearDown() }
model.currentTier = { .pro }
let ref = try openBoard(model, at: fixture.root)
let session = try #require(model.session(for: ref))
let provider = try #require(session.history as? GitHistoryProvider)
// What `restore(_:to:message:)` hands the seam when libgit2 refuses: the direction, and the
// library's own message. The `operation` string on the failure is developer-facing and is
// deliberately not what the user reads.
provider.reportFailure?(.undo, GitOperationFailure(
operation: GitRestoreOperation.operationName,
message: "could not write to 'index.md': Permission denied"
))
let banners = session.store.banners
#expect(banners.gitFailures.count == 1)
#expect(banners.gitFailures.first?.operation == .undo)
#expect(banners.gitFailures.first?.reason == "could not write to 'index.md': Permission denied")
#expect(banners.losses.isEmpty, "the loss-row compromise is retired — this is a failure")
#expect(banners.oneShots.isEmpty, "and it stays off the closed WriteOperation vocabulary")
// Z's mirror, from the same seam and the same closure.
provider.reportFailure?(.redo, GitOperationFailure(
operation: GitRestoreOperation.operationName,
message: "the repository is locked"
))
#expect(banners.gitFailures.map(\.operation) == [.redo, .undo], "newest first, like every one-shot")
}
@Test("A failed branch switch posts the same shape; the interruption recovery stays a loss row")
func theSwitcherReportsFailureAndRecoveryDifferently() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let seed = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
#expect(await seed.addGit())
let (model, tearDown) = try makeModel()
defer { tearDown() }
model.currentTier = { .pro }
let ref = try openBoard(model, at: fixture.root)
let session = try #require(model.session(for: ref))
let switcher = try #require(session.git?.switcher)
switcher.reportFailure?(GitOperationFailure(
operation: GitBranchOperation.operationName,
message: "your local changes would be overwritten"
))
// **The recovery is a success report** "a branch switch was interrupted the previous
// state is restored" so it keeps the warning tone the ruling leaves it (02).
switcher.reportRecovery?(GitOperationStamp.interruptionMessage)
let banners = session.store.banners
#expect(banners.gitFailures.map(\.operation) == [.branchSwitch])
#expect(banners.losses.map(\.message) == [GitOperationStamp.interruptionMessage])
}
}
// MARK: - Routing
@MainActor
+342
View File
@@ -0,0 +1,342 @@
import Foundation
import Testing
@testable import Kanban
/// **The board's noise definition, as a parser** (01-storage-format.md § Fractal layout Rules,
/// ruled 2026-07-31: "`.gitignore` is the noise gate"; 06-history-undo.md Repository hygiene).
///
/// `GitignoreRules` is pure text in, verdicts out so this suite is a table and nothing else: no
/// filesystem, no board, no store. What it pins is that the app's own matcher is **git's**, because
/// the ruling's whole premise is one definition of noise shared with the committer: "on Pro boards
/// the same file governs the committer, so ignored noise neither relocates nor commits". A matcher
/// that read the file differently from libgit2 would make that one sentence two behaviours.
///
/// The claims are grouped as `gitignore(5)` states them, in its order, plus the seed the app writes
/// and the three deliberate divergences the type documents.
/// One assertion, spelled the way the file reads: patterns on the left, a path on the right.
private func ignores(_ file: String, _ path: String, isDirectory: Bool = false) -> Bool {
GitignoreRules(parsing: file).isIgnored(relativePath: path, isDirectory: isDirectory)
}
// MARK: - What is a pattern at all
@Suite("Gitignore ▸ the line grammar")
struct GitignoreLineGrammarTests {
@Test("Blank lines and comments match nothing")
func blanksAndComments() {
let file = """
# a comment
#notes.txt
"""
let rules = GitignoreRules(parsing: file)
#expect(rules.isEmpty)
#expect(!rules.isIgnored(relativePath: "notes.txt"))
#expect(!rules.isIgnored(relativePath: "# a comment"))
}
/// An empty file is 06's own escape hatch "the escape hatch for wanting no exclusions is an
/// *empty* file, which the app honors" and it must read as "excludes nothing", never as
/// "excludes everything".
@Test("An empty file excludes nothing, and says so")
func emptyFile() {
#expect(GitignoreRules(parsing: "").isEmpty)
#expect(!ignores("", "notes.txt"))
#expect(!ignores("", "lane/card/notes.txt"))
#expect(!ignores("\n\n\n", "notes.txt"))
}
/// `#` only comments when it *begins* the line, and `\#` writes a pattern that starts with one.
@Test("A hash is escapable, and only leading hashes comment")
func escapedHash() {
#expect(ignores("\\#notes.txt", "#notes.txt"))
#expect(!ignores("\\#notes.txt", "notes.txt"))
#expect(ignores("notes#1.txt", "notes#1.txt"))
}
/// "Trailing spaces are ignored unless they are quoted with backslash."
@Test("Trailing spaces are dropped unless escaped")
func trailingSpaces() {
#expect(ignores("notes.txt ", "notes.txt"))
// The escaped one is part of the name so the bare name no longer matches, and the
// space-suffixed one does.
#expect(ignores("notes.txt\\ ", "notes.txt "))
#expect(!ignores("notes.txt\\ ", "notes.txt"))
// Only spaces, and only trailing: a tab is part of the pattern (git trims spaces alone).
#expect(ignores("notes.txt\t", "notes.txt\t"))
}
/// A `.gitignore` hand-edited on Windows must not become a file of patterns nobody can match
/// git terminates each pattern before the `\r`, and so does this.
@Test("CRLF line endings parse, and a BOM is skipped")
func lineEndingsAndBOM() {
#expect(ignores("*.tmp\r\nbuild/\r\n", "scratch.tmp"))
#expect(ignores("\u{FEFF}*.tmp\n", "scratch.tmp"))
}
}
// MARK: - Anchoring
@Suite("Gitignore ▸ anchoring")
struct GitignoreAnchoringTests {
/// "If there is no separator the pattern may also match at any level below" which is the
/// property the seed leans on entirely: one `.DS_Store` line covers every folder in the board.
@Test("A separator-less pattern matches at every depth")
func unanchoredMatchesEverywhere() {
let file = ".DS_Store\n"
#expect(ignores(file, ".DS_Store"))
#expect(ignores(file, "lane/.DS_Store"))
#expect(ignores(file, "lane/card/.DS_Store"))
#expect(!ignores(file, "lane/card/DS_Store"))
#expect(ignores(file, "lane/.DS_Store/inside.txt"), "and a directory by that name takes everything with it")
}
@Test("A leading slash anchors to the board root")
func leadingSlashAnchors() {
let file = "/notes.txt\n"
#expect(ignores(file, "notes.txt"))
#expect(!ignores(file, "lane/notes.txt"))
#expect(!ignores(file, "lane/card/notes.txt"))
}
/// "If there is a separator at the beginning or middle (or both) the pattern is relative to
/// the directory level of the particular `.gitignore` file itself" which for a board is its
/// root, and the reason the loose-file gate matches the **board-relative** path.
@Test("An interior slash anchors too")
func interiorSlashAnchors() {
let file = "lane/notes.txt\n"
#expect(ignores(file, "lane/notes.txt"))
#expect(!ignores(file, "other/lane/notes.txt"))
#expect(!ignores(file, "notes.txt"))
}
/// A *trailing* separator is the directory marker and does not anchor: `build/` still means "any
/// folder called build, anywhere".
@Test("A trailing slash does not anchor")
func trailingSlashDoesNotAnchor() {
let file = "build/\n"
#expect(ignores(file, "build", isDirectory: true))
#expect(ignores(file, "lane/card/build", isDirectory: true))
#expect(ignores(file, "lane/card/build/output.o"))
}
}
// MARK: - Directory-only patterns
@Suite("Gitignore ▸ directory-only patterns")
struct GitignoreDirectoryTests {
@Test("A trailing slash matches only directories")
func directoryOnly() {
let file = "cache/\n"
#expect(ignores(file, "cache", isDirectory: true))
#expect(!ignores(file, "cache", isDirectory: false), "a *file* called cache is not what the pattern is about")
// and everything inside the directory rides along.
#expect(ignores(file, "cache/thing.bin"))
}
@Test("Without the slash, files and directories both match")
func withoutTheSlash() {
#expect(ignores("cache\n", "cache", isDirectory: false))
#expect(ignores("cache\n", "cache", isDirectory: true))
}
}
// MARK: - Negation and last-match-wins
@Suite("Gitignore ▸ negation and precedence")
struct GitignoreNegationTests {
@Test("The last matching pattern decides")
func lastMatchWins() {
#expect(!ignores("*.txt\n!notes.txt\n", "notes.txt"))
#expect(ignores("!notes.txt\n*.txt\n", "notes.txt"), "order is the whole of the rule")
#expect(ignores("*.txt\n!notes.txt\n*.txt\n", "notes.txt"))
#expect(ignores("*.txt\n!notes.txt\n", "other.txt"))
}
/// "It is not possible to re-include a file if a parent directory of that file is excluded"
/// git never descends into an ignored directory, so the rule that would have rescued the file is
/// never read at all.
@Test("A negation cannot reach inside an excluded directory")
func negationCannotEscapeAnExcludedParent() {
let file = "build/\n!build/keep.txt\n"
#expect(ignores(file, "build/keep.txt"))
#expect(ignores(file, "build/deep/keep.txt"))
}
/// A directory the file re-includes is not excluded, so its contents are reachable again.
@Test("A re-included directory lets its contents through")
func reIncludedDirectory() {
let file = "lane\n!lane\n"
#expect(!ignores(file, "lane/card/notes.txt"))
}
@Test("A leading bang is escapable")
func escapedBang() {
#expect(ignores("\\!important.txt\n", "!important.txt"))
#expect(!ignores("\\!important.txt\n", "important.txt"))
}
}
// MARK: - Wildcards
@Suite("Gitignore ▸ wildcards")
struct GitignoreWildcardTests {
@Test("A star matches any run of characters but never a separator")
func starDoesNotCrossSeparators() {
#expect(ignores("*.tmp\n", "scratch.tmp"))
#expect(ignores("*.tmp\n", "lane/card/scratch.tmp"))
#expect(ignores("lane/*.tmp\n", "lane/scratch.tmp"))
#expect(!ignores("lane/*.tmp\n", "lane/card/scratch.tmp"), "one star, one segment")
#expect(ignores("*\n", "anything"))
}
@Test("A star matches nothing at all, at either end")
func starMatchesEmpty() {
#expect(ignores("*.tmp\n", ".tmp"))
#expect(ignores("notes*\n", "notes"))
#expect(ignores("*notes*\n", "notes"))
}
@Test("A question mark is exactly one character, and never a separator")
func questionMark() {
#expect(ignores("shot?.png\n", "shot1.png"))
#expect(!ignores("shot?.png\n", "shot.png"))
#expect(!ignores("shot?.png\n", "shot10.png"))
#expect(!ignores("a?b\n", "a/b"))
}
@Test("Character classes: sets, ranges, negation, and a literal bracket")
func characterClasses() {
#expect(ignores("shot[0-9].png\n", "shot7.png"))
#expect(!ignores("shot[0-9].png\n", "shotX.png"))
#expect(ignores("shot[abc].png\n", "shotb.png"))
#expect(!ignores("shot[!abc].png\n", "shotb.png"))
#expect(ignores("shot[!abc].png\n", "shotz.png"))
#expect(ignores("shot[^abc].png\n", "shotz.png"), "^ negates too")
// A `]` first in the group is a literal member, not the terminator.
#expect(ignores("weird[]].txt\n", "weird].txt"))
// An unterminated group is a literal bracket the reading that cannot lose a character.
#expect(ignores("draft[1.txt\n", "draft[1.txt"))
}
@Test("Escapes make a wildcard literal")
func escapedWildcards() {
#expect(ignores("star\\*.txt\n", "star*.txt"))
#expect(!ignores("star\\*.txt\n", "starry.txt"))
}
}
// MARK: - Globstar
@Suite("Gitignore ▸ ** segments")
struct GitignoreGlobstarTests {
@Test("A leading **/ matches at any depth")
func leadingGlobstar() {
let file = "**/notes.txt\n"
#expect(ignores(file, "notes.txt"))
#expect(ignores(file, "lane/notes.txt"))
#expect(ignores(file, "lane/card/notes.txt"))
}
/// "A trailing `/**` matches everything inside" everything *inside*, so the directory itself is
/// not what this pattern is about.
@Test("A trailing /** matches everything inside, not the folder itself")
func trailingGlobstar() {
let file = "lane/**\n"
#expect(ignores(file, "lane/card"))
#expect(ignores(file, "lane/card/notes.txt"))
#expect(!ignores(file, "lane", isDirectory: true))
}
/// "`a/**/b` matches `a/b`, `a/x/b`, `a/x/y/b`" zero or more segments, verbatim.
@Test("A middle /**/ spans zero or more directories")
func middleGlobstar() {
let file = "a/**/b\n"
#expect(ignores(file, "a/b"))
#expect(ignores(file, "a/x/b"))
#expect(ignores(file, "a/x/y/b"))
#expect(!ignores(file, "b"))
#expect(!ignores(file, "x/a/b"))
}
/// "Other consecutive asterisks are considered regular asterisks" inside a segment, `**` is
/// just `*`, so it still cannot cross a separator.
@Test("Asterisks inside a segment are ordinary stars")
func consecutiveStarsInsideASegment() {
#expect(ignores("a**b\n", "axxb"))
#expect(!ignores("a**b\n", "a/x/b"))
}
}
// MARK: - The seed
@Suite("Gitignore ▸ the seed the app writes")
struct GitignoreSeedSemanticsTests {
private let seed = GitignoreRules(parsing: BoardWriter.gitignoreSeed)
@Test("The seed is exactly the two lines the ruling names")
func theSeedText() {
// 06-history-undo.md Repository hygiene: "`.DS_Store` plus the writer's temp pattern
// (`.*.lanework-*`)". Files the app creates end with LF.
#expect(BoardWriter.gitignoreSeed == ".DS_Store\n.*.lanework-*\n")
}
@Test("It covers the Finder's litter at every level")
func finderLitter() {
#expect(seed.isIgnored(relativePath: ".DS_Store"))
#expect(seed.isIgnored(relativePath: "\(Ident.lane1)/.DS_Store"))
#expect(seed.isIgnored(relativePath: "\(Ident.lane1)/\(Ident.card1)/.DS_Store"))
}
/// The second line and `BoardWriter.atomicReplace`'s temp name are one fact spelled twice, so
/// this asserts against a name the Writer's own rule produces rather than a hand-written one.
@Test("It covers a crashed write's residue")
func writerTempResidue() {
let residue = ".index.md.lanework-\(UUID().uuidString)"
#expect(seed.isIgnored(relativePath: "\(Ident.lane1)/\(Ident.card1)/\(residue)"))
#expect(seed.isIgnored(relativePath: ".\(IntegrityRules.gitignoreFileName).lanework-\(UUID().uuidString)"))
}
@Test("And nothing else — a card's real files are not noise")
func nothingElse() {
#expect(!seed.isIgnored(relativePath: "\(Ident.lane1)/\(Ident.card1)/notes.txt"))
#expect(!seed.isIgnored(relativePath: "\(Ident.lane1)/\(Ident.card1)/index.md"))
#expect(!seed.isIgnored(relativePath: "\(Ident.lane1)/\(Ident.card1)/DS_Store.txt"))
#expect(!seed.isIgnored(relativePath: "CLAUDE.md"))
}
}
// MARK: - The documented divergences
@Suite("Gitignore ▸ the deliberate divergences")
struct GitignoreDivergenceTests {
/// Matching is case-sensitive, always: `core.ignorecase` is a repository setting on a file this
/// app reads on boards that have no repository, and folding a *pattern* would silently widen
/// what the user wrote.
@Test("Patterns are case-sensitive")
func caseSensitive() {
#expect(ignores("*.tmp\n", "scratch.tmp"))
#expect(!ignores("*.tmp\n", "scratch.TMP"))
#expect(!ignores(".DS_Store\n", ".ds_store"))
}
/// POSIX bracket expressions are not a grammar this matcher has: `[[:digit:]]` reads as the
/// ordinary group `[` `[:digt]` the characters between the brackets followed by a literal
/// `]`, so it matches `shot:].png` rather than `shot7.png`. Nothing realistic in a board's noise
/// file writes one, and a second character-class grammar to hold them would be exactly the
/// over-engineering the type exists to avoid.
@Test("POSIX bracket expressions are read as ordinary classes")
func posixClasses() {
#expect(ignores("shot[[:digit:]].png\n", "shot:].png"))
#expect(!ignores("shot[[:digit:]].png\n", "shot7.png"))
}
}
+59
View File
@@ -295,6 +295,65 @@ struct HistoryStoreAddGitTests {
#expect(reason.operation == "Adding git to this board")
#expect(!reason.message.isEmpty)
}
@Test("Create re-runs full detection and refuses a board that became repo-nested")
func createRefusesAStaleModeNone() async throws {
// **The hardening** (06 Rules Detection, ruled 2026-07-31): "add-git's create re-runs full
// detection and refuses unless it reads clean none, so the forbidden nested init is impossible
// even on a raced or stale read."
let outer = try WriterFixture()
defer { outer.tearDown() }
let boardRoot = outer.root.appendingPathComponent("board", isDirectory: true)
try FileManager.default.createDirectory(at: boardRoot, withIntermediateDirectories: true)
try Data(Item.board.utf8).write(to: boardRoot.appendingPathComponent("index.md"))
// Composed while the enclosing folder is still a plain one: the store's mode is `none`, and
// that is the reading that goes stale.
let git = try #require(HistoryStore.compose(boardRoot: boardRoot, tier: .pro))
#expect(git.mode == .none)
// A terminal `git init` one level up, after the detection the store is holding.
try plantGitDirectory(in: outer)
#expect(await git.addGit() == false, "a root-only check would have let this through")
#expect(
!FileManager.default.fileExists(atPath: boardRoot.appendingPathComponent(".git").path),
"no nested repository, ever"
)
#expect(git.mode == .none, "a refused add-git changes nothing, mode included")
}
@Test("A failure answers at the form when it is up, and at the banner when it is not")
@MainActor
func aFailureAnswersAtTheFormOrTheBanner() async throws {
// **Form-anchored operations answer at the form first** (06 Interaction with external
// writers, ruled 2026-07-31) "inline is the primary surface, never a silence trap".
let fixture = try makeBoard()
defer { fixture.tearDown() }
// Mode is read once, at composition so a store composed before a `.git` appeared still says
// `none` and reaches `create`, which is the layer that refuses. Any refusal will do here; the
// question is where the answer lands.
let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
try plantGitDirectory(in: fixture)
var banners: [String] = []
git.reportFailure = { banners.append($0.message) }
// The form is up: inline, and nothing on the strip.
git.noteFormVisible(true)
#expect(await git.addGit() == false)
#expect(git.lastFailure != nil)
#expect(banners.isEmpty, "the user is looking at the form the answer belongs in")
// Dismissing it dismisses the stale error.
git.noteFormVisible(false)
#expect(git.lastFailure == nil)
// Asked again with no form on screen, the answer takes the banner instead of nobody.
#expect(await git.addGit() == false)
#expect(git.lastFailure == nil)
#expect(banners.count == 1)
}
}
// MARK: - The loader's history ranker
+90 -23
View File
@@ -172,14 +172,6 @@ struct IntegrityValidationTests {
private func bytes(_ text: String) -> Data { Data(text.utf8) }
/// The per-kind field table: `order` is required on lanes and cards, **never** on the board.
@Test("Order is required per kind")
func orderIsRequiredPerKind() {
#expect(!IntegrityRules.requiresOrder(.board))
#expect(IntegrityRules.requiresOrder(.lane))
#expect(IntegrityRules.requiresOrder(.card))
}
@Test("A board index validates without an order")
func boardValidatesWithoutOrder() throws {
let document = try IntegrityRules.validateIndex(
@@ -191,33 +183,71 @@ struct IntegrityValidationTests {
#expect(document.title == .valid("Board"))
}
@Test("A lane or card index without an order is refused")
func laneAndCardRequireOrder() {
/// **`order` is optional at every kind** (01-storage-format.md § Ordering, re-ruled 2026-07-31):
/// a lane or card without one reads as append-at-end rather than being refused, so the validator
/// has nothing to say about it.
@Test("A lane or card index without an order validates")
func laneAndCardDoNotRequireOrder() throws {
for kind in [IntegrityRules.ObjectKind.lane, .card] {
#expect(throws: BoardLoadError(path: "index.md", reason: .missingOrder)) {
try IntegrityRules.validateIndex(
bytes("---\nschema: 1\n---\nbody\n"),
path: "index.md",
kind: kind,
supportedSchema: 1
)
}
let document = try IntegrityRules.validateIndex(
bytes("---\nschema: 1\n---\nbody\n"),
path: "index.md",
kind: kind,
supportedSchema: 1
)
#expect(document.order.isMissing)
}
}
/// **The root's `schema` is required; below it, absence reads as 1** the one per-kind
/// difference the validator still draws.
@Test("Schema is required at the board and optional below it")
func schemaIsRequiredAtTheRootOnly() throws {
let schemaless = bytes("---\ntitle: No Schema\n---\nbody\n")
#expect(throws: BoardLoadError(path: "index.md", reason: .missingSchema)) {
try IntegrityRules.validateIndex(
schemaless, path: "index.md", kind: .board, supportedSchema: 1)
}
for kind in [IntegrityRules.ObjectKind.lane, .card, .comment] {
let document = try IntegrityRules.validateIndex(
schemaless, path: "index.md", kind: kind, supportedSchema: 1)
#expect(document.schema.isMissing)
}
}
/// The card window's gate is this rule at `kind: .card` one function, not a copy.
@Test("validateCardIndex is validateIndex at card")
func cardValidatorIsTheGeneralOne() {
let missingOrder = bytes("---\nschema: 1\n---\nbody\n")
#expect(throws: BoardLoadError(path: "index.md", reason: .missingOrder)) {
try BoardLoader.validateCardIndex(missingOrder, path: "index.md")
}
func cardValidatorIsTheGeneralOne() throws {
// No `order`, no `schema` the minimum agent card, and a legal raw-source Apply since
// 2026-07-31.
let minimum = try BoardLoader.validateCardIndex(bytes("---\ntitle: Minimum\n---\nbody\n"), path: "index.md")
#expect(minimum.title == .valid("Minimum"))
let newer = bytes("---\nschema: 99\norder: 1\n---\n")
#expect(throws: BoardLoadError(path: "index.md", reason: .schemaNewerThanApp(found: 99))) {
try BoardLoader.validateCardIndex(newer, path: "index.md")
}
}
/// The rulebook's own readings, without a filesystem in the way.
@Test("The order reading is stated over usability")
func orderReadingIsStatedOverUsability() throws {
func reading(_ frontmatter: String) throws -> (order: Double?, coerced: CoercedField?) {
IntegrityRules.resolvedOrder(in: try FrontmatterDocument.parse("---\n\(frontmatter)---\n"))
}
#expect(try reading("order: 1024\n").order == 1024)
#expect(try reading("order: 1024\n").coerced == nil)
for (frontmatter, raw) in [
("schema: 1\n", ""), ("order:\n", ""), ("order: null\n", "null"),
("order: banana\n", "banana"), ("order: .nan\n", ".nan"), ("order: .inf\n", ".inf"),
] {
let read = try reading(frontmatter)
#expect(read.order == nil, "\(frontmatter) should be unusable")
#expect(read.coerced == CoercedField(key: "order", raw: raw), "\(frontmatter)")
}
}
/// The refuse-writes verdict's rule, named in the vocabulary rather than left as a property one
/// call site happens to read.
@Test("The uneditable shape is the document's, named here")
@@ -275,6 +305,43 @@ struct IntegrityOnTouchTests {
#expect(IntegrityRules.healOnTouch(&document, kind: nil).isEmpty)
#expect(document.kind == .missing)
}
/// **The rank materializes on touch** (01-storage-format.md § Ordering, re-ruled 2026-07-31):
/// the value written is the append-at-end reading the board was already rendering, so nothing
/// moves when the stamp lands.
@Test("A missing order is stamped with the rank it read as")
func missingOrderIsStamped() throws {
var document = try FrontmatterDocument.parse("---\nschema: 1\ntitle: Minimum\n---\nbody\n")
let heals = IntegrityRules.healOnTouch(&document, kind: .card, rank: 3072)
#expect(heals == [.kindBackfilled(.card), .rankStamped(3072)])
#expect(document.order == .valid(3072))
#expect(document.keys == ["schema", "title", "kind", "order"])
#expect(document.body == "body\n")
}
/// **Unlike `kind`, an unusable *present* value is replaced**: a rank has to be a number for the
/// midpoint math to mean anything, so `banana` and `.nan` heal exactly like an absent key.
@Test("An unusable order is stamped too")
func unusableOrderIsStamped() throws {
for text in ["order: banana", "order: .nan", "order:"] {
var document = try FrontmatterDocument.parse("---\nschema: 1\n\(text)\nkind: card\n---\n")
#expect(IntegrityRules.healOnTouch(&document, kind: .card, rank: 2048) == [.rankStamped(2048)])
#expect(document.order == .valid(2048), "\(text)")
}
}
/// A usable rank is never rewritten, and a caller with no rank to offer stamps nothing the
/// board root and a comment, which have no ladder to sit in.
@Test("A present rank, and a nil rank, stamp nothing")
func presentOrAbsentRankStampsNothing() throws {
var ranked = try FrontmatterDocument.parse("---\nschema: 1\norder: 1024\nkind: card\n---\n")
#expect(IntegrityRules.healOnTouch(&ranked, kind: .card, rank: 9999).isEmpty)
#expect(ranked.order == .valid(1024))
var rankless = try FrontmatterDocument.parse("---\nschema: 1\nkind: board\n---\n")
#expect(IntegrityRules.healOnTouch(&rankless, kind: .board, rank: nil).isEmpty)
#expect(rankless.order == .missing)
}
}
// MARK: - The defect vocabulary
+177 -6
View File
@@ -15,6 +15,12 @@ import Testing
/// lock, and never hot-looping on a failure.
/// 4. **A paste normalizes at the boundary** the pasted card lands already tidy.
///
/// Since 2026-07-31 there is a gate in front of all four: **the board-root `.gitignore` is the noise
/// definition**, and a loose file matching it is not work at all (§ Rules "skipped, preserved
/// verbatim, logged, never relocated, never announced"). That is section 1b, between the detection
/// and the write, because it is a property of what counts as a defect rather than of what is done
/// about one.
///
/// Like every other write suite here these read back through the loader or through raw bytes, never
/// through a snapshot the store handed out: the claims are about the files. `WriterFixture`, `Ident`
/// and `Item` come from `WriterTestSupport.swift`; `FakePasteboard` and `ClipboardHarness` from
@@ -24,15 +30,17 @@ import Testing
/// A one-lane, one-card board, ready for whatever the test wants to leave beside `index.md`.
///
/// It carries a **current agent guide**, which is what any board the app has opened once looks like
/// (08-agent-integration.md The agent guide). Without it the store's own guide refresh which
/// runs on every successful reload, beside this file's relocation would write a `CLAUDE.md` on
/// the first reload and open a bracket of its own, and the bracket counts below would stop being
/// claims about the relocation.
private func makeCardBoard() throws -> WriterFixture {
/// It carries a **current agent guide** and the **seeded `.gitignore`**, which is what any board the
/// app has opened once looks like (08-agent-integration.md The agent guide; 06-history-undo.md
/// Repository hygiene). Without them the store's own scheduled heals the guide refresh and the
/// seed, which run on every successful reload beside this file's relocation would write those two
/// files on the first reload and open brackets of their own, and the bracket counts below would stop
/// being claims about the relocation.
private func makeCardBoard(gitignore: String = BoardWriter.gitignoreSeed) throws -> WriterFixture {
let fixture = try WriterFixture()
try fixture.item("", Item.board)
try fixture.file(AgentGuide.filename, Data(AgentGuide.content.utf8))
try fixture.file(IntegrityRules.gitignoreFileName, Data(gitignore.utf8))
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Fix login"))
return fixture
@@ -221,6 +229,169 @@ struct LooseFileDetectionTests {
}
}
// MARK: - 1b. The noise gate (the board-root .gitignore)
/// **`.gitignore` is the noise gate** (01-storage-format.md § Fractal layout Rules, ruled
/// 2026-07-31): "a file matching the board-root `.gitignore` standard gitignore pattern semantics
/// against the board-relative path; nested `.gitignore` files are ordinary strays the heal never
/// consults keeps the ordinary stray posture: skipped, preserved verbatim, logged, never
/// relocated, never announced. The exclusion list is exactly that file, nothing hardcoded".
///
/// The pattern *semantics* are `GitignoreRulesTests`'; what this suite pins is the wiring which
/// file is read, which path it is matched against, and that a match makes the file a stray rather
/// than work.
@Suite("Loose files ▸ the .gitignore noise gate")
struct LooseFileNoiseGateTests {
@Test("A file matching the board's .gitignore is not a loose file")
func matchedFileIsNotWork() throws {
let fixture = try makeCardBoard(gitignore: "*.tmp\n")
defer { fixture.tearDown() }
try fixture.file("\(cardPath)/scratch.tmp", Data("noise".utf8))
#expect(try looseFiles(in: fixture).isEmpty)
}
/// The seed's own first line, doing its job on the file it was written for. `.DS_Store` is also
/// hidden and hidden entries are excluded for a structural reason of their own (a relocated
/// hidden file would land where `attachmentNames` can never list it) so this is the belt and
/// the braces, which is what the ruling asks for: the seed is why it is *noise*.
@Test("A .DS_Store beside a card's index.md is neither relocated nor announced")
@MainActor
func finderLitterIsSilent() throws {
let fixture = try makeCardBoard()
defer { fixture.tearDown() }
try fixture.file("\(cardPath)/.DS_Store", Data("finder".utf8))
let store = try BoardStore(rootURL: fixture.root)
store.relocateLooseCardFiles()
#expect(fixture.exists("\(cardPath)/.DS_Store"), "preserved verbatim, exactly where it was")
#expect(!fixture.exists("\(cardPath)/attachments"))
#expect(store.banners.losses.isEmpty, "never announced")
#expect(store.banners.oneShots.isEmpty)
}
@Test("A matched custom pattern is skipped, preserved and never announced")
@MainActor
func matchedCustomPatternIsSilent() throws {
let fixture = try makeCardBoard(gitignore: "*.tmp\nbuild/\n")
defer { fixture.tearDown() }
let noise = try fixture.file("\(cardPath)/scratch.tmp", Data("noise".utf8))
let before = try stat(noise)
let store = try BoardStore(rootURL: fixture.root)
store.relocateLooseCardFiles()
let after = try stat(noise)
#expect(after.bytes == before.bytes)
#expect(after.modified == before.modified, "not opened, not moved, not touched")
#expect(!fixture.exists("\(cardPath)/attachments"))
#expect(store.banners.losses.isEmpty)
}
@Test("An unmatched loose file still relocates, with its notice")
@MainActor
func unmatchedFileStillRelocates() throws {
let fixture = try makeCardBoard(gitignore: "*.tmp\n")
defer { fixture.tearDown() }
try fixture.file("\(cardPath)/scratch.tmp", Data("noise".utf8))
try fixture.file("\(cardPath)/notes.txt", Data("notes".utf8))
let store = try BoardStore(rootURL: fixture.root)
store.relocateLooseCardFiles()
#expect(try fixture.data("\(cardPath)/attachments/notes.txt") == Data("notes".utf8))
#expect(fixture.exists("\(cardPath)/scratch.tmp"))
// One file moved, so one file is named the noise is not in the sentence either.
#expect(store.banners.losses.map(\.message) == ["Moved 'notes.txt' into attachments — 'Fix login'"])
}
/// 06's escape hatch, working: "the escape hatch for wanting no exclusions is an *empty* file".
@Test("An empty .gitignore excludes nothing — everything loose relocates")
@MainActor
func emptyFileExcludesNothing() throws {
let fixture = try makeCardBoard(gitignore: "")
defer { fixture.tearDown() }
try fixture.file("\(cardPath)/scratch.tmp", Data("noise".utf8))
try fixture.file("\(cardPath)/notes.txt", Data("notes".utf8))
let store = try BoardStore(rootURL: fixture.root)
store.relocateLooseCardFiles()
#expect(try fixture.entryNames("\(cardPath)/attachments") == ["notes.txt", "scratch.tmp"])
#expect(store.banners.losses.map(\.message) == ["Moved 2 files into attachments — 'Fix login'"])
}
/// A board that has never been opened by this version has no gate at all, and that reads as "no
/// exclusions" the pre-ruling behaviour, and the same answer the empty file gives.
@Test("A board with no .gitignore excludes nothing")
func noFileExcludesNothing() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
try fixture.item(cardPath, Item.rich(order: "1024", title: "Fix login"))
try fixture.file("\(cardPath)/notes.txt", Data("notes".utf8))
#expect(try looseFiles(in: fixture).map(\.fileNames) == [["notes.txt"]])
}
/// **The board-relative path is what patterns match**, which is what makes an anchored pattern
/// mean the board root rather than every card in the board.
@Test("Patterns match the board-relative path, so anchoring means the board root")
func anchoredPatternsMeanTheBoardRoot() throws {
let fixture = try makeCardBoard(gitignore: "/notes.txt\n")
defer { fixture.tearDown() }
try fixture.file("\(cardPath)/notes.txt", Data("notes".utf8))
// The pattern is about `<root>/notes.txt`; the card's file is a different path entirely.
#expect(try looseFiles(in: fixture).map(\.fileNames) == [["notes.txt"]])
// The same file, named the way the card actually sits on disk, is excluded.
let anchored = try makeCardBoard(gitignore: "/\(cardPath)/notes.txt\n")
defer { anchored.tearDown() }
try anchored.file("\(cardPath)/notes.txt", Data("notes".utf8))
#expect(try looseFiles(in: anchored).isEmpty)
}
/// "Nested `.gitignore` files are ordinary strays the heal never consults." One inside a card is
/// itself a hidden entry, so it is not even relocatable; one inside a *lane* is a lane-level
/// stray, and neither has any say over the card beside it.
@Test("Nested .gitignore files are never consulted")
func nestedFilesAreOrdinaryStrays() throws {
let fixture = try makeCardBoard(gitignore: "")
defer { fixture.tearDown() }
try fixture.file("\(Ident.lane1)/\(IntegrityRules.gitignoreFileName)", Data("*.txt\n".utf8))
try fixture.file("\(cardPath)/\(IntegrityRules.gitignoreFileName)", Data("*.txt\n".utf8))
try fixture.file("\(cardPath)/notes.txt", Data("notes".utf8))
#expect(try looseFiles(in: fixture).map(\.fileNames) == [["notes.txt"]])
#expect(fixture.exists("\(Ident.lane1)/\(IntegrityRules.gitignoreFileName)"))
#expect(fixture.exists("\(cardPath)/\(IntegrityRules.gitignoreFileName)"))
}
/// The import boundary reads the same file: a paste that swept a file the very next walk would
/// have left alone would be the app disagreeing with itself one gesture apart.
@Test("The import-boundary normalization obeys the same gate")
func normalizationObeysTheGate() throws {
let fixture = try makeCardBoard(gitignore: "*.tmp\n")
defer { fixture.tearDown() }
try fixture.file("\(cardPath)/scratch.tmp", Data("noise".utf8))
try fixture.file("\(cardPath)/notes.txt", Data("notes".utf8))
let moved = try BoardWriter.normalizeLooseFiles(inCard: cardFolder(in: fixture))
#expect(moved.map(\.fileName) == ["notes.txt"])
#expect(fixture.exists("\(cardPath)/scratch.tmp"))
// and the lane-level face, which reads the file once for the whole lane.
try fixture.file("\(cardPath)/second.tmp", Data("more noise".utf8))
#expect(try BoardWriter.normalizeLooseFiles(inLane: fixture.url(Ident.lane1)).isEmpty)
#expect(fixture.exists("\(cardPath)/second.tmp"))
}
}
// MARK: - 2. The write (BoardWriter)
@Suite("Loose files ▸ the relocation write")
+17 -10
View File
@@ -97,16 +97,23 @@ struct RawSourceValidationTests {
#expect(document.value(for: "sphere") == .string("work"))
}
@Test("The two required fields are required, with the loader's own words")
func schemaAndOrderAreRequired() {
let noSchema = validationFailure("---\ntitle: x\norder: 1\n---\nbody\n")
#expect(noSchema?.reason == .missingSchema)
/// **A card's `schema` and `order` are both optional** (01-storage-format.md § Frontmatter and
/// § Ordering, re-ruled 2026-07-31) the outlet's gate is the loader's rule, so it moved with
/// it: a card applied without either lands at its lane's bottom, read as schema 1, and gains a
/// real rank on its next touch.
@Test("The optional fields are optional, with the loader's own rule")
func schemaAndOrderAreOptional() throws {
for text in ["---\ntitle: x\norder: 1\n---\nbody\n", "---\nschema: 1\ntitle: x\n---\nbody\n", "---\ntitle: x\n---\nbody\n"] {
#expect(throws: Never.self) {
try BoardLoader.validateCardIndex(Data(text.utf8), path: "index.md")
}
}
let noOrder = validationFailure("---\nschema: 1\ntitle: x\n---\nbody\n")
#expect(noOrder?.reason == .missingOrder)
// What still refuses: a value that is there and unreadable, and a card from a newer app
// which this one has no honest way to rewrite.
let malformed = validationFailure("---\nschema: one\norder: 1\n---\nbody\n")
#expect(malformed?.reason == .malformedSchema(raw: "one"))
// The same fail-fast rule the loader applies at load: a card from a newer app is not
// something this one may rewrite.
let future = validationFailure("---\nschema: 99\norder: 1\n---\nbody\n")
#expect(future?.reason == .schemaNewerThanApp(found: 99))
}
@@ -409,10 +416,10 @@ struct RawSourceStoreTests {
let outcome = store.applyCardSource(
inCard: ItemID(rawValue: Ident.card1),
text: "---\ntitle: no schema here\norder: 1\n---\nbody\n"
text: "---\nschema: unreadable\norder: 1\n---\nbody\n"
)
#expect(outcome == .invalid(BoardLoadError(path: "index.md", reason: .missingSchema)))
#expect(outcome == .invalid(BoardLoadError(path: "index.md", reason: .malformedSchema(raw: "unreadable"))))
#expect(try fixture.indexData(cardPath) == before)
// The alert is the surfacing for this one a banner as well would say the same thing twice,
// and a write that never started is not a failed write.
+219 -11
View File
@@ -4,9 +4,10 @@ import Testing
import libgit2
@testable import Kanban
/// **Repository hygiene** (06-history-undo.md Repository hygiene) the two behaviours that keep a
/// git board's `.git` sane without ever rewriting anything: the `.gitignore` seeded once at init, and
/// the periodic repack that packs loose objects and touches nothing else.
/// **Repository hygiene** (06-history-undo.md Repository hygiene) the behaviours that keep a
/// board's noise out of the way without ever rewriting anything: the `.gitignore` **every board**
/// carries (re-ruled 2026-07-31 the file outgrew git, so it is seeded at creation and healed in at
/// open, git or not), and the periodic repack that packs loose objects and touches nothing else.
///
/// Every repository here is a **real** one, made by the app's own add-git through the bundled
/// libgit2, and every assertion is read off the filesystem or out of the object database rather than
@@ -154,11 +155,16 @@ private func historyWalk(at boardRoot: URL) throws -> [String] {
// MARK: - .gitignore seeding
/// **The add-git half.** Since 2026-07-31 the seed belongs to the *board* rather than to git (the
/// suite below this one), and what survives here is the last-chance check in front of the initial
/// commit: whatever else happened, the tree that becomes "Initial board state" carries a
/// `.gitignore`, because a `.DS_Store` that enters history can never be got out again (06 Deleting
/// never forgets).
@MainActor
@Suite("Repository hygiene ▸ the seeded .gitignore")
struct GitignoreSeedTests {
@Test("Add-git seeds a .gitignore containing .DS_Store, inside the initial commit")
@Test("Add-git guarantees a .gitignore inside the initial commit")
func addGitSeedsTheIgnoreFile() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
@@ -166,9 +172,9 @@ struct GitignoreSeedTests {
let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
#expect(await git.addGit())
// The file, and the whole of the file: one line, because one line is the rule
// (06 Repository hygiene: "a minimal `.gitignore` (`.DS_Store`)").
#expect(try fixture.data(".gitignore") == Data(".DS_Store\n".utf8))
// The file, and the whole of the file the one seed text, shared with board creation and
// the open-time heal (06 Repository hygiene: "`.DS_Store` plus the writer's temp pattern").
#expect(try fixture.data(".gitignore") == Data(BoardWriter.gitignoreSeed.utf8))
// **In "Initial board state", not after it.** Seeding after the commit would put the app's
// own file into the board's first *foreign* commit; seeding before makes it part of the
@@ -236,7 +242,34 @@ struct GitignoreSeedTests {
#expect(try snapshot(fixture.root, ".gitignore") == before)
}
@Test("Adoption seeds nothing — an adopted repository is somebody else's init")
/// **The second consumer of the one noise definition** (01-storage-format.md § Fractal layout
/// Rules: "On Pro boards the same file governs the committer, so ignored noise neither relocates
/// nor commits one definition of noise, two consumers"). The committer's own condition is
/// `changedPaths`, which stages through libgit2 with ignores respected; this pins that the file
/// the loose-file gate reads is the file that decides what commits.
@Test("The committer obeys the same file — ignored noise never becomes a changed path")
func theCommitterObeysTheSameFile() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
#expect(await git.addGit())
// The user fine-tunes their own noise definition, which is exactly what the file is for.
try fixture.file(".gitignore", Data((BoardWriter.gitignoreSeed + "*.tmp\n").utf8))
try fixture.file("\(Ident.lane1)/\(Ident.card1)/scratch.tmp", Data("noise".utf8))
try fixture.file("\(Ident.lane1)/notes.txt", Data("a real stray".utf8))
try fixture.file("\(Ident.lane1)/.DS_Store", Data([0x00, 0x01, 0x42]))
let changed = GitCommitOperation.changedPaths(at: fixture.root).map(\.path)
#expect(!changed.contains { $0.hasSuffix("scratch.tmp") })
#expect(!changed.contains { $0.hasSuffix(".DS_Store") })
#expect(changed.contains { $0.hasSuffix("notes.txt") }, "and an ordinary stray still commits")
}
/// Composing history over somebody else's repository writes nothing at all adoption is not an
/// init, and no *git* path seeds. (The board's own heal is what gives such a board its
/// `.gitignore`, at open, and it is exercised in the suite below.)
@Test("Adoption writes nothing — an adopted repository is somebody else's init")
func adoptionSeedsNothing() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
@@ -245,11 +278,14 @@ struct GitignoreSeedTests {
let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
#expect(git.mode == .git)
#expect(!fixture.exists(".gitignore"), "the seed belongs to the app's own init and nowhere else")
#expect(!fixture.exists(".gitignore"), "composing history is not a write")
}
@Test("A repo-nested board gets no seed, because it gets no app-managed git")
func repoNestedBoardsGetNothing() async throws {
/// A repo-nested board gets no *git* of the app's, so no git path can seed it and the
/// enclosing repository is never written into either. What such a board does get is the ordinary
/// board-level seed at open (06's "Repo-nested boards are seeded too"), which is the suite below.
@Test("The git paths never touch a repo-nested board, or its enclosing repo")
func repoNestedBoardsGetNothingFromGit() async throws {
let outer = try WriterFixture()
defer { outer.tearDown() }
try outer.file(".git/HEAD", Data("ref: refs/heads/main\n".utf8))
@@ -266,6 +302,178 @@ struct GitignoreSeedTests {
}
}
// MARK: - The .gitignore every board carries
/// **"`.gitignore` seeded on every board, never touched after"** (06-history-undo.md Repository
/// hygiene, re-ruled 2026-07-31 "the file outgrew git: it is the one noise definition the
/// loose-file relocation heal obeys so every board carries it, git or not").
///
/// Three claims, and they are the whole ruling: **creation writes it**, **a board missing it gains
/// it by scheduled heal at open**, and **the app never edits an existing one** an empty file
/// included, which is the ruling's own escape hatch. The gate it feeds is
/// `LooseFileRelocationTests` the noise gate; the pattern semantics are `GitignoreRulesTests`.
@MainActor
@Suite("Repository hygiene ▸ the .gitignore every board carries")
struct BoardGitignoreSeedTests {
private func seedURL(in fixture: WriterFixture) -> URL {
fixture.root.appendingPathComponent(IntegrityRules.gitignoreFileName)
}
private func stat(_ url: URL) throws -> (bytes: Data, modified: Date) {
let attributes = try FileManager.default.attributesOfItem(atPath: url.path)
guard let modified = attributes[.modificationDate] as? Date else {
throw NSError(domain: "BoardGitignoreSeedTests", code: 1)
}
return (try Data(contentsOf: url), modified)
}
@Test("Board creation writes the seed beside index.md")
func creationSeeds() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let root = fixture.url("New Board.kanban")
try BoardWriter.createBoard(at: root, title: "New Board")
#expect(try Data(contentsOf: root.appendingPathComponent(IntegrityRules.gitignoreFileName))
== Data(BoardWriter.gitignoreSeed.utf8))
}
@Test("A board missing the file gains it at open, silently")
func healSeedsAtOpen() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try fixture.file(AgentGuide.filename, Data(AgentGuide.content.utf8))
#expect(!fixture.exists(IntegrityRules.gitignoreFileName))
let store = try BoardStore(rootURL: fixture.root)
store.runScheduledHeals()
#expect(try fixture.data(IntegrityRules.gitignoreFileName) == Data(BoardWriter.gitignoreSeed.utf8))
// A courtesy file the user did not create and may not know exists the guide's posture.
#expect(store.banners.losses.isEmpty)
#expect(store.banners.oneShots.isEmpty)
}
/// The heal's memo, doing its two jobs: a picture already acted on is not acted on again (no
/// second write), and a picture that comes *back* a foreign deletion heals again, because the
/// memo was cleared on success.
@Test("Seeding twice writes once, and a deleted file comes back")
func memoIsArmedAndCleared() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try fixture.file(AgentGuide.filename, Data(AgentGuide.content.utf8))
let store = try BoardStore(rootURL: fixture.root)
store.seedGitignore()
let first = try stat(seedURL(in: fixture))
#expect(store.heals.memo(for: .missingGitignore) == nil, "cleared on success")
store.seedGitignore()
#expect(try stat(seedURL(in: fixture)) == first, "not rewritten — not even opened")
// What a foreign deletion looks like: the picture "missing" is restored, and a standing memo
// would have made that deletion the one thing this could not heal.
try FileManager.default.removeItem(at: seedURL(in: fixture))
store.seedGitignore()
#expect(try fixture.data(IntegrityRules.gitignoreFileName) == Data(BoardWriter.gitignoreSeed.utf8))
}
@Test("An existing .gitignore is left byte-for-byte alone, mtime included")
func existingFileIsNeverRewritten() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try fixture.file(AgentGuide.filename, Data(AgentGuide.content.utf8))
let theirs = Data("# mine\nbuild/\n*.tmp\n".utf8)
try fixture.file(IntegrityRules.gitignoreFileName, theirs)
let before = try stat(seedURL(in: fixture))
let store = try BoardStore(rootURL: fixture.root)
store.runScheduledHeals()
#expect(try fixture.data(IntegrityRules.gitignoreFileName) == theirs)
#expect(try stat(seedURL(in: fixture)) == before, "never merged, never appended to, never opened")
}
/// "The escape hatch for wanting no exclusions is an *empty* file, which the app honors and never
/// rewrites" the one case where re-seeding would look most reasonable and is most wrong.
@Test("An empty .gitignore is honored and never rewritten")
func emptyFileIsHonored() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try fixture.file(AgentGuide.filename, Data(AgentGuide.content.utf8))
try fixture.file(IntegrityRules.gitignoreFileName, Data())
let before = try stat(seedURL(in: fixture))
let store = try BoardStore(rootURL: fixture.root)
store.runScheduledHeals()
store.runScheduledHeals()
#expect(try fixture.data(IntegrityRules.gitignoreFileName) == Data())
#expect(try stat(seedURL(in: fixture)) == before)
}
/// "Repo-nested boards are seeded too (re-ruling the old no-app-`.gitignore` posture): the file
/// serves the heal there, not any app-managed git" so there is no repo-detection gate on this
/// heal, and the enclosing repository is still never written into.
@Test("A repo-nested board is seeded like any other")
func repoNestedBoardsAreSeeded() throws {
let outer = try WriterFixture()
defer { outer.tearDown() }
try outer.file(".git/HEAD", Data("ref: refs/heads/main\n".utf8))
let boardRoot = outer.root.appendingPathComponent("board", isDirectory: true)
try FileManager.default.createDirectory(at: boardRoot, withIntermediateDirectories: true)
try Data(Item.board.utf8).write(to: boardRoot.appendingPathComponent("index.md"))
try Data(AgentGuide.content.utf8).write(to: boardRoot.appendingPathComponent(AgentGuide.filename))
let store = try BoardStore(rootURL: boardRoot)
store.runScheduledHeals()
#expect(try Data(contentsOf: boardRoot.appendingPathComponent(IntegrityRules.gitignoreFileName))
== Data(BoardWriter.gitignoreSeed.utf8))
#expect(!FileManager.default.fileExists(atPath: outer.root.appendingPathComponent(IntegrityRules.gitignoreFileName).path))
}
/// **The claimed name that does not displace** (`IntegrityRules.claimedRootNames`): a wrong-kind
/// node wearing `.gitignore` is left exactly where it is, because a board with no readable noise
/// definition simply excludes nothing nothing breaks while the name is held, so nothing of the
/// user's is moved to buy a courtesy file.
@Test("A folder wearing the name is left alone, and nothing is written through it")
func squatterIsLeftAlone() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try fixture.file(AgentGuide.filename, Data(AgentGuide.content.utf8))
try fixture.file("\(IntegrityRules.gitignoreFileName)/inside.txt", Data("mine".utf8))
let store = try BoardStore(rootURL: fixture.root)
store.runScheduledHeals()
#expect(try fixture.data("\(IntegrityRules.gitignoreFileName)/inside.txt") == Data("mine".utf8))
#expect(store.banners.oneShots.isEmpty, "and no failure is reported for work nobody asked for")
#expect(store.banners.losses.isEmpty)
}
/// A board whose location cannot be written to defers rather than failing the engine's gate,
/// stated here because this heal runs at every open of every board and is the one most likely to
/// meet a read-only volume.
@Test("An unwritable board root is skipped silently")
func unwritableRootIsSkipped() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try fixture.file(AgentGuide.filename, Data(AgentGuide.content.utf8))
let store = try BoardStore(rootURL: fixture.root)
try FileManager.default.setAttributes([.posixPermissions: 0o500], ofItemAtPath: fixture.root.path)
defer { try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: fixture.root.path) }
store.seedGitignore()
#expect(!fixture.exists(IntegrityRules.gitignoreFileName))
#expect(store.banners.oneShots.isEmpty)
#expect(store.heals.memo(for: .missingGitignore) == nil, "deferred, never remembered")
}
}
// MARK: - The housekeeping pass
@MainActor
+17
View File
@@ -174,6 +174,23 @@ struct TemplateEngineRoundTripTests {
#expect(result.model.lanes.map(\.order) == [1024, 2048])
}
/// **An instantiated board is a board created today**, so it is born with the noise definition
/// every board carries (06-history-undo.md Repository hygiene, re-ruled 2026-07-31; the bundled
/// templates carry no `.gitignore` of their own, so this is the seeding half rather than the
/// copying one which `TemplateEngineFixtureTests` pins from the other side, where a template
/// that *does* carry one has it copied through byte for byte and left alone).
@Test("An instantiated board carries the seeded .gitignore")
func instantiationSeedsTheIgnoreFile() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let destination = fixture.url("Q3 Planning.kanban")
try TemplateEngine.instantiate(template: try bundledBasic(), to: destination, title: "Q3 Planning")
#expect(try Data(contentsOf: destination.appendingPathComponent(IntegrityRules.gitignoreFileName))
== Data(BoardWriter.gitignoreSeed.utf8))
}
@Test("The title is the document name the user chose, not the template's")
func titleIsTheChosenName() throws {
let fixture = try WriterFixture()
+21 -18
View File
@@ -225,9 +225,13 @@ struct TrashContainerLoadTests {
#expect(trashed.document.unknownFields.map(\.key) == ["project"])
}
/// Fail-fast is a property of the card parse, not of the container it ran in.
@Test("A malformed order inside .trash fails the load, naming its path")
func malformedOrderInTrashFailsFast() throws {
/// **The `order` reading is a property of the entry, not of the container it sits in**
/// (01-storage-format.md § Ordering, re-ruled 2026-07-31): an unusable rank in `.trash/` is the
/// same coercion it is on the live board append-at-end over the container's own entries, with
/// the text as written recorded. It decides nothing here anyway, since the trash sorts by
/// `modified`; the rank is what the entry carries back out on a restore.
@Test("An unusable order inside .trash coerces, like anywhere else")
func malformedOrderInTrashCoerces() throws {
let fixture = try TrashFixture()
defer { fixture.tearDown() }
@@ -235,13 +239,11 @@ struct TrashContainerLoadTests {
try fixture.index("", "schema: 1\n")
try fixture.index(".trash/\(card)", "schema: 1\norder: soon\n")
do {
_ = try BoardLoader.load(boardRoot: fixture.root)
Issue.record("expected the load to fail")
} catch let error as BoardLoadError {
#expect(error.path == ".trash/\(card)/index.md")
#expect(error.reason == .malformedOrder(raw: "soon"))
}
let result = try BoardLoader.load(boardRoot: fixture.root)
#expect(result.model.trash.map(\.id.rawValue) == [card])
#expect(result.model.trash[0].order == 1024)
#expect(result.coercedFrontmatter.map(\.path) == [".trash/\(card)/index.md"])
#expect(result.coercedFrontmatter[0].fields == [CoercedField(key: "order", raw: "soon")])
}
/// Symlinks are never traversed a symlinked container would render bytes living outside the
@@ -1091,11 +1093,11 @@ struct TrashKindDiscriminatorTests {
#expect(result.model.trashedLanes.isEmpty)
}
/// Both kinds are validated by the one rulebook: `schema` and `order` are required of a lane
/// exactly as of a card (`IntegrityRules.requiresOrder`), so a malformed entry fails fast
/// whichever kind the discriminator would have called it.
@Test("A trashed lane missing order fails the load, like any entry")
func trashedLaneFailsFastOnOrder() throws {
/// Both kinds are read by the one rulebook: a trashed lane's absent `order` reads as
/// append-at-end exactly as a trashed card's does (re-ruled 2026-07-31), and the entry loads
/// whichever kind the discriminator calls it.
@Test("A trashed lane missing order reads like any other entry")
func trashedLaneWithoutOrderReadsAsAppendAtEnd() throws {
let fixture = try TrashFixture()
defer { fixture.tearDown() }
let lane = uuidName()
@@ -1104,9 +1106,10 @@ struct TrashKindDiscriminatorTests {
try fixture.index(".trash/\(lane)", "schema: 1\nkind: lane\n")
try fixture.index(".trash/\(lane)/\(uuidName())", "schema: 1\norder: 1024\n")
#expect(throws: BoardLoadError.self) {
try BoardLoader.load(boardRoot: fixture.root)
}
let result = try BoardLoader.load(boardRoot: fixture.root)
#expect(result.model.trashedLanes.map(\.id.rawValue) == [lane])
#expect(result.model.trashedLanes[0].order == 1024)
#expect(result.model.trashedLanes[0].heldCards == 1)
}
/// The column is one list interleaved by **`modified` descending** (03 § Trash, re-ruled