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