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

Nine rulings land as code. Reorders don't stamp — one container-change
predicate (WriteOperation.rewritesOrderOnly): within-container reorders
and the renumber rescale rewrite only order, while cross-lane, cross-board,
and trash moves stamp modified and clear modified-by; no trash special
case exists, and the m8 undo inverses conform through the same seam.
Copies are transactions: the root-strict/nested-lenient split retires for
a whole-subtree stampability preflight that refuses loudly naming the
offender, and every item-level copy severs remote/remote-state at every
level (whole-board forks carry them verbatim). Paste refuses, never
degrades: the embedded-index.md materialization and its loss row retire;
a missing staged snapshot produces nothing and posts an error-tone
one-shot named from manifest metadata. Coerce-tier fallbacks log through
the Defect stream with path context attached loader-side. Displacement is
level-uniform: a file squatting attachments inside a card heals by the
same rename ladder as board-root squatters; comments stays tolerated.
Delete Immediately joins card and lane context menus as Delete's
⌥-alternate with its own VO custom action, routed through an explicit
container so the menu target outranks standing selection. Agent guide v7
teaches the stamp discipline and the card-level attachments claim, and
sheds two stale v6 lines (lanes trash now; kind is taught). Verified
conformant, unchanged: edition-aware Undo/Redo disable, trash marquee
full-height backdrop.

