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 /// 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 /// 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. /// 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 /// - Each `Entry` embeds the item's complete `index.md` text as **identification metadata**
/// snapshot is missing or unreadable "the staging-less fallback: content intact, attachments /// (04-interactions.md Clipboard, re-ruled 2026-07-29): menu validation, the refusal's wording,
/// absent", announced by a banner rather than discovered later. /// 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 /// `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 /// 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 public var folder: String
/// The title as written, or `nil` for an untitled item "Untitled" is a rendering, never a /// 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 /// value (03-board-ui.md § Card face). Feeds the plain-text representation and the refused
/// paste's banner. /// paste's banner, which names the offending entry from exactly this.
public var title: String? 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 public var index: String
/// How many files the item's own `attachments/` held. Zero for a lane, which has none; a /// 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. /// 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 public var attachmentCount: Int
/// A **lane** entry's cards, index text and all "a lane entry embeds its cards' too, /// 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 /// **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" /// 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 /// (04-interactions.md Drag and drop, resettled 2026-07-28). Like the lane's own `index`, the
/// materializes a copy a cut's move carries the real folder whole and never comes near this /// cards' text is identification metadata: it describes what the copy held, and nothing
/// array. So the embedded set is exactly what a fallback paste should produce. /// materializes from it.
public var cards: [Card] public var cards: [Card]
/// One card inside a copied lane. /// 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, /// Every file this entry's subtree carried in an `attachments/` its own plus, for a lane, its
/// for a lane, its cards'. /// cards'. Identification metadata; nothing gates on it since the degraded paste retired.
public var lostAttachmentCount: Int { public var totalAttachmentCount: Int {
attachmentCount + cards.reduce(0) { $0 + $1.attachmentCount } 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 /// folder trees, attachments and strays and all is **staged** under
/// `<group container>/Library/Application Support/Clipboard/<copyID>/`, so a paste reproduces the item /// `<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 /// 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**: /// `index.md` per entry is **identification metadata only** menu validation, the refusal's wording,
/// a banner names exactly what was lost. /// 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 /// **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 /// 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. /// > 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 /// 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 /// compute the same answer from the same input. The shared home is also what keeps the refusal a
/// for genuinely missing snapshots rather than being the structural cross-edition outcome. /// 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 { public static var defaultStagingRoot: URL {
AppGroup.stateDirectory.appendingPathComponent("Clipboard", isDirectory: true) 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 /// 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 /// `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 /// 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 /// pasteboard is written *before* the copies land, which is safe because a paste **awaits the same
/// carries the fallback text: a paste that somehow beat the chain would still materialize the /// chain** (`paste(into:)`): it can never read a half-written snapshot, so it never sees a tree the
/// right items. /// 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) { private func write(from store: BoardStore, cut: Bool) {
guard let capture = Self.capture(selection: store.selection, snapshot: store.snapshot) else { return } guard let capture = Self.capture(selection: store.selection, snapshot: store.snapshot) else { return }
@@ -348,9 +352,8 @@ public final class ClipboardStore {
// doing nothing. // doing nothing.
guard payload?.copyID == manifest.copyID else { return } guard payload?.copyID == manifest.copyID else { return }
store.transient.noteUserCreation()
if let move = armedMove(for: manifest) { if let move = armedMove(for: manifest) {
store.transient.noteUserCreation()
let sources = move.folders.map(BoardStore.ItemSource.folder) let sources = move.folders.map(BoardStore.ItemSource.folder)
switch plan { switch plan {
case let .cards(target): case let .cards(target):
@@ -373,31 +376,29 @@ public final class ClipboardStore {
return return
} }
// The copy path the staged snapshot per entry, or the embedded `index.md` where that // **The copy path's preflight: refuse, never degrade** (04-interactions.md Clipboard,
// snapshot is missing or unreadable. Mixed is legal and is the honest outcome of a partial // re-ruled 2026-07-29). Every entry must have its staged snapshot on disk *before* anything is
// staging failure: the entries that have snapshots arrive whole. // 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) let stagingDir = stagingRoot.appendingPathComponent(manifest.copyID, isDirectory: true)
var sources: [BoardStore.ItemSource] = [] var sources: [BoardStore.ItemSource] = []
var losses: [BannerCenter.AttachmentLoss] = []
for entry in manifest.entries { for entry in manifest.entries {
let staged = stagingDir.appendingPathComponent(entry.folder, isDirectory: true) let staged = stagingDir.appendingPathComponent(entry.folder, isDirectory: true)
if FileManager.default.fileExists( guard FileManager.default.fileExists(
atPath: staged.appendingPathComponent(BoardLoader.indexFileName).path atPath: staged.appendingPathComponent(BoardLoader.indexFileName).path
) { ) else {
sources.append(.folder(staged)) // The offending entry, named and the destination's search is left exactly as it was.
continue // "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*
sources.append(.text(index: entry.index, cards: entry.cards.map(\.index))) // `noteUserCreation`, so a refusal costs the user neither content nor their filter.
// "A degraded paste is loud, never silent a one-shot banner names exactly what was store.banners.postRefusedPaste(title: entry.title, stagedAt: staged.path)
// lost." An entry with no attachments lost nothing its content is intact and its bytes return
// are the source bytes so it contributes no row.
if entry.lostAttachmentCount > 0 {
losses.append(BannerCenter.AttachmentLoss(
title: entry.title,
attachments: entry.lostAttachmentCount
))
} }
sources.append(.folder(staged))
} }
store.transient.noteUserCreation()
// A card copied out of the trash needs nothing done to it on arrival: it carries no // 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 // `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 normalizingLooseFiles: true
) )
} }
store.banners.postDegradedPaste(losses)
} }
/// The armed cut's surviving originals, in flatten order and as folders under the **source** /// 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 let destination: URL
} }
/// Appends this copy's snapshots to the staging chain. Best-effort per item: one that fails to /// Appends this copy's snapshots to the staging chain. Best-effort per item, and the *consequence*
/// copy simply falls back to the manifest's embedded `index.md` at paste time, which is the /// of a failure changed with the refuse-don't-degrade ruling: an item whose snapshot never landed
/// degraded paste the banner already has words for. /// 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) { private func stage(_ jobs: [StagingJob], into stagingDir: URL) {
enqueue { [jobs, stagingDir] in enqueue { [jobs, stagingDir] in
guard (try? FileManager.default.createDirectory( guard (try? FileManager.default.createDirectory(
@@ -628,9 +630,11 @@ public final class ClipboardStore {
/// the app already states once. /// the app already states once.
/// ///
/// **The index text comes from the snapshot, not from disk.** `FrontmatterDocument` edits by line /// **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 /// span, so `serialized()` on an untouched document returns the file's bytes exactly which makes
/// makes the manifest's fallback text genuinely *the source bytes* while costing C no file I/O /// the manifest's embedded text a faithful record of the item while costing C no file I/O at all,
/// at all, even for a lane carrying two hundred cards. /// 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( static func capture(
selection: ItemReferenceSet, selection: ItemReferenceSet,
snapshot: BoardModel 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 /// 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`, /// 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 /// 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. /// 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 /// 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. /// 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 /// **An instantiation is a copy transaction**, which is `BoardWriter.copyItem`'s posture since
/// split for its reason: the root *must* be rewritten (it carries the title the user just typed), /// 2026-07-29 and for its reason: the whole tree is preflighted for stampability before a single
/// so a template whose own `index.md` cannot be edited in place refuses the create while a /// folder is reminted, and a template carrying one readable-but-uneditable card refuses the create
/// nested card that is readable-but-uneditable is copied byte-verbatim and simply not stamped, /// whole, naming that card (01-storage-format.md § Frontmatter: "preflights the entire subtree and
/// because failing a whole create over one hand-dropped flow mapping would be hostile. Its stale /// refuses whole, loudly, naming the offending item never a partial copy, never a silently
/// `modified-by` surviving is the self-reported-provenance honest limit 01 § Frontmatter already /// unstamped descendant").
/// acknowledges. ///
/// 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( private static func mintIdentitiesAndStamps(
at root: URL, at root: URL,
title: String, title: String,
operation: WriteOperation operation: WriteOperation
) throws(Failure) { ) throws(Failure) {
do throws(BoardWriteError) { 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] = [] var materialized: [URL] = []
try BoardWriter.remintDescendants(of: root, collecting: &materialized, operation: operation) 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 // 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). // (`BoardWriter.updateIndex`'s on-touch backfill; no template migration, by design).
try BoardWriter.updateIndex(inItemFolder: root, kind: .board, operation: operation) { document in 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)) document.set(FrontmatterKeys.title, to: .string(title))
} }
for folder in materialized { 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 /// 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 /// surface, "Loss rows are the warning-tone class for non-failure losses", settled 2026-07-28)
/// degraded paste, folders skipped from a Finder drop, their future kin. /// 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, /// **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 /// 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) case reloadBreakage(BoardLoadError)
/// A write that did not happen. Dismissable, error tone. /// A write that did not happen. Dismissable, error tone.
case oneShot(OneShotBanner) case oneShot(OneShotBanner)
/// Content that didn't arrive though nothing failed a degraded paste, folders skipped from a /// Content that didn't arrive though nothing failed folders skipped from a Finder drop, the
/// Finder drop, their future kin. Dismissable, warning tone: below the true failures above it, /// 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`). /// above the ambient notices below it (settled 2026-07-28, see `LossBanner`).
case loss(LossBanner) case loss(LossBanner)
/// History has stopped advancing. Condition, warning tone the files are safe, only the undo /// 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)) postSignpost(Self.skippedStepMessage(direction, subject: subject))
} }
/// One item a degraded paste could not bring its attachments with what /// **The refused paste** (04-interactions.md Clipboard, re-ruled 2026-07-29): the staged
/// `degradedPasteMessage(for:)` names. /// 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 `oneShot`, not a loss row** which is the pivot, and it is the vocabulary reading the
/// a value (03-board-ui.md § Card face), and the phrasing below says "the item" instead, exactly /// event correctly rather than a reclassification for its own sake. The degraded paste *was* a loss
/// as `actionPhrase(for:)` does for a failure whose title never got read. /// row because the items landed and only their attachments did not: content that didn't arrive
public struct AttachmentLoss: Sendable, Equatable { /// though nothing failed. Under refuse-don't-degrade nothing lands at all, which is exactly
public let title: String? /// 02-architecture.md's definition of a one-shot a write that did not happen so the row
public let attachments: Int /// carries a `BoardWriteError` like every other failure, ranks with the true failures, and says
/// "Couldn't paste" rather than "Pasted without".
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 loss row, not a `oneShot` and not a signpost** the vocabulary's answer rather than a /// The retired member is `postDegradedPaste(_:)` and its `AttachmentLoss` payload: with the
/// compromise (settled 2026-07-28). 02-architecture.md's `oneShot` is *a write that did not /// degraded materialization gone there is no partial arrival to account for, and the
/// happen*, carrying a `BoardWriteError`, and nothing here failed the items landed, whole but /// loss-accounting problem it existed to report what didn't arrive, and whether the totals were
/// for files that were never on the pasteboard's side of the transfer. This row first shipped as /// honest dissolves rather than being solved. **The loss class itself is untouched**: folder-drop
/// a signpost, the vocabulary's other one-shot-lifecycle member at the time, and it read quieter /// skips still post one (`postSkippedFolders`), and the relocation, migration, displacement and
/// than 04's "loud" deserved: a signpost ranks last and may collapse behind "+N more", exactly /// remint notices are all still its.
/// 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.
/// ///
/// An empty list posts nothing: a fallback that lost no attachments lost nothing at all, and a /// `title` is the offending entry's, from the manifest's own metadata, `nil` for an untitled item
/// banner announcing that would be noise. /// "Untitled" is a rendering, never a value (03-board-ui.md § Card face), so `actionPhrase(for:)`
public func postDegradedPaste(_ losses: [AttachmentLoss]) { /// says "the item" instead. The staging path is what the error names as its `path`: it is the file
guard let message = Self.degradedPasteMessage(for: losses) else { return } /// that was not there, and naming it is what makes a bug report about this actionable.
postLoss(message) 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 /// 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 /// 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 /// 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 /// 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 /// banner names the skipped folders" now a loss row (settled 2026-07-28): folders that never
/// one (settled 2026-07-28): folders that never arrived are a non-failure loss, not a write /// arrived are a non-failure loss, not a write failure. With the degraded paste retired
/// failure. /// (`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. /// A drop with no skipped folders posts nothing nothing was lost, so there is nothing to say.
public func postSkippedFolders(count: Int) { 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 /// `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` /// 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( public nonisolated static func rows(
lock: ReadOnlyLockReason?, lock: ReadOnlyLockReason?,
breakage: BoardLoadError?, breakage: BoardLoadError?,
@@ -756,6 +750,13 @@ public final class BannerCenter {
if let title { "Couldn't reorder '\(title)'" } else { "Couldn't reorder the item" } if let title { "Couldn't reorder '\(title)'" } else { "Couldn't reorder the item" }
case let .copy(title): case let .copy(title):
if let title { "Couldn't copy '\(title)'" } else { "Couldn't copy the item" } 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): case let .delete(title):
if let title { "Couldn't delete '\(title)'" } else { "Couldn't delete the item" } if let title { "Couldn't delete '\(title)'" } else { "Couldn't delete the item" }
case let .purge(title): 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 // 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. // write bracket so this line exists for a caller that reached the Writer directly.
error.reason.description 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) return trimmed(text)
} }
@@ -904,32 +912,6 @@ public final class BannerCenter {
return "\(subject): \(reason) — showing the last good view" 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 /// 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`, /// skipped", generalized over the count. `postSkippedFolders` never calls this at `count == 0`,
/// so every real call already has something to report. /// 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 /// 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. /// 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" /// - **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 /// (settled here, the judgment 01 leaves to the implementation): the true total plus the true
/// own shape "Pasted 2 items without their 5 files" and for its reason: the true total /// item count is the honest summary where a truncated list of titles would not
/// 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 /// 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. /// 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. /// Where one arriving item's bytes come from.
/// ///
/// **Two producers, one arrival path.** A drag names folders in the source board; a paste names /// **One producer shape, because there is only one kind of source left.** A drag names folders in
/// folders in the clipboard's staging directory and, when that snapshot is missing or /// the source board; a paste names folders in the clipboard's staging directory. There used to be a
/// unreadable, the manifest's embedded `index.md` instead (04-interactions.md Clipboard's /// second case the manifest's embedded `index.md`, materialized when the staged snapshot was
/// staging-less fallback). Modelling the fallback as a second kind of *source* rather than as a /// missing or unreadable and it is **retired with the degraded paste** (04-interactions.md
/// second arrival method is what keeps the rank insertion, the tombstone stripping and the /// Clipboard, re-ruled 2026-07-29: "A paste whose staged snapshot is missing or unreadable refuses
/// `deleted:` clearing stated once: everything downstream of "where do the bytes come from" is /// loudly never degrades"). An item arrives **whole index, attachments, loose files or not at
/// identical, and a paste that half-falls-back mixes the two cases inside one bracket. /// 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 { public enum ItemSource: Sendable, Equatable {
/// A folder on disk the source board's own, or a staged snapshot of it. /// A folder on disk the source board's own, or a staged snapshot of it.
case folder(URL) 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. /// 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). /// independently (04-interactions.md Clipboard).
/// ///
/// It is the same commit as a drop's, deliberately: `.copy` materializes from the staged snapshot /// 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 /// and `.move` is the armed cut's "the -drag move path identity travels", which is also the
/// move path identity travels", which is also the keyboard restore when the cut was made in the /// keyboard restore when the cut was made in the trash ( The trash: "cut in the trash, paste into a
/// trash ( The trash: "cut in the trash, paste into a lane is the keyboard-native restore, an /// lane is the keyboard-native restore, an ordinary folder move"). A copy whose staged snapshot is
/// ordinary folder move"). /// 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 /// **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 /// 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, /// One arrival's materialization the source crossed with the two operations, in the one place
/// in the one place both the card path and the lane path can share. /// 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 /// A copy is `copyItem` (fresh GUIDs throughout, the copy contract applied to every folder it
/// identity travels, and the only producer of text sources is the clipboard's fallback, which is /// materializes) and a move is `moveItem` (identity travels, the import boundary reminting only
/// a *copy* by construction (04-interactions.md Clipboard: an armed cut moves the surviving /// what collides). The `ItemID?` return survives the retired text case because the lane and card
/// originals, and a cut that cannot find them is void). Skipping is the standing posture for an /// arrival loops read it as "did this arrival land": a `nil` is the standing silent no-op for a
/// arrival that names nothing the same silent no-op every other drop commit gives a source that /// source that names nothing.
/// has gone.
private static func materialize( private static func materialize(
_ source: ItemSource, _ source: ItemSource,
operation: TransferOperation, operation: TransferOperation,
@@ -2644,15 +2648,6 @@ public final class BoardStore: HealHost {
destinationBoardRoot: destinationBoardRoot, destinationBoardRoot: destinationBoardRoot,
order: order order: order
).id ).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). /// (04-interactions.md Clipboard).
/// ///
/// The two operations keep their drag semantics exactly, because 04 says they are the same /// 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 /// 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 /// confirmation stands in front of, and what follows it is reading the board rather than pressing
/// the key again. /// 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 } let paths = ItemPath.resolve(ids, in: container, snapshot: snapshot).filter { !$0.isLane }
guard !paths.isEmpty else { return } guard !paths.isEmpty else { return }
let root = rootURL let root = rootURL
@@ -3604,19 +3610,28 @@ public final class BoardStore: HealHost {
// MARK: - The claimed-name displacement // MARK: - The claimed-name displacement
/// Moves a squatter off a board-root name the app claims, and posts the notice naming old and /// Moves squatters off the names the app claims, and posts the notice naming old and new the
/// new the **act** half of the claimed-names ruling (01-storage-format.md § Fractal layout /// **act** half of the claimed-names ruling (01-storage-format.md § Fractal layout Rules, ruled
/// Rules, ruled 2026-07-29: "Lanework owns the board, so an invalid artifact on a claimed name is /// 2026-07-29: "Lanework owns the board, so an invalid artifact on a claimed name is a defect, not
/// a defect, not a resident"). /// a resident").
/// ///
/// Today that is `.trash` and only `.trash`: a file or symlink sitting on the name the trash /// **Two levels, one heal** (extended 2026-07-29 "the rule is level-uniform"): the board root's
/// needs, which breaks deletion for as long as it stands which is exactly why the timing is /// `.trash`, and any card's `attachments`. Each is a file or symlink sitting on a name the app needs
/// *scheduled* rather than on-touch (§ Validation and healing: "proactive when the defect is /// breaking deletion in the first case, and every import, Finder drop and sidebar listing for that
/// load-bearing now"). `CLAUDE.md`'s squatter is displaced by the guide's own heal, which owns /// card in the second which is exactly why the timing is *scheduled* rather than on-touch
/// that file end to end. /// (§ 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 /// **One bracket over every displacement the load found**, whatever their levels: the batch is one
/// *next delete* creates the real `.trash/`, exactly as it does on a board that never had 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() { public func displaceClaimedNames() {
let work = claimedNameSquatters let work = claimedNameSquatters
let root = rootURL 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: /// 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` /// 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 /// 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. /// *why* the named-folder form is load-bearing, not just what to type. v7 teaches two more
static let version = 6 /// 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 // MARK: - The version marker
@@ -325,7 +333,7 @@ enum AgentGuide {
<board>/ this folder (the board) <board>/ this folder (the board)
index.md board title + settings; body = board description index.md board title + settings; body = board description
CLAUDE.md this guide (app-maintained) 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 <uuid>/ a LANE
index.md lane title + order; body = lane notes/policy index.md lane title + order; body = lane notes/policy
<uuid>/ a CARD <uuid>/ a CARD
@@ -350,7 +358,7 @@ enum AgentGuide {
- Lane titles carry the workflow semantics (e.g. To Do In Progress - 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 Done). Read the board's and lanes' index.md bodies for descriptions and
per-lane policy before deciding where a card belongs. 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. UUID-named folder is not part of the board's content.
## Frontmatter ## Frontmatter
@@ -397,6 +405,7 @@ enum AgentGuide {
```markdown ```markdown
--- ---
schema: 1 schema: 1
kind: card
title: Short imperative card title title: Short imperative card title
order: 3072 order: 3072
created: 2026-07-24T18:00:00Z created: 2026-07-24T18:00:00Z
@@ -406,11 +415,24 @@ enum AgentGuide {
The card's content any Markdown. The card's content any Markdown.
``` ```
Creating a lane is the same one level up (body optional; `order` ranks Creating a lane is the same one level up (body optional; `kind: lane`;
lanes leftright). `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 ## 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 - 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 IS the move. Then set the card's `order` to place it among the
destination's cards, update `modified`, and re-stamp `modified-by`. 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 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 sweeps the lane's identity file along with them and overwrites the
destination lane's. 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 ## Editing and deleting
- Edit bodies freely; update `modified` on every write. Preserve frontmatter - Edit bodies freely; update `modified` on every write. Preserve frontmatter
keys you don't recognize and don't reformat content you didn't change. 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> - **Delete a card or a lane = move its folder into `<board>/.trash/`**:
<board>/.trash/` (create `.trash/` if missing). Arrivals go on top: set `mv <lane>/<card-uuid> <board>/.trash/`, or `mv <lane-uuid>
the card's `order` to the smallest `order` already in `.trash/` minus <board>/.trash/` for a whole lane (create `.trash/` if missing). A lane
1024 (empty trash: any number), and update `modified`. Restore is the travels with its cards inside it. Arrivals go on top: set the moved
same move in reverse into a lane, with a fresh `order`. 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 - Never write a `deleted:` key that convention is retired; the app
migrates any it finds. migrates any it finds.
- Remove a folder outright (`rm -r`) only when you mean permanent, - Remove a folder outright (`rm -r`) only when you mean permanent,
unrecoverable deletion. Lanes have no trash: deleting a lane folder is unrecoverable deletion the trash is the recoverable path for both
permanent, so be sure. cards and lanes.
## Attachments ## Attachments
- A card's files live in `attachments/` inside the card folder, flat at its - 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 top level. **Put files there, never beside `index.md`** the app
relocates loose files into `attachments/` and tells the user it did. 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. - 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` If the name is taken, pick a free one Finder-style (`shot.png`
`shot 2.png`) never overwrite. `shot 2.png`) never overwrite.
+50
View File
@@ -168,6 +168,27 @@ public enum BoardLoader: Sendable {
// views over it (`LoadResult.looseCardFiles`, `.legacyTombstones`). // views over it (`LoadResult.looseCardFiles`, `.legacyTombstones`).
var defects: [IntegrityRules.Defect] = [] 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 // 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 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). // the displacement is the store's, through the Writer (the Repair precedent).
@@ -187,6 +208,8 @@ public enum BoardLoader: Sendable {
warn(.boardLevelDeletedIgnored) warn(.boardLevelDeletedIgnored)
} }
noteCoercions(in: boardDocument, at: indexFileName)
// Every lane the walk read, in folder order **not** `Lane` values yet. The board-wide // 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 // 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 // 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 laneDocument = try readDocument(at: laneURL.appendingPathComponent(indexFileName), path: lanePath)
let laneSchema = try validatedSchema(in: laneDocument, path: lanePath) let laneSchema = try validatedSchema(in: laneDocument, path: lanePath)
let laneOrder = try validatedOrder(in: laneDocument, path: lanePath) let laneOrder = try validatedOrder(in: laneDocument, path: lanePath)
noteCoercions(in: laneDocument, at: lanePath)
var cards: [Card] = [] var cards: [Card] = []
for cardURL in try directoryCandidates(in: laneURL) { for cardURL in try directoryCandidates(in: laneURL) {
@@ -226,6 +250,20 @@ public enum BoardLoader: Sendable {
} }
let card = try parseCard(at: cardURL, path: cardRelPath) 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. // Noticed, never acted on: the relocation is the store's, through the Writer.
let loose = looseFileNames(in: cardURL) let loose = looseFileNames(in: cardURL)
@@ -287,6 +325,7 @@ public enum BoardLoader: Sendable {
continue continue
} }
let entry = try parseCard(at: cardURL, path: cardRelPath) 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** // **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 // (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. // 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] { public var duplicateIdentities: [DuplicateIdentity] {
defects.compactMap { if case let .duplicateIdentity(work) = $0 { work } else { nil } } 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` /// 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 /// and the body survive every write by construction rather than by remembering to preserve
/// them. /// them.
/// - **`modified` stamped, `modified-by` cleared** (§ Frontmatter): on every app-mediated write /// - **`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 /// path that rewrites *content*. Absence of `modified-by` means "the board's user, via the app";
/// rewritten anyway, so clearing an external writer's self-reported stamp costs nothing. /// 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 /// - **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 /// UTF-8, and a file that does not decode is a loud, specific error rather than a
/// lossy best guess. /// lossy best guess.
@@ -44,6 +46,26 @@ public enum BoardWriter: Sendable {
/// two keys, and no call site has to remember them. /// two keys, and no call site has to remember them.
/// 4. **Atomic replace.** /// 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 /// 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 /// (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 /// `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, // After `edits`, so a caller that wrote its own `kind` is left alone, and before the stamps,
// which outrank everything for their own reason. // which outrank everything for their own reason.
IntegrityRules.healOnTouch(&document, kind: kind ?? derivedKind(ofItemFolder: folder)) IntegrityRules.healOnTouch(&document, kind: kind ?? derivedKind(ofItemFolder: folder))
document.set(FrontmatterKeys.modified, to: .date(Date())) // The reorders-don't-stamp predicate, read off the operation. An order-only rewrite restates
document.remove(FrontmatterKeys.modifiedBy) // 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) 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` /// - **Display order is the assignment order** (`Ranks.isOrderedForDisplay`: `order`
/// ascending, folder name breaking ties) the same rule the loader sorts by, so a /// ascending, folder name breaking ties) the same rule the loader sorts by, so a
/// renumber is guaranteed to be sequence-preserving: nothing visibly moves. /// renumber is guaranteed to be sequence-preserving: nothing visibly moves.
/// - **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 /// 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 /// 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. /// construction rather than by copying carefully.
/// ///
/// Exactly one file is rewritten the moved root's `index.md`, and only to carry its new /// 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 /// `order` (§ Ordering, "a reorder rewrites only the moved item's `index.md`"). `order` is the
/// `modified-by`-cleared like every other app write. `order` is the caller's explicit rank /// caller's explicit rank (a drop between two siblings), or `nil` to append after the
/// (a drop between two siblings), or `nil` to append after the destination's visible /// destination's visible siblings.
/// 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 /// **The import boundary is the one place within-board uniqueness is enforced** (§ Fractal
/// layout Rules). `sourceBoardRoot` and `destinationBoardRoot` are compared by resolved, /// 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 /// `Date` for the whole tree: a card made from a template is born today, not forked from
/// the template (09-templates.md). /// 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 /// ## A copy is a transaction (ruled 2026-07-29)
/// 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 /// **The whole subtree is preflighted for stampability before anything is materialized**, and a
/// *exactly* as it was rather than corrupted its stale `modified-by` attribution /// copy that cannot honor the contract on one nested card refuses whole, loudly, naming that card
/// surviving is the self-reported-provenance honest limit § Frontmatter already /// (01-storage-format.md § Frontmatter: "every copy flow that rewrites descendants' `index.md`
/// acknowledges. The **root** gets no such leniency: it must be rewritten (it needs its /// preflights the entire subtree and refuses whole, loudly, naming the offending item never a
/// new `order`), so an unreadable or uneditable root refuses the copy up front, before /// partial copy, never a silently unstamped descendant").
/// anything is materialized. ///
/// - A nested UUID-shaped folder with **no `index.md`** interrupted-create residue is /// This **retired the former root-strict/nested-lenient split**, which copied an unreadable or
/// copied and reminted like any other, and not rewritten: the same skip the loader applies /// readable-but-uneditable nested `index.md` byte-verbatim and simply skipped its stamp. The
/// to it (`.missingIndex`). /// 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 /// **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 /// 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. /// The source is never touched on any path.
public static func copyItem( public static func copyItem(
at sourceFolder: URL, 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- // failure from here on in this call including inside the materialized-but-not-yet-
// stamped tree below reuses the enriched value. // stamped tree below reuses the enriched value.
operation = try checkIndexIsRewritable(inItemFolder: sourceFolder, operation: operation) 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) let rank = try destinationOrder(order, inParent: destinationParent, operation: operation)
@@ -816,9 +872,7 @@ public enum BoardWriter: Sendable {
let now = Date() let now = Date()
try updateIndex(inItemFolder: root, operation: operation) { document in try updateIndex(inItemFolder: root, operation: operation) { document in
if case .born = stamps { applyCopyContract(to: &document, stamps: stamps, now: now)
document.set(FrontmatterKeys.created, to: .date(now))
}
document.set(FrontmatterKeys.order, to: .double(rank)) document.set(FrontmatterKeys.order, to: .double(rank))
} }
for folder in copied { 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 /// **The copy contract's frontmatter edits**, applied to every folder an item-level copy
/// missing, unreadable, or uneditable `index.md` is left exactly as the copy found it rather /// materializes root and descendants alike, in one place so the two can never disagree about
/// than failing the gesture. `order` is not touched: a nested item keeps its rank among its /// what a copy owes.
/// own siblings, which travelled with it. ///
/// 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 /// **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( static func stampCopiedDescendant(
at folder: URL, at folder: URL,
stamps: CopyStamps, stamps: CopyStamps,
@@ -878,85 +1021,13 @@ public enum BoardWriter: Sendable {
operation: WriteOperation operation: WriteOperation
) throws(BoardWriteError) { ) throws(BoardWriteError) {
let indexURL = folder.appendingPathComponent(BoardLoader.indexFileName) let indexURL = folder.appendingPathComponent(BoardLoader.indexFileName)
guard FileManager.default.fileExists(atPath: indexURL.path), guard FileManager.default.fileExists(atPath: indexURL.path) else { return }
let copied = try? readDocument(at: indexURL, operation: operation),
copied.uneditableShape == nil
else { return }
try updateIndex(inItemFolder: folder, operation: operation) { document in try updateIndex(inItemFolder: folder, operation: operation) { document in
if case .born = stamps { applyCopyContract(to: &document, stamps: stamps, now: now)
document.set(FrontmatterKeys.created, to: .date(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 // MARK: - The materialized trash
/// `<boardRoot>/.trash/` the board's trash container, named but not created. /// `<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 /// *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 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 /// **The `modified` stamp is the point, not a side effect** and it needs no exception to earn
/// moves-don't-stamp "deletion is an edit to the card's story" and the stamp is what a /// it. The trash move changes the card's *container*, which is the whole predicate
/// future age-based auto-purge reads. It falls out of `updateIndex` here rather than being /// (`WriteOperation.rewritesOrderOnly`, refined 2026-07-30): "deletion is an edit to the item's
/// asked for, which is why there is nothing extra in step 5. /// 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 /// **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 /// 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. /// trees.
/// - **The bytes are written verbatim** nothing is stamped, nothing is re-serialized, no /// - **The bytes are written verbatim** nothing is stamped, nothing is re-serialized, no
/// `index.md` is parsed. This replays; it does not edit. /// `index.md` is parsed. This replays; it does not edit.
/// - **All-or-nothing**: any failure removes the partial tree best-effort and rethrows, the /// - **All-or-nothing**: any failure removes the partial tree best-effort and rethrows,
/// `materializeItem` rule a half-restored lane is pure residue, since nothing was there. /// `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 /// `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 /// 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 /// 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). /// an undone create (13-native-undo.md Rules).
/// ///
/// **The identity is the point.** `createLane`/`createCard` mint a fresh UUID and /// **The identity is the point.** `createLane`/`createCard` mint a fresh UUID, so they cannot
/// `materializeItem` mints a fresh one too, so neither can replay a create: redoing through them /// replay a create: redoing through them
/// would produce a *different* item, and every step registered above this one on the stack /// 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 /// (a rename, a move, a body edit of that very card) would then name nothing. This call takes the
/// path as given. /// path as given.
@@ -1577,7 +1651,7 @@ public enum BoardWriter: Sendable {
operation: operation operation: operation
) )
} catch { } 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. // nothing was there before.
try? FileManager.default.removeItem(at: itemFolder) try? FileManager.default.removeItem(at: itemFolder)
throw error 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 /// **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). /// 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. /// - Returns: the name the squatter now has, or `nil` when the defect was already gone.
@discardableResult @discardableResult
public static func displaceClaimedName( public static func displaceClaimedName(
@@ -2185,13 +2267,14 @@ public enum BoardWriter: Sendable {
atBoardRoot root: URL atBoardRoot root: URL
) throws(BoardWriteError) -> String? { ) throws(BoardWriteError) -> String? {
let operation = WriteOperation.displaceClaimedName(name: squatter.name) 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 { guard let found = IntegrityRules.node(at: occupied), found != squatter.expected else {
return nil return nil
} }
let freed = freshName(for: squatter.name, in: root) let freed = freshName(for: squatter.name, in: container)
let destination = root.appendingPathComponent(freed) let destination = container.appendingPathComponent(freed)
do { do {
try FileManager.default.moveItem(at: occupied, to: destination) try FileManager.default.moveItem(at: occupied, to: destination)
} catch { } catch {
@@ -2565,6 +2648,23 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
case move(title: String?) case move(title: String?)
case reorder(title: String?) case reorder(title: String?)
case copy(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 /// / a card moving into `.trash/` (`deleteCardToTrash`) or a lane being removed
/// outright (`removeLane`). The word the user pressed, whichever staging it took. /// 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 .move: .move(title: title)
case .reorder: .reorder(title: title) case .reorder: .reorder(title: title)
case .copy: .copy(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 .delete: .delete(title: title)
case .purge: .purge(title: title) case .purge: .purge(title: title)
case .migrateTombstone: .migrateTombstone(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 /// A short imperative phrase `"move 'Fix login'"`, `"create card"`, `"import attachment
/// 'photo.png'"` for logs and diagnostics **only**: `BoardWriteError.description` (test /// 'photo.png'"` for logs and diagnostics **only**: `BoardWriteError.description` (test
/// failures, `po error`, console output), never the banner's text. The banner owns every /// 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 .move(title): Self.phrase("move", title)
case let .reorder(title): Self.phrase("reorder", title) case let .reorder(title): Self.phrase("reorder", title)
case let .copy(title): Self.phrase("copy", 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 .delete(title): Self.phrase("delete", title)
case let .purge(title): Self.phrase("purge", title) case let .purge(title): Self.phrase("purge", title)
case let .migrateTombstone(title): Self.phrase("migrate the legacy 'deleted' key on", 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. /// the *proposal* is not, so nothing was attempted and nothing changed.
case invalidSource(BoardLoadError) 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 { public var description: String {
switch self { switch self {
case let .unreadable(message): case let .unreadable(message):
@@ -2856,6 +3013,8 @@ public struct BoardWriteError: Error, Sendable, Equatable, CustomStringConvertib
message message
case let .invalidSource(error): case let .invalidSource(error):
"the source text wouldn't load: \(error.description)" "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. /// when absent (`IntegrityRules.healOnTouch`), and never stripped.
public static let kind = "kind" 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> = [ public static let schemaOwned: Set<String> = [
schema, title, order, width, created, modified, modifiedBy, deleted, background, icon, schema, title, order, width, created, modified, modifiedBy, deleted, background, icon,
iconColor, kind, 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 { 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`) // MARK: - Strict (structure the loader fails fast on `.malformed`)
public var schema: FieldValue<Int> { public var schema: FieldValue<Int> {
+157 -5
View File
@@ -182,6 +182,30 @@ public enum IntegrityRules: Sendable {
ClaimedName(name: ".gitignore", expected: .file, displacesSquatters: false), 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 /// The claimed names as the lane walk needs them: lowercased, for a `contains` against a
/// directory entry. Compared lowercased for `reservedCardChildNames`' reason. /// directory entry. Compared lowercased for `reservedCardChildNames`' reason.
public static let claimedRootNameSet: Set<String> = Set(claimedRootNames.map { $0.name.lowercased() }) 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) 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 // MARK: - Object kinds
/// The kinds the schema knows (01-storage-format.md § Frontmatter Common to all levels, the /// 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). /// and reminted by the scheduled heal (re-ruled 2026-07-29 the silent remint).
case duplicateIdentity(DuplicateIdentity) 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 /// The scheduled-heal classes, which are also the engine's memo keys and its
/// banner-posture rows (`HealScheduler`). /// banner-posture rows (`HealScheduler`).
/// ///
@@ -470,12 +540,18 @@ public enum IntegrityRules: Sendable {
case staleAgentGuide 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 { switch self {
case .looseCardFiles: .looseCardFiles case .looseCardFiles: .looseCardFiles
case .legacyTombstone: .legacyTombstone case .legacyTombstone: .legacyTombstone
case .claimedNameSquatted: .claimedNameSquatted case .claimedNameSquatted: .claimedNameSquatted
case .duplicateIdentity: .duplicateIdentity case .duplicateIdentity: .duplicateIdentity
case .coercedFrontmatter: nil
} }
} }
@@ -495,13 +571,21 @@ public enum IntegrityRules: Sendable {
case let .claimedNameSquatted(work): case let .claimedNameSquatted(work):
// The node *kind* is part of the picture: a squatter replaced by a different kind // 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 // of squatter is a new defect, and a heal that failed on one has no claim to have
// failed on the other. // failed on the other. The location leads, so two cards squatting `attachments` are two
["claimed:\(work.name):\(work.found.rawValue)"] // 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): case let .duplicateIdentity(work):
// The *identity* is part of the picture beside the path: the same folder losing a // 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 // 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. // heal that failed on one has no claim to have failed on the other.
["duplicate:\(work.path):\(work.identity)"] ["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 /// **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 /// 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. /// 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 { 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 public let name: String
/// What is actually sitting there never followed if it is a symlink. /// What is actually sitting there never followed if it is a symlink.
public let found: IntegrityRules.NodeKind public let found: IntegrityRules.NodeKind
/// What the app needs the name to be. /// What the app needs the name to be.
public let expected: IntegrityRules.NodeKind 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.name = name
self.found = found self.found = found
self.expected = expected 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 /// 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 /// 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 /// 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, slotWidth: slotWidth,
drops: dropContext, drops: dropContext,
marquee: marqueeControl, marquee: marqueeControl,
openCard: openCard openCard: openCard,
confirmations: confirmations
) )
.frame(width: resizing ? resize.liveWidth : slotWidth, alignment: .leading) .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), /// 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 /// 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). /// 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 { enum CardFaceRole {
/// A card in a lane. Carries the board window's card opener 's pointer twin /// A card in a lane. Carries the board window's card opener 's pointer twin
/// (04-interactions.md Selection). /// (04-interactions.md Selection) and the window's purge-alert host, for Delete's
case board(openCard: (ItemID) -> Void) /// -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 /// 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). /// is the permanent one and "confirms exactly where the loss is real" (03 § Trash).
@@ -143,7 +149,7 @@ struct CardFaceView: View {
@ViewBuilder @ViewBuilder
var body: some View { var body: some View {
switch role { switch role {
case let .board(openCard): case let .board(openCard, confirmations):
face face
// "A fast double-click opens the card window ('s pointer twin)" (04 Selection). // "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 } guard ClickModifier.current == .plain else { return }
openCard(card.id) openCard(card.id)
}) })
.contextMenu { boardMenu(openCard: openCard) } .contextMenu { boardMenu(openCard: openCard, confirmations: confirmations) }
// **The menu's rows, additionally as custom actions** "where SwiftUI additionally // **The menu's rows, additionally as custom actions** "where SwiftUI additionally
// surfaces menu items as custom accessibility actions, that's free improvement, not // surfaces menu items as custom accessibility actions, that's free improvement, not
// a separate design surface" (10-accessibility.md Actions come from the context // 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). // 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 // Style is absent for `LaneView`'s reason: it opens a popover, and the quick-style
// swatch `Picker` beside it is not an action. // swatch `Picker` beside it is not an action. Delete Immediately rides along too,
.accessibilityActions { boardActions(openCard: openCard) } // 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) { .popover(isPresented: styleEditorPresentation(store, anchor: card.id), arrowEdge: .bottom) {
StyleEditorPopover(store: store, recents: appModel.styleRecents) StyleEditorPopover(store: store, recents: appModel.styleRecents)
} }
@@ -443,10 +453,10 @@ struct CardFaceView: View {
// MARK: - Context menus // MARK: - Context menus
/// Open, Rename, Style, the quick-style recents row, Delete 11-command-nexus.md Context /// Open, Rename, Style, the quick-style recents row, Delete with Delete Immediately as its
/// menus' Card row, in its order. /// -alternate 11-command-nexus.md Context menus' Card row, in its order.
@ViewBuilder @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 // 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 // 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 // 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 // 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, // widened target set below (`targetIDs`) the successor-selection rule is `delete(_:)`'s own,
// so this row gets it for free. // 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() } Button("Delete") { deleteTargets() }
.disabled(!store.acceptsBoardMutations) .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 /// `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. /// 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 @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("Open") { openCard(card.id) }
Button("Rename") { beginRename() } Button("Rename") { beginRename() }
.disabled(!store.acceptsBoardMutations) .disabled(!store.acceptsBoardMutations)
Button("Delete") { deleteTargets() } Button("Delete") { deleteTargets() }
.disabled(!store.acceptsBoardMutations) .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 /// 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) 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() { private func revealInFinder() {
NSWorkspace.shared.activateFileViewerSelecting(targetFolders) NSWorkspace.shared.activateFileViewerSelecting(targetFolders)
} }
@@ -566,6 +604,18 @@ struct CardFaceView: View {
return store.selection.ids 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 /// 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. /// reveals `<root>/.trash/<uuid>` and never a lane path that no longer holds the card.
private var targetFolders: [URL] { private var targetFolders: [URL] {
@@ -605,7 +655,7 @@ struct CardFaceView: View {
onCommitAndOpen: { onCommitAndOpen: {
let id = card.id let id = card.id
store.commitRename() store.commitRename()
if case let .board(openCard) = role { openCard(id) } if case let .board(openCard, _) = role { openCard(id) }
} }
) )
.font(.body) .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" /// "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, /// (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' /// the quick-style recents row, the Width stepper and Delete with Delete Immediately as its
/// Lane row, in its order, complete as of m5. /// -alternate 11-command-nexus.md Context menus' Lane row, in its order, complete as of m5.
/// ///
/// ### The card face /// ### The card face
/// ///
@@ -65,6 +65,11 @@ struct LaneView: View {
/// business knowing about `WindowGroup` keys. /// business knowing about `WindowGroup` keys.
let openCard: (ItemID) -> Void 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 /// Reduce Motion, for the card transition below (10-accessibility.md). Read from the environment
/// and handed to `Motion`, which owns what "reduced" means. /// and handed to `Motion`, which owns what "reduced" means.
@Environment(\.accessibilityReduceMotion) private var reduceMotion @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 // 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 // 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. // 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() } Button("Delete") { deleteTargets() }
.disabled(!store.acceptsBoardMutations) .disabled(!store.acceptsBoardMutations)
.modifierKeyAlternate(.option) {
Button("Delete Immediately") { requestDeleteImmediately() }
.disabled(!canDeleteImmediately)
}
} }
/// The menu's plain rows again, as VoiceOver custom actions (see the `.accessibilityActions` /// 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 /// 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 /// surfaces cannot drift into meaning different things which is the only way "not a separate
/// design surface" is checkable rather than merely intended. /// 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 @ViewBuilder
private var laneActions: some View { private var laneActions: some View {
let units = LaneLayoutMath.displayUnits(of: lane) let units = LaneLayoutMath.displayUnits(of: lane)
@@ -317,6 +335,8 @@ struct LaneView: View {
.disabled(!store.acceptsBoardMutations || units <= 1) .disabled(!store.acceptsBoardMutations || units <= 1)
Button("Delete") { deleteTargets() } Button("Delete") { deleteTargets() }
.disabled(!store.acceptsBoardMutations) .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 /// 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) 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 /// 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. /// only the *plain* branch reads it the branch is already a toggle, which is the point.
private func toggleLaneSelection() { private func toggleLaneSelection() {
@@ -389,6 +418,24 @@ struct LaneView: View {
return store.selection.ids 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 { private var headerContent: some View {
HStack(alignment: .firstTextBaseline, spacing: BoardMetrics.laneHeaderSpacing(bodyPointSize: pointSize)) { HStack(alignment: .firstTextBaseline, spacing: BoardMetrics.laneHeaderSpacing(bodyPointSize: pointSize)) {
Image(systemName: ItemSymbol.name(lane.icon, fallback: ItemSymbol.lane)) Image(systemName: ItemSymbol.name(lane.icon, fallback: ItemSymbol.lane))
@@ -669,7 +716,7 @@ struct LaneView: View {
CardFaceView( CardFaceView(
store: store, store: store,
card: card, card: card,
role: .board(openCard: openCard), role: .board(openCard: openCard, confirmations: confirmations),
marquee: marquee, marquee: marquee,
drops: drops 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). /// names the whole container and re-derives its targets at the moment it runs).
enum Action: Equatable { enum Action: Equatable {
case deleteTrashCards(Set<ItemID>) 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 case emptyTrash
} }
} }
@@ -91,20 +96,54 @@ final class TrashConfirmations {
/// expression rather than three call sites. /// expression rather than three call sites.
/// ///
/// Its one caller is File Delete Immediately, which passes the selection's own ids which is /// 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 /// what makes reading `store.selection.container` correct here and wrong for a context menu
/// context menu (`requestTrashDelete` above exists for exactly that difference). /// (`requestTrashDelete` above exists for exactly that difference).
func requestPurge(of ids: Set<ItemID>, in store: BoardStore) { func requestPurge(of ids: Set<ItemID>, in store: BoardStore) {
let container = store.selection.container
guard store.purgeIsUnrecoverable else { guard store.purgeIsUnrecoverable else {
store.deleteImmediately(ids) store.deleteImmediately(ids, in: container)
return return
} }
guard let prompt = TrashModel.purgePrompt( guard let prompt = TrashModel.purgePrompt(
for: ids, for: ids,
in: store.selection.container, in: container,
snapshot: store.snapshot, snapshot: store.snapshot,
unrecoverable: true unrecoverable: true
) else { return } ) 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 /// Raises Empty Trash's alert. **Always** it guards bulk scope rather than per-item
@@ -124,7 +163,7 @@ final class TrashConfirmations {
self.pending = nil self.pending = nil
switch pending.action { switch pending.action {
case let .deleteTrashCards(ids): store.deleteTrashCards(ids) 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() case .emptyTrash: store.emptyTrash()
} }
} }
+24
View File
@@ -647,6 +647,25 @@ struct AgentGuideContentTests {
#expect(content.contains("schema: 1")) #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 /// 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 /// (01-storage-format.md Changes from the pathfinder schema; Deletion). The one legitimate
/// mention of `deleted:` is the warning never to write it. /// mention of `deleted:` is the warning never to write it.
@@ -656,5 +675,10 @@ struct AgentGuideContentTests {
#expect(!content.contains("media/")) #expect(!content.contains("media/"))
#expect(!content.contains("tombstone")) #expect(!content.contains("tombstone"))
#expect(content.contains("Never write a `deleted:` key")) #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")) #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() let fixture = try WriterFixture()
defer { fixture.tearDown() } defer { fixture.tearDown() }
let lane = try crowdedLane(fixture) let lane = try crowdedLane(fixture)
let priorModified = try FrontmatterDocument.parse(fixture.indexText("lane/\(Child.a)"))
.rawValue(for: FrontmatterKeys.modified)
try BoardWriter.renumberVisibleChildren(of: lane) try BoardWriter.renumberVisibleChildren(of: lane)
for name in [Child.a, Child.b, Child.c] { for name in [Child.a, Child.b, Child.c] {
let text = try fixture.indexText("lane/\(name)") let text = try fixture.indexText("lane/\(name)")
let document = try FrontmatterDocument.parse(text) let document = try FrontmatterDocument.parse(text)
#expect(document.modifiedBy == .missing) #expect(document.modifiedBy == .valid("claude"), "a rescale touches no content, so attribution stands")
#expect(document.modified.value != nil) #expect(document.rawValue(for: FrontmatterKeys.modified) == priorModified, "and nothing is stamped")
#expect(document.unknownFields.map(\.key) == ["project"]) #expect(document.unknownFields.map(\.key) == ["project"])
#expect(text.contains("project: lanework # agent overlay\n")) #expect(text.contains("project: lanework # agent overlay\n"))
#expect(document.body.hasSuffix(" body\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 /// **A copy is a transaction** (01-storage-format.md § Frontmatter, ruled 2026-07-29): a nested
/// verbatim rather than failing the gesture stale `modified-by` and all while its /// card the surgical editor cannot key refuses the **whole** copy, naming that card, and nothing is
/// editable siblings are stamped normally. /// materialized at the destination.
@Test func aNestedUneditableFileCopiesVerbatimWhileItsSiblingsAreStamped() throws { ///
/// 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() let fixture = try WriterFixture()
defer { fixture.tearDown() } defer { fixture.tearDown() }
try board(fixture) try board(fixture)
try fixture.item("A.kanban/\(Ident.lane1)/\(Ident.card3)", Item.uneditable) 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( let id = try BoardWriter.copyItem(
at: fixture.url("A.kanban/\(Ident.lane1)"), at: fixture.url("A.kanban/\(Ident.lane1)"),
@@ -1474,10 +1551,51 @@ struct BoardWriterCopyTests {
stamps: .fork stamps: .fork
) )
let copied = try childrenByTitle(of: "A.kanban/\(id.rawValue)", in: fixture) let lane = try FrontmatterDocument.parse(fixture.indexText("A.kanban/\(id.rawValue)"))
#expect(try fixture.indexText("A.kanban/\(id.rawValue)/\(try #require(copied["Odd"]))") == Item.uneditable) #expect(lane.value(for: "remote-state") == nil, "the copied lane's tracker mapping is severed")
#expect(try FrontmatterDocument.parse(fixture.indexText("A.kanban/\(id.rawValue)/\(try #require(copied["Card One"]))")) #expect(lane.value(for: "project") != nil, "and every other unknown key is untouched")
.modifiedBy == .missing)
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 /// 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) #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, /// All-or-nothing at the destination and since the copy became a **transaction** (ruled
/// because a partial copy is pure residue nothing was there before. /// 2026-07-29) this case never even materializes: an unreadable nested `index.md` is caught by the
@Test func aFailedCopyLeavesNothingAtTheDestination() throws { /// 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() let fixture = try WriterFixture()
defer { fixture.tearDown() } defer { fixture.tearDown() }
try board(fixture) try board(fixture)
@@ -1548,10 +1669,11 @@ struct BoardWriterCopyTests {
stamps: .fork stamps: .fork
) )
} }
guard case .io = error?.reason else { guard case .unreadable = error?.reason else {
Issue.record("expected .io, got \(String(describing: error?.reason))") Issue.record("expected .unreadable, got \(String(describing: error?.reason))")
return return
} }
#expect(error?.path.contains(Ident.card2) == true, "the offending file is named")
#expect(try fixture.entryNames("A.kanban") == before) #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). // and not after itself miscounted (which would give 2048... or 4096+1024).
let text = try fixture.indexText("A.kanban/\(Ident.lane1)/\(Ident.card1)") let text = try fixture.indexText("A.kanban/\(Ident.lane1)/\(Ident.card1)")
#expect(text.contains("order: 4096")) #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 { @Test func aSameParentMoveWithAnExplicitOrderJustRewritesIt() throws {
+171
View File
@@ -287,3 +287,174 @@ struct ClaimedNamePhrasingTests {
== "Couldn't move '.trash' aside — Lanework needs that name — permission denied") == "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) #expect(ClipboardManifest(data: data) == nil)
} }
@Test("A lane entry's lost-attachment count totals its cards'") @Test("A lane entry's attachment count totals its cards'")
func lostAttachments() { func totalAttachments() {
let lane = ClipboardManifest.Entry( let lane = ClipboardManifest.Entry(
id: Ident.lane1, id: Ident.lane1,
folder: Ident.lane1, folder: Ident.lane1,
@@ -194,7 +194,7 @@ struct ClipboardManifestTests {
.init(id: Ident.card2, title: "Second", index: "b", attachmentCount: 1), .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") @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.kind == .lane)
#expect(manifest.entries.map(\.id) == [Ident.lane1]) #expect(manifest.entries.map(\.id) == [Ident.lane1])
#expect(manifest.entries[0].cards.map(\.id) == [Ident.card1, Ident.card2]) #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") @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") /// **04-interactions.md Clipboard, re-ruled 2026-07-29** refuse, never degrade:
struct DegradedPasteBannerTests { ///
/// > 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") @Test("04's own example sentence")
@MainActor
func theExampleSentence() { func theExampleSentence() {
#expect(BannerCenter.degradedPasteMessage( let center = BannerCenter()
for: [.init(title: "Fix login", attachments: 3)] center.postRefusedPaste(title: "Fix login", stagedAt: "/tmp/staging/abc")
) == "Pasted 'Fix login' without its 3 attachments")
#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") /// "Untitled" is a rendering, never a value (03-board-ui.md § Card face), so an untitled entry is
func singular() { /// "the item" `actionPhrase`'s standing convention for a failure with no title to quote.
#expect(BannerCenter.degradedPasteMessage( @Test("An untitled entry is 'the item', never the Untitled rendering")
for: [.init(title: "Fix login", attachments: 1)] @MainActor
) == "Pasted 'Fix login' without its attachment")
}
@Test("An untitled item is 'the item', never the Untitled rendering")
func untitled() { 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() let center = BannerCenter()
center.postDegradedPaste([]) center.postRefusedPaste(title: nil, stagedAt: "/tmp/staging/abc")
#expect(center.losses.isEmpty) 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) #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( 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.count == 1)
#expect(rows[0].tone == .warning) #expect(rows[0].tone == .error)
#expect(rows[0].dismissID == center.losses.first?.id) #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) #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 /// **A refused paste normalizes nothing, because it materializes nothing** (04-interactions.md
/// normalization must not invent an `attachments/` for a card that has none. /// Clipboard, re-ruled 2026-07-29 refuse, never degrade).
@Test("A degraded paste normalizes nothing and mints no attachments folder") ///
func degradedPasteIsUnaffected() async throws { /// 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()) let harness = try ClipboardHarness(fixture: try makeLooseFileClipboardBoard())
defer { harness.tearDown() } defer { harness.tearDown() }
let destination = try makePasteDestination() let destination = try makePasteDestination()
@@ -776,10 +781,12 @@ struct LooseFilePasteTests {
} }
target.select([ItemID(rawValue: Ident.lane4)], in: .board) target.select([ItemID(rawValue: Ident.lane4)], in: .board)
let before = try destination.entryNames(Ident.lane4).sorted()
await harness.clipboard.paste(into: target)?.value await harness.clipboard.paste(into: target)?.value
let arrived = try arrivedCard(in: destination) #expect(try destination.entryNames(Ident.lane4).sorted() == before, "no card arrived")
#expect(!destination.exists("\(Ident.lane4)/\(arrived)/attachments")) #expect(target.banners.oneShots.count == 1, "and the refusal said so")
#expect(try destination.entryNames("\(Ident.lane4)/\(arrived)") == ["index.md"]) // 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 @MainActor
@Suite("Paste ▸ the staging-less fallback") @Suite("Paste ▸ refuse, never degrade")
struct PasteFallbackTests { struct PasteRefusalTests {
@Test("A missing snapshot falls back to the embedded index.md, byte-faithfully") /// Drops the staged tree the way the world does: a sweep that ran early, an unreadable container,
func fallbackWritesTheSourceBytes() async throws { /// 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() let harness = try makeClipboardHarness()
defer { harness.tearDown() } defer { harness.tearDown() }
let destination = try makeDestination() let destination = try makeDestination()
@@ -545,31 +565,21 @@ struct PasteFallbackTests {
harness.store.select([clipboardCard1], in: .board) harness.store.select([clipboardCard1], in: .board)
harness.clipboard.copy(from: harness.store) harness.clipboard.copy(from: harness.store)
await harness.clipboard.stagingSettled() await harness.clipboard.stagingSettled()
// The snapshot goes a swept tree, a full disk, an unreadable container. try loseTheSnapshot(harness)
let copyID = try #require(harness.clipboard.payload?.copyID)
try FileManager.default.removeItem(
at: harness.staging.appendingPathComponent(copyID, isDirectory: true)
)
target.select([destinationLane], in: .board) target.select([destinationLane], in: .board)
await harness.clipboard.paste(into: target)?.value await harness.clipboard.paste(into: target)?.value
let arrived = try #require(try pastedIDs(destinationLane, in: destination).last) #expect(
let document = try FrontmatterDocument.parse(destination.indexText("\(Ident.lane4)/\(arrived)")) try pastedTitles(destinationLane, in: destination) == ["Resident"],
#expect(document.title.value == "First") "the destination holds exactly what it held before"
// 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"))
} }
@Test("A degraded paste banners, naming exactly what was lost") /// 04's own example sentence, end to end: the entry is named from the manifest's metadata, which is
func fallbackBanners() async throws { /// 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() let harness = try makeClipboardHarness()
defer { harness.tearDown() } defer { harness.tearDown() }
let destination = try makeDestination() let destination = try makeDestination()
@@ -579,43 +589,43 @@ struct PasteFallbackTests {
harness.store.select([clipboardCard1], in: .board) harness.store.select([clipboardCard1], in: .board)
harness.clipboard.copy(from: harness.store) harness.clipboard.copy(from: harness.store)
await harness.clipboard.stagingSettled() await harness.clipboard.stagingSettled()
let copyID = try #require(harness.clipboard.payload?.copyID) try loseTheSnapshot(harness)
try FileManager.default.removeItem(
at: harness.staging.appendingPathComponent(copyID, isDirectory: true)
)
target.select([destinationLane], in: .board) target.select([destinationLane], in: .board)
await harness.clipboard.paste(into: target)?.value 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") /// **The attachment-less case refuses too**, which is the pivot at its sharpest: under the degraded
func fallbackWithoutAttachmentsIsSilent() async throws { /// 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() let harness = try makeClipboardHarness()
defer { harness.tearDown() } defer { harness.tearDown() }
let destination = try makeDestination() let destination = try makeDestination()
defer { destination.tearDown() } defer { destination.tearDown() }
let target = try BoardStore(rootURL: destination.root) 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.store.select([clipboardCard2], in: .board)
harness.clipboard.copy(from: harness.store) harness.clipboard.copy(from: harness.store)
await harness.clipboard.stagingSettled() await harness.clipboard.stagingSettled()
let copyID = try #require(harness.clipboard.payload?.copyID) try loseTheSnapshot(harness)
try FileManager.default.removeItem(
at: harness.staging.appendingPathComponent(copyID, isDirectory: true)
)
target.select([destinationLane], in: .board) target.select([destinationLane], in: .board)
await harness.clipboard.paste(into: target)?.value await harness.clipboard.paste(into: target)?.value
#expect(try pastedTitles(destinationLane, in: destination) == ["Resident", "Second"]) #expect(try pastedTitles(destinationLane, in: destination) == ["Resident"])
#expect(target.banners.losses.isEmpty) #expect(target.banners.oneShots.count == 1)
} }
@Test("A lane's fallback materializes its embedded cards") @Test("A lane payload refuses whole — no lane, no cards")
func laneFallbackCarriesItsCards() async throws { func aLanePayloadRefusesWhole() async throws {
let harness = try makeClipboardHarness() let harness = try makeClipboardHarness()
defer { harness.tearDown() } defer { harness.tearDown() }
let destination = try makeDestination() let destination = try makeDestination()
@@ -625,115 +635,93 @@ struct PasteFallbackTests {
harness.store.select([clipboardLane1], in: .board) harness.store.select([clipboardLane1], in: .board)
harness.clipboard.copy(from: harness.store) harness.clipboard.copy(from: harness.store)
await harness.clipboard.stagingSettled() await harness.clipboard.stagingSettled()
let copyID = try #require(harness.clipboard.payload?.copyID) let before = try pasted(destination).lanes.map(\.id)
try FileManager.default.removeItem( try loseTheSnapshot(harness)
at: harness.staging.appendingPathComponent(copyID, isDirectory: true)
)
await harness.clipboard.paste(into: target)?.value await harness.clipboard.paste(into: target)?.value
let arrived = try #require(try pasted(destination).lanes.last) #expect(try pasted(destination).lanes.map(\.id) == before, "the strip is untouched")
#expect(arrived.title.value == "Todo") let error = try #require(target.banners.oneShots.first).error
// Both of the lane's cards. #expect(BannerCenter.headline(for: error) == "Couldn't paste 'Todo' — the copied content is gone")
#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"])
} }
@Test("A trash-sourced fallback materializes an ordinary card — there is no key to strip") /// **All-or-nothing for the whole paste** the transactional half of the ruling, which the former
func trashedFallbackIsOrdinary() async throws { /// 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() let harness = try makeClipboardHarness()
defer { harness.tearDown() } defer { harness.tearDown() }
let destination = try makeDestination() let destination = try makeDestination()
defer { destination.tearDown() } defer { destination.tearDown() }
let target = try BoardStore(rootURL: destination.root) let target = try BoardStore(rootURL: destination.root)
harness.store.transient.isTrashVisible = true harness.store.select([clipboardCard1, clipboardCard2], in: .board)
harness.store.select([clipboardCard3], in: .trash)
harness.clipboard.copy(from: harness.store) harness.clipboard.copy(from: harness.store)
await harness.clipboard.stagingSettled() 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) let copyID = try #require(harness.clipboard.payload?.copyID)
try FileManager.default.removeItem( 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) target.select([destinationLane], in: .board)
await harness.clipboard.paste(into: target)?.value await harness.clipboard.paste(into: target)?.value
#expect(try pastedTitles(destinationLane, in: destination) == ["Resident", "Trashed"]) #expect(
} try pastedTitles(destinationLane, in: destination) == ["Resident"],
} "not even the entry that could have arrived whole"
// 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(target.banners.oneShots.count == 1, "one refusal for one gesture")
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"))
} }
@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( /// **A refusal costs the user their content *and* nothing else** the destination's active search
inParent: fixture.root, /// survives it. "Any user-initiated creation on the board clears the query" (04 Search) is a rule
indexText: Item.rich(order: "1024", title: "Lane"), /// about creations, and a refused paste creates nothing.
children: [Item.rich(order: "1024", title: "One"), Item.uneditable], @Test("A refused paste leaves the destination's search alone")
order: 1024 func aRefusalKeepsTheSearch() async throws {
) let harness = try makeClipboardHarness()
defer { harness.tearDown() }
let lane = try #require(try BoardLoader.load(boardRoot: fixture.root).model.lanes.first) let destination = try makeDestination()
#expect(lane.id == id) defer { destination.tearDown() }
#expect(lane.cards.count == 2) let target = try BoardStore(rootURL: destination.root)
// An uneditable child arrives exactly as it was the leniency `copyItem` extends below its target.transient.searchQuery = "resident"
// root, applied here.
let names = try FileManager.default.contentsOfDirectory(atPath: fixture.url(id.rawValue).path) harness.store.select([clipboardCard1], in: .board)
.filter { $0 != "index.md" } harness.clipboard.copy(from: harness.store)
let odd = try #require(names.first { name in await harness.clipboard.stagingSettled()
(try? fixture.indexText("\(id.rawValue)/\(name)")) == Item.uneditable try loseTheSnapshot(harness)
})
#expect(try fixture.indexText("\(id.rawValue)/\(odd)") == Item.uneditable) 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 { /// A cut whose staged snapshot is gone is a different story and stays one: an armed cut moves the
_ = try BoardWriter.materializeItem( /// **originals**, which are real folders in the source board, so it never reads staging at all.
inParent: fixture.root, /// The refusal is the copy path's, and this pins that it did not spread.
indexText: "no frontmatter here at all\n", @Test("An armed cut still moves its originals — it never reads staging")
order: 1024 func anArmedCutIsUnaffected() async throws {
) let harness = try makeClipboardHarness()
} defer { harness.tearDown() }
#expect(error != nil) let destination = try makeDestination()
#expect(try fixture.entryNames("") == ["index.md"]) 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 // MARK: - Shared
/// Every card folder under an instantiated board `<root>/<lane>/<card>`, by the loader's own level /// 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) let store = try BoardStore(rootURL: fixture.root)
store.select([card1], in: .board) store.select([card1], in: .board)
store.deleteImmediately([card1]) store.deleteImmediately([card1], in: .board)
#expect(!fixture.exists("\(Ident.lane1)/\(Ident.card1)")) #expect(!fixture.exists("\(Ident.lane1)/\(Ident.card1)"))
#expect(!fixture.exists(".trash/\(Ident.card1)"), "03 ▸ Trash: ⌥⌘⌫ skips the trash from anywhere") #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) let store = try BoardStore(rootURL: fixture.root)
store.select([trashed], in: .trash) store.select([trashed], in: .trash)
store.deleteImmediately([trashed]) store.deleteImmediately([trashed], in: .trash)
#expect(!fixture.exists(".trash/\(Ident.indexless)")) #expect(!fixture.exists(".trash/\(Ident.indexless)"))
#expect(fixture.exists(".trash/\(More.newer)"), "and only what it named") #expect(fixture.exists(".trash/\(More.newer)"), "and only what it named")
@@ -417,7 +417,7 @@ struct PurgeTests {
let store = try BoardStore(rootURL: fixture.root) let store = try BoardStore(rootURL: fixture.root)
store.select([lane3], in: .board) store.select([lane3], in: .board)
store.deleteImmediately([lane3]) store.deleteImmediately([lane3], in: .board)
#expect(fixture.exists(Ident.lane3)) #expect(fixture.exists(Ident.lane3))
} }
@@ -473,7 +473,7 @@ struct PurgeTests {
store.select([trashed], in: .trash) store.select([trashed], in: .trash)
store.deleteTrashCards([trashed]) store.deleteTrashCards([trashed])
store.deleteImmediately([newer]) store.deleteImmediately([newer], in: .trash)
store.emptyTrash() store.emptyTrash()
// 13-native-undo.md Rules: "Permanently delete (Delete Immediately, Empty Trash) // 13-native-undo.md Rules: "Permanently delete (Delete Immediately, Empty Trash)
@@ -745,7 +745,7 @@ struct TrashConfirmationsTests {
let pending = try #require(confirmations.pending) let pending = try #require(confirmations.pending)
#expect(pending.prompt.title == "Permanently delete \u{201C}Trashed\u{201D}?") #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. // Nothing has happened yet the alert is what stands between the keystroke and the loss.
#expect(fixture.exists(".trash/\(Ident.indexless)")) #expect(fixture.exists(".trash/\(Ident.indexless)"))
@@ -756,6 +756,72 @@ struct TrashConfirmationsTests {
confirmations.confirm(in: store) 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 /// 03-board-ui.md § Trash: "on a trash card, Delete (/) is permanent Both confirm exactly
/// where the loss is real." /// where the loss is real."
@Test("The trash's own Delete confirms; the board's goes straight through") @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) #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") @Test("⌥⌘↓ undoes the whole permutation, siblings included")
func sortRoundTrip() throws { func sortRoundTrip() throws {
let fixture = try makeBoard() let fixture = try makeBoard()
@@ -735,7 +820,7 @@ struct NotUndoableTests {
let armed = try #require(history.undoActionName) let armed = try #require(history.undoActionName)
store.select([trashed], in: .trash) store.select([trashed], in: .trash)
store.deleteImmediately([trashed]) store.deleteImmediately([trashed], in: .trash)
#expect(fixture.exists(trashedPath) == false) #expect(fixture.exists(trashedPath) == false)
#expect(store.purgeIsUnrecoverable) #expect(store.purgeIsUnrecoverable)
+200
View File
@@ -393,3 +393,203 @@ struct WriteFidelityCompositeTests {
#expect(forkedThird.body == "Third body.\n") #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 /// A whole-frontmatter flow mapping carrying a `modified-by`: readable, uneditable, and so
/// left byte-verbatim by a copy stale attribution included. /// 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" 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 // MARK: - Failure assertion