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
+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)