Both schemes 1854 tests / 318 suites green; verify-editions 30/30.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-30 06:49:11 -04:00
parent 5ae48de0ea
commit 69084fdff7
27 changed files with 2159 additions and 542 deletions
+22 -12
View File
@@ -21,9 +21,11 @@ import UniformTypeIdentifiers
/// the full folder snapshots a paste reproduces byte-for-byte from and to a pending cut. It is
/// also the whole of "the snapshot survives relaunch exactly as long as the pasteboard still points
/// at it": a sweep keeps the one directory this id names and collects every other.
/// - Each `Entry` embeds the item's complete `index.md` text, so a paste still lands when the
/// snapshot is missing or unreadable "the staging-less fallback: content intact, attachments
/// absent", announced by a banner rather than discovered later.
/// - Each `Entry` embeds the item's complete `index.md` text as **identification metadata**
/// (04-interactions.md Clipboard, re-ruled 2026-07-29): menu validation, the refusal's wording,
/// and the plain-text flavor read it. It is emphatically **not** a materialization source a paste
/// whose staged snapshot is missing or unreadable refuses whole and writes nothing, because "an item
/// arrives whole index, attachments, loose files or not at all".
///
/// `kind` and `container` are the selection's own vocabulary (`SelectionKind`, `ItemContainer`) rather than
/// near-copies of it: a clipboard payload is a selection that was copied, and the cards-XOR-lanes and
@@ -67,15 +69,23 @@ public struct ClipboardManifest: Codable, Sendable, Equatable {
public var folder: String
/// The title as written, or `nil` for an untitled item "Untitled" is a rendering, never a
/// value (03-board-ui.md § Card face). Feeds the plain-text representation and the degraded
/// paste's banner.
/// value (03-board-ui.md § Card face). Feeds the plain-text representation and the refused
/// paste's banner, which names the offending entry from exactly this.
public var title: String?
/// The complete `index.md` at copy time the staging-less fallback's source bytes.
/// The complete `index.md` at copy time **identification metadata, never materialized**
/// (see the type comment). Kept because it is what lets the app answer "what was on the
/// clipboard" without touching the staging store: the plain-text flavor and a refusal's wording
/// both come from here, and both have to work when the snapshot is exactly what is missing.
public var index: String
/// How many files the item's own `attachments/` held. Zero for a lane, which has none; a
/// lane's attachments are its cards' and are counted there.
///
/// Identification metadata like the rest of the entry. It used to feed the degraded paste's
/// loss accounting ("Pasted 'Fix login' without its 3 attachments"), which is retired with the
/// degraded paste itself an item now arrives whole or not at all, so there is no partial
/// arrival left to count.
public var attachmentCount: Int
/// A **lane** entry's cards, index text and all "a lane entry embeds its cards' too,
@@ -83,9 +93,9 @@ public struct ClipboardManifest: Codable, Sendable, Equatable {
///
/// **Exactly the lane's cards**, which needs no filter: "a lane carries exactly its cards
/// the trash is board-level, so there is nothing lane-nested to strip or carry"
/// (04-interactions.md Drag and drop, resettled 2026-07-28), and the fallback only ever
/// materializes a copy a cut's move carries the real folder whole and never comes near this
/// array. So the embedded set is exactly what a fallback paste should produce.
/// (04-interactions.md Drag and drop, resettled 2026-07-28). Like the lane's own `index`, the
/// cards' text is identification metadata: it describes what the copy held, and nothing
/// materializes from it.
public var cards: [Card]
/// One card inside a copied lane.
@@ -103,9 +113,9 @@ public struct ClipboardManifest: Codable, Sendable, Equatable {
}
}
/// Everything a fallback paste of this entry would leave behind its own attachments plus,
/// for a lane, its cards'.
public var lostAttachmentCount: Int {
/// Every file this entry's subtree carried in an `attachments/` its own plus, for a lane, its
/// cards'. Identification metadata; nothing gates on it since the degraded paste retired.
public var totalAttachmentCount: Int {
attachmentCount + cards.reduce(0) { $0 + $1.attachmentCount }
}
+38 -34
View File
@@ -14,8 +14,10 @@ import os
/// folder trees, attachments and strays and all is **staged** under
/// `<group container>/Library/Application Support/Clipboard/<copyID>/`, so a paste reproduces the item
/// byte-for-byte across boards rather than reconstructing it from a summary. The manifest's embedded
/// `index.md` per entry is the fallback when a snapshot is missing, and a fallback paste is **loud**:
/// a banner names exactly what was lost.
/// `index.md` per entry is **identification metadata only** menu validation, the refusal's wording,
/// the plain-text flavor and never a materialization source: a paste whose staged snapshot is
/// missing or unreadable **refuses whole and writes nothing** (04-interactions.md Clipboard,
/// re-ruled 2026-07-29 Finder's invariant: an item arrives whole or not at all).
///
/// **The store is shared by every installed edition** (12-editions.md Both editions installed, ruled
/// 2026-07-29): the group container is one container, so C in base pastes full-fidelity in Pro. The
@@ -116,8 +118,9 @@ public final class ClipboardStore {
/// > edition pastes **full-fidelity** in the other snapshot, attachments and all.
///
/// Nothing about the lifecycle changes: both editions read the same pasteboard, so both sweeps
/// compute the same answer from the same input, and the degraded embedded-`index.md` fallback stays
/// for genuinely missing snapshots rather than being the structural cross-edition outcome.
/// compute the same answer from the same input. The shared home is also what keeps the refusal a
/// rare corner rather than the structural cross-edition outcome a copy in one edition pastes
/// full-fidelity in the other, so neither has to reach for bytes that are not there.
public static var defaultStagingRoot: URL {
AppGroup.stateDirectory.appendingPathComponent("Clipboard", isDirectory: true)
}
@@ -182,9 +185,10 @@ public final class ClipboardStore {
/// The order is the contract: capture from the snapshot (main actor, no I/O every item's
/// `index.md` is already parsed into the snapshot and `FrontmatterDocument.serialized()` returns
/// it verbatim), schedule the snapshots behind it, then write the pasteboard, then sweep. The
/// pasteboard is written *before* the copies land, which is safe precisely because the manifest
/// carries the fallback text: a paste that somehow beat the chain would still materialize the
/// right items.
/// pasteboard is written *before* the copies land, which is safe because a paste **awaits the same
/// chain** (`paste(into:)`): it can never read a half-written snapshot, so it never sees a tree the
/// staging has not finished. This used to lean on the manifest's fallback text instead; with
/// refuse-don't-degrade the chain is the whole guarantee, and it is the stronger one.
private func write(from store: BoardStore, cut: Bool) {
guard let capture = Self.capture(selection: store.selection, snapshot: store.snapshot) else { return }
@@ -348,9 +352,8 @@ public final class ClipboardStore {
// doing nothing.
guard payload?.copyID == manifest.copyID else { return }
store.transient.noteUserCreation()
if let move = armedMove(for: manifest) {
store.transient.noteUserCreation()
let sources = move.folders.map(BoardStore.ItemSource.folder)
switch plan {
case let .cards(target):
@@ -373,31 +376,29 @@ public final class ClipboardStore {
return
}
// The copy path the staged snapshot per entry, or the embedded `index.md` where that
// snapshot is missing or unreadable. Mixed is legal and is the honest outcome of a partial
// staging failure: the entries that have snapshots arrive whole.
// **The copy path's preflight: refuse, never degrade** (04-interactions.md Clipboard,
// re-ruled 2026-07-29). Every entry must have its staged snapshot on disk *before* anything is
// materialized the first one that does not refuses the whole paste, names itself from the
// manifest's metadata, and writes nothing at all. All-or-nothing for the whole paste, which is
// the copies-are-transactions posture (01-storage-format.md § Frontmatter) read one level up:
// the transaction is the gesture, not the entry.
let stagingDir = stagingRoot.appendingPathComponent(manifest.copyID, isDirectory: true)
var sources: [BoardStore.ItemSource] = []
var losses: [BannerCenter.AttachmentLoss] = []
for entry in manifest.entries {
let staged = stagingDir.appendingPathComponent(entry.folder, isDirectory: true)
if FileManager.default.fileExists(
guard FileManager.default.fileExists(
atPath: staged.appendingPathComponent(BoardLoader.indexFileName).path
) {
sources.append(.folder(staged))
continue
}
sources.append(.text(index: entry.index, cards: entry.cards.map(\.index)))
// "A degraded paste is loud, never silent a one-shot banner names exactly what was
// lost." An entry with no attachments lost nothing its content is intact and its bytes
// are the source bytes so it contributes no row.
if entry.lostAttachmentCount > 0 {
losses.append(BannerCenter.AttachmentLoss(
title: entry.title,
attachments: entry.lostAttachmentCount
))
) else {
// The offending entry, named and the destination's search is left exactly as it was.
// "Any user-initiated creation on the board clears the query" (04 Search) is a rule
// about creations, and this paste created nothing; the preflight therefore runs *before*
// `noteUserCreation`, so a refusal costs the user neither content nor their filter.
store.banners.postRefusedPaste(title: entry.title, stagedAt: staged.path)
return
}
sources.append(.folder(staged))
}
store.transient.noteUserCreation()
// A card copied out of the trash needs nothing done to it on arrival: it carries no
// `deleted:` key, because there is no such key any more (03-board-ui.md § Trash, resettled
@@ -419,7 +420,6 @@ public final class ClipboardStore {
normalizingLooseFiles: true
)
}
store.banners.postDegradedPaste(losses)
}
/// The armed cut's surviving originals, in flatten order and as folders under the **source**
@@ -486,9 +486,11 @@ public final class ClipboardStore {
let destination: URL
}
/// Appends this copy's snapshots to the staging chain. Best-effort per item: one that fails to
/// copy simply falls back to the manifest's embedded `index.md` at paste time, which is the
/// degraded paste the banner already has words for.
/// Appends this copy's snapshots to the staging chain. Best-effort per item, and the *consequence*
/// of a failure changed with the refuse-don't-degrade ruling: an item whose snapshot never landed
/// makes the next paste **refuse whole**, naming it (`perform`'s preflight), rather than
/// materializing it hollow from the manifest's embedded `index.md`. Failing to stage is therefore
/// as loud as it should be, one gesture later.
private func stage(_ jobs: [StagingJob], into stagingDir: URL) {
enqueue { [jobs, stagingDir] in
guard (try? FileManager.default.createDirectory(
@@ -628,9 +630,11 @@ public final class ClipboardStore {
/// the app already states once.
///
/// **The index text comes from the snapshot, not from disk.** `FrontmatterDocument` edits by line
/// span, so `serialized()` on an untouched document returns the file's bytes exactly which
/// makes the manifest's fallback text genuinely *the source bytes* while costing C no file I/O
/// at all, even for a lane carrying two hundred cards.
/// span, so `serialized()` on an untouched document returns the file's bytes exactly which makes
/// the manifest's embedded text a faithful record of the item while costing C no file I/O at all,
/// even for a lane carrying two hundred cards. It is **identification metadata**, not a
/// materialization source (see the type comment): the refusal's wording and the plain-text flavor
/// read it, and nothing writes it.
static func capture(
selection: ItemReferenceSet,
snapshot: BoardModel
+29 -9
View File
@@ -8,7 +8,8 @@ import os
///
/// A template is a board folder, so discovery is `BoardLoader.load` and instantiation is a tree copy
/// plus the Writer's own remint-and-restamp machinery (`BoardWriter.remintDescendants`,
/// `stampCopiedDescendant`, `updateIndex`). There is no template schema, no template catalog in
/// `checkCopiedDescendantsAreStampable`, `applyCopyContract`, `stampCopiedDescendant`,
/// `updateIndex`). There is no template schema, no template catalog in
/// Swift, and no second copy path which is 09's "dogfood" clause and 02-architecture.md's single
/// write door, both held by having nothing here to hold them with.
///
@@ -379,19 +380,35 @@ enum TemplateEngine {
/// The born half, on the tree already at the destination: fresh identities, fresh stamps, the
/// chosen title, and the loose-file normalization an import boundary owes.
///
/// **The root is strict and the descendants are lenient**, which is `BoardWriter.copyItem`'s
/// split for its reason: the root *must* be rewritten (it carries the title the user just typed),
/// so a template whose own `index.md` cannot be edited in place refuses the create while a
/// nested card that is readable-but-uneditable is copied byte-verbatim and simply not stamped,
/// because failing a whole create over one hand-dropped flow mapping would be hostile. Its stale
/// `modified-by` surviving is the self-reported-provenance honest limit 01 § Frontmatter already
/// acknowledges.
/// **An instantiation is a copy transaction**, which is `BoardWriter.copyItem`'s posture since
/// 2026-07-29 and for its reason: the whole tree is preflighted for stampability before a single
/// folder is reminted, and a template carrying one readable-but-uneditable card refuses the create
/// whole, naming that card (01-storage-format.md § Frontmatter: "preflights the entire subtree and
/// refuses whole, loudly, naming the offending item never a partial copy, never a silently
/// unstamped descendant").
///
/// This retired the former root-strict/descendants-lenient split, which copied such a card
/// byte-verbatim and skipped its stamp. Two things were wrong with the kindness: an unstamped
/// descendant keeps a `modified-by` naming somebody who never touched this board, and since the
/// tracker sever joined the copy contract a live `remote` claim on an object the new board has
/// no relationship with. "Proceed partially, lose a little" is never a verdict (01's leniency
/// doctrine).
///
/// **The preflight runs on the destination, not the template**, deliberately: the copy has already
/// applied its top-level exclusions, so `.trash/`'s cards which are not part of what a template
/// instantiates cannot refuse a create they were never going to appear in. Nothing is lost by
/// preflighting a step later, because `instantiate`'s construct-then-clean removes the whole
/// destination on any throw (09-templates.md's atomicity).
private static func mintIdentitiesAndStamps(
at root: URL,
title: String,
operation: WriteOperation
) throws(Failure) {
do throws(BoardWriteError) {
// Before the remint, so a refusal names folders by the paths the user's template actually
// has rather than by minted UUIDs they have never seen.
try BoardWriter.checkCopiedDescendantsAreStampable(of: root, operation: operation)
var materialized: [URL] = []
try BoardWriter.remintDescendants(of: root, collecting: &materialized, operation: operation)
@@ -402,7 +419,10 @@ enum TemplateEngine {
// cannot answer, and this write is where a template's kind-less root gains it
// (`BoardWriter.updateIndex`'s on-touch backfill; no template migration, by design).
try BoardWriter.updateIndex(inItemFolder: root, kind: .board, operation: operation) { document in
document.set(FrontmatterKeys.created, to: .date(now))
// `.born` restamps `created`; the copy contract also severs the reserved tracker keys,
// which at board level is the `remote` a template could have carried in from the board
// it was saved from (01 Identity lifecycle, ruled 2026-07-29).
BoardWriter.applyCopyContract(to: &document, stamps: .born, now: now)
document.set(FrontmatterKeys.title, to: .string(title))
}
for folder in materialized {
+52 -71
View File
@@ -37,8 +37,8 @@ public struct OneShotBanner: Identifiable, Sendable, Equatable {
}
/// 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) a
/// degraded paste, folders skipped from a Finder drop, their future kin.
/// 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.
///
/// **It takes the one-shot's lifecycle**, deliberately: "a loss the user didn't notice is the harm,
/// so it never auto-expires" is `OneShotBanner`'s "an error never evaporates unread", read for a row
@@ -173,8 +173,8 @@ public enum BannerRow: Identifiable, Sendable {
case reloadBreakage(BoardLoadError)
/// A write that did not happen. Dismissable, error tone.
case oneShot(OneShotBanner)
/// Content that didn't arrive though nothing failed a degraded paste, folders skipped from a
/// Finder drop, their future kin. Dismissable, warning tone: below the true failures above it,
/// 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`).
case loss(LossBanner)
/// History has stopped advancing. Condition, warning tone the files are safe, only the undo
@@ -392,43 +392,36 @@ public final class BannerCenter {
postSignpost(Self.skippedStepMessage(direction, subject: subject))
}
/// One item a degraded paste could not bring its attachments with what
/// `degradedPasteMessage(for:)` names.
/// **The refused paste** (04-interactions.md Clipboard, re-ruled 2026-07-29): the staged
/// snapshot was missing or unreadable, so the paste produced **nothing**, and this is the row that
/// says so "the paste produces nothing, and a one-shot failure banner names it from the
/// manifest's metadata".
///
/// `title` is the item's as written, `nil` for an untitled one: "Untitled" is a rendering, never
/// a value (03-board-ui.md § Card face), and the phrasing below says "the item" instead, exactly
/// as `actionPhrase(for:)` does for a failure whose title never got read.
public struct AttachmentLoss: Sendable, Equatable {
public let title: String?
public let attachments: Int
public init(title: String?, attachments: Int) {
self.title = title
self.attachments = attachments
}
}
/// **The degraded paste** (04-interactions.md Clipboard, settled): the staged snapshot was
/// missing or unreadable, so the paste fell back to the manifest's embedded `index.md` content
/// intact, attachments absent and this is the row that says so. "A degraded paste is loud,
/// never silent the user never discovers an empty `attachments/` later."
/// **A `oneShot`, not a loss row** which is the pivot, and it is the vocabulary reading the
/// event correctly rather than a reclassification for its own sake. The degraded paste *was* a loss
/// row because the items landed and only their attachments did not: content that didn't arrive
/// though nothing failed. Under refuse-don't-degrade nothing lands at all, which is exactly
/// 02-architecture.md's definition of a one-shot a write that did not happen so the row
/// carries a `BoardWriteError` like every other failure, ranks with the true failures, and says
/// "Couldn't paste" rather than "Pasted without".
///
/// **A loss row, not a `oneShot` and not a signpost** the vocabulary's answer rather than a
/// compromise (settled 2026-07-28). 02-architecture.md's `oneShot` is *a write that did not
/// happen*, carrying a `BoardWriteError`, and nothing here failed the items landed, whole but
/// for files that were never on the pasteboard's side of the transfer. This row first shipped as
/// a signpost, the vocabulary's other one-shot-lifecycle member at the time, and it read quieter
/// than 04's "loud" deserved: a signpost ranks last and may collapse behind "+N more", exactly
/// where a board already showing real trouble would bury it. The loss class exists to close that
/// gap content that didn't arrive though nothing failed ranks below the true failures and
/// above the ambient notices, keeping the signpost's dismissable, untimed lifecycle without
/// inheriting its bottom-of-the-strip precedence.
/// The retired member is `postDegradedPaste(_:)` and its `AttachmentLoss` payload: with the
/// degraded materialization gone there is no partial arrival to account for, and the
/// loss-accounting problem it existed to report what didn't arrive, and whether the totals were
/// honest dissolves rather than being solved. **The loss class itself is untouched**: folder-drop
/// skips still post one (`postSkippedFolders`), and the relocation, migration, displacement and
/// remint notices are all still its.
///
/// An empty list posts nothing: a fallback that lost no attachments lost nothing at all, and a
/// banner announcing that would be noise.
public func postDegradedPaste(_ losses: [AttachmentLoss]) {
guard let message = Self.degradedPasteMessage(for: losses) else { return }
postLoss(message)
/// `title` is the offending entry's, from the manifest's own metadata, `nil` for an untitled item
/// "Untitled" is a rendering, never a value (03-board-ui.md § Card face), so `actionPhrase(for:)`
/// says "the item" instead. The staging path is what the error names as its `path`: it is the file
/// that was not there, and naming it is what makes a bug report about this actionable.
public func postRefusedPaste(title: String?, stagedAt path: String) {
post(BoardWriteError(
operation: .paste(title: title),
path: path,
reason: .clipboardContentGone
))
}
/// One card whose loose files were relocated into `attachments/` what
@@ -547,9 +540,10 @@ public final class BannerCenter {
/// Posts the skipped-folders loss row for a Finder drop that imported its files but refused its
/// folders (04-interactions.md Selection, drag & drop, "Folders are refused at hover"): "a
/// mixed drag proposes for its files only, and the drop imports the files while a one-shot
/// banner names the skipped folders" now a loss row, for the same reason the degraded paste is
/// one (settled 2026-07-28): folders that never arrived are a non-failure loss, not a write
/// failure.
/// banner names the skipped folders" now a loss row (settled 2026-07-28): folders that never
/// arrived are a non-failure loss, not a write failure. With the degraded paste retired
/// (`postRefusedPaste`) this is the loss class's clearest remaining instance: the drop *did* land,
/// and only the payload the attachment model cannot hold stayed behind.
///
/// A drop with no skipped folders posts nothing nothing was lost, so there is nothing to say.
public func postSkippedFolders(count: Int) {
@@ -653,7 +647,7 @@ public final class BannerCenter {
///
/// `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 degraded paste already posts one (`postDegradedPaste`).
/// included, since a Finder drop that skipped folders already posts one (`postSkippedFolders`).
public nonisolated static func rows(
lock: ReadOnlyLockReason?,
breakage: BoardLoadError?,
@@ -756,6 +750,13 @@ public final class BannerCenter {
if let title { "Couldn't reorder '\(title)'" } else { "Couldn't reorder the item" }
case let .copy(title):
if let title { "Couldn't copy '\(title)'" } else { "Couldn't copy the item" }
case let .paste(title):
// **The command's own verb** (04-interactions.md Clipboard's own example sentence,
// "Couldn't paste 'Fix login' the copied content is gone"). The user pressed V, and
// "Couldn't copy" the operation the Writer would have run would name a gesture they
// never made. The item is named from the manifest's metadata, which is what the embedded
// `index.md` is kept for now that it is never a materialization source.
if let title { "Couldn't paste '\(title)'" } else { "Couldn't paste the item" }
case let .delete(title):
if let title { "Couldn't delete '\(title)'" } else { "Couldn't delete the item" }
case let .purge(title):
@@ -865,6 +866,13 @@ public final class BannerCenter {
// outlet raises this in its alert and never here the store validates before it opens a
// write bracket so this line exists for a caller that reached the Writer directly.
error.reason.description
case .clipboardContentGone:
// **04's own words** ("the copied content is gone"), and the whole of what can honestly be
// said: the snapshot the pasteboard promised is not on disk, so there is no cause to
// diagnose past that. It deliberately says nothing about *why* a sweep that ran early, a
// container the system reclaimed, an unmounted volume because the user's recovery is the
// same in every case, and 04 names it: C again.
"the copied content is gone"
}
return trimmed(text)
}
@@ -904,32 +912,6 @@ public final class BannerCenter {
return "\(subject): \(reason) — showing the last good view"
}
/// The degraded paste's line 04-interactions.md's own example sentence, "Pasted 'Fix login'
/// without its 3 attachments", generalized over the two axes it can vary on.
///
/// **It names exactly what was lost**, which is what the design asks for and what decides every
/// choice below: the count is real (never "some"), the singular and the plural are both spelled,
/// and a multi-item paste totals the attachments rather than listing every title a banner is one
/// line, and "2 items" plus the true total is the honest summary where a truncated list would not
/// be. `nil` for an empty list: nothing was lost, so there is nothing to say.
///
/// The count is the item's `attachments/` as the snapshot listed it at copy time the design's
/// own vocabulary for what a card carries (01-storage-format.md § Attachments). A stray file
/// sitting loose in the card folder is not in it and is not named here; see the report's
/// design-gap note.
public nonisolated static func degradedPasteMessage(for losses: [AttachmentLoss]) -> String? {
guard !losses.isEmpty else { return nil }
let total = losses.reduce(0) { $0 + $1.attachments }
guard total > 0 else { return nil }
guard losses.count == 1, let only = losses.first else {
return "Pasted \(losses.count) items without their \(total) attachments"
}
let subject = only.title.map { "'\($0)'" } ?? "the item"
let tail = total == 1 ? "its attachment" : "its \(total) attachments"
return "Pasted \(subject) without \(tail)"
}
/// The skipped-folders line 04-interactions.md's own example, "Folders can't be attached 2
/// skipped", generalized over the count. `postSkippedFolders` never calls this at `count == 0`,
/// so every real call already has something to report.
@@ -954,9 +936,8 @@ public final class BannerCenter {
/// a list of names would be the first thing to truncate; the card is still named, which is
/// what makes the notice actionable the user knows exactly which `attachments/` to look in.
/// - **Several cards** folds again, to two counts: "Moved 5 files into attachments 3 cards"
/// (settled here, the judgment 01 leaves to the implementation). It is the degraded paste's
/// own shape "Pasted 2 items without their 5 files" and for its reason: the true total
/// plus the true item count is the honest summary where a truncated list of titles would not
/// (settled here, the judgment 01 leaves to the implementation): the true total plus the true
/// item count is the honest summary where a truncated list of titles would not
/// be. This case is the whole-board sweep (a board opened after an agent scattered files
/// across it), where naming three cards of eleven would read as a bug.
///
+61 -46
View File
@@ -2495,21 +2495,25 @@ public final class BoardStore: HealHost {
/// Where one arriving item's bytes come from.
///
/// **Two producers, one arrival path.** A drag names folders in the source board; a paste names
/// folders in the clipboard's staging directory and, when that snapshot is missing or
/// unreadable, the manifest's embedded `index.md` instead (04-interactions.md Clipboard's
/// staging-less fallback). Modelling the fallback as a second kind of *source* rather than as a
/// second arrival method is what keeps the rank insertion, the tombstone stripping and the
/// `deleted:` clearing stated once: everything downstream of "where do the bytes come from" is
/// identical, and a paste that half-falls-back mixes the two cases inside one bracket.
/// **One producer shape, because there is only one kind of source left.** A drag names folders in
/// the source board; a paste names folders in the clipboard's staging directory. There used to be a
/// second case the manifest's embedded `index.md`, materialized when the staged snapshot was
/// missing or unreadable and it is **retired with the degraded paste** (04-interactions.md
/// Clipboard, re-ruled 2026-07-29: "A paste whose staged snapshot is missing or unreadable refuses
/// loudly never degrades"). An item arrives **whole index, attachments, loose files or not at
/// all**, so a paste can no longer half-fall-back inside one bracket, and the loss-accounting
/// problem the second case created dissolves rather than being solved. The manifest still embeds
/// `index.md`, now purely as identification metadata: menu validation, the refusal's wording, and
/// the plain-text flavor.
///
/// It stays a single-case enum rather than collapsing to a bare `URL`: the arrival paths read as
/// "where do these bytes come from", the drag and the clipboard each say so at their own call
/// sites, and a future third producer (an import, a drop from another document type) has a place to
/// land that is not a rewrite of every signature in between.
public enum ItemSource: Sendable, Equatable {
/// A folder on disk the source board's own, or a staged snapshot of it.
case folder(URL)
/// The manifest's embedded text: the item's `index.md`, and (for a lane) its cards'.
/// Materialized by `BoardWriter.materializeItem`, byte-faithfully.
case text(index: String, cards: [String])
}
/// A cross-board card drop, landing contiguously at `index` among `laneID`'s rendered cards.
@@ -2535,10 +2539,11 @@ public final class BoardStore: HealHost {
/// independently (04-interactions.md Clipboard).
///
/// It is the same commit as a drop's, deliberately: `.copy` materializes from the staged snapshot
/// (or, per entry, from the embedded `index.md`) and `.move` is the armed cut's "the -drag
/// move path identity travels", which is also the keyboard restore when the cut was made in the
/// trash ( The trash: "cut in the trash, paste into a lane is the keyboard-native restore, an
/// ordinary folder move").
/// and `.move` is the armed cut's "the -drag move path identity travels", which is also the
/// keyboard restore when the cut was made in the trash ( The trash: "cut in the trash, paste into a
/// lane is the keyboard-native restore, an ordinary folder move"). A copy whose staged snapshot is
/// missing never reaches here at all: the clipboard refuses the whole paste in front of this call
/// (04 Clipboard, re-ruled 2026-07-29), so every source this sees is a folder that exists.
///
/// **The trash's old copy-out rule is gone with the tombstone it stripped** (resettled
/// 2026-07-28): a trashed card carries no `deleted:` key, so a card copied out of the trash is
@@ -2616,15 +2621,14 @@ public final class BoardStore: HealHost {
}
}
/// One arrival's materialization the two `ItemSource` kinds crossed with the two operations,
/// in the one place both the card path and the lane path can share.
/// One arrival's materialization the source crossed with the two operations, in the one place
/// both the card path and the lane path can share.
///
/// **`.move` of a `.text` source is unreachable and answers `nil`.** A move needs a folder whose
/// identity travels, and the only producer of text sources is the clipboard's fallback, which is
/// a *copy* by construction (04-interactions.md Clipboard: an armed cut moves the surviving
/// originals, and a cut that cannot find them is void). Skipping is the standing posture for an
/// arrival that names nothing the same silent no-op every other drop commit gives a source that
/// has gone.
/// A copy is `copyItem` (fresh GUIDs throughout, the copy contract applied to every folder it
/// materializes) and a move is `moveItem` (identity travels, the import boundary reminting only
/// what collides). The `ItemID?` return survives the retired text case because the lane and card
/// arrival loops read it as "did this arrival land": a `nil` is the standing silent no-op for a
/// source that names nothing.
private static func materialize(
_ source: ItemSource,
operation: TransferOperation,
@@ -2644,15 +2648,6 @@ public final class BoardStore: HealHost {
destinationBoardRoot: destinationBoardRoot,
order: order
).id
case let (.text(index, cards), .copy):
return try BoardWriter.materializeItem(
inParent: parent,
indexText: index,
children: cards,
order: order
)
case (.text, .move):
return nil
}
}
@@ -2677,7 +2672,7 @@ public final class BoardStore: HealHost {
)
}
/// **The clipboard's lane arrival** `receiveLanes` with the staging-less fallback folded in
/// **The clipboard's lane arrival** `receiveLanes` with the paste's own axis folded in
/// (04-interactions.md Clipboard).
///
/// The two operations keep their drag semantics exactly, because 04 says they are the same
@@ -3445,8 +3440,19 @@ public final class BoardStore: HealHost {
/// The selection is cleared rather than walked to a successor: unlike , this is the command a
/// confirmation stands in front of, and what follows it is reading the board rather than pressing
/// the key again.
public func deleteImmediately(_ ids: Set<ItemID>) {
let container = selection.container
///
/// - Parameters:
/// - ids: the items to purge the caller's explicit set, not this store's selection.
/// - container: **which side those ids live on**, supplied rather than read off the selection
/// (added with the context menu's -alternate 11-command-nexus.md Context menus). The
/// menu-bar caller passes the selection's own container and behaves exactly as before; a
/// context-menu caller passes `.board`, because "the click names its target" and the target can
/// legitimately sit on the other side of the container boundary from a standing selection
/// (797d020's explicit-set resolution, whose whole point is that the two can disagree). Reading
/// it off the selection here would silently resolve a board card against `.trash` and purge
/// nothing a confirmed destructive command turning into a no-op, which is the one outcome a
/// confirmation must never lead to.
public func deleteImmediately(_ ids: Set<ItemID>, in container: ItemContainer) {
let paths = ItemPath.resolve(ids, in: container, snapshot: snapshot).filter { !$0.isLane }
guard !paths.isEmpty else { return }
let root = rootURL
@@ -3604,19 +3610,28 @@ public final class BoardStore: HealHost {
// MARK: - The claimed-name displacement
/// Moves a squatter off a board-root name the app claims, and posts the notice naming old and
/// new the **act** half of the claimed-names ruling (01-storage-format.md § Fractal layout
/// Rules, ruled 2026-07-29: "Lanework owns the board, so an invalid artifact on a claimed name is
/// a defect, not a resident").
/// Moves squatters off the names the app claims, and posts the notice naming old and new the
/// **act** half of the claimed-names ruling (01-storage-format.md § Fractal layout Rules, ruled
/// 2026-07-29: "Lanework owns the board, so an invalid artifact on a claimed name is a defect, not
/// a resident").
///
/// Today that is `.trash` and only `.trash`: a file or symlink sitting on the name the trash
/// needs, which breaks deletion for as long as it stands which is exactly why the timing is
/// *scheduled* rather than on-touch (§ Validation and healing: "proactive when the defect is
/// load-bearing now"). `CLAUDE.md`'s squatter is displaced by the guide's own heal, which owns
/// that file end to end.
/// **Two levels, one heal** (extended 2026-07-29 "the rule is level-uniform"): the board root's
/// `.trash`, and any card's `attachments`. Each is a file or symlink sitting on a name the app needs
/// breaking deletion in the first case, and every import, Finder drop and sidebar listing for that
/// card in the second which is exactly why the timing is *scheduled* rather than on-touch
/// (§ Validation and healing: "proactive when the defect is load-bearing now"). `CLAUDE.md`'s
/// squatter is displaced by the guide's own heal, which owns that file end to end; a wrong-kinded
/// `comments` is a tolerated stray until the feature consumes the name.
///
/// **Displacement, never destruction**, and never a mint: the freed name is left empty and the
/// *next delete* creates the real `.trash/`, exactly as it does on a board that never had one.
/// **One bracket over every displacement the load found**, whatever their levels: the batch is one
/// app-mediated reload and, on git boards, one heal commit the loose-file relocation's rule, and
/// this heal's own memo is board-wide anyway.
///
/// **Displacement, never destruction**, and never a mint: the freed name is left empty and the next
/// gesture that needs it creates the real folder the next delete mints `.trash/`, the next import
/// mints `attachments/` exactly as on a board that never had one. The displaced file, now an
/// ordinary loose file beside the card's `index.md`, is picked up by the next load's loose-file
/// relocation and lands in the real `attachments/`: the heals compose, which is 01's own word for it.
public func displaceClaimedNames() {
let work = claimedNameSquatters
let root = rootURL
+47 -14
View File
@@ -55,8 +55,16 @@ enum AgentGuide {
/// pointer) and supersedes it on the next open. v6 adds the one-folder-at-a-time move warning:
/// a real agent incident (2026-07-29) showed `mv <lane>/*` sweeping the lane's own `index.md`
/// along with the cards and destroying the destination lane's identity the guide now says
/// *why* the named-folder form is load-bearing, not just what to type.
static let version = 6
/// *why* the named-folder form is load-bearing, not just what to type. v7 teaches two more
/// things settled after v6 shipped: the refined stamp-discipline predicate
/// (01-storage-format.md `modified`'s scope, ruled 2026-07-29, refined 2026-07-30) a
/// within-container reorder rewrites only `order`, while a move that changes an item's
/// container (another lane, another board, into or out of `.trash/`) stamps `modified` and
/// `modified-by` like any content edit, the trash move included, since it's the same rule and
/// not a special case and the card-level `attachments` claimed name
/// (01-storage-format.md § Fractal layout Rules, "level-uniform"): that name belongs to the
/// app's own folder, so a *file* by that name is a defect the app displaces on sight.
static let version = 7
// MARK: - The version marker
@@ -325,7 +333,7 @@ enum AgentGuide {
<board>/ this folder (the board)
index.md board title + settings; body = board description
CLAUDE.md this guide (app-maintained)
.trash/ deleted cards (app-managed see Deleting)
.trash/ deleted cards and lanes (app-managed see Deleting)
<uuid>/ a LANE
index.md lane title + order; body = lane notes/policy
<uuid>/ a CARD
@@ -350,7 +358,7 @@ enum AgentGuide {
- 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.
- `.trash/` holds deleted cards; everything else at board root that isn't a
- `.trash/` holds deleted cards and lanes; everything else at board root that isn't a
UUID-named folder is not part of the board's content.
## Frontmatter
@@ -397,6 +405,7 @@ enum AgentGuide {
```markdown
---
schema: 1
kind: card
title: Short imperative card title
order: 3072
created: 2026-07-24T18:00:00Z
@@ -406,11 +415,24 @@ enum AgentGuide {
The card's content any Markdown.
```
Creating a lane is the same one level up (body optional; `order` ranks
lanes leftright).
Creating a lane is the same one level up (body optional; `kind: lane`;
`order` ranks lanes leftright).
**Always write `kind`** at creation `kind: card`, `kind: lane`,
`kind: board` at board root. Depth already says what an item is on the
board, but `.trash/` is flat, and there the value is the only thing that
tells a trashed lane from a card. Omitting it is healable, never fatal: the
app fills a missing `kind` in the next time it rewrites that file.
## Moving and reordering
- **The stamp rule**: a move that changes an item's container another
lane, another board, or into/out of `.trash/` stamps `modified` and
re-stamps `modified-by`, the same as any content edit. A reorder that
keeps an item in the same container (a card among its lane's cards, a
lane among the board's) rewrites only `order`; leave `modified` and
`modified-by` alone. The trash move isn't an exception to this it
stamps because every container change stamps.
- Move to another lane: `mv <laneA>/<card-uuid> <laneB>/` the folder move
IS the move. Then set the card's `order` to place it among the
destination's cards, update `modified`, and re-stamp `modified-by`.
@@ -418,28 +440,39 @@ enum AgentGuide {
A lane folder holds its own `index.md` beside its cards, so a glob
sweeps the lane's identity file along with them and overwrites the
destination lane's.
- Reorder within a lane: rewrite only that card's `order`.
- Reorder within a lane: rewrite only that card's `order` don't touch
`modified` or `modified-by`.
## Editing and deleting
- Edit bodies freely; update `modified` on every write. Preserve frontmatter
keys you don't recognize and don't reformat content you didn't change.
- **Delete a card = move it into `<board>/.trash/`**: `mv <lane>/<card-uuid>
<board>/.trash/` (create `.trash/` if missing). Arrivals go on top: set
the card's `order` to the smallest `order` already in `.trash/` minus
1024 (empty trash: any number), and update `modified`. Restore is the
same move in reverse into a lane, with a fresh `order`.
- **Delete a card or a lane = move its folder into `<board>/.trash/`**:
`mv <lane>/<card-uuid> <board>/.trash/`, or `mv <lane-uuid>
<board>/.trash/` for a whole lane (create `.trash/` if missing). A lane
travels with its cards inside it. Arrivals go on top: set the moved
item's `order` to the smallest `order` already in `.trash/` minus 1024
(empty trash: any number). It's a container change like any other move
(Moving and reordering above): stamp `modified` and re-stamp
`modified-by`. Restore is the same move in reverse a card into a lane,
a lane back to board root, with a fresh `order`, stamped the same way.
- **Stamp `kind: lane` when you trash a lane that lacks it.** `.trash/` is
flat, so an empty lane folder looks exactly like a card folder; the `kind`
value is what tells them apart in there.
- Never write a `deleted:` key that convention is retired; the app
migrates any it finds.
- Remove a folder outright (`rm -r`) only when you mean permanent,
unrecoverable deletion. Lanes have no trash: deleting a lane folder is
permanent, so be sure.
unrecoverable deletion the trash is the recoverable path for both
cards and lanes.
## Attachments
- A card's files live in `attachments/` inside the card folder, flat at its
top level. **Put files there, never beside `index.md`** the app
relocates loose files into `attachments/` and tells the user it did.
The name `attachments` itself belongs to that folder never create a
*file* called `attachments` in a card; the app treats one as a defect
and displaces it on sight.
- To attach a file: create `attachments/` if missing and copy the file in.
If the name is taken, pick a free one Finder-style (`shot.png`
`shot 2.png`) never overwrite.
+50
View File
@@ -168,6 +168,27 @@ public enum BoardLoader: Sendable {
// views over it (`LoadResult.looseCardFiles`, `.legacyTombstones`).
var defects: [IntegrityRules.Defect] = []
// **The coerce tier's trace** (01-storage-format.md § Frontmatter, ruled 2026-07-29: "A
// no-sensible-reading fallback logs: field, path, and raw text, carried as coerce-tier entries
// in the integrity service's Defect stream"). Called once per document this walk parses, at
// every level, because the rule is about *fields* and every level has them and called here
// rather than inside `readDocument` for the reason the whole defect stream lives in `load`: the
// reading functions are pure and total, and the walk is what owns what it found.
//
// `logger.info`, not `warning`: the value rendered as its default, nothing is degraded, and the
// line exists to be findable later rather than to be noticed now ("no banner, no behavior
// change").
func noteCoercions(in document: FrontmatterDocument, at path: String) {
let fields = document.coercedFields
guard !fields.isEmpty else { return }
defects.append(.coercedFrontmatter(CoercedFrontmatter(path: path, fields: fields)))
for field in fields {
logger.info(
"\(path, privacy: .public): '\(field.key, privacy: .public)' has no sensible reading — \(field.raw, privacy: .public) — rendering the field's default"
)
}
}
// Detected before the walk, so a board whose `.trash` is squatted reports it even though
// the trash read below finds nothing to parse. Read-only here, like every other detection:
// the displacement is the store's, through the Writer (the Repair precedent).
@@ -187,6 +208,8 @@ public enum BoardLoader: Sendable {
warn(.boardLevelDeletedIgnored)
}
noteCoercions(in: boardDocument, at: indexFileName)
// 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
@@ -211,6 +234,7 @@ public enum BoardLoader: Sendable {
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)
var cards: [Card] = []
for cardURL in try directoryCandidates(in: laneURL) {
@@ -226,6 +250,20 @@ public enum BoardLoader: Sendable {
}
let card = try parseCard(at: cardURL, path: cardRelPath)
noteCoercions(in: card.document, at: cardRelPath + "/" + indexFileName)
// **The card-level claimed name** (01-storage-format.md § Fractal layout Rules,
// extended 2026-07-29 "the rule is level-uniform"): a file or symlink wearing
// `attachments` breaks every import into this card, every Finder drop on it and the
// window's listing for as long as it stands, so it is scheduled work exactly as a
// squatted `.trash` is. Detection is read-only here, like every other defect; the
// displacement is the store's, through the Writer.
for squatter in IntegrityRules.squattedClaimedNames(inCardAt: cardURL, path: cardRelPath) {
defects.append(.claimedNameSquatted(squatter))
logger.warning(
"\(cardRelPath, privacy: .public)/\(squatter.name, privacy: .public): claimed name held by \(squatter.found.description, privacy: .public) — to be displaced"
)
}
// Noticed, never acted on: the relocation is the store's, through the Writer.
let loose = looseFileNames(in: cardURL)
@@ -287,6 +325,7 @@ public enum BoardLoader: Sendable {
continue
}
let entry = try parseCard(at: cardURL, path: cardRelPath)
noteCoercions(in: entry.document, at: cardRelPath + "/" + indexFileName)
// **The trash's discriminator, applied where the flat container needs it**
// (01-storage-format.md § Deletion, re-ruled 2026-07-29): the *value* is trusted
// outright, and only an unrecognized value or no key at all falls through to shape.
@@ -944,6 +983,17 @@ public struct LoadResult: Sendable {
public var duplicateIdentities: [DuplicateIdentity] {
defects.compactMap { if case let .duplicateIdentity(work) = $0 { work } else { nil } }
}
/// Every file whose lenient fields fell back to their defaults a **view over `defects`**
/// (01-storage-format.md § Frontmatter, ruled 2026-07-29).
///
/// **Nothing consumes it, and that is the point**: the coerce tier changes no behavior, so this is
/// the observability handle a suite pins it, a developer reads the log lines it produced, and the
/// day a shape shows up often enough to deserve a heuristic heal, this is where the evidence
/// already is. In walk order: the board, then each lane and its cards, then the trash.
public var coercedFrontmatter: [CoercedFrontmatter] {
defects.compactMap { if case let .coercedFrontmatter(work) = $0 { work } else { nil } }
}
}
/// A tolerated anomaly the loader kept going past. Never blocks a load see `BoardLoadError`
+274 -115
View File
@@ -17,8 +17,10 @@ import Foundation
/// and the body survive every write by construction rather than by remembering to preserve
/// them.
/// - **`modified` stamped, `modified-by` cleared** (§ Frontmatter): on every app-mediated write
/// path. Absence of `modified-by` means "the board's user, via the app"; the file is being
/// rewritten anyway, so clearing an external writer's self-reported stamp costs nothing.
/// path that rewrites *content*. Absence of `modified-by` means "the board's user, via the app";
/// the file is being rewritten anyway, so clearing an external writer's self-reported stamp costs
/// nothing. The one class of write that does neither is the **order-only rewrite**
/// (`WriteOperation.rewritesOrderOnly` the reorders-don't-stamp rule).
/// - **Encoding** (§ Fractal layout Rules): writes are BOM-less UTF-8; reads are strict
/// UTF-8, and a file that does not decode is a loud, specific error rather than a
/// lossy best guess.
@@ -44,6 +46,26 @@ public enum BoardWriter: Sendable {
/// two keys, and no call site has to remember them.
/// 4. **Atomic replace.**
///
/// ## The reorders-don't-stamp predicate
///
/// Step 3 is **skipped for an order-only rewrite** (01-storage-format.md § Frontmatter
/// `modified`'s scope ruled 2026-07-29 as moves-don't-stamp, refined 2026-07-30 to the
/// container-change predicate). `order` is logically the *container's* property a relationship
/// among a lane's members that the format happens to store inside each member's file so a
/// rewrite that only restates position touches no content: no `modified` stamp, and no
/// `modified-by` clear (the two are paired; attribution cannot change when content didn't).
///
/// **The predicate is the operation's, not a parameter** (`WriteOperation.rewritesOrderOnly`):
/// the vocabulary already draws the line this rule needs `.reorder` is by construction the
/// same-container case (`moveItem` decides it from the two URLs before it touches disk) and
/// `.renumberChildren` is the whole-lane rescale. Deriving it here rather than asking each call
/// site means no caller can forget, and the rule stays one exhaustive switch a suite can pin
/// without a filesystem.
///
/// **There is no trash branch anywhere**, deliberately: a move into or out of `.trash/` changes
/// the item's container, so it stamps for the same reason a cross-lane move does. The trash move
/// is the container rule's plainest instance rather than an exception to a rule about moves.
///
/// The **one path that deliberately bypasses this** is the card window's raw-source Apply
/// (05-card-window.md): it writes the user's text byte-for-byte and does *not* clear a
/// `modified-by` the user typed or kept the validated-then-verbatim contract outranks the
@@ -90,8 +112,13 @@ public enum BoardWriter: Sendable {
// 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))
document.set(FrontmatterKeys.modified, to: .date(Date()))
document.remove(FrontmatterKeys.modifiedBy)
// 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.
if !operation.rewritesOrderOnly {
document.set(FrontmatterKeys.modified, to: .date(Date()))
document.remove(FrontmatterKeys.modifiedBy)
}
try atomicReplace(text: document.serialized(), at: indexURL, operation: operation)
}
@@ -403,6 +430,12 @@ public enum BoardWriter: Sendable {
/// - **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.
/// - **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
/// `.renumberChildren` answering `rewritesOrderOnly` rather than being arranged here which is
/// what keeps a whole lane's worth of bookkeeping from looking like a whole lane's worth of
/// edits to the card window, to a future auto-purge, and to an agent's own attribution.
///
/// Each child's rewrite is atomic; the batch is not. An interrupted renumber leaves some
/// siblings renumbered and some not every `order` still a valid float, display order
@@ -498,10 +531,17 @@ public enum BoardWriter: Sendable {
/// construction rather than by copying carefully.
///
/// Exactly one file is rewritten the moved root's `index.md`, and only to carry its new
/// `order` (§ Ordering, "a reorder rewrites only the moved item's `index.md`"), stamped and
/// `modified-by`-cleared like every other app write. `order` is the caller's explicit rank
/// (a drop between two siblings), or `nil` to append after the destination's visible
/// siblings.
/// `order` (§ Ordering, "a reorder rewrites only the moved item's `index.md`"). `order` is the
/// caller's explicit rank (a drop between two siblings), or `nil` to append after the
/// destination's visible siblings.
///
/// **Whether that rewrite stamps is the container question** (§ Frontmatter `modified`'s scope,
/// refined 2026-07-30 `WriteOperation.rewritesOrderOnly`), and this call is where it is
/// answered for every move in the app: the same-parent degenerate path below is a `.reorder` and
/// rewrites `order` alone, while a real move cross-lane, cross-board, into or out of `.trash/`
/// is a `.move` and stamps `modified` and clears `modified-by` like any content write. The
/// branch that already exists for the *rank arithmetic* is therefore the whole of the stamping
/// rule too; there is no second test, and pointedly no trash case.
///
/// **The import boundary is the one place within-board uniqueness is enforced** (§ Fractal
/// layout Rules). `sourceBoardRoot` and `destinationBoardRoot` are compared by resolved,
@@ -762,23 +802,36 @@ public enum BoardWriter: Sendable {
/// `Date` for the whole tree: a card made from a template is born today, not forked from
/// the template (09-templates.md).
///
/// Two deliberate leniencies below the root, both of them "what a hand copy would do":
/// Plus the tracker sever, which is the copy contract's third clause: every folder this
/// materializes drops the reserved `remote`/`remote-state` keys, at every level
/// (`applyCopyContract`).
///
/// - A nested `index.md` that is **readable-but-uneditable** (§ Frontmatter), or that cannot
/// be read at all, is copied byte-verbatim and simply not stamped. Refusing an entire copy
/// because one nested card is a flow mapping would be hostile, and the file arrives
/// *exactly* as it was rather than corrupted its stale `modified-by` attribution
/// surviving is the self-reported-provenance honest limit § Frontmatter already
/// acknowledges. The **root** gets no such leniency: it must be rewritten (it needs its
/// new `order`), so an unreadable or uneditable root refuses the copy up front, before
/// anything is materialized.
/// - A nested UUID-shaped folder with **no `index.md`** interrupted-create residue is
/// copied and reminted like any other, and not rewritten: the same skip the loader applies
/// to it (`.missingIndex`).
/// ## A copy is a transaction (ruled 2026-07-29)
///
/// **The whole subtree is preflighted for stampability before anything is materialized**, and a
/// copy that cannot honor the contract on one nested card refuses whole, loudly, naming that card
/// (01-storage-format.md § Frontmatter: "every copy flow that rewrites descendants' `index.md`
/// preflights the entire subtree and refuses whole, loudly, naming the offending item never a
/// partial copy, never a silently unstamped descendant").
///
/// This **retired the former root-strict/nested-lenient split**, which copied an unreadable or
/// readable-but-uneditable nested `index.md` byte-verbatim and simply skipped its stamp. The
/// leniency read as kindness and was in fact the one verdict 01's doctrine forbids: "leniency is
/// recovering recoverable issues through reliable heuristics never accepting loss that could
/// surprise the user 'proceed partially, lose a little' is never a verdict". A silently
/// unstamped descendant carries a stale `modified-by` and, since 2026-07-29, a *live tracker
/// claim* into a second local object which is exactly the surprise. The finest-grain precedent
/// covers identity collisions (a per-folder repair that loses nothing), not skipped contract work.
///
/// The preflight runs over the **source**, so a refusal costs nothing on disk: nothing is copied,
/// nothing is renamed, nothing has to be cleaned up. A folder with **no `index.md`**
/// interrupted-create residue is not an offense: it is copied and reminted like any other and
/// simply has nothing to stamp, the same skip the loader applies to it (`.missingIndex`).
///
/// **All-or-nothing at the destination**, unlike a move: any failure once copying has begun
/// removes the partially copied tree best-effort and rethrows, because a half-copied item is
/// pure residue nothing was there before, so there is no true state for a reload to show.
/// pure residue nothing was there before, so there is no true state for a reload to show. After
/// a clean preflight the only thing left to fail is disk, and that is what this catch is for.
/// The source is never touched on any path.
public static func copyItem(
at sourceFolder: URL,
@@ -794,6 +847,9 @@ public enum BoardWriter: Sendable {
// failure from here on in this call including inside the materialized-but-not-yet-
// stamped tree below reuses the enriched value.
operation = try checkIndexIsRewritable(inItemFolder: sourceFolder, operation: operation)
// The transaction's preflight, over the *source*: a subtree that cannot honor the copy
// contract refuses here, where nothing has been materialized and there is nothing to undo.
try checkCopiedDescendantsAreStampable(of: sourceFolder, operation: operation)
let rank = try destinationOrder(order, inParent: destinationParent, operation: operation)
@@ -816,9 +872,7 @@ public enum BoardWriter: Sendable {
let now = Date()
try updateIndex(inItemFolder: root, operation: operation) { document in
if case .born = stamps {
document.set(FrontmatterKeys.created, to: .date(now))
}
applyCopyContract(to: &document, stamps: stamps, now: now)
document.set(FrontmatterKeys.order, to: .double(rank))
}
for folder in copied {
@@ -864,13 +918,102 @@ public enum BoardWriter: Sendable {
}
}
/// Stamps one copied folder below the root best-effort by design (see `copyItem`): a
/// missing, unreadable, or uneditable `index.md` is left exactly as the copy found it rather
/// than failing the gesture. `order` is not touched: a nested item keeps its rank among its
/// own siblings, which travelled with it.
/// **The copy contract's frontmatter edits**, applied to every folder an item-level copy
/// materializes root and descendants alike, in one place so the two can never disagree about
/// what a copy owes.
///
/// Two clauses, and `updateIndex` adds the third:
///
/// - **`created` per `stamps`** `.fork` keeps it (a copy really was created when its original
/// was), `.born` restamps it, because a board or card made from a template is born today
/// (09-templates.md).
/// - **The reserved tracker keys go** `remote` and `remote-state`, at every level
/// (01-storage-format.md § Fractal layout Rules, ruled 2026-07-29: "**Item-level copies sever
/// tracker identity** because two local objects must never both claim to be the same remote
/// object"). Content preserved, mapping severed. Nothing reads the keys until Teams, and that is
/// the argument rather than an objection: the copies made today are the boards Teams will meet,
/// so the sever costs nothing now and spares a stale double-claim later. `FrontmatterDocument.remove`
/// takes every occurrence, so a hand-duplicated key cannot leave a twin behind to resurrect the
/// claim.
/// - **`modified` stamped and `modified-by` cleared** come from `updateIndex`, because a copy is an
/// app write like any other (§ Frontmatter) not something this function has to remember.
///
/// `order` is deliberately absent: the copied *root* takes its new rank from its caller, and a
/// nested item keeps its rank among its own siblings, which travelled with it.
///
/// **Whole-board forks do not call this at all** Duplicate and Save as Template carry bytes
/// verbatim, GUIDs, timestamps and tracker keys included (01 Identity lifecycle's carve-out).
static func applyCopyContract(
to document: inout FrontmatterDocument,
stamps: CopyStamps,
now: Date
) {
if case .born = stamps {
document.set(FrontmatterKeys.created, to: .date(now))
}
document.remove(FrontmatterKeys.remote)
document.remove(FrontmatterKeys.remoteState)
}
/// **The copy transaction's preflight**: every identity-bearing folder beneath `folder` whose
/// `index.md` the copy contract will rewrite, checked for readability and editability *before*
/// anything is materialized and the first offender refuses the whole copy, named
/// (01-storage-format.md § Frontmatter, ruled 2026-07-29: "preflights the entire subtree and
/// refuses whole, loudly, naming the offending item").
///
/// **Naming the offender is the point**, so the thrown error is re-enriched with *that* item's
/// title rather than the copy root's: a refusal reading "Couldn't copy 'Sprint 12'" when the
/// unwritable file is one card inside it would send the user looking in the wrong place. A file
/// that cannot be read at all has no title to offer, and its path which the error always
/// carries is then the whole of what can honestly be said about it.
///
/// **A folder with no `index.md` is not an offense** and is skipped: it is interrupted-create
/// residue, the loader skips it too (`.missingIndex`), and there is no contract work to fail. The
/// walk is `identityDescendants`', which is `remintDescendants`' own reach so the set checked
/// here is exactly the set that will be stamped, never a superset that could refuse a copy over a
/// file nobody was going to touch.
///
/// **Internal rather than `private`**: template instantiation preflights its own tree with this,
/// for `remintDescendants`' reason one definition of what a copy owes its descendants.
static func checkCopiedDescendantsAreStampable(
of folder: URL,
operation: WriteOperation
) throws(BoardWriteError) {
for descendant in identityDescendants(of: folder) {
let indexURL = descendant.appendingPathComponent(BoardLoader.indexFileName)
guard FileManager.default.fileExists(atPath: indexURL.path) else { continue }
let document = try readDocument(at: indexURL, operation: operation)
try checkEditable(document, at: indexURL, operation: operation.withTitle(document.title.value))
}
}
/// Every identity-bearing folder beneath `folder`, depth first `remintDescendants`' walk with
/// the renaming taken out, so the preflight and the remint can never disagree about which folders
/// a copy materializes as items.
private static func identityDescendants(of folder: URL) -> [URL] {
var found: [URL] = []
for child in childCandidates(of: folder) {
found.append(child)
found.append(contentsOf: identityDescendants(of: child))
}
return found
}
/// Stamps one copied folder below the root **strictly**, since the preflight has already cleared
/// the whole subtree (`checkCopiedDescendantsAreStampable`): an `index.md` that cannot be read or
/// edited here is a disk failure between the two reads, not a shape to tolerate, and it fails the
/// copy like any other mid-flight failure (whose partial result the caller removes wholesale).
///
/// The former best-effort posture copy it verbatim, skip its stamp is retired with the
/// root-strict/nested-lenient split (see `copyItem`): a silently unstamped descendant is a
/// descendant still carrying somebody else's `modified-by` and, since 2026-07-29, somebody else's
/// tracker claim.
///
/// A folder with **no `index.md`** is still skipped, and for a different reason entirely: there is
/// nothing there to stamp (interrupted-create residue, which the loader skips too).
///
/// **Internal rather than `private`**, with `remintDescendants` and for its reason: an
/// instantiated board's lanes and cards are stamped by this exact rule, leniency included.
/// instantiated board's lanes and cards are stamped by this exact rule.
static func stampCopiedDescendant(
at folder: URL,
stamps: CopyStamps,
@@ -878,85 +1021,13 @@ public enum BoardWriter: Sendable {
operation: WriteOperation
) throws(BoardWriteError) {
let indexURL = folder.appendingPathComponent(BoardLoader.indexFileName)
guard FileManager.default.fileExists(atPath: indexURL.path),
let copied = try? readDocument(at: indexURL, operation: operation),
copied.uneditableShape == nil
else { return }
guard FileManager.default.fileExists(atPath: indexURL.path) else { return }
try updateIndex(inItemFolder: folder, operation: operation) { document in
if case .born = stamps {
document.set(FrontmatterKeys.created, to: .date(now))
}
applyCopyContract(to: &document, stamps: stamps, now: now)
}
}
/// Materializes an item from **supplied `index.md` text** rather than from a folder on disk
/// the clipboard's staging-less fallback (04-interactions.md Clipboard: "if the staged
/// snapshot is missing or unreadable at paste time, paste falls back to the embedded
/// `index.md` content intact, attachments absent").
///
/// **The text is written byte-faithfully, because it *is* the source bytes.** It was captured
/// verbatim at copy time and travels through the manifest untouched, so this call writes it as
/// given unknown keys, comments, blank lines, line endings, body and all rather than
/// re-serializing anything. That is the round-trip guarantee applied to a file the app is
/// minting from bytes it was handed (01-storage-format.md § Fractal layout Rules).
///
/// **Fresh identity, fork stamps** the same semantics `copyItem` gives an ordinary copy, and
/// necessarily so: this is a copy that happened to arrive as text. Every folder is a fresh mint,
/// `created` survives in the supplied bytes (a duplicate is a fork), and the root's `order` and
/// `modified` are rewritten by the closing `updateIndex`, which also clears `modified-by`.
///
/// `children` are a **lane's** cards, each its own supplied `index.md`, materialized under the
/// new root in the order given and deliberately *not* rewritten: "a nested item keeps its rank
/// among its own siblings, which travelled with it" (`copyItem`'s rule). A card passes none.
///
/// **The root gets `copyItem`'s strictness and the children get its leniency.** The root must be
/// rewritten it needs its new `order` so unparseable or uneditable text fails the call;
/// a child is never rewritten, so whatever it is arrives exactly as it was.
///
/// **All-or-nothing at the destination**, `copyItem`'s rule for its reason: any failure once the
/// folder exists removes the partial tree best-effort and rethrows, because a half-materialized
/// item is pure residue nothing was there before.
public static func materializeItem(
inParent destinationParent: URL,
indexText: String,
children: [String] = [],
order: Double?
) throws(BoardWriteError) -> ItemID {
let operation = WriteOperation.copy(title: nil)
try checkIsDirectory(destinationParent, describedAs: "destination parent folder", operation: operation)
let rank = try destinationOrder(order, inParent: destinationParent, operation: operation)
// The same mint the create path uses, so every identity this app materializes is materialized
// one way fresh lowercase UUIDv4, folder and all.
let root = try mintUUIDFolder(in: destinationParent, operation: operation)
do throws(BoardWriteError) {
try atomicReplace(
text: indexText,
at: root.appendingPathComponent(BoardLoader.indexFileName),
operation: operation
)
for child in children {
let childFolder = try mintUUIDFolder(in: root, operation: operation)
try atomicReplace(
text: child,
at: childFolder.appendingPathComponent(BoardLoader.indexFileName),
operation: operation
)
}
try updateIndex(inItemFolder: root, operation: operation) { document in
document.set(FrontmatterKeys.order, to: .double(rank))
}
} catch {
try? FileManager.default.removeItem(at: root)
throw error
}
return ItemID(rawValue: root.lastPathComponent)
}
// MARK: - The materialized trash
/// `<boardRoot>/.trash/` the board's trash container, named but not created.
@@ -994,10 +1065,13 @@ public enum BoardWriter: Sendable {
/// *snapshot*, which the store holds and this stateless layer does not. Value-passing keeps
/// the seam: the Writer takes a rank, the store computes it.
///
/// **The `modified` stamp is the point, not a side effect.** Deletion is the one exception to
/// moves-don't-stamp "deletion is an edit to the card's story" and the stamp is what a
/// future age-based auto-purge reads. It falls out of `updateIndex` here rather than being
/// asked for, which is why there is nothing extra in step 5.
/// **The `modified` stamp is the point, not a side effect** and it needs no exception to earn
/// it. The trash move changes the card's *container*, which is the whole predicate
/// (`WriteOperation.rewritesOrderOnly`, refined 2026-07-30): "deletion is an edit to the item's
/// story", so it stamps exactly as a cross-lane move does, and the stamp is what a future
/// age-based auto-purge reads. It falls out of `updateIndex` here rather than being asked for,
/// which is why there is nothing extra in step 5 and why the reorders-don't-stamp rule needs no
/// trash carve-out to coexist with this call.
///
/// **Collision inside `.trash/` is impossible by construction**, and it is checked anyway. The
/// card is a resident of this very board, and board-wide uniqueness now spans lanes *and* the
@@ -1358,8 +1432,8 @@ public enum BoardWriter: Sendable {
/// trees.
/// - **The bytes are written verbatim** nothing is stamped, nothing is re-serialized, no
/// `index.md` is parsed. This replays; it does not edit.
/// - **All-or-nothing**: any failure removes the partial tree best-effort and rethrows, the
/// `materializeItem` rule a half-restored lane is pure residue, since nothing was there.
/// - **All-or-nothing**: any failure removes the partial tree best-effort and rethrows,
/// `copyItem`'s rule a half-restored lane is pure residue, since nothing was there.
///
/// `folder`'s own name governs, not `snapshot.name`: a caller restoring to the path it removed
/// passes the same URL, and the snapshot's name is carried for identification, not as an
@@ -1533,8 +1607,8 @@ public enum BoardWriter: Sendable {
/// Puts a removed item's folder back, at its own path and with its own bytes the redo half of
/// an undone create (13-native-undo.md Rules).
///
/// **The identity is the point.** `createLane`/`createCard` mint a fresh UUID and
/// `materializeItem` mints a fresh one too, so neither can replay a create: redoing through them
/// **The identity is the point.** `createLane`/`createCard` mint a fresh UUID, so they cannot
/// replay a create: redoing through them
/// would produce a *different* item, and every step registered above this one on the stack
/// (a rename, a move, a body edit of that very card) would then name nothing. This call takes the
/// path as given.
@@ -1577,7 +1651,7 @@ public enum BoardWriter: Sendable {
operation: operation
)
} catch {
// `materializeItem`'s all-or-nothing rule: a half-made folder is pure residue, since
// `copyItem`'s all-or-nothing rule: a half-made folder is pure residue, since
// nothing was there before.
try? FileManager.default.removeItem(at: itemFolder)
throw error
@@ -2178,6 +2252,14 @@ public enum BoardWriter: Sendable {
/// **It stamps nothing.** A heal that only renames never opens an `index.md`, so the existing
/// write discipline decides and there is no rule to add (§ Validation and healing).
///
/// **Level-uniform** (extended 2026-07-29): the squatter's own `location` names the folder the
/// claimed name lives in the board root, or a card's folder for a file wearing `attachments`. That
/// is the whole of the difference, which is the point of the ruling: one ladder, one notice, one
/// write, whichever level the name is claimed at. A card whose folder has since gone takes the
/// re-verification's `nil` path like any other vanished defect.
///
/// - Parameter root: the board root. The squatter's location is resolved against it, so a board
/// renamed since the load heals at its new location.
/// - Returns: the name the squatter now has, or `nil` when the defect was already gone.
@discardableResult
public static func displaceClaimedName(
@@ -2185,13 +2267,14 @@ public enum BoardWriter: Sendable {
atBoardRoot root: URL
) throws(BoardWriteError) -> String? {
let operation = WriteOperation.displaceClaimedName(name: squatter.name)
let occupied = root.appendingPathComponent(squatter.name)
let container = squatter.location.folder(under: root)
let occupied = container.appendingPathComponent(squatter.name)
guard let found = IntegrityRules.node(at: occupied), found != squatter.expected else {
return nil
}
let freed = freshName(for: squatter.name, in: root)
let destination = root.appendingPathComponent(freed)
let freed = freshName(for: squatter.name, in: container)
let destination = container.appendingPathComponent(freed)
do {
try FileManager.default.moveItem(at: occupied, to: destination)
} catch {
@@ -2565,6 +2648,23 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
case move(title: String?)
case reorder(title: String?)
case copy(title: String?)
/// V **a paste that refused before it wrote anything** (04-interactions.md Clipboard,
/// re-ruled 2026-07-29: "A paste whose staged snapshot is missing or unreadable refuses loudly
/// never degrades the paste produces **nothing**, and a one-shot failure banner names it from
/// the manifest's metadata").
///
/// **Its own case rather than a fold into `.copy`**, on `.rename`'s and `.duplicateBoard`'s
/// reasoning: the user pressed *Paste*, and a banner telling them the app "couldn't copy 'Fix
/// login'" would name a gesture they never made. It is also the one operation in this vocabulary
/// the Writer itself never performs a paste's *arrivals* are `.copy` and `.move` because the
/// refusal happens at the clipboard's preflight, before any arrival is materialized; the
/// vocabulary grows with the surfaces, and the surface here is the refusal.
///
/// `title` is the offending entry's, read off the manifest's own metadata (which is exactly what
/// the embedded `index.md` is kept for now that it is never a materialization source), `nil` for
/// an untitled item.
case paste(title: String?)
/// / a card moving into `.trash/` (`deleteCardToTrash`) or a lane being removed
/// outright (`removeLane`). The word the user pressed, whichever staging it took.
///
@@ -2733,6 +2833,10 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
case .move: .move(title: title)
case .reorder: .reorder(title: title)
case .copy: .copy(title: title)
// Identity, like `.repairDuplicateID` and for its reason: a refused paste never opens an
// `index.md`, so there is no `readDocument` to enrich from its title arrives already filled
// in from the manifest entry the refusal names.
case .paste: self
case .delete: .delete(title: title)
case .purge: .purge(title: title)
case .migrateTombstone: .migrateTombstone(title: title)
@@ -2747,6 +2851,46 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
}
}
/// **Whether this rewrite only restates the item's position among its container's members**
/// the reorders-don't-stamp predicate (01-storage-format.md § Frontmatter `modified`'s scope,
/// ruled 2026-07-29 as moves-don't-stamp, refined 2026-07-30).
///
/// One question decides it: **does the rewrite change the item's container?** If it does a
/// cross-lane move, a cross-board arrival, a move into or out of `.trash/` the item's story
/// changed (which lane a card lives in is state) and the write stamps `modified` and clears
/// `modified-by` like any content write. If it does not a card reordered among its lane's
/// siblings, a lane reordered on the board, a renumber's whole-lane rescale only `order` is
/// rewritten: no stamp, no clear. Where an item *stands in line* is presentation, and `order` is
/// logically the container's property that the format happens to store inside the member's file.
///
/// **The vocabulary already draws the line**, which is why this is a property here rather than a
/// flag threaded through `updateIndex`'s call sites:
///
/// - `.reorder` is the same-container case by construction `moveItem` decides it from the source
/// parent and the destination parent before it touches disk (`isSameLocation`), and the two
/// inverse paths that rewrite a rank directly (`BoardStore.setOrder`, the within-lane sort) are
/// same-container for the same reason: a lane's parent is the board root, and a card the sort
/// permutes never leaves its lane.
/// - `.renumberChildren` is the midpoint-exhaustion rescale "order-only rewrites, so no
/// `modified` stamp and no `modified-by` clear" (01 § Ordering, verbatim).
/// - **Everything else stamps.** `.move` covers every container change including the trash's, and
/// there is deliberately **no trash case anywhere**: the trash move stamps because every
/// container change stamps, so a branch for it would be a second rule saying the same thing.
///
/// Exhaustive with no `default`, like every other switch over this enum: a new operation has to
/// answer "does this rewrite content?" before it compiles.
public var rewritesOrderOnly: Bool {
switch self {
case .reorder, .renumberChildren:
true
case .createBoard, .createLane, .createCard, .move, .copy, .delete, .purge, .migrateTombstone,
.style, .resize, .rename, .duplicateBoard, .saveAsTemplate, .paste, .importAttachment,
.listAttachments, .removeAttachment, .relocateLooseFile, .agentGuide, .displaceClaimedName,
.repairDuplicateID, .toggleTask, .editBody, .rawSource:
false
}
}
/// A short imperative phrase `"move 'Fix login'"`, `"create card"`, `"import attachment
/// 'photo.png'"` for logs and diagnostics **only**: `BoardWriteError.description` (test
/// failures, `po error`, console output), never the banner's text. The banner owns every
@@ -2761,6 +2905,7 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
case let .move(title): Self.phrase("move", title)
case let .reorder(title): Self.phrase("reorder", title)
case let .copy(title): Self.phrase("copy", title)
case let .paste(title): Self.phrase("paste", title)
case let .delete(title): Self.phrase("delete", title)
case let .purge(title): Self.phrase("purge", title)
case let .migrateTombstone(title): Self.phrase("migrate the legacy 'deleted' key on", title)
@@ -2844,6 +2989,18 @@ public struct BoardWriteError: Error, Sendable, Equatable, CustomStringConvertib
/// the *proposal* is not, so nothing was attempted and nothing changed.
case invalidSource(BoardLoadError)
/// **The bytes a paste was to reproduce are not there** the staged clipboard snapshot is
/// missing or unreadable, so the paste produces nothing rather than a hollowed item
/// (04-interactions.md Clipboard, re-ruled 2026-07-29 Finder's invariant, and 01's
/// leniency doctrine: "proceed partially, lose a little" is never a verdict).
///
/// **Payload-free on purpose.** There is exactly one thing to say about it, the banner owns
/// the words (`BannerCenter.causePhrase`), and the offending item is already named by the
/// operation's own title so a free-form message here could only be a second, worse copy of
/// a sentence that lives one layer up. That also keeps it distinct from `.unreadable`, whose
/// message is a developer's diagnostic about a file the app *did* open.
case clipboardContentGone
public var description: String {
switch self {
case let .unreadable(message):
@@ -2856,6 +3013,8 @@ public struct BoardWriteError: Error, Sendable, Equatable, CustomStringConvertib
message
case let .invalidSource(error):
"the source text wouldn't load: \(error.description)"
case .clipboardContentGone:
"the copied content is no longer staged"
}
}
}
+16
View File
@@ -573,6 +573,22 @@ public enum FrontmatterKeys {
/// when absent (`IntegrityRules.healOnTouch`), and never stripped.
public static let kind = "kind"
/// The reserved tracker keys `remote` (board, card) and `remote-state` (lane) of the enhanced
/// schema's future connectors (01-storage-format.md § Enhanced schema).
///
/// **Named here without joining `schemaOwned`**, which is the ruling rather than an oversight: 01
/// says the app "treats reserved keys as ordinary unknown keys (preserved verbatim, invisible in
/// the UI)", and `schemaOwned` is exactly the set `unknownFields` subtracts. Nothing about their
/// ordinary posture changes.
///
/// They are named at all because of the one act that does touch them: **an item-level copy severs
/// tracker identity** (§ Fractal layout Rules, ruled 2026-07-29) "two local objects must never
/// both claim to be the same remote object" so every folder a paste, an -drag duplicate, a
/// cross-board copy or a template instantiation materializes drops both keys, at every level.
/// Whole-board forks (Duplicate, Save as Template) carry them verbatim, as always.
public static let remote = "remote"
public static let remoteState = "remote-state"
public static let schemaOwned: Set<String> = [
schema, title, order, width, created, modified, modifiedBy, deleted, background, icon,
iconColor, kind,
+62
View File
@@ -39,7 +39,69 @@ public enum FieldValue<Value: Sendable & Equatable>: Sendable, Equatable {
}
}
/// One **lenient** field that had no sensible reading and fell back to its default the coerce
/// tier's observability record (01-storage-format.md § Frontmatter, ruled 2026-07-29: "A
/// no-sensible-reading fallback logs: field, path, and raw text, carried as coerce-tier entries in the
/// integrity service's Defect stream").
///
/// **Two of the three facts, because this layer only has two.** `FrontmatterFields` is a pure reading
/// of one document's bytes and has no idea which file it came from, so the *path* is attached by the
/// loader, which does (`CoercedFrontmatter`). Threading a path down here to satisfy the record's shape
/// would put filesystem context into the one layer that is deliberately free of it.
public struct CoercedField: Sendable, Equatable {
/// The frontmatter key, as the schema spells it.
public let key: String
/// The value exactly as written the raw source span, which is the only form worth recording:
/// this exists so that a shape observed in the wild can later be promoted to a heuristic heal, and
/// a normalized rendering of a value nobody could read would defeat that.
public let raw: String
public init(key: String, raw: String) {
self.key = key
self.raw = raw
}
}
extension FrontmatterDocument {
// MARK: - The coerce tier's own report
/// Every **lenient** field in this document that had no sensible reading, in schema order the
/// coerce tier's whole observability contribution (01-storage-format.md § Frontmatter, ruled
/// 2026-07-29: "the family posture: every silent recovery leaves a trace").
///
/// **Read-side only, and nothing branches on it.** The values still render as their defaults
/// untitled placeholder, width 1, no color, no icon, no timestamp the bytes on disk are still
/// preserved verbatim, and no banner is raised. The list exists so the fallback is *visible*: it is
/// "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.
///
/// A document whose fields all read cleanly answers `[]`, which is the overwhelmingly common case
/// and costs one pass over the lenient fields.
public var coercedFields: [CoercedField] {
var found: [CoercedField] = []
func record(_ key: String, _ field: FieldValue<some Sendable & Equatable>) {
guard let raw = field.rawText else { return }
found.append(CoercedField(key: key, raw: raw))
}
record(FrontmatterKeys.title, title)
record(FrontmatterKeys.width, width)
record(FrontmatterKeys.created, created)
record(FrontmatterKeys.modified, modified)
record(FrontmatterKeys.modifiedBy, modifiedBy)
record(FrontmatterKeys.background, background)
record(FrontmatterKeys.icon, icon)
record(FrontmatterKeys.iconColor, iconColor)
record(FrontmatterKeys.kind, kind)
return found
}
// MARK: - Strict (structure the loader fails fast on `.malformed`)
public var schema: FieldValue<Int> {
+157 -5
View File
@@ -182,6 +182,30 @@ public enum IntegrityRules: Sendable {
ClaimedName(name: ".gitignore", expected: .file, displacesSquatters: false),
]
/// **The card-level claimed names** the same table one level down (01-storage-format.md § Fractal
/// layout Rules, extended 2026-07-29: "**The rule is level-uniform**: a card's reserved child
/// names are claimed the same way a regular file or symlink squatting `attachments` (a directory
/// name) displaces by the same ladder (`attachments` `attachments 2`), so imports, Finder drops,
/// and the sidebar listing never fail one gesture at a time against a squatted name").
///
/// **`attachments` displaces; `comments` does not**, and the split is the *timing principle*
/// rather than a hedge 01 calls the reserved-but-unconsumed `comments` "the timing principle's own
/// illustration": nothing reads that name until the tracker era, so a wrong-kind holder degrades no
/// behavior while it stands and stays a **tolerated stray** today, joining the scheduled class the
/// day the name becomes load-bearing. `attachments`, by contrast, is load-bearing now: while a file
/// wears the name, every import into that card, every Finder drop on it, and the card window's
/// listing are broken which is exactly the "proactive when the defect is load-bearing now"
/// condition (§ Validation and healing).
///
/// `index.md` is deliberately not here. It is not a *reserved child* the app protects from
/// squatters it is the card's content, and a directory named `index.md` makes the folder an
/// index-less stray the loader already skips with a warning (§ Fractal layout Rules). Displacing it
/// would mean the app deciding a folder's content is a squatter.
public static let claimedCardChildNames: [ClaimedName] = [
ClaimedName(name: attachmentsFolderName, expected: .directory, displacesSquatters: true),
ClaimedName(name: "comments", expected: .directory, displacesSquatters: false),
]
/// The claimed names as the lane walk needs them: lowercased, for a `contains` against a
/// directory entry. Compared lowercased for `reservedCardChildNames`' reason.
public static let claimedRootNameSet: Set<String> = Set(claimedRootNames.map { $0.name.lowercased() })
@@ -222,6 +246,38 @@ public enum IntegrityRules: Sendable {
return ClaimedNameSquatter(name: claimed.name, found: found, expected: claimed.expected)
}
/// The claimed-name defects inside one **card** folder the level-uniform half of the same ruling
/// (`claimedCardChildNames`, extended 2026-07-29).
///
/// Only names whose `displacesSquatters` is `true` can produce one, which today means `attachments`
/// and only `attachments`: a `comments` held by the wrong kind of node is a tolerated stray until
/// the feature consumes the name.
///
/// **A plural answer, unlike the board root's**, because the reason the root's is singular does not
/// apply here: there, the second displacing name (`CLAUDE.md`) is the agent guide's own to heal, so
/// answering it twice would be two mechanisms racing one node. Nothing else owns a card's children,
/// so this returns every offender it finds and the table stays the only thing to edit when
/// `comments` graduates.
///
/// - Parameter path: the card folder's path **relative to the board root**, carried into the defect
/// so the write lands wherever the board lives at heal time (`LooseCardFiles`' convention).
public static func squattedClaimedNames(inCardAt cardFolder: URL, path: String) -> [ClaimedNameSquatter] {
claimedCardChildNames.compactMap { claimed in
guard claimed.displacesSquatters,
let found = node(at: cardFolder.appendingPathComponent(claimed.name)),
found != claimed.expected
else {
return nil
}
return ClaimedNameSquatter(
name: claimed.name,
found: found,
expected: claimed.expected,
location: .card(path: path)
)
}
}
// MARK: - Object kinds
/// The kinds the schema knows (01-storage-format.md § Frontmatter Common to all levels, the
@@ -454,6 +510,20 @@ public enum IntegrityRules: Sendable {
/// and reminted by the scheduled heal (re-ruled 2026-07-29 the silent remint).
case duplicateIdentity(DuplicateIdentity)
/// **A coerce-tier fallback**: one file's lenient fields that had no sensible reading and
/// rendered as their defaults (ruled 2026-07-29 "A no-sensible-reading fallback logs: field,
/// path, and raw text, carried as coerce-tier entries in the integrity service's Defect
/// stream").
///
/// **The one case in this enum that is not work**, which is the ruling rather than a
/// contradiction: the design puts coerce-tier fallbacks in *this* stream on purpose, because
/// this is where "an observed-in-the-wild shape can later be promoted to a heuristic heal or a
/// notice" and the promotion would happen right here, by giving the case a heal class. Until
/// then it has none (`healClass` answers `nil`), raises no banner, and changes no behavior: the
/// value already rendered as its default and the bytes on disk are untouched. It is
/// observability, carried in the vocabulary that would act on it if the app ever decided to.
case coercedFrontmatter(CoercedFrontmatter)
/// The scheduled-heal classes, which are also the engine's memo keys and its
/// banner-posture rows (`HealScheduler`).
///
@@ -470,12 +540,18 @@ public enum IntegrityRules: Sendable {
case staleAgentGuide
}
public var healClass: Class {
/// The scheduled-heal class this defect belongs to, or **`nil` where there is no heal** the
/// coerce tier (`coercedFrontmatter`), which is carried for observability and acted on by
/// nothing. Optional rather than a synthetic class, because a class *is* a memo key and a
/// banner-posture row in the engine (`HealScheduler`): inventing one for work that does not
/// exist would arm a memo against a repair nobody wrote.
public var healClass: Class? {
switch self {
case .looseCardFiles: .looseCardFiles
case .legacyTombstone: .legacyTombstone
case .claimedNameSquatted: .claimedNameSquatted
case .duplicateIdentity: .duplicateIdentity
case .coercedFrontmatter: nil
}
}
@@ -495,13 +571,21 @@ public enum IntegrityRules: Sendable {
case let .claimedNameSquatted(work):
// The node *kind* is part of the picture: a squatter replaced by a different kind
// of squatter is a new defect, and a heal that failed on one has no claim to have
// failed on the other.
["claimed:\(work.name):\(work.found.rawValue)"]
// failed on the other. The location leads, so two cards squatting `attachments` are two
// pieces of work and a board-root squatter signs exactly as it always did.
["claimed:\(work.location.signatureComponent)\(work.name):\(work.found.rawValue)"]
case let .duplicateIdentity(work):
// The *identity* is part of the picture beside the path: the same folder losing a
// different collision (its winner reminted, a third copy landing) is new work, and a
// heal that failed on one has no claim to have failed on the other.
["duplicate:\(work.path):\(work.identity)"]
case let .coercedFrontmatter(work):
// One signature per field, `looseCardFiles`' shape: the unit of the observation is a
// field, and a file whose `width` healed while its `icon` did not is a changed picture.
// Nothing memoizes these today there is no heal to guard but a signature is what a
// defect *is* in this vocabulary, and omitting it would make this case the one members
// of the stream cannot be compared by.
work.fields.map { "coerce:\(work.path):\($0.key)" }
}
}
}
@@ -805,18 +889,59 @@ public struct LegacyTombstone: Sendable, Equatable {
/// **An invalid artifact, not a resident.** The heal moves it aside via the Finder-style rename
/// ladder (`.trash` `.trash 2`), preserved verbatim and never destroyed, with a warning-tone
/// notice naming old and new the invariant that survives is displacement-never-destruction.
/// **The rule is level-uniform** (extended 2026-07-29): a card's `attachments` is claimed exactly as the
/// board root's `.trash` is, and displaces by the same ladder which is why `location` exists rather
/// than a second defect type. The heals compose: the displaced file, now an ordinary loose file, rides
/// the next loose-file relocation into the real `attachments/`.
public struct ClaimedNameSquatter: Sendable, Equatable {
/// The claimed name, exactly as the app spells it (`.trash`, `CLAUDE.md`).
/// **Which claimed name this is** the board root's, or one card's reserved child.
///
/// A path rather than a URL, relative to the board root, so the heal joins it onto the store's
/// *current* root and a board renamed mid-session heals at its new location (`LooseCardFiles`' and
/// `IdentityOccurrence.path`'s convention).
public enum Location: Sendable, Equatable {
case boardRoot
case card(path: String)
/// The folder the claimed name lives in, under `root`.
public func folder(under root: URL) -> URL {
switch self {
case .boardRoot: root
case let .card(path): root.appendingPathComponent(path, isDirectory: true)
}
}
/// The location as a signature component `""` for the board root, so the existing root-level
/// signature spelling is unchanged and only a card-level defect adds a path segment.
var signatureComponent: String {
switch self {
case .boardRoot: ""
case let .card(path): path + "/"
}
}
}
/// The claimed name, exactly as the app spells it (`.trash`, `CLAUDE.md`, `attachments`).
public let name: String
/// What is actually sitting there never followed if it is a symlink.
public let found: IntegrityRules.NodeKind
/// What the app needs the name to be.
public let expected: IntegrityRules.NodeKind
/// Where the name lives. Defaults to the board root, which is where every squatter was before the
/// rule went level-uniform so the root-level call sites and their tests read exactly as they did.
public let location: Location
public init(name: String, found: IntegrityRules.NodeKind, expected: IntegrityRules.NodeKind) {
public init(
name: String,
found: IntegrityRules.NodeKind,
expected: IntegrityRules.NodeKind,
location: Location = .boardRoot
) {
self.name = name
self.found = found
self.expected = expected
self.location = location
}
}
@@ -855,6 +980,33 @@ public struct DuplicateIdentity: Sendable, Equatable {
}
}
/// One file's **coerce-tier fallbacks**: the lenient fields whose value had no sensible reading, so the
/// field rendered as its default (01-storage-format.md § Frontmatter, ruled 2026-07-29: "A
/// no-sensible-reading fallback logs: field, path, and raw text, carried as coerce-tier entries in the
/// integrity service's Defect stream the one place where an observed-in-the-wild shape can later be
/// promoted to a heuristic heal or a notice; no banner, no behavior change").
///
/// **Per file, not per field** `LooseCardFiles`' shape and for its reason: the walk meets a document
/// once and reads all of its fields there, so one record per `index.md` is what the loader naturally
/// has, and a caller that wants per-field granularity has `fields` (and the per-field `signatures`).
///
/// The path is root-relative, as every load-side path in this app is (`BoardLoadError.path`,
/// `IdentityOccurrence.path`) it names the `index.md`, because that is the file whose bytes were
/// read and the thing a developer would open.
public struct CoercedFrontmatter: Sendable, Equatable {
/// The `index.md`'s path relative to the board root `"index.md"`, `"<lane>/index.md"`,
/// `"<lane>/<card>/index.md"`, `".trash/<card>/index.md"`.
public let path: String
/// The fields that fell back, in schema order. Never empty a document that read cleanly
/// contributes no defect at all.
public let fields: [CoercedField]
public init(path: String, fields: [CoercedField]) {
self.path = path
self.fields = fields
}
}
/// A folder whose name is a **case-spelled twin** of another occurrence of the same identity one
/// item typed two ways, not two items (01-storage-format.md § Fractal layout Rules: "the canonical
/// all-lowercase spelling wins where present, else the lexicographically first spelling; the loser
+2 -1
View File
@@ -452,7 +452,8 @@ struct BoardView: View {
slotWidth: slotWidth,
drops: dropContext,
marquee: marqueeControl,
openCard: openCard
openCard: openCard,
confirmations: confirmations
)
.frame(width: resizing ? resize.liveWidth : slotWidth, alignment: .leading)
}
+61 -11
View File
@@ -26,11 +26,17 @@ import SwiftUI
/// Plus the two that are not about editing: Finder file drops are inert over the trash ( The trash),
/// so the file-hover highlight is board-only; and the trash's context-menu Delete is *permanent*, so
/// it needs the window's confirmation host (11-command-nexus.md Context menus' Trash cards row).
///
/// **Both sides carry the confirmation host now** (settled Delete's -alternate, Delete
/// Immediately, 11-command-nexus.md Context menus' Card row): the board side's Delete stays the
/// ordinary staged move, but the alternate skips straight to the permanent purge, which needs the
/// same window-level alert the trash side's Delete already does (`TrashConfirmations`).
enum CardFaceRole {
/// A card in a lane. Carries the board window's card opener 's pointer twin
/// (04-interactions.md Selection).
case board(openCard: (ItemID) -> Void)
/// (04-interactions.md Selection) and the window's purge-alert host, for Delete's
/// -alternate.
case board(openCard: (ItemID) -> Void, confirmations: TrashConfirmations)
/// A card in `<root>/.trash/`. Carries the window's purge-alert host, because the trash's Delete
/// is the permanent one and "confirms exactly where the loss is real" (03 § Trash).
@@ -143,7 +149,7 @@ struct CardFaceView: View {
@ViewBuilder
var body: some View {
switch role {
case let .board(openCard):
case let .board(openCard, confirmations):
face
// "A fast double-click opens the card window ('s pointer twin)" (04 Selection).
//
@@ -158,14 +164,18 @@ struct CardFaceView: View {
guard ClickModifier.current == .plain else { return }
openCard(card.id)
})
.contextMenu { boardMenu(openCard: openCard) }
.contextMenu { boardMenu(openCard: openCard, confirmations: confirmations) }
// **The menu's rows, additionally as custom actions** "where SwiftUI additionally
// surfaces menu items as custom accessibility actions, that's free improvement, not
// a separate design surface" (10-accessibility.md Actions come from the context
// menu). The menu stays the inventory and stays reachable the standard way (VO--M).
// Style is absent for `LaneView`'s reason: it opens a popover, and the quick-style
// swatch `Picker` beside it is not an action.
.accessibilityActions { boardActions(openCard: openCard) }
// swatch `Picker` beside it is not an action. Delete Immediately rides along too,
// its own row rather than the -alternate above VoiceOver's action rotor has no
// held-key concept, so the alternate needs a first-class custom action of its own
// (11-command-nexus.md Context menus' Card row: "surfaces as its own VO custom
// action per 10's cut").
.accessibilityActions { boardActions(openCard: openCard, confirmations: confirmations) }
.popover(isPresented: styleEditorPresentation(store, anchor: card.id), arrowEdge: .bottom) {
StyleEditorPopover(store: store, recents: appModel.styleRecents)
}
@@ -443,10 +453,10 @@ struct CardFaceView: View {
// MARK: - Context menus
/// Open, Rename, Style, the quick-style recents row, Delete 11-command-nexus.md Context
/// menus' Card row, in its order.
/// Open, Rename, Style, the quick-style recents row, Delete with Delete Immediately as its
/// -alternate 11-command-nexus.md Context menus' Card row, in its order.
@ViewBuilder
private func boardMenu(openCard: @escaping (ItemID) -> Void) -> some View {
private func boardMenu(openCard: @escaping (ItemID) -> Void, confirmations: TrashConfirmations) -> some View {
// Open: Board Open Card's pointer twin (`OpenCardCommand`), restricted to the clicked card
// alone "a card window is tied to one card" (11-command-nexus.md), so unlike Style and
// Delete below it, this row never widens to the selection; Open never opens multiple, even
@@ -474,19 +484,38 @@ struct CardFaceView: View {
// Delete: File Delete's exact store path (`store.delete`, `TrashCommands`'s twin), on the
// widened target set below (`targetIDs`) the successor-selection rule is `delete(_:)`'s own,
// so this row gets it for free.
//
// **Delete Immediately rides as its -alternate** (Finder's own pattern hold and Delete
// becomes Delete Immediately, 11-command-nexus.md Context menus' Card row, settled
// 2026-07-29). `.modifierKeyAlternate(.option)` is SwiftUI's macOS-native mechanism for
// exactly this swap (macOS 15+); the alternate's title matches File Delete Immediately's
// own verbatim (`TrashCommands`), since menu titles are the system remapping key and the two
// rows name the same command. It widens over the same `targetIDs` Delete itself reads, so an
// -held click purges exactly what a plain click would have trashed.
Button("Delete") { deleteTargets() }
.disabled(!store.acceptsBoardMutations)
.modifierKeyAlternate(.option) {
Button("Delete Immediately") { requestDeleteImmediately(confirmations) }
.disabled(!canDeleteImmediately)
}
}
/// `boardMenu`'s plain rows as VoiceOver custom actions every one calling the *same* private
/// method its menu row does, so the two surfaces cannot come to mean different things.
///
/// Delete Immediately gets its own row here rather than riding `modifierKeyAlternate` the
/// action rotor has no held-key concept, so the alternate needs a first-class custom action of
/// its own to be reachable at all ("surfaces as its own VO custom action per 10's cut",
/// 11-command-nexus.md Context menus' Card row).
@ViewBuilder
private func boardActions(openCard: @escaping (ItemID) -> Void) -> some View {
private func boardActions(openCard: @escaping (ItemID) -> Void, confirmations: TrashConfirmations) -> some View {
Button("Open") { openCard(card.id) }
Button("Rename") { beginRename() }
.disabled(!store.acceptsBoardMutations)
Button("Delete") { deleteTargets() }
.disabled(!store.acceptsBoardMutations)
Button("Delete Immediately") { requestDeleteImmediately(confirmations) }
.disabled(!canDeleteImmediately)
}
/// Delete and Reveal in Finder the two rows 11-command-nexus.md gives a trash card, and no
@@ -536,6 +565,15 @@ struct CardFaceView: View {
confirmations.requestTrashDelete(of: targetIDs, in: store)
}
/// The board side's **permanent** delete Delete's -alternate and its VoiceOver custom-action
/// twin through the same window confirmation host `requestPurge` above uses, but
/// `TrashConfirmations`'s board-side entry point (`requestBoardPurge`): the alert (or the
/// git-board shrug) is what stands between this row and an unrecoverable loss, exactly as it does
/// for the trash's own Delete (03 § Trash).
private func requestDeleteImmediately(_ confirmations: TrashConfirmations) {
confirmations.requestBoardPurge(of: targetIDs, in: store)
}
private func revealInFinder() {
NSWorkspace.shared.activateFileViewerSelecting(targetFolders)
}
@@ -566,6 +604,18 @@ struct CardFaceView: View {
return store.selection.ids
}
/// Whether Delete Immediately's -alternate has something to purge File Delete Immediately's
/// own predicate (`TrashModel.canDeleteImmediately`), read over this row's `targetIDs` rather
/// than the standing selection, `targetIDs`' own reason: a context menu names its target by where
/// it was invoked (11-command-nexus.md Context menus' Card row).
private var canDeleteImmediately: Bool {
guard store.acceptsBoardMutations else { return false }
return TrashModel.canDeleteImmediately(
selection: ItemReferenceSet(ids: targetIDs, container: role.container),
in: store.snapshot
)
}
/// The folders Reveal in Finder points at resolved in this face's container, so a trash row
/// reveals `<root>/.trash/<uuid>` and never a lane path that no longer holds the card.
private var targetFolders: [URL] {
@@ -605,7 +655,7 @@ struct CardFaceView: View {
onCommitAndOpen: {
let id = card.id
store.commitRename()
if case let .board(openCard) = role { openCard(id) }
if case let .board(openCard, _) = role { openCard(id) }
}
)
.font(.body)
+50 -3
View File
@@ -21,8 +21,8 @@ import SwiftUI
///
/// "The lane has one context menu (settled), invoked on the header or on lane empty space alike"
/// (03-board-ui.md § Lane), so both surfaces attach the *same* `laneMenu`. It carries Rename, Style,
/// the quick-style recents row, the Width stepper and Delete 11-command-nexus.md Context menus'
/// Lane row, in its order, complete as of m5.
/// the quick-style recents row, the Width stepper and Delete with Delete Immediately as its
/// -alternate 11-command-nexus.md Context menus' Lane row, in its order, complete as of m5.
///
/// ### The card face
///
@@ -65,6 +65,11 @@ struct LaneView: View {
/// business knowing about `WindowGroup` keys.
let openCard: (ItemID) -> Void
/// The window's purge-alert host Delete's -alternate needs it to raise the same confirmation
/// File Delete Immediately does (`TrashConfirmations`, `BoardView`'s own copy handed straight
/// down, since a lane has no business owning window-scoped state either).
let confirmations: TrashConfirmations
/// Reduce Motion, for the card transition below (10-accessibility.md). Read from the environment
/// and handed to `Motion`, which owns what "reduced" means.
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@@ -298,14 +303,27 @@ struct LaneView: View {
// Delete: File Delete's exact store path (`store.delete`), on the same widened target set
// Style above reads (`targetIDs`, `styleTarget`'s `Set<ItemID>` sibling below) the
// successor-selection rule is `delete(_:)`'s own, so this row gets it for free.
//
// **Delete Immediately as its -alternate** the same swap the card row wears
// (11-command-nexus.md Context menus' Lane row: "Delete with the same -alternate Delete
// Immediately. The trash needs no alternate"), `modifierKeyAlternate(.option)` again, the
// title matching File Delete Immediately's own verbatim (`TrashCommands`).
Button("Delete") { deleteTargets() }
.disabled(!store.acceptsBoardMutations)
.modifierKeyAlternate(.option) {
Button("Delete Immediately") { requestDeleteImmediately() }
.disabled(!canDeleteImmediately)
}
}
/// The menu's plain rows again, as VoiceOver custom actions (see the `.accessibilityActions`
/// call site). Every one of them calls the *same* private method its menu row does, so the two
/// surfaces cannot drift into meaning different things which is the only way "not a separate
/// design surface" is checkable rather than merely intended.
///
/// Delete Immediately gets its own row rather than riding `modifierKeyAlternate`, `CardFaceView
/// .boardActions`' own reason: the action rotor has no held-key concept, so the alternate needs a
/// first-class custom action to be reachable at all.
@ViewBuilder
private var laneActions: some View {
let units = LaneLayoutMath.displayUnits(of: lane)
@@ -317,6 +335,8 @@ struct LaneView: View {
.disabled(!store.acceptsBoardMutations || units <= 1)
Button("Delete") { deleteTargets() }
.disabled(!store.acceptsBoardMutations)
Button("Delete Immediately") { requestDeleteImmediately() }
.disabled(!canDeleteImmediately)
}
/// Board Rename's store path, seeded with the lane's live title one method, two callers
@@ -331,6 +351,15 @@ struct LaneView: View {
store.delete(targetIDs)
}
/// Delete's -alternate the lane's **permanent** delete, through the window's confirmation host
/// rather than straight to the store (`CardFaceView.requestDeleteImmediately`'s own reason): the
/// alert (or the git-board shrug) is what stands between this row and an unrecoverable loss.
/// `TrashConfirmations.requestBoardPurge` is the shared entry point both rows call, over each
/// one's own `targetIDs`.
private func requestDeleteImmediately() {
confirmations.requestBoardPurge(of: targetIDs, in: store)
}
/// VO-Space's landing: the -click funnel, on this lane. `togglesOnRepeat` stays false because
/// only the *plain* branch reads it the branch is already a toggle, which is the point.
private func toggleLaneSelection() {
@@ -389,6 +418,24 @@ struct LaneView: View {
return store.selection.ids
}
/// Whether Delete Immediately's -alternate has something to purge File Delete Immediately's
/// own predicate (`TrashModel.canDeleteImmediately`), read over this row's `targetIDs`
/// (`CardFaceView.canDeleteImmediately`'s own reason: a context menu names its target by where it
/// was invoked, not by the standing selection).
///
/// **Presently always disabled on a lane-only target**: `canDeleteImmediately` is cards only
/// today "a lane's delete is physical already there is nothing for 'skip the trash' to mean
/// on one" (`TrashModel`) so this row is wired per 11-command-nexus.md's Lane row ahead of the
/// store gaining the capability, the same posture the File-menu command already takes on a
/// lane-only selection.
private var canDeleteImmediately: Bool {
guard store.acceptsBoardMutations else { return false }
return TrashModel.canDeleteImmediately(
selection: ItemReferenceSet(ids: targetIDs, container: .board),
in: store.snapshot
)
}
private var headerContent: some View {
HStack(alignment: .firstTextBaseline, spacing: BoardMetrics.laneHeaderSpacing(bodyPointSize: pointSize)) {
Image(systemName: ItemSymbol.name(lane.icon, fallback: ItemSymbol.lane))
@@ -669,7 +716,7 @@ struct LaneView: View {
CardFaceView(
store: store,
card: card,
role: .board(openCard: openCard),
role: .board(openCard: openCard, confirmations: confirmations),
marquee: marquee,
drops: drops
)
+46 -7
View File
@@ -36,7 +36,12 @@ final class TrashConfirmations {
/// names the whole container and re-derives its targets at the moment it runs).
enum Action: Equatable {
case deleteTrashCards(Set<ItemID>)
case purge(Set<ItemID>)
/// **The container travels with the ids**, because a purge can be aimed at either side and
/// the two entry points below disagree about which: the menu-bar command means the
/// selection's container, a context menu means `.board` whatever is selected. Re-reading
/// the selection when the alert is answered would resolve a board card against `.trash` and
/// purge nothing a confirmed destructive command becoming a silent no-op.
case purge(Set<ItemID>, ItemContainer)
case emptyTrash
}
}
@@ -91,20 +96,54 @@ final class TrashConfirmations {
/// expression rather than three call sites.
///
/// Its one caller is File Delete Immediately, which passes the selection's own ids which is
/// what makes reading `store.selection.container` for the prompt correct here and wrong for a
/// context menu (`requestTrashDelete` above exists for exactly that difference).
/// what makes reading `store.selection.container` correct here and wrong for a context menu
/// (`requestTrashDelete` above exists for exactly that difference).
func requestPurge(of ids: Set<ItemID>, in store: BoardStore) {
let container = store.selection.container
guard store.purgeIsUnrecoverable else {
store.deleteImmediately(ids)
store.deleteImmediately(ids, in: container)
return
}
guard let prompt = TrashModel.purgePrompt(
for: ids,
in: store.selection.container,
in: container,
snapshot: store.snapshot,
unrecoverable: true
) else { return }
pending = Pending(prompt: prompt, action: .purge(ids))
pending = Pending(prompt: prompt, action: .purge(ids, container))
}
/// **Delete's -alternate, aimed at an explicit set** the board-side card and lane
/// context-menu rows' Delete Immediately (11-command-nexus.md Context menus' Card and Lane
/// rows: "Delete with Delete Immediately as its -alternate Finder's pattern: hold and
/// Delete becomes Delete Immediately").
///
/// A third entry point beside `requestPurge` and `requestTrashDelete`, for `requestTrashDelete`'s
/// own reason mirrored onto the other container: `requestPurge(of:in:)` reads
/// `store.selection.container` for the prompt, which is correct for its one caller (File
/// Delete Immediately, whose ids *are* the selection) and wrong for a context menu, which names
/// its target by where the -held click landed right-clicking a card or lane while a *trash*
/// selection stands must still purge the clicked item.
///
/// The container is always `.board`: this alternate exists only on the board-side rows
/// "the trash needs no alternate: its Delete is already permanent" (11-command-nexus.md's Lane
/// row).
///
/// **The container is supplied end to end**, prompt and write alike: `store.deleteImmediately`
/// takes it as a parameter rather than reading the selection, so a confirmed purge aimed at a board
/// item cannot silently find nothing because a trash selection happened to be standing.
func requestBoardPurge(of ids: Set<ItemID>, in store: BoardStore) {
guard store.purgeIsUnrecoverable else {
store.deleteImmediately(ids, in: .board)
return
}
guard let prompt = TrashModel.purgePrompt(
for: ids,
in: .board,
snapshot: store.snapshot,
unrecoverable: true
) else { return }
pending = Pending(prompt: prompt, action: .purge(ids, .board))
}
/// Raises Empty Trash's alert. **Always** it guards bulk scope rather than per-item
@@ -124,7 +163,7 @@ final class TrashConfirmations {
self.pending = nil
switch pending.action {
case let .deleteTrashCards(ids): store.deleteTrashCards(ids)
case let .purge(ids): store.deleteImmediately(ids)
case let .purge(ids, container): store.deleteImmediately(ids, in: container)
case .emptyTrash: store.emptyTrash()
}
}
+24
View File
@@ -647,6 +647,25 @@ struct AgentGuideContentTests {
#expect(content.contains("schema: 1"))
}
/// v7's list, 08 The agent guide's "v7 additionally teaches" bullet: the refined
/// stamp-discipline predicate (01-storage-format.md `modified`'s scope, ruled 2026-07-29,
/// refined 2026-07-30) and the card-level `attachments` claimed name (01-storage-format.md
/// § Fractal layout Rules, "level-uniform"). Each pin is a phrase an agent reading the guide
/// would actually see, not a paraphrase so a wording rewrite that silently drops the rule
/// fails here instead of shipping quietly.
@Test("v7 teaches the stamp-discipline predicate and the attachments claimed name")
func v7VocabularyIsPresent() {
let content = AgentGuide.content
// The predicate itself: one rule, not a trash special case.
#expect(content.contains("a move that changes an item's container"))
#expect(content.contains("rewrites only `order`"))
#expect(content.contains("into/out of `.trash/`"))
#expect(content.contains("The trash move isn't an exception"))
// The attachments claimed name: don't squat the folder's name with a file.
#expect(content.contains("The name `attachments` itself belongs to that folder"))
#expect(content.contains("*file* called `attachments` in a card"))
}
/// 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.
@@ -656,5 +675,10 @@ struct AgentGuideContentTests {
#expect(!content.contains("media/"))
#expect(!content.contains("tombstone"))
#expect(content.contains("Never write a `deleted:` key"))
// The 2026-07-29 rule was first named "moves-don't-stamp"; the 2026-07-30 refinement
// retired that framing (container changes stamp, the trash move included) the guide
// must never teach the superseded shape of the rule.
#expect(!content.contains("moves don't stamp"))
#expect(!content.contains("moves-don't-stamp"))
}
}
+127
View File
@@ -828,3 +828,130 @@ struct BoardLoaderEncodingTests {
}
}
}
// MARK: - The coerce tier's trace (01-storage-format.md § Frontmatter, ruled 2026-07-29)
/// **"A no-sensible-reading fallback logs"** field, path, and raw text, carried as coerce-tier
/// entries in the integrity service's Defect stream:
///
/// > the one place where an observed-in-the-wild shape can later be promoted to a heuristic heal or a
/// > notice; no banner, no behavior change.
///
/// So these tests assert two things at once, and the second matters as much as the first: the fallback
/// is *reported*, and nothing about the board changed because of it the field still renders its
/// default, the bytes on disk are still verbatim, and no heal is scheduled.
@Suite("BoardLoader ▸ coerce-tier fallbacks")
struct BoardLoaderCoercionTraceTests {
/// The pure half first: which lenient fields report, and which deliberately do not.
///
/// `schema` and `order` are the **refuse** tier a malformed one fails the load loudly, so there is
/// no silent recovery to leave a trace of and `deleted`'s rule is presence-not-validity, so
/// nothing falls back to a default there either.
@Test("The document reports its lenient fallbacks, and only those")
func theDocumentReportsItsLenientFallbacks() throws {
let document = try FrontmatterDocument.parse("""
---
schema: 1
order: 1024
title: [a, b]
width: 1.5
created: not-a-date
icon: {a: b}
deleted: also-not-a-date
---
Body.
""")
let byKey = Dictionary(uniqueKeysWithValues: document.coercedFields.map { ($0.key, $0.raw) })
#expect(Set(byKey.keys) == ["title", "width", "created", "icon"])
#expect(byKey["width"] == "1.5", "the raw text as written — what a future heuristic would read")
#expect(byKey["created"] == "not-a-date")
#expect(byKey["deleted"] == nil, "presence, not validity, decides a tombstone")
}
@Test("A clean document reports nothing")
func aCleanDocumentReportsNothing() throws {
let document = try FrontmatterDocument.parse("""
---
schema: 1
order: 1024
title: Fine
width: 2
---
""")
#expect(document.coercedFields.isEmpty)
}
/// **A scalar of the wrong type is not a fallback** it coerces to the text the author typed
/// (`title: 2048` reads as "2048"), which is a *successful* reading and leaves no trace. Only "no
/// sensible reading exists" does.
@Test("A coerced scalar leaves no trace — it was read, not defaulted")
func aCoercedScalarLeavesNoTrace() throws {
let document = try FrontmatterDocument.parse("---\nschema: 1\ntitle: 2048\nwidth: \"3\"\n---\n")
#expect(document.title.value == "2048")
#expect(document.width.value == 3)
#expect(document.coercedFields.isEmpty)
}
/// The loader's half: the path is attached at every level, because the rule is about fields and
/// every level has them.
@Test("The loader attaches the path, at every level")
func theLoaderAttachesThePath() throws {
let fixture = try BoardFixture()
defer { fixture.tearDown() }
let lane = uuidFolderName()
let card = uuidFolderName()
let trashed = uuidFolderName()
try fixture.index("", "schema: 1\ntitle: [a, b]\n")
try fixture.index(lane, "schema: 1\norder: 1024\nwidth: 1.5\n")
try fixture.index("\(lane)/\(card)", "schema: 1\norder: 1024\nicon: {x: y}\n")
try fixture.index(".trash/\(trashed)", "schema: 1\norder: 1024\ncreated: nope\n")
let reported = try BoardLoader.load(boardRoot: fixture.root).coercedFrontmatter
let byPath = Dictionary(uniqueKeysWithValues: reported.map { ($0.path, $0.fields.map(\.key)) })
#expect(byPath["index.md"] == ["title"])
#expect(byPath["\(lane)/index.md"] == ["width"])
#expect(byPath["\(lane)/\(card)/index.md"] == ["icon"])
#expect(byPath[".trash/\(trashed)/index.md"] == ["created"])
}
/// **No behavior change** the whole point of the tier. The fields render their defaults exactly as
/// they did before anything was reported, and the bytes are preserved verbatim.
@Test("Nothing about the board changes — defaults render, bytes stay")
func nothingChanges() throws {
let fixture = try BoardFixture()
defer { fixture.tearDown() }
let lane = uuidFolderName()
try fixture.index("", "schema: 1\ntitle: Board\n")
try fixture.index(lane, "schema: 1\norder: 1024\ntitle: [a, b]\nwidth: 0.5\n")
let before = try Data(contentsOf: fixture.root.appendingPathComponent("\(lane)/index.md"))
let result = try BoardLoader.load(boardRoot: fixture.root)
let loaded = try #require(result.model.lanes.first)
#expect(loaded.title.isMalformed, "the field still reads as malformed")
#expect(loaded.title.value == nil, "and renders its default — the untitled placeholder")
#expect(loaded.width.value == nil, "width falls back to 1 at the render layer, not here")
#expect(try Data(contentsOf: fixture.root.appendingPathComponent("\(lane)/index.md")) == before,
"read-side only: the loader never writes")
#expect(result.warnings.isEmpty, "a coercion is not a stray warning")
}
/// **It is not healable work**, which is why it has no class: a class is a memo key and a
/// banner-posture row in the engine, and inventing one would arm a memo against a repair nobody
/// wrote. The other defects keep theirs.
@Test("A coerce-tier defect has no heal class, and signs per field")
func itHasNoHealClass() {
let defect = IntegrityRules.Defect.coercedFrontmatter(CoercedFrontmatter(
path: "lane/card/index.md",
fields: [CoercedField(key: "width", raw: "1.5"), CoercedField(key: "icon", raw: "{}")]
))
#expect(defect.healClass == nil)
#expect(Set(defect.signatures) == [
"coerce:lane/card/index.md:width",
"coerce:lane/card/index.md:icon",
])
}
}
+143 -17
View File
@@ -423,18 +423,28 @@ struct BoardWriterRenumberTests {
#expect(try fixture.indexText("lane/\(Child.b)").contains("order: 1024\n"))
}
@Test func eachRewrittenChildIsStampedAndKeepsItsUnknownKeys() throws {
/// **A rescale stamps nothing** (01-storage-format.md § Ordering, verbatim: "order-only rewrites,
/// so no `modified` stamp and no `modified-by` clear"; § Frontmatter `modified`'s scope, refined
/// 2026-07-30). Every sibling's file is rewritten and not one of them is stamped a foreign
/// `modified-by` survives, which is the pairing read at its sharpest: attribution cannot change when
/// content didn't.
///
/// The prior version of this test asserted the opposite (`modifiedBy == .missing`, `modified != nil`)
/// under the pre-2026-07-29 rule that every app write stamps.
@Test func eachRewrittenChildKeepsItsStampsAndItsUnknownKeys() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let lane = try crowdedLane(fixture)
let priorModified = try FrontmatterDocument.parse(fixture.indexText("lane/\(Child.a)"))
.rawValue(for: FrontmatterKeys.modified)
try BoardWriter.renumberVisibleChildren(of: lane)
for name in [Child.a, Child.b, Child.c] {
let text = try fixture.indexText("lane/\(name)")
let document = try FrontmatterDocument.parse(text)
#expect(document.modifiedBy == .missing)
#expect(document.modified.value != nil)
#expect(document.modifiedBy == .valid("claude"), "a rescale touches no content, so attribution stands")
#expect(document.rawValue(for: FrontmatterKeys.modified) == priorModified, "and nothing is stamped")
#expect(document.unknownFields.map(\.key) == ["project"])
#expect(text.contains("project: lanework # agent overlay\n"))
#expect(document.body.hasSuffix(" body\n"))
@@ -1458,14 +1468,81 @@ struct BoardWriterCopyTests {
}
}
/// The leniency below the root: a nested file the surgical editor cannot key is copied
/// verbatim rather than failing the gesture stale `modified-by` and all while its
/// editable siblings are stamped normally.
@Test func aNestedUneditableFileCopiesVerbatimWhileItsSiblingsAreStamped() throws {
/// **A copy is a transaction** (01-storage-format.md § Frontmatter, ruled 2026-07-29): a nested
/// card the surgical editor cannot key refuses the **whole** copy, naming that card, and nothing is
/// materialized at the destination.
///
/// This replaced the former nested leniency, which copied such a card verbatim stale `modified-by`
/// and all and stamped its siblings normally. The kindness was the one verdict 01's doctrine
/// forbids: "proceed partially, lose a little" is never a verdict, and a silently unstamped
/// descendant now also carries a live tracker claim it has no right to (the `remote` sever).
@Test func aNestedUneditableFileRefusesTheWholeCopyNamingIt() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try board(fixture)
// `Item.uneditable`'s own title, so the refusal has a name to carry.
try fixture.item("A.kanban/\(Ident.lane1)/\(Ident.card3)", Item.uneditable)
let before = try fixture.entryNames("A.kanban")
let error = writeFailure {
_ = try BoardWriter.copyItem(
at: fixture.url("A.kanban/\(Ident.lane1)"),
toParent: fixture.url("A.kanban"),
order: nil,
stamps: .fork
)
}
let failure = try #require(error)
#expect(failure.operation == .copy(title: "Odd"), "the refusal names the offending item")
if case .uneditableFrontmatter = failure.reason {} else {
Issue.record("expected the uneditable-shape refusal, got \(failure.reason)")
}
#expect(failure.path.contains(Ident.card3), "and the offending file's own path")
#expect(try fixture.entryNames("A.kanban") == before, "nothing was materialized")
}
/// The preflight runs over the **source**, so a refusal is free: the copy is refused before a single
/// byte is written, rather than materialized and then cleaned up.
@Test func aRefusedCopyNeverTouchesTheDestinationAtAll() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try board(fixture)
try fixture.item("A.kanban/\(Ident.lane1)/\(Ident.card3)", Item.uneditable)
// A second board, so "nothing at the destination" is a claim about an empty container rather
// than about a folder that happens to hold the source too.
try fixture.item("B.kanban", Item.board)
try fixture.item("B.kanban/\(Ident.lane3)", Item.rich(order: "1024", title: "Elsewhere"))
_ = writeFailure {
_ = try BoardWriter.copyItem(
at: fixture.url("A.kanban/\(Ident.lane1)"),
toParent: fixture.url("B.kanban"),
order: nil,
stamps: .fork
)
}
#expect(try fixture.entryNames("B.kanban").sorted() == ["index.md", Ident.lane3].sorted())
}
/// **Item-level copies sever tracker identity** (01-storage-format.md § Fractal layout Rules,
/// ruled 2026-07-29): "every folder an item-level copy materializes drops the reserved
/// `remote`/`remote-state` keys, at every level because two local objects must never both claim to
/// be the same remote object". The **source** keeps both, because a sever is something a copy does to
/// itself.
///
/// A lane copy, so both keys are exercised where the schema puts them `remote-state` on the lane,
/// `remote` on its cards and both levels are asserted, which is what "at every level" means.
@Test func anItemLevelCopySeversTheReservedTrackerKeys() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("A.kanban", Item.board)
try fixture.item("A.kanban/\(Ident.lane1)", Item.tracked(order: "1024", title: "Todo", key: "remote-state"))
try fixture.item(
"A.kanban/\(Ident.lane1)/\(Ident.card1)",
Item.tracked(order: "1024", title: "Card One", key: "remote")
)
let id = try BoardWriter.copyItem(
at: fixture.url("A.kanban/\(Ident.lane1)"),
@@ -1474,10 +1551,51 @@ struct BoardWriterCopyTests {
stamps: .fork
)
let copied = try childrenByTitle(of: "A.kanban/\(id.rawValue)", in: fixture)
#expect(try fixture.indexText("A.kanban/\(id.rawValue)/\(try #require(copied["Odd"]))") == Item.uneditable)
#expect(try FrontmatterDocument.parse(fixture.indexText("A.kanban/\(id.rawValue)/\(try #require(copied["Card One"]))"))
.modifiedBy == .missing)
let lane = try FrontmatterDocument.parse(fixture.indexText("A.kanban/\(id.rawValue)"))
#expect(lane.value(for: "remote-state") == nil, "the copied lane's tracker mapping is severed")
#expect(lane.value(for: "project") != nil, "and every other unknown key is untouched")
let copiedCard = try #require(try fixture.entryNames("A.kanban/\(id.rawValue)").first(where: BoardLoader.isUUIDShaped))
let card = try FrontmatterDocument.parse(fixture.indexText("A.kanban/\(id.rawValue)/\(copiedCard)"))
#expect(card.value(for: "remote") == nil, "the copied card's too — at every level")
#expect(card.value(for: "project") != nil)
// The originals still claim their remote objects: only the copy is severed.
#expect(try FrontmatterDocument.parse(fixture.indexText("A.kanban/\(Ident.lane1)"))
.value(for: "remote-state") != nil)
#expect(try FrontmatterDocument.parse(fixture.indexText("A.kanban/\(Ident.lane1)/\(Ident.card1)"))
.value(for: "remote") != nil)
}
/// Every occurrence goes, not just the winning one `FrontmatterDocument.remove`'s own rule, and it
/// matters here more than anywhere: a hand-duplicated `remote:` left behind would resurrect the
/// claim the moment the winner were removed.
@Test func theSeverTakesEveryOccurrenceOfTheKey() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("A.kanban", Item.board)
try fixture.item("A.kanban/\(Ident.lane1)", Item.rich(order: "1024", title: "Todo"))
try fixture.item("A.kanban/\(Ident.lane1)/\(Ident.card1)", """
---
schema: 1
title: Twinned
order: 1024
remote: gitea#1
remote: gitea#2
---
Body.
""")
let id = try BoardWriter.copyItem(
at: fixture.url("A.kanban/\(Ident.lane1)/\(Ident.card1)"),
toParent: fixture.url("A.kanban/\(Ident.lane1)"),
order: nil,
stamps: .fork
)
let text = try fixture.indexText("A.kanban/\(Ident.lane1)/\(id.rawValue)")
#expect(!text.contains("remote:"), "both occurrences went")
}
/// A UUID-shaped folder with no `index.md` interrupted-create residue is reminted and
@@ -1529,9 +1647,12 @@ struct BoardWriterCopyTests {
#expect(try fixture.entryNames("A.kanban/\(Ident.lane2)") == before)
}
/// All-or-nothing at the destination: a failure part-way through leaves no half-copied tree,
/// because a partial copy is pure residue nothing was there before.
@Test func aFailedCopyLeavesNothingAtTheDestination() throws {
/// All-or-nothing at the destination and since the copy became a **transaction** (ruled
/// 2026-07-29) this case never even materializes: an unreadable nested `index.md` is caught by the
/// preflight, over the *source*, before a byte is copied. The claim is the same one, met earlier and
/// more cheaply: nothing is at the destination, and `.unreadable` names the file rather than the
/// half-finished copy of it.
@Test func anUnreadableDescendantRefusesTheCopyBeforeItStarts() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try board(fixture)
@@ -1548,10 +1669,11 @@ struct BoardWriterCopyTests {
stamps: .fork
)
}
guard case .io = error?.reason else {
Issue.record("expected .io, got \(String(describing: error?.reason))")
guard case .unreadable = error?.reason else {
Issue.record("expected .unreadable, got \(String(describing: error?.reason))")
return
}
#expect(error?.path.contains(Ident.card2) == true, "the offending file is named")
#expect(try fixture.entryNames("A.kanban") == before)
}
@@ -1668,7 +1790,11 @@ struct BoardWriterSameParentMoveTests {
// and not after itself miscounted (which would give 2048... or 4096+1024).
let text = try fixture.indexText("A.kanban/\(Ident.lane1)/\(Ident.card1)")
#expect(text.contains("order: 4096"))
#expect(!text.contains("modified-by:"))
// **The same-parent path is a reorder, so it stamps nothing** (01-storage-format.md
// § Frontmatter `modified`'s scope, refined 2026-07-30): the container never changed, so the
// foreign `modified-by` survives. This assertion was `!text.contains("modified-by:")` under the
// pre-refinement rule that every app write clears it.
#expect(text.contains("modified-by: claude"))
}
@Test func aSameParentMoveWithAnExplicitOrderJustRewritesIt() throws {
+171
View File
@@ -287,3 +287,174 @@ struct ClaimedNamePhrasingTests {
== "Couldn't move '.trash' aside — Lanework needs that name — permission denied")
}
}
// MARK: - The card level
/// **The rule is level-uniform** (01-storage-format.md § Fractal layout Rules, extended
/// 2026-07-29):
///
/// > a card's reserved child names are claimed the same way a regular file or symlink squatting
/// > `attachments` (a directory name) displaces by the same ladder (`attachments` `attachments 2`),
/// > so imports, Finder drops, and the sidebar listing never fail one gesture at a time against a
/// > squatted name; the displaced file, now an ordinary loose file, rides the next relocation into the
/// > real `attachments/` the heals compose.
///
/// The **reserved-but-unconsumed `comments`** is the timing principle's own illustration and is
/// deliberately *not* displaced: nothing reads that name until the tracker era, so a wrong-kind holder
/// degrades nothing while it stands and keeps the tolerated-stray posture.
@MainActor
@Suite("Claimed names ▸ the card level")
struct CardClaimedNameTests {
/// A card holding a *file* called `attachments`. The load reports it and moves nothing detection
/// is read-only at every level.
@Test("A file on a card's attachments is reported as a defect, and the load moves nothing")
func fileOnAttachmentsIsADefect() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments", Data("not a folder".utf8))
let result = try BoardLoader.load(boardRoot: fixture.root)
#expect(result.claimedNameSquatters == [
ClaimedNameSquatter(
name: "attachments",
found: .file,
expected: .directory,
location: .card(path: "\(Ident.lane1)/\(Ident.card1)")
),
])
#expect(
try fixture.data("\(Ident.lane1)/\(Ident.card1)/attachments") == Data("not a folder".utf8),
"the loader never writes"
)
// A claimed name is not a stray, so it never earns the stray-tolerance vocabulary and it is
// not a loose file either, so the relocation has nothing to say about it yet.
#expect(result.warnings.isEmpty)
#expect(result.looseCardFiles.isEmpty)
}
/// A **symlink** wearing the name is the same defect and is moved *as a link*, never followed
/// (01 § Fractal layout Rules: "symlinks are never traversed").
@Test("A symlink on a card's attachments is the same defect")
func symlinkOnAttachmentsIsADefect() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try FileManager.default.createSymbolicLink(
atPath: fixture.url("\(Ident.lane1)/\(Ident.card1)").appendingPathComponent("attachments").path,
withDestinationPath: "../elsewhere"
)
let result = try BoardLoader.load(boardRoot: fixture.root)
#expect(result.claimedNameSquatters.map(\.found) == [.symlink])
}
/// **`comments` stays tolerated** the timing principle, stated as the absence of a defect.
@Test("A file on a card's comments is not displaced — the name is not load-bearing yet")
func fileOnCommentsIsTolerated() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try fixture.file("\(Ident.lane1)/\(Ident.card1)/comments", Data("someday".utf8))
let result = try BoardLoader.load(boardRoot: fixture.root)
#expect(result.claimedNameSquatters.isEmpty)
#expect(result.looseCardFiles.isEmpty, "a reserved name is not a loose file either")
}
/// A real `attachments/` folder is a resident, not a squatter the check is about the node's
/// *kind*, and this is the negative case that keeps it honest.
@Test("A real attachments folder is no defect at all")
func aRealAttachmentsFolderIsFine() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/shot.png", Data([0x01]))
#expect(try BoardLoader.load(boardRoot: fixture.root).claimedNameSquatters.isEmpty)
}
/// The heal itself, end to end: the ladder renames it inside the **card's** folder, the notice names
/// old and new, and the file's bytes are exactly what they were.
@Test("The heal displaces it by the ladder, inside the card's own folder")
func theHealDisplacesItInsideTheCard() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let cardPath = "\(Ident.lane1)/\(Ident.card1)"
try fixture.file("\(cardPath)/attachments", Data("squatter".utf8))
let store = try BoardStore(rootURL: fixture.root)
store.displaceClaimedNames()
await store.awaitQuiescence()
#expect(try fixture.data("\(cardPath)/attachments 2") == Data("squatter".utf8), "preserved verbatim")
#expect(
IntegrityRules.node(at: fixture.url(cardPath).appendingPathComponent("attachments")) == nil,
"and the name is free for the app"
)
#expect(store.banners.losses.count == 1)
let message = try #require(store.banners.losses.first?.message)
#expect(message.contains("attachments"))
#expect(message.contains("attachments 2"))
}
/// **The heals compose** 01's own word for it: once displaced, the file is an ordinary loose file
/// beside the card's `index.md`, which is exactly what the loose-file relocation exists for. One
/// reload later it is inside the real `attachments/`.
@Test("Displaced, then relocated: the heals compose")
func theHealsCompose() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let cardPath = "\(Ident.lane1)/\(Ident.card1)"
try fixture.file("\(cardPath)/attachments", Data("squatter".utf8))
let store = try BoardStore(rootURL: fixture.root)
store.displaceClaimedNames()
await store.awaitQuiescence()
// The next load sees an ordinary loose file where the squatter was.
let after = try BoardLoader.load(boardRoot: fixture.root)
#expect(after.claimedNameSquatters.isEmpty)
#expect(after.looseCardFiles.map(\.fileNames) == [["attachments 2"]])
let relocating = try BoardStore(rootURL: fixture.root)
relocating.relocateLooseCardFiles()
await relocating.awaitQuiescence()
#expect(
try fixture.data("\(cardPath)/attachments/attachments 2") == Data("squatter".utf8),
"and it landed in the real attachments/"
)
}
/// Two cards squatting the name are **two pieces of work** in one bracket the signature carries
/// the location, so one card's failed heal has no claim to have failed the other's.
@Test("Two squatted cards are two defects, healed in one bracket")
func twoCardsAreTwoDefects() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Second"))
try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments", Data("one".utf8))
try fixture.file("\(Ident.lane1)/\(Ident.card2)/attachments", Data("two".utf8))
let result = try BoardLoader.load(boardRoot: fixture.root)
#expect(result.claimedNameSquatters.count == 2)
#expect(Set(result.defects.flatMap(\.signatures)).count == 2, "distinct work, by location")
let store = try BoardStore(rootURL: fixture.root)
store.displaceClaimedNames()
await store.awaitQuiescence()
#expect(try fixture.data("\(Ident.lane1)/\(Ident.card1)/attachments 2") == Data("one".utf8))
#expect(try fixture.data("\(Ident.lane1)/\(Ident.card2)/attachments 2") == Data("two".utf8))
}
/// The table is the only thing to edit when `comments` graduates pinned so the split is a stated
/// rule rather than an accident of the probe's implementation.
@Test("The card-level table claims attachments and comments, and displaces only attachments")
func theTableStatesTheSplit() {
let names = IntegrityRules.claimedCardChildNames
#expect(names.map(\.name) == ["attachments", "comments"])
#expect(names.allSatisfy { $0.expected == .directory })
#expect(names.first { $0.name == "attachments" }?.displacesSquatters == true)
#expect(names.first { $0.name == "comments" }?.displacesSquatters == false)
}
}
+72 -59
View File
@@ -181,8 +181,8 @@ struct ClipboardManifestTests {
#expect(ClipboardManifest(data: data) == nil)
}
@Test("A lane entry's lost-attachment count totals its cards'")
func lostAttachments() {
@Test("A lane entry's attachment count totals its cards'")
func totalAttachments() {
let lane = ClipboardManifest.Entry(
id: Ident.lane1,
folder: Ident.lane1,
@@ -194,7 +194,7 @@ struct ClipboardManifestTests {
.init(id: Ident.card2, title: "Second", index: "b", attachmentCount: 1),
]
)
#expect(lane.lostAttachmentCount == 3)
#expect(lane.totalAttachmentCount == 3)
}
@Test("The plain-text rendering is the titles, untitled items rendered as the board renders them")
@@ -285,7 +285,7 @@ struct ClipboardCopyTests {
#expect(manifest.kind == .lane)
#expect(manifest.entries.map(\.id) == [Ident.lane1])
#expect(manifest.entries[0].cards.map(\.id) == [Ident.card1, Ident.card2])
#expect(manifest.entries[0].lostAttachmentCount == 2)
#expect(manifest.entries[0].totalAttachmentCount == 2)
}
@Test("A trash selection copies out, container recorded")
@@ -830,73 +830,86 @@ struct PasteTargetTests {
}
}
// MARK: - The degraded paste's phrasing
// MARK: - The refused paste's phrasing
@Suite("BannerCenter ▸ degraded paste")
struct DegradedPasteBannerTests {
/// **04-interactions.md Clipboard, re-ruled 2026-07-29** refuse, never degrade:
///
/// > A paste whose staged snapshot is missing or unreadable refuses loudly never degrades
/// > the paste produces **nothing**, and a one-shot failure banner names it from the manifest's
/// > metadata ("Couldn't paste 'Fix login' the copied content is gone").
///
/// The retired suite these replace pinned `degradedPasteMessage(for:)` and its loss row "Pasted
/// 'Fix login' without its 3 attachments". Both are gone with the degraded materialization: nothing
/// arrives, so there is no partial arrival to account for.
@Suite("BannerCenter ▸ refused paste")
struct RefusedPasteBannerTests {
/// 04's own example sentence, composed the way every failure headline is: the action clause the
/// banner owns, an em dash, the cause.
@Test("04's own example sentence")
@MainActor
func theExampleSentence() {
#expect(BannerCenter.degradedPasteMessage(
for: [.init(title: "Fix login", attachments: 3)]
) == "Pasted 'Fix login' without its 3 attachments")
let center = BannerCenter()
center.postRefusedPaste(title: "Fix login", stagedAt: "/tmp/staging/abc")
#expect(center.oneShots.count == 1)
let headline = try? #require(center.oneShots.first).error
#expect(headline.map(BannerCenter.headline(for:)) == "Couldn't paste 'Fix login' — the copied content is gone")
}
@Test("One attachment is singular")
func singular() {
#expect(BannerCenter.degradedPasteMessage(
for: [.init(title: "Fix login", attachments: 1)]
) == "Pasted 'Fix login' without its attachment")
}
@Test("An untitled item is 'the item', never the Untitled rendering")
/// "Untitled" is a rendering, never a value (03-board-ui.md § Card face), so an untitled entry is
/// "the item" `actionPhrase`'s standing convention for a failure with no title to quote.
@Test("An untitled entry is 'the item', never the Untitled rendering")
@MainActor
func untitled() {
#expect(BannerCenter.degradedPasteMessage(
for: [.init(title: nil, attachments: 2)]
) == "Pasted the item without its 2 attachments")
}
@Test("Several items total their attachments rather than listing titles")
func several() {
#expect(BannerCenter.degradedPasteMessage(
for: [.init(title: "A", attachments: 2), .init(title: "B", attachments: 3)]
) == "Pasted 2 items without their 5 attachments")
}
@Test("Nothing lost says nothing")
func nothingLost() {
#expect(BannerCenter.degradedPasteMessage(for: []) == nil)
#expect(BannerCenter.degradedPasteMessage(for: [.init(title: "A", attachments: 0)]) == nil)
}
@Test("Posting an empty loss list adds no row")
@MainActor
func postingNothing() {
let center = BannerCenter()
center.postDegradedPaste([])
#expect(center.losses.isEmpty)
center.postRefusedPaste(title: nil, stagedAt: "/tmp/staging/abc")
let error = try? #require(center.oneShots.first).error
#expect(error.map(BannerCenter.headline(for:)) == "Couldn't paste the item — the copied content is gone")
}
/// **The pivot, stated as a class change**: the degraded paste was a loss row because the items
/// landed and only their attachments did not. A refusal is *a write that did not happen*, which is
/// 02-architecture.md's own definition of a one-shot so it ranks with the true failures, carries
/// the error tone, and posts no loss row at all.
@Test("A refused paste is an error-tone one-shot, not a loss row")
@MainActor
func refusalIsAOneShotNotALossRow() {
let center = BannerCenter()
center.postRefusedPaste(title: "Fix login", stagedAt: "/tmp/staging/abc")
#expect(center.losses.isEmpty, "the degraded paste's loss row is retired")
#expect(center.signposts.isEmpty)
}
@Test("A degraded paste lands in the loss class, not the signpost class")
@MainActor
func postingLandsAsALossRow() {
// Settled 2026-07-28 (DESIGN/02-architecture.md § The banner surface, "Loss rows"): the
// degraded paste retoned from a signpost onto the new warning-tone loss class content
// that didn't arrive though nothing failed, ranking below the true failures and above the
// ambient notices rather than at the bottom of the strip.
let center = BannerCenter()
center.postDegradedPaste([.init(title: "Fix login", attachments: 3)])
#expect(center.losses.count == 1)
#expect(center.losses.first?.message == "Pasted 'Fix login' without its 3 attachments")
#expect(center.signposts.isEmpty, "the degraded paste no longer posts a signpost")
let rows = BannerCenter.rows(
lock: nil, breakage: nil, oneShots: [], losses: center.losses, suspension: nil, operations: []
lock: nil, breakage: nil, oneShots: center.oneShots, losses: [], suspension: nil, operations: []
)
#expect(rows.count == 1)
#expect(rows[0].tone == .warning)
#expect(rows[0].dismissID == center.losses.first?.id)
#expect(rows[0].tone == .error)
#expect(rows[0].dismissID == center.oneShots.first?.id)
}
/// The staging path is what the error names, so a bug report about a refusal has something to go
/// on the file that was not there.
@Test("The refusal names the staged path it could not find")
@MainActor
func namesTheStagedPath() {
let center = BannerCenter()
center.postRefusedPaste(title: "Fix login", stagedAt: "/tmp/staging/abc")
#expect(center.oneShots.first?.error.path == "/tmp/staging/abc")
#expect(center.oneShots.first?.error.reason == .clipboardContentGone)
#expect(center.oneShots.first?.error.operation == .paste(title: "Fix login"))
}
/// **The loss class survives the retirement** 02's warning-tone class still has live producers
/// (a Finder drop that skipped folders, the app's own relocation and repair notices); only the
/// degraded-paste row left it.
@Test("The loss class still has its other producers")
@MainActor
func theLossClassSurvives() {
let center = BannerCenter()
center.postSkippedFolders(count: 2)
#expect(center.losses.count == 1)
#expect(center.losses.first?.message == "Folders can't be attached — 2 skipped")
}
}
+14 -7
View File
@@ -757,10 +757,15 @@ struct LooseFilePasteTests {
#expect(try BoardLoader.load(boardRoot: destination.root).looseCardFiles.isEmpty)
}
/// The staging-less fallback carries only `index.md`, so there is nothing to normalize and the
/// normalization must not invent an `attachments/` for a card that has none.
@Test("A degraded paste normalizes nothing and mints no attachments folder")
func degradedPasteIsUnaffected() async throws {
/// **A refused paste normalizes nothing, because it materializes nothing** (04-interactions.md
/// Clipboard, re-ruled 2026-07-29 refuse, never degrade).
///
/// This was the degraded fallback's normalization case: the fallback carried only `index.md`, so the
/// claim was that normalization must not invent an `attachments/` for a card that had none. With the
/// fallback retired the claim gets stronger and simpler there is no arrival to normalize at all,
/// and the destination is exactly what it was.
@Test("A refused paste normalizes nothing because nothing arrives")
func aRefusedPasteNormalizesNothing() async throws {
let harness = try ClipboardHarness(fixture: try makeLooseFileClipboardBoard())
defer { harness.tearDown() }
let destination = try makePasteDestination()
@@ -776,10 +781,12 @@ struct LooseFilePasteTests {
}
target.select([ItemID(rawValue: Ident.lane4)], in: .board)
let before = try destination.entryNames(Ident.lane4).sorted()
await harness.clipboard.paste(into: target)?.value
let arrived = try arrivedCard(in: destination)
#expect(!destination.exists("\(Ident.lane4)/\(arrived)/attachments"))
#expect(try destination.entryNames("\(Ident.lane4)/\(arrived)") == ["index.md"])
#expect(try destination.entryNames(Ident.lane4).sorted() == before, "no card arrived")
#expect(target.banners.oneShots.count == 1, "and the refusal said so")
// The resident is untouched no attachments folder was invented anywhere in the lane.
#expect(try destination.entryNames("\(Ident.lane4)/\(Ident.indexless)") == ["index.md"])
}
}
+113 -125
View File
@@ -528,14 +528,34 @@ struct PasteCutTests {
}
}
// MARK: - The staging-less fallback
// MARK: - Refuse, never degrade
/// **04-interactions.md Clipboard, re-ruled 2026-07-29** Finder's invariant adopted:
///
/// > A paste whose staged snapshot is missing or unreadable refuses loudly never degrades
/// > the paste produces **nothing**, and a one-shot failure banner names it from the manifest's
/// > metadata. An item arrives **whole index, attachments, loose files, and comments when they ship
/// > or not at all** The refusal is transactional all-or-nothing for the whole paste.
///
/// These are the former `PasteFallbackTests`, turned around: every case that used to assert an item
/// materialized from the manifest's embedded `index.md` now asserts that **nothing** was written and a
/// failure banner names the entry. The manifest still embeds the text it is what names the entry in
/// the sentence below it is simply never a materialization source.
@MainActor
@Suite("Paste ▸ the staging-less fallback")
struct PasteFallbackTests {
@Suite("Paste ▸ refuse, never degrade")
struct PasteRefusalTests {
@Test("A missing snapshot falls back to the embedded index.md, byte-faithfully")
func fallbackWritesTheSourceBytes() async throws {
/// Drops the staged tree the way the world does: a sweep that ran early, an unreadable container,
/// a full disk mid-copy.
private func loseTheSnapshot(_ harness: ClipboardHarness) throws {
let copyID = try #require(harness.clipboard.payload?.copyID)
try FileManager.default.removeItem(
at: harness.staging.appendingPathComponent(copyID, isDirectory: true)
)
}
@Test("A missing snapshot writes nothing at all")
func aMissingSnapshotWritesNothing() async throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
let destination = try makeDestination()
@@ -545,31 +565,21 @@ struct PasteFallbackTests {
harness.store.select([clipboardCard1], in: .board)
harness.clipboard.copy(from: harness.store)
await harness.clipboard.stagingSettled()
// The snapshot goes a swept tree, a full disk, an unreadable container.
let copyID = try #require(harness.clipboard.payload?.copyID)
try FileManager.default.removeItem(
at: harness.staging.appendingPathComponent(copyID, isDirectory: true)
)
try loseTheSnapshot(harness)
target.select([destinationLane], in: .board)
await harness.clipboard.paste(into: target)?.value
let arrived = try #require(try pastedIDs(destinationLane, in: destination).last)
let document = try FrontmatterDocument.parse(destination.indexText("\(Ident.lane4)/\(arrived)"))
#expect(document.title.value == "First")
// Content intact: unknown keys, the comment's key, and the body all survived.
#expect(document.value(for: "project") != nil)
#expect(document.value(for: "labels") != nil)
#expect(document.body.contains("First body — with *markdown*"))
// `created` kept, fresh `order`.
#expect(document.created.value == ISO8601DateFormatter().date(from: "2026-01-01T09:00:00Z"))
#expect(document.order.value != 1024)
// Attachments absent which is exactly what the banner is about to say.
#expect(!destination.exists("\(Ident.lane4)/\(arrived)/attachments"))
#expect(
try pastedTitles(destinationLane, in: destination) == ["Resident"],
"the destination holds exactly what it held before"
)
}
@Test("A degraded paste banners, naming exactly what was lost")
func fallbackBanners() async throws {
/// 04's own example sentence, end to end: the entry is named from the manifest's metadata, which is
/// the whole reason the embedded `index.md` is still carried.
@Test("The refusal banners as a failure, naming the entry from the manifest")
func theRefusalBanners() async throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
let destination = try makeDestination()
@@ -579,43 +589,43 @@ struct PasteFallbackTests {
harness.store.select([clipboardCard1], in: .board)
harness.clipboard.copy(from: harness.store)
await harness.clipboard.stagingSettled()
let copyID = try #require(harness.clipboard.payload?.copyID)
try FileManager.default.removeItem(
at: harness.staging.appendingPathComponent(copyID, isDirectory: true)
)
try loseTheSnapshot(harness)
target.select([destinationLane], in: .board)
await harness.clipboard.paste(into: target)?.value
#expect(target.banners.losses.map(\.message) == ["Pasted 'First' without its 2 attachments"])
#expect(target.banners.losses.isEmpty, "the degraded paste's loss row is retired")
#expect(target.banners.oneShots.count == 1)
let error = try #require(target.banners.oneShots.first).error
#expect(BannerCenter.headline(for: error) == "Couldn't paste 'First' — the copied content is gone")
}
@Test("A fallback that lost nothing says nothing")
func fallbackWithoutAttachmentsIsSilent() async throws {
/// **The attachment-less case refuses too**, which is the pivot at its sharpest: under the degraded
/// rule this entry pasted *silently* its content was intact and it had no attachments to lose, so
/// nothing was reported. Refuse-don't-degrade does not ask what would have been lost; the bytes the
/// paste was to reproduce are gone, so there is nothing honest to write.
@Test("An entry with no attachments refuses just the same")
func anAttachmentLessEntryRefusesToo() async throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
let destination = try makeDestination()
defer { destination.tearDown() }
let target = try BoardStore(rootURL: destination.root)
// `card2` has no attachments, so a fallback loses nothing at all.
harness.store.select([clipboardCard2], in: .board)
harness.clipboard.copy(from: harness.store)
await harness.clipboard.stagingSettled()
let copyID = try #require(harness.clipboard.payload?.copyID)
try FileManager.default.removeItem(
at: harness.staging.appendingPathComponent(copyID, isDirectory: true)
)
try loseTheSnapshot(harness)
target.select([destinationLane], in: .board)
await harness.clipboard.paste(into: target)?.value
#expect(try pastedTitles(destinationLane, in: destination) == ["Resident", "Second"])
#expect(target.banners.losses.isEmpty)
#expect(try pastedTitles(destinationLane, in: destination) == ["Resident"])
#expect(target.banners.oneShots.count == 1)
}
@Test("A lane's fallback materializes its embedded cards")
func laneFallbackCarriesItsCards() async throws {
@Test("A lane payload refuses whole — no lane, no cards")
func aLanePayloadRefusesWhole() async throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
let destination = try makeDestination()
@@ -625,115 +635,93 @@ struct PasteFallbackTests {
harness.store.select([clipboardLane1], in: .board)
harness.clipboard.copy(from: harness.store)
await harness.clipboard.stagingSettled()
let copyID = try #require(harness.clipboard.payload?.copyID)
try FileManager.default.removeItem(
at: harness.staging.appendingPathComponent(copyID, isDirectory: true)
)
let before = try pasted(destination).lanes.map(\.id)
try loseTheSnapshot(harness)
await harness.clipboard.paste(into: target)?.value
let arrived = try #require(try pasted(destination).lanes.last)
#expect(arrived.title.value == "Todo")
// Both of the lane's cards.
#expect(arrived.cards.count == 2)
#expect(Set(arrived.cards.compactMap(\.title.value)) == ["First", "Second"])
#expect(target.banners.losses.map(\.message) == ["Pasted 'Todo' without its 2 attachments"])
#expect(try pasted(destination).lanes.map(\.id) == before, "the strip is untouched")
let error = try #require(target.banners.oneShots.first).error
#expect(BannerCenter.headline(for: error) == "Couldn't paste 'Todo' — the copied content is gone")
}
@Test("A trash-sourced fallback materializes an ordinary card — there is no key to strip")
func trashedFallbackIsOrdinary() async throws {
/// **All-or-nothing for the whole paste** the transactional half of the ruling, which the former
/// mixed path is exactly what retired: one entry's snapshot going missing used to leave its
/// siblings arriving whole beside a hollowed copy of it. Now the gesture refuses as a unit.
@Test("One missing snapshot refuses the whole multi-entry paste")
func oneMissingEntryRefusesTheWholePaste() async throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
let destination = try makeDestination()
defer { destination.tearDown() }
let target = try BoardStore(rootURL: destination.root)
harness.store.transient.isTrashVisible = true
harness.store.select([clipboardCard3], in: .trash)
harness.store.select([clipboardCard1, clipboardCard2], in: .board)
harness.clipboard.copy(from: harness.store)
await harness.clipboard.stagingSettled()
// Only the *first* entry's tree is removed; the second is staged and perfectly pasteable.
let copyID = try #require(harness.clipboard.payload?.copyID)
try FileManager.default.removeItem(
at: harness.staging.appendingPathComponent(copyID, isDirectory: true)
at: harness.staging
.appendingPathComponent(copyID, isDirectory: true)
.appendingPathComponent(Ident.card1, isDirectory: true)
)
target.select([destinationLane], in: .board)
await harness.clipboard.paste(into: target)?.value
#expect(try pastedTitles(destinationLane, in: destination) == ["Resident", "Trashed"])
}
}
// MARK: - BoardWriter.materializeItem
@Suite("BoardWriter ▸ materializeItem")
struct MaterializeItemTests {
@Test("The supplied bytes land verbatim but for the rewritten order and stamps")
func writesTheSuppliedBytes() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
let id = try BoardWriter.materializeItem(
inParent: fixture.url(Ident.lane1),
indexText: Item.rich(order: "9999", title: "Pasted"),
order: 512
#expect(
try pastedTitles(destinationLane, in: destination) == ["Resident"],
"not even the entry that could have arrived whole"
)
let document = try FrontmatterDocument.parse(fixture.indexText("\(Ident.lane1)/\(id.rawValue)"))
#expect(document.title.value == "Pasted")
#expect(document.order.value == 512)
#expect(document.value(for: "project") != nil)
#expect(document.value(for: "labels") != nil)
// The app-write stamps: `modified` set, `modified-by` cleared.
#expect(document.modified.value != ISO8601DateFormatter().date(from: "2026-02-02T09:00:00Z"))
#expect(document.modifiedBy.isMissing)
// `created` untouched a paste is a fork.
#expect(document.created.value == ISO8601DateFormatter().date(from: "2026-01-01T09:00:00Z"))
#expect(target.banners.oneShots.count == 1, "one refusal for one gesture")
}
@Test("Children are materialized under fresh identities and never rewritten")
func childrenAreVerbatim() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
let id = try BoardWriter.materializeItem(
inParent: fixture.root,
indexText: Item.rich(order: "1024", title: "Lane"),
children: [Item.rich(order: "1024", title: "One"), Item.uneditable],
order: 1024
)
let lane = try #require(try BoardLoader.load(boardRoot: fixture.root).model.lanes.first)
#expect(lane.id == id)
#expect(lane.cards.count == 2)
// An uneditable child arrives exactly as it was the leniency `copyItem` extends below its
// root, applied here.
let names = try FileManager.default.contentsOfDirectory(atPath: fixture.url(id.rawValue).path)
.filter { $0 != "index.md" }
let odd = try #require(names.first { name in
(try? fixture.indexText("\(id.rawValue)/\(name)")) == Item.uneditable
})
#expect(try fixture.indexText("\(id.rawValue)/\(odd)") == Item.uneditable)
/// **A refusal costs the user their content *and* nothing else** the destination's active search
/// survives it. "Any user-initiated creation on the board clears the query" (04 Search) is a rule
/// about creations, and a refused paste creates nothing.
@Test("A refused paste leaves the destination's search alone")
func aRefusalKeepsTheSearch() async throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
let destination = try makeDestination()
defer { destination.tearDown() }
let target = try BoardStore(rootURL: destination.root)
target.transient.searchQuery = "resident"
harness.store.select([clipboardCard1], in: .board)
harness.clipboard.copy(from: harness.store)
await harness.clipboard.stagingSettled()
try loseTheSnapshot(harness)
target.select([destinationLane], in: .board)
await harness.clipboard.paste(into: target)?.value
#expect(target.transient.searchQuery == "resident")
}
@Test("An unparseable root refuses and leaves nothing behind")
func unparseableRootLeavesNoResidue() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
let error = writeFailure {
_ = try BoardWriter.materializeItem(
inParent: fixture.root,
indexText: "no frontmatter here at all\n",
order: 1024
)
}
#expect(error != nil)
#expect(try fixture.entryNames("") == ["index.md"])
/// A cut whose staged snapshot is gone is a different story and stays one: an armed cut moves the
/// **originals**, which are real folders in the source board, so it never reads staging at all.
/// The refusal is the copy path's, and this pins that it did not spread.
@Test("An armed cut still moves its originals — it never reads staging")
func anArmedCutIsUnaffected() async throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
let destination = try makeDestination()
defer { destination.tearDown() }
let target = try BoardStore(rootURL: destination.root)
harness.store.select([clipboardCard1], in: .board)
harness.clipboard.cut(from: harness.store)
await harness.clipboard.stagingSettled()
try loseTheSnapshot(harness)
target.select([destinationLane], in: .board)
await harness.clipboard.paste(into: target)?.value
#expect(try pastedTitles(destinationLane, in: destination) == ["Resident", "First"])
#expect(target.banners.oneShots.isEmpty, "nothing failed — the folder moved")
#expect(harness.fixture.exists("\(Ident.lane1)/\(Ident.card1)") == false, "and it left the source")
}
}
+150
View File
@@ -528,6 +528,156 @@ struct TemplateEngineAtomicityTests {
}
}
// MARK: - The copy contract
/// **Instantiation is a copy transaction, and it severs tracker identity** the two 2026-07-29
/// rulings applied to the flow 01 names alongside paste and the -drag duplicate (01-storage-format.md
/// § Frontmatter's compound-operations clause; § Fractal layout Rules' item-level sever).
@Suite("TemplateEngine — the copy contract")
struct TemplateEngineCopyContractTests {
/// A template carrying a readable-but-uneditable card refuses the **whole** create, naming that
/// card, and leaves nothing where the user pointed the former root-strict/descendants-lenient
/// split would have made a board from it with one silently unstamped card inside.
@Test("An uneditable card in the template refuses the create, naming it")
func anUneditableCardRefusesTheCreate() throws {
let template = try FixtureTemplate()
defer { template.tearDown() }
try template.fixture.item(
"\(FixtureTemplate.name)/\(Ident.lane1)/\(Ident.card3)",
Item.uneditable
)
let destination = template.destination()
let failure = instantiationFailure {
try TemplateEngine.instantiate(template: try template.template(), to: destination, title: "Doomed")
}
guard case let .failed(write) = failure else {
Issue.record("expected an ordinary failure, got \(String(describing: failure))")
return
}
if case .uneditableFrontmatter = write.reason {} else {
Issue.record("expected the uneditable-shape refusal, got \(write.reason)")
}
#expect(write.operation == .createBoard, "the create is what refused")
#expect(
!FileManager.default.fileExists(atPath: destination.path),
"construct-then-clean: the partial destination goes with the refusal"
)
}
/// A template whose `.trash/` holds a broken card still instantiates: the preflight runs on the
/// **destination**, after the copy applied its exclusions, so a card that was never going to be
/// copied cannot refuse the create it has nothing to do with.
@Test("An uneditable card in the template's trash refuses nothing — it is never copied")
func anUneditableTrashCardIsIrrelevant() throws {
let template = try FixtureTemplate()
defer { template.tearDown() }
try template.fixture.item("\(FixtureTemplate.name)/.trash/\(Ident.card3)", Item.uneditable)
let destination = template.destination()
try TemplateEngine.instantiate(template: try template.template(), to: destination, title: "Fine")
#expect(FileManager.default.fileExists(atPath: destination.appendingPathComponent("index.md").path))
#expect(
!FileManager.default.fileExists(atPath: destination.appendingPathComponent(".trash").path),
"and the trash was excluded, as always"
)
}
/// **The tracker sever, at every level an instantiation materializes** board root, lane, and card.
/// A template can carry the keys in from the board it was saved from (Save as Template is a fork and
/// keeps them verbatim), and the board born from it must not claim those remote objects.
@Test("Instantiation drops the reserved tracker keys at every level")
func instantiationSeversTrackerIdentity() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let name = "Tracked.kanban"
try fixture.item(name, """
---
schema: 1
title: Tracked Template
template: {order: 1}
project: lanework
remote: gitea#7
---
Blurb.
""")
try fixture.item("\(name)/\(Ident.lane1)", Item.tracked(order: "1024", title: "To Do", key: "remote-state"))
try fixture.item(
"\(name)/\(Ident.lane1)/\(Ident.card1)",
Item.tracked(order: "1024", title: "Starter", key: "remote")
)
let template: BoardTemplate = switch TemplateEngine.load(templateAt: fixture.url(name), origin: .user) {
case let .success(loaded): loaded
case let .failure(error): throw error
}
let destination = fixture.url("Born.kanban")
try TemplateEngine.instantiate(template: template, to: destination, title: "Born")
let board = try FrontmatterDocument.parse(String(
decoding: Data(contentsOf: destination.appendingPathComponent("index.md")), as: UTF8.self
))
#expect(board.value(for: "remote") == nil, "the board born today claims no remote object")
#expect(board.value(for: "project") != nil, "and every other unknown key rode along")
let lanes = ((try? BoardLoader.directoryCandidates(in: destination)) ?? [])
.filter { BoardLoader.isUUIDShaped($0.lastPathComponent) }
let lane = try FrontmatterDocument.parse(String(
decoding: Data(contentsOf: try #require(lanes.first).appendingPathComponent("index.md")), as: UTF8.self
))
#expect(lane.value(for: "remote-state") == nil)
let card = try FrontmatterDocument.parse(String(
decoding: Data(contentsOf: try #require(cardFolders(under: destination).first)
.appendingPathComponent("index.md")), as: UTF8.self
))
#expect(card.value(for: "remote") == nil)
#expect(card.value(for: "project") != nil)
}
/// **Save as Template is a whole-board fork and is exempt** (01 Identity lifecycle's carve-out):
/// it "carries them verbatim", GUIDs, timestamps and tracker keys alike, because a fork is a new
/// namespace rather than a second claimant inside one board. The sever belongs to *item-level*
/// copies, and this is the line between them.
@Test("Save as Template carries the tracker keys verbatim")
func saveAsTemplateIsExempt() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("Board.kanban", """
---
schema: 1
title: Live Board
remote: gitea#7
---
Body.
""")
try fixture.item("Board.kanban/\(Ident.lane1)", Item.tracked(order: "1024", title: "To Do", key: "remote-state"))
try fixture.item(
"Board.kanban/\(Ident.lane1)/\(Ident.card1)",
Item.tracked(order: "1024", title: "Card", key: "remote")
)
let store = fixture.url("Store")
let saved = try TemplateEngine.saveAsTemplate(
boardAt: fixture.url("Board.kanban"), titled: "Live Board", into: store
)
let board = try FrontmatterDocument.parse(String(
decoding: Data(contentsOf: saved.appendingPathComponent("index.md")), as: UTF8.self
))
#expect(board.value(for: "remote") != nil, "a fork carries them verbatim")
let lane = try FrontmatterDocument.parse(String(
decoding: Data(contentsOf: saved.appendingPathComponent(Ident.lane1).appendingPathComponent("index.md")),
as: UTF8.self
))
#expect(lane.value(for: "remote-state") != nil)
}
}
// MARK: - Shared
/// Every card folder under an instantiated board `<root>/<lane>/<card>`, by the loader's own level
+71 -5
View File
@@ -390,7 +390,7 @@ struct PurgeTests {
let store = try BoardStore(rootURL: fixture.root)
store.select([card1], in: .board)
store.deleteImmediately([card1])
store.deleteImmediately([card1], in: .board)
#expect(!fixture.exists("\(Ident.lane1)/\(Ident.card1)"))
#expect(!fixture.exists(".trash/\(Ident.card1)"), "03 ▸ Trash: ⌥⌘⌫ skips the trash from anywhere")
@@ -404,7 +404,7 @@ struct PurgeTests {
let store = try BoardStore(rootURL: fixture.root)
store.select([trashed], in: .trash)
store.deleteImmediately([trashed])
store.deleteImmediately([trashed], in: .trash)
#expect(!fixture.exists(".trash/\(Ident.indexless)"))
#expect(fixture.exists(".trash/\(More.newer)"), "and only what it named")
@@ -417,7 +417,7 @@ struct PurgeTests {
let store = try BoardStore(rootURL: fixture.root)
store.select([lane3], in: .board)
store.deleteImmediately([lane3])
store.deleteImmediately([lane3], in: .board)
#expect(fixture.exists(Ident.lane3))
}
@@ -473,7 +473,7 @@ struct PurgeTests {
store.select([trashed], in: .trash)
store.deleteTrashCards([trashed])
store.deleteImmediately([newer])
store.deleteImmediately([newer], in: .trash)
store.emptyTrash()
// 13-native-undo.md Rules: "Permanently delete (Delete Immediately, Empty Trash)
@@ -745,7 +745,7 @@ struct TrashConfirmationsTests {
let pending = try #require(confirmations.pending)
#expect(pending.prompt.title == "Permanently delete \u{201C}Trashed\u{201D}?")
#expect(pending.action == .purge([trashed]))
#expect(pending.action == .purge([trashed], .trash))
// Nothing has happened yet the alert is what stands between the keystroke and the loss.
#expect(fixture.exists(".trash/\(Ident.indexless)"))
@@ -756,6 +756,72 @@ struct TrashConfirmationsTests {
confirmations.confirm(in: store)
}
/// **The card and lane context menus' -alternate** Delete Immediately, routed through
/// `requestBoardPurge` rather than through `requestPurge` (11-command-nexus.md Context menus'
/// Card and Lane rows: "Delete with Delete Immediately as its -alternate").
/// `purgeConfirmsThenActs`'s twin for the board side: same alert, same rule, a board card as the
/// target instead of a trash one.
@Test("The board-side ⌥-alternate raises the same alert, and purges the board card on confirm")
func boardPurgeConfirmsThenActs() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let confirmations = TrashConfirmations()
confirmations.requestBoardPurge(of: [card1], in: store)
let pending = try #require(confirmations.pending)
#expect(pending.prompt.title == "Permanently delete \u{201C}First\u{201D}?")
#expect(pending.action == .purge([card1], .board))
// Nothing has happened yet same alert, same rule.
#expect(fixture.exists("\(Ident.lane1)/\(Ident.card1)"))
confirmations.confirm(in: store)
#expect(!fixture.exists("\(Ident.lane1)/\(Ident.card1)"))
#expect(!fixture.exists(".trash/\(Ident.card1)"), "skips the trash — purged, not moved")
#expect(confirmations.pending == nil)
}
/// A context menu names its target by where it was invoked, so a card row's Delete Immediately
/// must purge the *clicked* card even while a different card is selected `TrashMenuValidation
/// Tests.contextMenuDeleteIgnoresTheSelection`'s claim, mirrored onto the board side.
@Test("The board-side ⌥-alternate acts on its own target, not the standing selection")
func boardPurgeIgnoresTheSelection() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let confirmations = TrashConfirmations()
// A right-click on `card1` without first selecting it must still purge `card1`, never the
// card the standing selection happens to hold (`CardFaceView.targetIDs`'s targeting rule).
store.select([card2], in: .board)
confirmations.requestBoardPurge(of: [card1], in: store)
let pending = try #require(confirmations.pending)
#expect(pending.action == .purge([card1], .board))
confirmations.confirm(in: store)
#expect(!fixture.exists("\(Ident.lane1)/\(Ident.card1)"), "the clicked card is gone")
#expect(fixture.exists("\(Ident.lane1)/\(Ident.card2)"), "the selected card was never the subject")
}
/// `TrashModel.canDeleteImmediately` is cards only (`TrashValidationTests
/// .canDeleteImmediatelyIsCardsOnly`: "a lane's delete is physical already there is nothing for
/// 'skip the trash' to mean on one"), and the lane row's alternate inherits that unchanged: it is
/// wired per 11-command-nexus.md's Lane row, but presently inert on a lane-only target the same
/// posture File Delete Immediately already takes on a lane-only selection.
@Test("A lane-only target raises no prompt — the alternate is still cards only")
func boardPurgeIsStillCardsOnlyForALaneTarget() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let confirmations = TrashConfirmations()
confirmations.requestBoardPurge(of: [lane1], in: store)
#expect(confirmations.pending == nil)
#expect(fixture.exists(Ident.lane1))
}
/// 03-board-ui.md § Trash: "on a trash card, Delete (/) is permanent Both confirm exactly
/// where the loss is real."
@Test("The trash's own Delete confirms; the board's goes straight through")
+86 -1
View File
@@ -487,6 +487,91 @@ struct MoveUndoTests {
#expect(try document(fixture, Ident.lane1).order.value == moved)
}
/// **The inverses conform to the container-change predicate** (01-storage-format.md
/// § Frontmatter `modified`'s scope, refined 2026-07-30) the m8 conformance check, stated at
/// the level the rule is about: an inverse is an ordinary app-mediated write, so it is subject to
/// the *same* predicate as the gesture it inverts, not to a rule of its own.
///
/// Three claims in one round trip, because they are one claim: the undo of a within-lane reorder is
/// itself a within-lane reorder and rewrites only `order`; the undo of a cross-lane move is itself a
/// cross-lane move and stamps; and **no trash-specific branch exists in either direction** the
/// trash round trip stamps for the same reason the cross-lane one does.
///
/// It reads `modified-by` rather than `modified`, deliberately: `untouchedLines` filters the whole
/// `modified*` family precisely because a content write is *expected* to move it, so the foreign
/// stamp's survival is the assertion with a sharp edge it survives an order-only rewrite and is
/// cleared by a content one, and `Item.rich` plants one on every fixture card for exactly this.
@Test("An inverse stamps only when it changes a container")
func inversesFollowTheContainerPredicate() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (store, history) = try makeStore(fixture)
// Within-lane, there and back: nothing on either leg is a content write.
store.moveCards([card1], toLane: lane1, at: 2)
#expect(try document(fixture, card1Path).rawValue(for: FrontmatterKeys.modifiedBy) == "claude")
history.undo()
#expect(
try document(fixture, card1Path).rawValue(for: FrontmatterKeys.modifiedBy) == "claude",
"undoing a reorder is a reorder — order-only, both ways"
)
// Cross-lane, there and back: both legs change the container, so both stamp.
store.moveCards([card2], toLane: lane2, at: 0)
#expect(try document(fixture, "\(Ident.lane2)/\(Ident.card2)").rawValue(for: FrontmatterKeys.modifiedBy) == nil)
// Re-planted by hand, standing in for an agent that stamped the card in its new lane the
// inverse has to clear it again, because moving back is itself a container change.
try BoardWriter.updateIndex(
inItemFolder: fixture.url("\(Ident.lane2)/\(Ident.card2)"), operation: .style(title: nil)
) { $0.set(FrontmatterKeys.modifiedBy, to: .string("claude")) }
history.undo()
#expect(
try document(fixture, card2Path).rawValue(for: FrontmatterKeys.modifiedBy) == nil,
"undoing a cross-lane move is a cross-lane move — it stamps"
)
}
/// The lane half of the same claim: a lane's container is the board root and never changes, so a
/// lane drag and its inverse are both order-only.
@Test("A lane reorder and its inverse are both order-only")
func laneInversesAreOrderOnly() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (store, history) = try makeStore(fixture)
store.moveLane(lane1, toIndex: 1)
#expect(try document(fixture, Ident.lane1).rawValue(for: FrontmatterKeys.modifiedBy) == "claude")
history.undo()
#expect(try document(fixture, Ident.lane1).rawValue(for: FrontmatterKeys.modifiedBy) == "claude")
history.redo()
#expect(try document(fixture, Ident.lane1).rawValue(for: FrontmatterKeys.modifiedBy) == "claude")
}
/// The trash round trip, from the undo stack rather than the Writer: the delete stamps and its
/// inverse the move back out stamps too. **Neither is a special case**; both are container
/// changes, which is the whole of the refinement.
@Test("A delete and its inverse both stamp, with no trash branch")
func theTrashRoundTripStampsBothWays() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (store, history) = try makeStore(fixture)
store.select([card1], in: .board)
store.deleteSelection()
#expect(fixture.exists(".trash/\(Ident.card1)"))
#expect(try document(fixture, ".trash/\(Ident.card1)").rawValue(for: FrontmatterKeys.modifiedBy) == nil)
try BoardWriter.updateIndex(
inItemFolder: fixture.url(".trash/\(Ident.card1)"), operation: .style(title: nil)
) { $0.set(FrontmatterKeys.modifiedBy, to: .string("claude")) }
history.undo()
#expect(fixture.exists(card1Path))
#expect(
try document(fixture, card1Path).rawValue(for: FrontmatterKeys.modifiedBy) == nil,
"restoring out of the trash is a container change and clears the stamp"
)
}
@Test("⌥⌘↓ undoes the whole permutation, siblings included")
func sortRoundTrip() throws {
let fixture = try makeBoard()
@@ -735,7 +820,7 @@ struct NotUndoableTests {
let armed = try #require(history.undoActionName)
store.select([trashed], in: .trash)
store.deleteImmediately([trashed])
store.deleteImmediately([trashed], in: .trash)
#expect(fixture.exists(trashedPath) == false)
#expect(store.purgeIsUnrecoverable)
+200
View File
@@ -393,3 +393,203 @@ struct WriteFidelityCompositeTests {
#expect(forkedThird.body == "Third body.\n")
}
}
// MARK: - The container-change predicate
/// **01-storage-format.md § Frontmatter `modified`'s scope** ruled 2026-07-29 as
/// moves-don't-stamp, **refined 2026-07-30** to one container-change predicate:
///
/// > a reorder within the item's container (a card among its lane's siblings, a lane among the
/// > board's lanes) and a renumber's whole-lane rescale rewrite `index.md` without touching content:
/// > no stamp, and no `modified-by` clear **A move that changes the item's container stamps both**:
/// > a cross-lane move, a cross-board arrival, and the trash move.
///
/// The pairing is the thing these tests are really pinning: `modified` and `modified-by` move
/// together, always, because "attribution can't change when content didn't". So every case below
/// asserts both keys, and the fixtures deliberately carry a foreign `modified-by: claude` the key
/// whose survival is the only visible difference between an order-only rewrite and a content one.
///
/// **There is deliberately no trash case in the implementation**, and that is what
/// `theTrashMoveStampsBecauseEveryContainerChangeDoes` exists to state from the outside: the trash
/// move stamps, and it does so through the same predicate as a cross-lane move rather than through a
/// branch of its own.
struct WriteFidelityStampingTests {
/// The prior stamps every fixture below starts from `Item.rich`'s own, so a test asserting
/// "unchanged" is asserting against a real value that a stamp would visibly replace.
private static let priorModified = "2026-02-02T09:00:00Z"
private func stamps(_ fixture: WriterFixture, _ path: String) throws -> (modified: String?, modifiedBy: String?) {
let document = try FrontmatterDocument.parse(fixture.indexText(path))
return (document.rawValue(for: FrontmatterKeys.modified), document.rawValue(for: FrontmatterKeys.modifiedBy))
}
private func twoLaneBoard() throws -> WriterFixture {
let fixture = try WriterFixture()
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First"))
try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Second"))
try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing"))
return fixture
}
/// A card dropped back into its own lane `moveItem`'s same-parent degenerate path, which is
/// every within-lane drag, every / sort step, and every inverse of one.
@Test("A card reordered among its lane's siblings rewrites only order")
func aWithinLaneReorderRewritesOnlyOrder() throws {
let fixture = try twoLaneBoard()
defer { fixture.tearDown() }
_ = try BoardWriter.moveItem(
at: fixture.url("\(Ident.lane1)/\(Ident.card1)"),
toParent: fixture.url(Ident.lane1),
sourceBoardRoot: fixture.root,
destinationBoardRoot: fixture.root,
order: 3072
)
let after = try stamps(fixture, "\(Ident.lane1)/\(Ident.card1)")
#expect(after.modified == Self.priorModified, "a reorder is not a content write")
#expect(after.modifiedBy == "claude", "and attribution can't change when content didn't")
let document = try FrontmatterDocument.parse(fixture.indexText("\(Ident.lane1)/\(Ident.card1)"))
#expect(document.order.value == 3072, "the one key a reorder owns did move")
}
/// A lane's parent is the board root and nothing else, so *every* lane reorder is
/// within-container /, the strip drag, and their inverses alike.
@Test("A lane reordered on the board rewrites only order")
func aLaneReorderRewritesOnlyOrder() throws {
let fixture = try twoLaneBoard()
defer { fixture.tearDown() }
_ = try BoardWriter.moveItem(
at: fixture.url(Ident.lane2),
toParent: fixture.root,
sourceBoardRoot: fixture.root,
destinationBoardRoot: fixture.root,
order: 512
)
let after = try stamps(fixture, Ident.lane2)
#expect(after.modified == Self.priorModified)
#expect(after.modifiedBy == "claude")
#expect(try FrontmatterDocument.parse(fixture.indexText(Ident.lane2)).order.value == 512)
}
/// The renumber rescale 01 § Ordering, verbatim: "order-only rewrites, so no `modified` stamp
/// and no `modified-by` clear". Every sibling in the lane is rewritten, and not one of them is
/// stamped, which is what keeps a midpoint exhaustion from reading as a lane's worth of edits.
@Test("A renumber rescale stamps nothing, on any sibling")
func aRenumberRescaleStampsNothing() throws {
let fixture = try twoLaneBoard()
defer { fixture.tearDown() }
try BoardWriter.renumberVisibleChildren(of: fixture.url(Ident.lane1))
for path in ["\(Ident.lane1)/\(Ident.card1)", "\(Ident.lane1)/\(Ident.card2)"] {
let after = try stamps(fixture, path)
#expect(after.modified == Self.priorModified, "\(path) was stamped by a rescale")
#expect(after.modifiedBy == "claude", "\(path) lost its attribution to a rescale")
}
#expect(try FrontmatterDocument.parse(fixture.indexText("\(Ident.lane1)/\(Ident.card1)")).order.value == 1024)
#expect(try FrontmatterDocument.parse(fixture.indexText("\(Ident.lane1)/\(Ident.card2)")).order.value == 2048)
}
/// The other side of the predicate: which lane a card lives in is *state*, so crossing lanes is a
/// content write and stamps both keys.
@Test("A cross-lane move stamps modified and clears modified-by")
func aCrossLaneMoveStamps() throws {
let fixture = try twoLaneBoard()
defer { fixture.tearDown() }
_ = try BoardWriter.moveItem(
at: fixture.url("\(Ident.lane1)/\(Ident.card1)"),
toParent: fixture.url(Ident.lane2),
sourceBoardRoot: fixture.root,
destinationBoardRoot: fixture.root,
order: 1024
)
let after = try stamps(fixture, "\(Ident.lane2)/\(Ident.card1)")
#expect(after.modified != Self.priorModified, "a container change is a content write")
#expect(after.modifiedBy == nil, "and clears the foreign stamp like any app write")
}
/// **No trash special case anywhere.** The delete stamps, the restore stamps, and both do it
/// through the container predicate rather than through a rule of their own which is why this
/// test asserts the same two facts as `aCrossLaneMoveStamps` and nothing extra.
@Test("The trash move stamps because every container change does — in and out")
func theTrashMoveStampsBecauseEveryContainerChangeDoes() throws {
let fixture = try twoLaneBoard()
defer { fixture.tearDown() }
try BoardWriter.deleteCardToTrash(
at: fixture.url("\(Ident.lane1)/\(Ident.card1)"), inBoard: fixture.root, order: 1024
)
let trashed = try stamps(fixture, ".trash/\(Ident.card1)")
#expect(trashed.modified != Self.priorModified, "into the trash is a container change")
#expect(trashed.modifiedBy == nil)
// And out again. `modified-by` is re-planted by hand first, standing in for the agent that
// re-stamped the card while it sat in the trash: the restore has to clear it again.
try BoardWriter.updateIndex(
inItemFolder: fixture.url(".trash/\(Ident.card1)"), operation: .style(title: nil)
) { $0.set(FrontmatterKeys.modifiedBy, to: .string("claude")) }
_ = try BoardWriter.moveItem(
at: fixture.url(".trash/\(Ident.card1)"),
toParent: fixture.url(Ident.lane2),
sourceBoardRoot: fixture.root,
destinationBoardRoot: fixture.root,
order: 4096
)
let restored = try stamps(fixture, "\(Ident.lane2)/\(Ident.card1)")
#expect(restored.modifiedBy == nil, "out of the trash is a container change too")
}
/// A cross-board arrival changes the container as surely as a cross-lane move does, and the
/// import boundary's remint does not change that: the arrived file is stamped either way.
@Test("A cross-board arrival stamps")
func aCrossBoardArrivalStamps() throws {
let source = try twoLaneBoard()
defer { source.tearDown() }
let destination = try WriterFixture()
defer { destination.tearDown() }
try destination.item("", Item.board)
try destination.item(Ident.lane3, Item.rich(order: "1024", title: "Elsewhere"))
let result = try BoardWriter.moveItem(
at: source.url("\(Ident.lane1)/\(Ident.card1)"),
toParent: destination.url(Ident.lane3),
sourceBoardRoot: source.root,
destinationBoardRoot: destination.root,
order: 1024
)
let after = try stamps(destination, "\(Ident.lane3)/\(result.id.rawValue)")
#expect(after.modified != Self.priorModified)
#expect(after.modifiedBy == nil)
}
/// The predicate as a pure value one exhaustive statement of which operations are order-only,
/// so a new `WriteOperation` cannot quietly join or leave the class. **`.reorder` and
/// `.renumberChildren`, and nothing else**; `.delete` and `.move` are named explicitly because
/// they are the two a "moves don't stamp" reading would have put on the wrong side.
@Test("Only reorder and renumber are order-only")
func theOrderOnlyClassIsExactlyTwoOperations() {
#expect(WriteOperation.reorder(title: nil).rewritesOrderOnly)
#expect(WriteOperation.renumberChildren.rewritesOrderOnly)
for operation: WriteOperation in [
.createBoard, .createLane, .createCard, .move(title: nil), .copy(title: nil),
.paste(title: nil), .delete(title: nil), .purge(title: nil), .migrateTombstone(title: nil),
.style(title: nil), .resize(title: nil), .rename(title: nil), .duplicateBoard(title: nil),
.saveAsTemplate(title: nil), .importAttachment(filename: "a"), .listAttachments,
.removeAttachment(filename: "a"), .relocateLooseFile(filename: "a"), .agentGuide,
.displaceClaimedName(name: ".trash"), .repairDuplicateID(title: nil),
.toggleTask(title: nil), .editBody(title: nil), .rawSource(title: nil),
] {
#expect(operation.rewritesOrderOnly == false, "\(operation) should be a content write")
}
}
}
+21
View File
@@ -211,6 +211,27 @@ enum Item {
/// A whole-frontmatter flow mapping carrying a `modified-by`: readable, uneditable, and so
/// left byte-verbatim by a copy stale attribution included.
static let uneditable = "---\n{schema: 1, order: 1024, title: Odd, modified-by: claude}\n---\nodd body\n"
/// An item carrying one of the **reserved tracker keys** `remote` on a board or card,
/// `remote-state` on a lane (01-storage-format.md § Enhanced schema) beside an ordinary unknown
/// key, so a copy's tracker sever can be told apart from unknown-key preservation breaking.
///
/// Nothing in this version reads the keys; what the suites pin is that an **item-level copy drops
/// them** (ruled 2026-07-29) while a whole-board fork carries them verbatim.
static func tracked(order: String, title: String, key: String) -> String {
"""
---
schema: 1
title: \(title)
order: \(order)
project: lanework # agent overlay
\(key): gitea#42
created: 2026-01-01T09:00:00Z
---
\(title) body.
"""
}
}
// MARK: - Failure assertion