From bec75e428262aed59385ddff56c6da41e041a7c4 Mon Sep 17 00:00:00 2001 From: rzen Date: Fri, 31 Jul 2026 18:35:07 -0400 Subject: [PATCH] Realign code with the 2026-07-31 rulings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The trash sorts by modified descending — the arrival rank mint retires (Ranks.isOrderedForTrash one comparator, loader + merged order agree; the legacy deleted: migration stamps modified from the tombstone timestamp where parseable; delete undo steps validate existence-only; agent guide v8). Trash selection goes kind-blind — ranges, marquee, Select All, and the successor walk sweep both kinds; the guard moves to the exits (mixed-payload drop refusal, copy/cut validation). The copy stamping preflight widens back to comment depth (load-scoped posture — the board always loads, the gesture refuses whole). Fixes a latent no-op: trashed-lane drag restore never fired (DragSession.beginLanes hard-coded the board container). 2403 tests in 413 suites green. Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97 --- Kanban/App/ClipboardStore.swift | 8 + Kanban/App/UITestLaunch.swift | 9 +- Kanban/LiveStore/BannerCenter.swift | 21 +++ Kanban/LiveStore/BoardStore.swift | 169 ++++++++----------- Kanban/LiveStore/ItemLocation.swift | 52 ++++-- Kanban/LiveStore/SelectionGrammar.swift | 208 ++++++++++++++++-------- Kanban/LiveStore/TrashModel.swift | 6 +- Kanban/Storage/AgentGuide.swift | 23 ++- Kanban/Storage/BoardLoader.swift | 26 ++- Kanban/Storage/BoardModel.swift | 33 ++-- Kanban/Storage/BoardWriter.swift | 172 ++++++++++++-------- Kanban/Storage/Ranks.swift | 60 +++++++ Kanban/UI/Board/BoardDrops.swift | 17 ++ Kanban/UI/Board/BoardView.swift | 16 +- Kanban/UI/Board/CardFaceView.swift | 12 +- Kanban/UI/Board/DragSession.swift | 52 ++++-- Kanban/UI/Board/TrashLaneRowView.swift | 14 +- Kanban/UI/Board/TrashLaneView.swift | 19 ++- KanbanTests/AgentGuideTests.swift | 18 ++ KanbanTests/BodyWriteTests.swift | 2 +- KanbanTests/CardWindowFateTests.swift | 4 +- KanbanTests/ClaimedNameHealTests.swift | 3 +- KanbanTests/ClipboardTests.swift | 49 ++++++ KanbanTests/CommentSearchTests.swift | 1 + KanbanTests/CommentWriteTests.swift | 64 ++++++-- KanbanTests/DragSessionTests.swift | 74 +++++++++ KanbanTests/DragWriteTests.swift | 13 +- KanbanTests/EchoLedgerTests.swift | 8 +- KanbanTests/InertGitTests.swift | 2 +- KanbanTests/IntegrityRulesTests.swift | 3 +- KanbanTests/SearchFilterTests.swift | 30 ++-- KanbanTests/SelectionGrammarTests.swift | 144 +++++++++------- KanbanTests/TrashModelTests.swift | 37 +++-- KanbanTests/TrashStorageTests.swift | 196 ++++++++++++++++------ KanbanTests/TrashWriteTests.swift | 125 +++++++++----- KanbanTests/UndoWriteTests.swift | 51 +++--- KanbanTests/WriteFidelityTests.swift | 10 +- 37 files changed, 1200 insertions(+), 551 deletions(-) diff --git a/Kanban/App/ClipboardStore.swift b/Kanban/App/ClipboardStore.swift index 9b5be2d..2f4d5fd 100644 --- a/Kanban/App/ClipboardStore.swift +++ b/Kanban/App/ClipboardStore.swift @@ -227,8 +227,16 @@ public final class ClipboardStore { /// the text (04 ▸ Grammar). The field consumes the selector natively, so this guard is belt over /// braces — but a board command that stayed armed under an editor would be exactly the kind of /// fall-through 04 is careful about. + /// + /// **A mixed trash selection closes it — this is one of the two exits the kind guard moved to** + /// (04-interactions.md ▸ The trash, ruled 2026-07-31 with kind-blind trash selection): "the + /// pasteboard's payload types are per-kind, so Cut and Copy grey out via ordinary menu validation + /// while a trash selection mixes kinds — no failed gesture, no beep". It is also what keeps + /// `capture`'s single `kind` honest: the manifest names one payload type, and a set spanning both + /// never reaches it. public func canCopy(from store: BoardStore) -> Bool { guard !store.isEditingInline else { return false } + guard !SelectionGrammar.mixesKinds(store.selection, in: store.snapshot) else { return false } return SelectionGrammar.kind(of: store.selection, in: store.snapshot) != nil } diff --git a/Kanban/App/UITestLaunch.swift b/Kanban/App/UITestLaunch.swift index 33056ea..096a9e8 100644 --- a/Kanban/App/UITestLaunch.swift +++ b/Kanban/App/UITestLaunch.swift @@ -498,13 +498,10 @@ enum UITestLaunch { // The delete goes last so the trashed card's identity is one the lanes above have already // finished with — and through the ordinary delete door, so `.trash/` ends up holding exactly - // what a user's ⌘⌫ would have put there, stamps and `order` included. + // what a user's ⌘⌫ would have put there: the `modified` stamp that positions it, its `order` + // riding along untouched. let doomed = cardURLs[trashedCardIndex.lane][trashedCardIndex.card] - try BoardWriter.deleteCardToTrash( - at: doomed, - inBoard: root, - order: Ranks.append(toVisible: [] as [Double]) - ) + try BoardWriter.deleteCardToTrash(at: doomed, inBoard: root) return root } diff --git a/Kanban/LiveStore/BannerCenter.swift b/Kanban/LiveStore/BannerCenter.swift index b47d009..0a770d4 100644 --- a/Kanban/LiveStore/BannerCenter.swift +++ b/Kanban/LiveStore/BannerCenter.swift @@ -554,6 +554,27 @@ public final class BannerCenter { postLoss(Self.skippedFoldersMessage(count: count)) } + /// **The mixed-kind drag that tried to leave the trash** (04-interactions.md ▸ The trash, ruled + /// 2026-07-31 with kind-blind trash selection): "pickup is allowed — the selection is legal — but + /// every out-of-trash drop target refuses the mixed payload, and the release surfaces a notice + /// explaining the rule … the refused drag ends like any refusal, rows staying put". + /// + /// **A loss row**, with the relocation family and `postSkippedFolders`: nothing failed — no write + /// was attempted — and nothing is wrong with the board, but the gesture the user made did not + /// happen, which is exactly the warning-tone "didn't arrive" register. The wording is 04's own, + /// verbatim, and lives here because BannerCenter owns the phrasing (▸ Clipboard). + /// + /// It is the *teaching* half of the guard: ⌘C/⌘X grey out silently (menu validation says no + /// before the gesture starts), while the drag has no such surface to say it in advance — so the + /// drop-time explanation "is where the rule teaches itself". + public func postMixedTrashDrag() { + postLoss(Self.mixedTrashDragMessage) + } + + /// 04-interactions.md ▸ The trash's own sentence. + nonisolated static let mixedTrashDragMessage = + "Cards and lanes leave the trash separately \u{2014} restore one kind at a time" + /// Removes a dismissable row: a one-shot failure, a loss row, or a signpost. **An id that names /// an in-progress operation is ignored** rather than ending it, because "dismiss" and "cancel" /// are different promises and a row that offers one must never quietly do the other. diff --git a/Kanban/LiveStore/BoardStore.swift b/Kanban/LiveStore/BoardStore.swift index 82b93d2..991a2fc 100644 --- a/Kanban/LiveStore/BoardStore.swift +++ b/Kanban/LiveStore/BoardStore.swift @@ -2343,8 +2343,10 @@ public final class BoardStore: HealHost { let rendered = snapshot.lanes let target = min(max(0, index), rendered.count) - // What an undo puts back: the rank the row held in the trash, captured before the write. - var arrivals: [(id: ItemID, order: Double, trashRank: Double, title: String?)] = [] + // What an undo puts back: the `order` the row was **carrying** while trashed — its old strip + // rank, which the trash move never rewrote (01-storage-format.md § Deletion, re-ruled + // 2026-07-31) and which this restore is about to overwrite with a drop-position rank. + var arrivals: [(id: ItemID, order: Double, carriedOrder: Double, title: String?)] = [] let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in // The shared two-step; the arriving lanes are not among the renumbered children. guard let placed = try HealScheduler.placingRanks( @@ -2365,29 +2367,31 @@ public final class BoardStore: HealHost { arrivals.append(( id: member.id, order: rank, - trashRank: member.order, + carriedOrder: member.order, title: member.title.value )) } } guard landed != nil, !arrivals.isEmpty else { return } - // restore-by-move → **move back in** (13-native-undo.md ▸ Interaction with the trash), at the - // trash rank the row was holding — the delete's step read from the other end. + // restore-by-move → **move back in** (13-native-undo.md ▸ Interaction with the trash), + // carrying back the `order` the row had while it sat there. The restore is what overwrote it + // (a move writes a landing rank), so putting it back is what makes this a true inverse — + // there is no *trash* rank involved either way, since the trash's own sequence is `modified`. let trashFolder = Self.parentFolder(of: .trashLane(arrivals[0].id), under: root) let steps = arrivals.map { arrival in ( restored: ItemPath.lane(arrival.id).folder(under: root), trashed: ItemPath.trashLane(arrival.id).folder(under: root), order: arrival.order, - trashRank: arrival.trashRank + carriedOrder: arrival.carriedOrder ) } registerStep( HistoryPhrase.name(.move, kind: .lane, count: steps.count), subject: arrivals.count == 1 ? arrivals[0].title : nil, undoExpects: steps.map { .present($0.restored, .order($0.order)) }, - redoExpects: steps.map { .present($0.trashed, .order($0.trashRank)) } + redoExpects: steps.map { .present($0.trashed, .order($0.carriedOrder)) } ) { _ in for step in steps { _ = try BoardWriter.moveItem( @@ -2395,7 +2399,7 @@ public final class BoardStore: HealHost { toParent: trashFolder, sourceBoardRoot: root, destinationBoardRoot: root, - order: step.trashRank + order: step.carriedOrder ) } } redo: { _ in @@ -3492,18 +3496,17 @@ public final class BoardStore: HealHost { return (lane, id) } - /// **The delete write itself: a physical move into `/.trash/`, at a freshly minted top - /// rank** — one `performWrite` bracket whatever the set's size and whichever gesture asked. + /// **The delete write itself: a physical move into `/.trash/`** — one `performWrite` + /// bracket whatever the set's size and whichever gesture asked. /// /// Spelled once so ⌫, drop-on-trash and the card window's button cannot drift apart on disk; /// everything that differs between them is about the *selection*, and lives in the callers. /// - /// **The rank is the store's to mint** (03-board-ui.md § Trash: "every arrival lands at the - /// trash's topmost position, minting an `order` rank above the current top"). That is a question - /// about the snapshot, which the stateless Writer does not have — so `Ranks.insertAtHead` runs - /// here over `trashRanks`, and a multi-card delete threads the minted rank back through the - /// running list so each card in the run lands above the one before it. Newest-first therefore - /// falls out of ordinary ranks, with no timestamp sort anywhere. + /// **There is no rank to mint** (03-board-ui.md § Trash, re-ruled 2026-07-31: "newest-first with + /// no `order` rewrite, no rank minting, the item's `order` key riding along untouched for its + /// eventual restore"). The trash sorts by `modified` descending and the move stamps it, so the + /// position is the Writer's own doing and the store has no snapshot question left to answer — + /// the head-of-the-trash ladder this method used to thread through the run retired with the rule. /// /// - Returns: whether the write landed, so a caller can decide what to do with the selection. @discardableResult @@ -3517,23 +3520,11 @@ public final class BoardStore: HealHost { guard !moves.isEmpty else { return false } let root = rootURL - // The ranks, minted against the trash as it stands and threaded forward: each arrival is - // above the previous one, so a three-card ⌫ reads newest-first in the column exactly as three - // separate deletes would. - var ladder = trashRanks - var ranks: [Double] = [] - for _ in moves { - let rank = Ranks.insertAtHead(ofVisible: ladder) - ranks.append(rank) - ladder.insert(rank, at: 0) - } - let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in - for (move, rank) in zip(moves, ranks) { + for move in moves { try BoardWriter.deleteCardToTrash( at: ItemPath.card(lane: move.laneID, id: move.id).folder(under: root), - inBoard: root, - order: rank + inBoard: root ) } } @@ -3543,28 +3534,29 @@ public final class BoardStore: HealHost { // trash: "a card delete is a move into `.trash/`, so its undo is the ordinary inverse move, // returning the card to its source lane and rank"). // - // The redo replays the *forward* write with its own captured rank, exactly as every other - // redo in this file replays the values its gesture wrote — so a redone delete lands the card - // back where the undo took it from, rather than at whatever the top of the trash has become - // in the meantime. + // The redo replays the forward write, which now takes no values at all — a delete is a folder + // move plus a fresh stamp, and a redone delete lands where a first one would. // // **The expectations are one swap, and the container rides in the path** (`HistoryStaleness`): - // the undo wants the card in the trash holding the rank the delete gave it; the redo wants it - // back in its lane holding the rank it left. A foreign restore empties the trash path and the - // undo skips; a foreign re-delete empties the lane path and the redo skips. - let steps = zip(moves, ranks).map { move, rank in + // the undo wants the card **in the trash**, and that is the whole of it — 13's field-level + // predicate compares "what its write set", and this write sets no field an expectation can + // name (the `modified` stamp is a clock reading, not a value the step chose). Existence is + // the honest expectation, and it is the one that matters: a foreign restore empties the trash + // path and the undo skips. The redo's side is unchanged and still field-level, because the + // *undo* set it: the card back in its lane holding the rank it left. A foreign re-delete + // empties the lane path and the redo skips. + let steps = moves.map { move in ( trashed: ItemPath.trashCard(move.id).folder(under: root), origin: ItemPath.card(lane: move.laneID, id: move.id).folder(under: root), laneFolder: ItemPath.lane(move.laneID).folder(under: root), - priorOrder: move.order, - trashRank: rank + priorOrder: move.order ) } registerStep( HistoryPhrase.name(.delete, kind: .card, count: steps.count), subject: moves.count == 1 ? moves[0].title : nil, - undoExpects: steps.map { .present($0.trashed, .order($0.trashRank)) }, + undoExpects: steps.map { .present($0.trashed) }, redoExpects: steps.map { .present($0.origin, .order($0.priorOrder)) } ) { _ in for step in steps { @@ -3578,7 +3570,7 @@ public final class BoardStore: HealHost { } } redo: { _ in for step in steps { - try BoardWriter.deleteCardToTrash(at: step.origin, inBoard: root, order: step.trashRank) + try BoardWriter.deleteCardToTrash(at: step.origin, inBoard: root) } } return true @@ -3589,8 +3581,8 @@ public final class BoardStore: HealHost { /// exactly as a card moves … The no-dialog posture survives for a better reason: the move is /// recoverable, so nothing needs confirming"). /// - /// **`moveToTrash`'s twin, and deliberately its mirror image**: the same head-of-the-trash rank - /// mint threaded through the run, the same one bracket, the same one step — because on disk it is + /// **`moveToTrash`'s twin, and deliberately its mirror image**: no rank, the same one bracket, + /// the same one step — because on disk it is /// the same write one level up (`BoardWriter.deleteLaneToTrash`, which differs only in the guard /// it passes and the `kind` it stamps). What is *not* here any more is the whole capture layer: /// the lane's bytes never leave the disk, so nothing has to hold them (13-native-undo.md ▸ @@ -3607,20 +3599,11 @@ public final class BoardStore: HealHost { guard !lanes.isEmpty else { return false } let root = rootURL - var ladder = trashRanks - var ranks: [Double] = [] - for _ in lanes { - let rank = Ranks.insertAtHead(ofVisible: ladder) - ranks.append(rank) - ladder.insert(rank, at: 0) - } - let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in - for (lane, rank) in zip(lanes, ranks) { + for lane in lanes { try BoardWriter.deleteLaneToTrash( at: ItemPath.lane(lane.id).folder(under: root), - inBoard: root, - order: rank + inBoard: root ) } } @@ -3628,26 +3611,24 @@ public final class BoardStore: HealHost { // lane delete → **the ordinary move back** (13-native-undo.md ▸ Interaction with the trash: // "a lane [returns] to its strip position (subtree intact — it never left the folder)"). The - // redo replays the forward write with its own captured trash rank, exactly as the card - // delete's does, so a redone delete lands the lane where the undo took it from rather than at - // whatever the top of the trash has become in the meantime. + // redo replays the forward write, which takes no values — the card delete's shape exactly. // // The expectations are one swap, and the container rides in the path (`HistoryStaleness`): - // the undo wants the lane in the trash holding the rank the delete gave it, the redo wants it - // back on the strip holding the rank it left. A foreign restore empties the trash path and - // the undo skips; a foreign re-delete empties the strip path and the redo skips. - let steps = zip(lanes, ranks).map { lane, rank in + // the undo wants the lane **in the trash** (existence alone — the delete sets no field an + // expectation can name, `moveToTrash`' note), the redo wants it back on the strip holding the + // rank the undo put back. A foreign restore empties the trash path and the undo skips; a + // foreign re-delete empties the strip path and the redo skips. + let steps = lanes.map { lane in ( trashed: ItemPath.trashLane(lane.id).folder(under: root), origin: ItemPath.lane(lane.id).folder(under: root), - priorOrder: lane.order, - trashRank: rank + priorOrder: lane.order ) } registerStep( HistoryPhrase.name(.delete, kind: .lane, count: steps.count), subject: lanes.count == 1 ? lanes[0].title.value : nil, - undoExpects: steps.map { .present($0.trashed, .order($0.trashRank)) }, + undoExpects: steps.map { .present($0.trashed) }, redoExpects: steps.map { .present($0.origin, .order($0.priorOrder)) } ) { _ in for step in steps { @@ -3661,19 +3642,12 @@ public final class BoardStore: HealHost { } } redo: { _ in for step in steps { - try BoardWriter.deleteLaneToTrash(at: step.origin, inBoard: root, order: step.trashRank) + try BoardWriter.deleteLaneToTrash(at: step.origin, inBoard: root) } } return true } - /// Every rank the trash currently holds, **both kinds** — what a fresh arrival mints above - /// (03-board-ui.md § Trash: "Every arrival lands at the top … regardless of kind", so the ladder - /// is the whole container and not one array of it). - private var trashRanks: [Double] { - snapshot.trash.map(\.order) + snapshot.trashedLanes.map(\.order) - } - /// **The trash's own Delete — permanent** (03-board-ui.md § Trash: "on a trash selection, Delete /// (⌫/⌘⌫) is permanent … in the trash it removes the folder"). /// @@ -3763,15 +3737,17 @@ public final class BoardStore: HealHost { /// notice"). Old tombstoned lanes reappearing is the accepted cost, stated in the ruling; a /// tombstoned lane's own cards still migrate on their own account, as ordinary tombstoned cards. /// - /// ### The order among migrating cards is `deleted:`-ascending, deliberately + /// ### The column order is the stamps', not the batch's /// - /// Every arrival mints a rank above the current top, so the *last* card migrated ends up topmost. - /// Migrating oldest-first therefore reproduces the newest-first column the tombstone model's - /// timestamp sort used to render — the same board, read the same way, with ordinary ranks doing - /// the work. A card whose stamp is missing or unparseable sorts as **oldest** (the retired sort's - /// own rule: "a corrupt stamp must not outrank fresh deletions"), and ties fall to the loader's - /// walk order — lane `order`, then card `order` — which is the deterministic tie-break the whole - /// corpus already uses. + /// **Each migrated card takes its own `deleted:` timestamp as its `modified`** where it parses + /// (01-storage-format.md § Deletion, re-ruled 2026-07-31; `BoardWriter.migrateTombstonedCard`), + /// so the board's real deletion order survives into the trash's `modified`-descending sort no + /// matter what order the batch runs in — the sequencing that used to *be* the ordering is now + /// only a batch order. It is kept, `deleted:`-ascending, for determinism: the notice's card list + /// and the commit's path order read the same way twice. A card whose stamp is missing or + /// unparseable sorts as **oldest** here and takes migration time as its `modified`, landing it + /// among the freshest — the honest reading, since a stamp that cannot be read is no evidence of + /// when the card was deleted; ties fall to the loader's walk order. /// /// ### The write half re-verifies against disk /// @@ -3786,9 +3762,6 @@ public final class BoardStore: HealHost { let cards = Self.migrationOrder(of: work, in: snapshot) var movedCards: [String?] = [] - // The ranks are minted exactly as a delete's are — head of the trash, threaded forward — so a - // migrated card is indistinguishable on disk from one the user deletes today. - var ladder = trashRanks heals.run( .legacyTombstone, signature: Self.signature(of: work.map(IntegrityRules.Defect.legacyTombstone)), @@ -3797,9 +3770,7 @@ public final class BoardStore: HealHost { for card in cards { let folder = ItemPath.card(lane: card.laneID, id: card.cardID).folder(under: root) guard Self.stillTombstoned(at: folder) else { continue } - let rank = Ranks.insertAtHead(ofVisible: ladder) - try BoardWriter.migrateTombstonedCard(at: folder, inBoard: root, order: rank) - ladder.insert(rank, at: 0) + try BoardWriter.migrateTombstonedCard(at: folder, inBoard: root) movedCards.append(card.title) } } posting: { @@ -3823,8 +3794,9 @@ public final class BoardStore: HealHost { return !document.deleted.isMissing } - /// The tombstoned cards in the order they should be filed into the trash — oldest `deleted:` - /// first, so the newest ends up on top (see `migrateLegacyTombstones`). + /// The tombstoned cards in the order the batch files them — oldest `deleted:` first, a + /// deterministic batch order rather than the column's (see `migrateLegacyTombstones`: each card's + /// own stamp decides where it lands). /// /// `sorted(by:)` is not stable in the standard library, so the walk position is folded into the /// key rather than relied on: an unparseable or missing stamp takes `Date.distantPast` and ties @@ -4207,14 +4179,15 @@ public final class BoardStore: HealHost { /// trash's own reading of the same command when the trash side is the one in play. /// /// Two branches, and the trash's is the narrow one: it fires only when the column is **shown**, - /// the selection is in the trash, and it still names a card — the exact conditions under which - /// "all" could mean anything but the board (04 ▸ The map, resettled 2026-07-28: "with the trash - /// visible and a non-empty trash selection, Select All selects all visible trash cards; in every - /// other state, all visible live cards — the container boundary decides which 'all' is meant"). - /// A trash selection naming nothing (a foreign restore, a purge) falls through to the board - /// rather than selecting the trash wholesale on a guess. **Select All is card-scoped in both - /// containers, never lane rows** (04 ▸ The trash, re-affirmed 2026-07-29 with lanes back in the - /// trash), which is why the trash branch reads its cards and asks no kind question. + /// the selection is in the trash, and it still names a row — the exact conditions under which + /// "all" could mean anything but the board (04 ▸ The map, resettled 2026-07-28: "the container + /// boundary decides which 'all' is meant"). A trash selection naming nothing (a foreign restore, + /// a purge) falls through to the board rather than selecting the trash wholesale on a guess. + /// + /// **In the trash "all" is all *rows*, both kinds** (04 ▸ The trash and 11-command-nexus.md ▸ + /// Select All, re-ruled 2026-07-31 with kind-blind trash selection: "Select All with a non-empty + /// trash selection selects **all visible trash rows**"). The board's own Select All stays + /// card-scoped, as everywhere. /// /// The anchor — and the navigation head with it — **survives if it is still in the set** and is /// dropped otherwise: Select All is not a click, so it names no new origin and no new cursor, @@ -4227,7 +4200,7 @@ public final class BoardStore: HealHost { let filter = searchFilter if transient.isTrashVisible, selection.container == .trash, !selection.isEmpty, SelectionGrammar.kind(of: selection, in: snapshot) != nil { - apply(Set(SelectionGrammar.trashCards(in: snapshot, filter: filter)), in: .trash) + apply(Set(SelectionGrammar.trashRows(in: snapshot, filter: filter)), in: .trash) return } apply(Set(SelectionGrammar.boardCards(in: snapshot, filter: filter)), in: .board) diff --git a/Kanban/LiveStore/ItemLocation.swift b/Kanban/LiveStore/ItemLocation.swift index 6023f0d..4eed298 100644 --- a/Kanban/LiveStore/ItemLocation.swift +++ b/Kanban/LiveStore/ItemLocation.swift @@ -76,12 +76,12 @@ extension ItemContainer { // MARK: - The trash's two kinds, in one order /// One row of the trash column — a card, or a trashed lane's opaque unit (03-board-ui.md § Trash, -/// re-ruled 2026-07-29: "Lane rows and cards interleave in the one trash column purely by trash -/// rank"). +/// re-ruled 2026-07-31: "Lane rows and cards interleave in the one trash column by `modified` +/// descending"). /// /// **It exists so the interleave is written once.** The container's two kinds are two arrays on the /// snapshot, for the reason `BoardModel.trash` states — a trashed card is an ordinary card every -/// card-shaped surface already reads, a trashed lane is an opaque row none of them may — but *rank +/// card-shaped surface already reads, a trashed lane is an opaque row none of them may — but *column /// order* is a question about the container as a whole, and it is asked by the column that draws the /// rows, by the grammar that ranges and navigates over them, and by the path resolver that batches /// them. Three merges would be three chances to disagree about what "the row below this one" is. @@ -100,8 +100,28 @@ public enum TrashEntry: Identifiable, Sendable, Equatable { } } - /// The rank that decides where this row sits among the others — the one field both kinds carry - /// for the same purpose. + /// **The stamp that decides where this row sits among the others** — the trash sorts by + /// `modified` descending (01-storage-format.md § Deletion, re-ruled 2026-07-31), and the trash + /// move is what writes it. `nil` for an entry whose mover skipped the restamp, which sorts below + /// every dated sibling (`Ranks.isOrderedForTrash`). + public var modified: Date? { + switch self { + case let .card(card): card.modified.value + case let .lane(lane): lane.modified.value + } + } + + /// The first tie-break's key — the title as the row draws it, `nil` when the entry has none. + public var title: String? { + switch self { + case let .card(card): card.title.value + case let .lane(lane): lane.title.value + } + } + + /// The rank the entry is *carrying*, untouched by the trash move — its position among the lane + /// (or board) siblings it left, which a restore returns it to. Deliberately **not** what orders + /// this row: the trash is sorted by `modified` (above). public var order: Double { switch self { case let .card(card): card.order @@ -131,15 +151,17 @@ public enum TrashEntry: Identifiable, Sendable, Equatable { extension BoardModel { /// The trash's rows, top to bottom — **the container's one order**, both kinds interleaved by - /// rank (03-board-ui.md § Trash). + /// `modified` descending (03-board-ui.md § Trash, re-ruled 2026-07-31: "The merged order is one + /// derivation … a second implementation of 'the row below this one' is a bug by definition"). /// - /// The tie-break is the folder name's, `Ranks.sortedForDisplay`'s own, which is what the loader - /// already applied within each kind: two rows minted the same rank by two writers order the same - /// way twice. + /// The tail is `Ranks.isOrderedForTrash`'s — title case-insensitively, then folder name — which + /// is what the loader already applied within each kind, so the merge of two sorted arrays and + /// each array alone agree everywhere they overlap. public var trashEntries: [TrashEntry] { - Ranks.sortedForDisplay( + Ranks.sortedForTrash( trash.map(TrashEntry.card) + trashedLanes.map(TrashEntry.lane), - order: \.order, + modified: \.modified, + title: \.title, name: { $0.id.rawValue } ) } @@ -230,10 +252,10 @@ extension ItemPath { /// fails partway must fail the same way twice (`BoardStore.styleSubjects` makes the same choice /// for the same reason). /// - /// **The trash's order interleaves its two kinds by rank** (03-board-ui.md § Trash: "lane rows - /// and cards interleave in the one trash column purely by trash rank"), which is why the walk is - /// `trashEntries` rather than the two arrays concatenated: the column's order is the batch's - /// order, and it is stated in exactly one place. + /// **The trash's order interleaves its two kinds by `modified`** (03-board-ui.md § Trash: "lane + /// rows and cards interleave in the one trash column by `modified` descending"), which is why the + /// walk is `trashEntries` rather than the two arrays concatenated: the column's order is the + /// batch's order, and it is stated in exactly one place. /// /// Ids the container does not hold are simply absent, which is every caller's standing posture: /// a selection the next reload will drop writes nothing rather than being refused. diff --git a/Kanban/LiveStore/SelectionGrammar.swift b/Kanban/LiveStore/SelectionGrammar.swift index f86e7b4..8ade503 100644 --- a/Kanban/LiveStore/SelectionGrammar.swift +++ b/Kanban/LiveStore/SelectionGrammar.swift @@ -58,14 +58,20 @@ public enum ClickModifier: Sendable, Equatable { /// function of the click, the current selection, the anchor, and the snapshot /// (`SelectionGrammarTests`). /// -/// **Homogeneity is the invariant, and it is enforced here or nowhere.** The selection is -/// homogeneous on **two** axes — cards XOR lanes (§ Selection) and board XOR trash (§ The trash's -/// "single container rule replacing the old liveness law") — and since lanes rejoined the trash -/// (2026-07-29) the kind axis simply reaches into the second container too: "a trash selection is -/// either cards or lane rows, kind-homogeneous like the live board's own grammar". Both axes are a -/// property of what a *click* is allowed to produce, so no outcome below is ever mixed: a modifier -/// that would cross an axis degrades to a replace, which is the only answer that keeps the invariant -/// true without silently dropping what the user asked for. +/// **The container is the invariant, and it is enforced here or nowhere.** A selection never mixes +/// trash rows with board items (§ The trash's "single container rule replacing the old liveness +/// law"), and *on the board* it is also cards XOR lanes (§ Selection). **Inside the trash it is +/// kind-blind** (re-ruled 2026-07-31, superseding the lanes-rejoin pass's kind-homogeneous trash +/// grammar): "within the trash cards and lane rows select together — clicks, ⇧-click ranges, +/// ⇧-arrow extension, and the rubber band all sweep every row". The kind axis therefore stops at the +/// container boundary rather than reaching through it, and the guard the trash used to need moved to +/// the exits — the mixed-payload drop refusal and ⌘C/⌘X validation (§ The trash), since "inside the +/// trash the only verbs are Delete and the restore paths, so upstream homogeneity bought nothing the +/// exits don't". +/// +/// What has not changed is what a modifier does when it *would* cross an axis that still stands: it +/// degrades to a replace, which is the only answer that keeps the invariant true without silently +/// dropping what the user asked for. /// /// **Pure, for `NewCardTarget`'s reason**: the branches become lines of test rather than gestures to /// drive, and the four surfaces that clicks arrive on (card face, lane header, lane empty space, @@ -157,14 +163,20 @@ public enum SelectionGrammar { ) } - /// **⌘-click toggles** — but only *within* a homogeneous set. Crossing either axis (a card - /// clicked while lanes are selected, a trash card clicked while board cards are) is not a mixed - /// selection and not a refusal: it is a **replace**, the same outcome a plain click would give, - /// because the click unambiguously names a new set of one. + /// **⌘-click toggles** — but only *within* a set it can legally join. Crossing the container (a + /// trash row clicked while board cards are selected) or, **on the board**, the kind (a card + /// clicked while lanes are selected) is not a mixed selection and not a refusal: it is a + /// **replace**, the same outcome a plain click would give, because the click unambiguously names + /// a new set of one. + /// + /// **Inside the trash the kind clause simply does not apply** (04-interactions.md ▸ The trash, + /// re-ruled 2026-07-31): a ⌘-click on a lane row extends a set of trash cards, because there + /// "cards and lane rows select together". The container clause is untouched. /// /// The current kind is derived from the snapshot rather than remembered (`kind(of:in:)`); a /// selection whose members all name nothing the board renders counts as empty, so a ⌘-click - /// after a foreign delete starts a fresh set rather than extending a ghost. + /// after a foreign delete starts a fresh set rather than extending a ghost — which is why the + /// call stands even in the trash, where its *answer* no longer gates anything. private static func command( _ target: SelectionTarget, selection: ItemReferenceSet, @@ -172,7 +184,7 @@ public enum SelectionGrammar { ) -> Outcome { guard selection.container == target.container, let current = kind(of: selection, in: snapshot), - current == target.kind + target.container == .trash || current == target.kind else { return plain(target, selection: selection, togglesOnRepeat: false) } @@ -196,8 +208,10 @@ public enum SelectionGrammar { /// origin rather than walking it along. /// /// The anchor is valid **iff both it and the target sit in the same order list** — which folds - /// the nil anchor, the vanished anchor, and every axis crossing into one test, since a list is - /// exactly one (container, kind) pair. An invalid anchor makes the click a plain one, never a no-op: + /// the nil anchor, the vanished anchor, and every standing axis crossing into one test, since a + /// list is one container, and on the board one kind of it (`order(of:in:)`; inside the trash the + /// list is the whole column, so a range from a card to a lane row is an ordinary range). + /// An invalid anchor makes the click a plain one, never a no-op: /// the keyboard's ⇧-arrow goes inert at a boundary because its next step is ambiguous, while a /// click names an unambiguous target and so always has something to do. private static func shift( @@ -235,8 +249,8 @@ public enum SelectionGrammar { /// pointer and the keyboard to disagree about what a range is. /// /// **`nil` means the two do not share a list**, which folds the vanished endpoint, the nil - /// anchor's caller-side absence, and every axis crossing into one test — a list is exactly one - /// (container, kind) pair. The callers differ on what they do with that: a click degrades to a + /// anchor's caller-side absence, and every standing axis crossing into one test. The callers + /// differ on what they do with that: a click degrades to a /// plain click (it names an unambiguous target), while a ⇧-arrow goes inert (its next step is /// ambiguous). /// @@ -276,21 +290,27 @@ public enum SelectionGrammar { /// the opaque unit's own (03-board-ui.md § Trash: "The row matches the search filter by lane /// title only") — a trashed lane is a row in a column, not a container a query can empty. /// - /// **Both trash lists are kind-narrowed slices of one order** (`BoardModel.trashEntries`, - /// 04-interactions.md ▸ The trash: "⇧-click ranges skip rows of the other kind"), which is what - /// makes a range skip the other kind rather than needing a rule that says so: a list is exactly - /// one (container, kind) pair, and the rows in between simply are not in it. + /// **The trash has one list, whatever the kind** (04-interactions.md ▸ The trash, re-ruled + /// 2026-07-31 — superseding the kind-narrowed slices this returned while the trash's grammar was + /// kind-homogeneous): "⇧-click ranges … sweep every row". So a trash range walks + /// `BoardModel.trashEntries` — the column as drawn — and picks up rows of both kinds between its + /// endpoints, which is the ruling implemented as an absence rather than as a clause. The `kind` + /// argument is simply not consulted there; on the board it still names one of two lists, because + /// the live board is still cards XOR lanes. public static func order( of kind: SelectionKind, in container: ItemContainer, snapshot: BoardModel, filter: SearchFilter = .inactive ) -> [ItemID] { - switch (container, kind) { - case (.board, .card): boardCards(in: snapshot, filter: filter) - case (.board, .lane): lanes(in: snapshot) - case (.trash, .card): trashCards(in: snapshot, filter: filter) - case (.trash, .lane): trashLanes(in: snapshot, filter: filter) + switch container { + case .trash: + return trashRows(in: snapshot, filter: filter) + case .board: + switch kind { + case .card: return boardCards(in: snapshot, filter: filter) + case .lane: return lanes(in: snapshot) + } } } @@ -325,12 +345,15 @@ public enum SelectionGrammar { } /// The trash's cards, top to bottom — `snapshot.trash` itself, which the loader already sorted - /// by `order` like any lane's children (03-board-ui.md § Trash: "the trash sorts by `order` like - /// any lane", newest-first falling out of the ranks rather than a timestamp sort). + /// by `modified` descending (03-board-ui.md § Trash, re-ruled 2026-07-31: "the trash sorts by + /// `modified` descending", newest-first falling out of the stamp rather than a minted rank). /// /// **Filtered like any lane** (03-board-ui.md § Trash: "shown, its cards participate in the - /// filter exactly like any other card") — the same predicate `TrashLaneView` applies to the same - /// cards, so a trash-side range walks exactly what the column is showing. + /// filter exactly like any other card"). + /// + /// **Not the ranging grammar's list any more** (kind-blind trash selection, 2026-07-31 — see + /// `order(of:in:)`): this is the kind-narrowed slice, kept for the consumers that genuinely mean + /// "the trash's *cards*" and for the search suite that pins the predicate. public static func trashCards(in snapshot: BoardModel, filter: SearchFilter = .inactive) -> [ItemID] { snapshot.trash.filter { filter.matches($0) }.map(\.id) } @@ -338,20 +361,21 @@ public enum SelectionGrammar { /// The trash's **lane rows**, top to bottom — the opaque units (03-board-ui.md § Trash, lanes /// rejoined 2026-07-29), filtered by title alone (`SearchFilter.matches(_ lane:)`). /// - /// Its own list rather than a kind flag on `trashCards` because that is what an order list *is* - /// here: one (container, kind) pair, and the pair is what a ⇧-range walks. The rows' positions - /// among the cards are `trashRows`' business. + /// Its own list rather than a kind flag on `trashCards`, and for the same reason that one + /// survives: some consumers mean the rows of one kind. The rows' positions among the cards are + /// `trashRows`' business. public static func trashLanes(in snapshot: BoardModel, filter: SearchFilter = .inactive) -> [ItemID] { snapshot.trashedLanes.filter { filter.matches($0) }.map(\.id) } - /// **Every row the trash column shows, both kinds, in rank order** — what *navigation* walks - /// (04-interactions.md ▸ The trash: "inside, plain arrows walk every row, card and lane row - /// alike (navigation crosses kinds)"). + /// **Every row the trash column shows, both kinds, in the column's own order** — what + /// *everything* in the trash walks (04-interactions.md ▸ The trash: "inside, plain arrows walk + /// every row, card and lane row alike", and since 2026-07-31 the ranging grammar too: "clicks, + /// ⇧-click ranges, ⇧-arrow extension, and the rubber band all sweep every row"). /// - /// The deliberate counterpart to the two lists above: extension and ranging are per-kind, so - /// they stop at a kind boundary, while navigation is over the column as drawn and crosses it. - /// One merge for both — `BoardModel.trashEntries`. + /// Navigation and ranging read the same sequence, which is what the kind-blind ruling bought: + /// there is no longer a per-kind list that could disagree with the column about "the row below + /// this one". One merge for all of it — `BoardModel.trashEntries`. public static func trashRows(in snapshot: BoardModel, filter: SearchFilter = .inactive) -> [ItemID] { snapshot.trashEntries.filter { filter.matches($0) }.map(\.id) } @@ -360,15 +384,19 @@ public enum SelectionGrammar { /// Which level the selection holds, or `nil` when it holds nothing its container renders. /// - /// **Any member answers, because the set is homogeneous** — but the walk is the snapshot's order - /// rather than the set's iteration order, so the answer is deterministic even for a set that - /// somehow was not. Members that name nothing are ignored, and a set of only such members reads - /// as empty: a selection the next reload will drop must not decide what a click does now. + /// **On the board any member answers, because the set is homogeneous there** — but the walk is + /// the snapshot's order rather than the set's iteration order, so the answer is deterministic + /// even for a set that somehow was not. Members that name nothing are ignored, and a set of only + /// such members reads as empty: a selection the next reload will drop must not decide what a + /// click does now. /// - /// **The trash answers for both kinds** (04-interactions.md ▸ The trash, lanes rejoined - /// 2026-07-29: "a trash selection is either cards or lane rows, kind-homogeneous like the live - /// board's own grammar"), walked in the column's own rank order so a set that somehow held both - /// answers by what is topmost rather than by array iteration order. + /// **In the trash it answers for the topmost row, and a mixed set is legal** (04-interactions.md + /// ▸ The trash, re-ruled 2026-07-31 — trash selection is kind-blind). Two callers still want it + /// there and neither is asking about homogeneity: the ⌘-click branch uses it as a liveness test + /// ("does this selection still name anything"), and the clipboard's capture wants the payload's + /// kind — which is sound precisely because Cut and Copy are validated against + /// `mixesKinds(_:in:)` first. Anything that needs to know whether the set is of one kind asks + /// that, never this. public static func kind(of selection: ItemReferenceSet, in snapshot: BoardModel) -> SelectionKind? { guard !selection.isEmpty else { return nil } switch selection.container { @@ -383,6 +411,45 @@ public enum SelectionGrammar { } } + /// **Whether the selection names rows of both kinds** — the predicate the *exits* are validated + /// against now that the trash's selection grammar is kind-blind (04-interactions.md ▸ The trash, + /// ruled 2026-07-31: "The guard moves to the exits (the mixed-payload drop refusal and ⌘C/⌘X + /// validation)"). + /// + /// Only the trash can answer `true`: the live board's grammar is still cards XOR lanes, and its + /// branch is a walk rather than a `false` so a caller cannot be misled by a set some future + /// gesture built wrongly. + /// + /// Rows the container no longer holds are ignored, like everywhere else here — a selection whose + /// lane row a foreign purge took is a single-kind selection now, and greying out ⌘C for a ghost + /// would be a refusal the user cannot see the reason for. + public static func mixesKinds(_ selection: ItemReferenceSet, in snapshot: BoardModel) -> Bool { + guard !selection.isEmpty else { return false } + var seen: SelectionKind? + switch selection.container { + case .trash: + for entry in snapshot.trashEntries where selection.ids.contains(entry.id) { + guard let seen else { + seen = entry.kind + continue + } + if seen != entry.kind { return true } + } + case .board: + for lane in snapshot.lanes { + if selection.ids.contains(lane.id) { + if seen == .card { return true } + seen = .lane + } + if lane.cards.contains(where: { selection.ids.contains($0.id) }) { + if seen == .lane { return true } + seen = .card + } + } + } + return false + } + // MARK: - Successor on delete /// What ⌫ selects after deleting `ids` — 04-interactions.md ▸ The map's Finder-style @@ -407,7 +474,7 @@ public enum SelectionGrammar { /// staged by place"): `container` says which side the gesture ran on, and the trash walks its own /// ordered rows exactly as a lane walks its own cards. The permanent delete is as deliberate an /// act as the move-to-trash, so it keeps the repeatable-keystroke property the rule exists for. - /// **The trash's own successor crosses kinds** — an interim, and the branch below says why. + /// **The trash's own successor is kind-blind** — ruled, and the branch below says so. /// /// **Deliberate deletes only.** External vanishing never picks a successor (02-architecture.md's /// reload-survival rule: "the selection just shrinks"), which is why this is called by @@ -430,15 +497,13 @@ public enum SelectionGrammar { let siblings: [ItemID] switch (container, kind) { case (.trash, _): - // **The siblings are every row, both kinds** — the interim answer to an open gap. 04 - // ▸ The trash settles navigation (plain arrows cross kinds) and extension (⇧-arrows stop - // at the kind boundary) for the trash's two kinds, but says nothing about which row the - // *successor* lands on after a lane row is purged. Until that is ruled, this follows - // navigation rather than extension: the successor is the next row down the column - // whatever its kind, so a purge never strands the selection with nothing selected while - // rows the user can see sit right below it. The conservative direction — the alternative - // (kind-scoped siblings) clears the selection whenever the purged row was its kind's - // last, which is a deselect wearing a successor's clothes. + // **The siblings are every row, both kinds** (04-interactions.md ▸ The map, ruled + // 2026-07-31 — ratifying what stood here as an interim): "In the trash the successor walk + // is kind-blind: the next row of either kind, in the same all-rows order plain arrows + // walk — a successor is a fresh singleton selection, so the landing violates no grammar, + // and repeated ⌘⌫ empties a mixed trash without dead-ends". The alternative — kind-scoped + // siblings — clears the selection whenever the purged row was its kind's last, which is a + // deselect wearing a successor's clothes. siblings = trashRows(in: snapshot, filter: filter) case (.board, .lane): siblings = lanes(in: snapshot) @@ -489,16 +554,15 @@ public struct MarqueeTarget: Sendable, Equatable { /// - **The band stays on the side of the boundary it started on** (▸ The trash), which is why /// `container` is a parameter rather than something derived from what the rect happens to touch: a /// band begun on the board and dragged over the trash column selects board cards and nothing else. -/// - **The band never selects lanes** (§ Selection gives it to cards: "click-drag rubber-bands -/// across lanes" — across them, not over them). A live lane is never registered as a target at -/// all; a **trashed lane row is**, because the arrows navigate by the same frames -/// (`NavigationMath`) and the band's begin guard reads them too — so the kind filter below is -/// load-bearing rather than belt over braces. -/// -/// **The kind filter is the whole of the rule, in both containers**: "the rubber band stays on the -/// side it started on and selects cards only (as the board marquee does); lane rows join by click -/// grammar" (04-interactions.md ▸ The trash, re-affirmed 2026-07-29). A band never has to break a -/// tie between a card and a lane row, because a lane row is not in the answer whatever it sweeps. +/// - **On the board the band never selects lanes** (§ Selection gives it to cards: "click-drag +/// rubber-bands across lanes" — across them, not over them). A live lane is never registered as a +/// target at all, so that half is true twice over. +/// - **In the trash it sweeps every row** (04-interactions.md ▸ The trash, re-ruled 2026-07-31 — +/// superseding the card-only band this enforced while the trash's grammar was kind-homogeneous): +/// "the rubber band [sweeps] every row (the band's full-height backdrop covers both kinds)". A +/// trashed lane row is registered as a target — the arrows navigate by the same frames +/// (`NavigationMath`) and the band's begin guard reads them — so dropping the kind filter on that +/// side is the whole of the change. public enum MarqueeMath { /// The ids `rect` sweeps. @@ -509,7 +573,13 @@ public enum MarqueeMath { ) -> Set { Set( targets.lazy - .filter { $0.container == container && $0.kind == .card && rect.intersects($0.frame) } + .filter { target in + guard target.container == container, rect.intersects(target.frame) else { + return false + } + // Kind-blind in the trash, card-only on the board. + return container == .trash || target.kind == .card + } .map(\.id) ) } diff --git a/Kanban/LiveStore/TrashModel.swift b/Kanban/LiveStore/TrashModel.swift index 8eb2659..58b8bc2 100644 --- a/Kanban/LiveStore/TrashModel.swift +++ b/Kanban/LiveStore/TrashModel.swift @@ -11,9 +11,9 @@ import Foundation /// returning-card count, a deterministic `deleted`-timestamp sort, and a paths function that had to /// restate which items were addressable. All of it is gone. A trashed card is "an ordinary card in a /// special place", so the trash's contents *are* `snapshot.trash` and `snapshot.trashedLanes` — -/// already parsed by the loader, already in `order` display order, already newest-first because -/// every arrival mints a rank above the current top. There is nothing to derive, and no second -/// definition to keep in step with the loader's. +/// already parsed by the loader, already in the column's own order, already newest-first because +/// every arrival stamps `modified` and the container sorts by it descending (re-ruled 2026-07-31). +/// There is nothing to derive, and no second definition to keep in step with the loader's. /// /// What genuinely remains is what the *commands* need and no view can answer: the two purge /// confirmations' phrasing — which since lanes rejoined the trash (2026-07-29) has to **count the diff --git a/Kanban/Storage/AgentGuide.swift b/Kanban/Storage/AgentGuide.swift index 61e24db..2c1aa53 100644 --- a/Kanban/Storage/AgentGuide.swift +++ b/Kanban/Storage/AgentGuide.swift @@ -63,8 +63,13 @@ enum AgentGuide { /// `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 + /// app's own folder, so a *file* by that name is a defect the app displaces on sight. **v8 + /// retires the trash's arrival rank** (01-storage-format.md § Deletion and 03-board-ui.md ▸ + /// Trash, re-ruled 2026-07-31; 08-agent-integration.md's own line): the trash sorts by `modified` + /// descending, so there is no rank to mint on the way in — the guide's smallest-`order`-minus-1024 + /// formula is replaced by "restamp `modified`, leave `order` alone", which is the same stamp + /// discipline v7 already taught, now doing the ordering as well. + static let version = 8 // MARK: - The version marker @@ -450,12 +455,14 @@ enum AgentGuide { - **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. + travels with its cards inside it. It's a container change like any other + move (Moving and reordering above): stamp `modified` and re-stamp + `modified-by` — and here the stamp is also the position. **The trash + sorts by `modified`, newest first**, so a restamped arrival lands on top; + there is no rank to mint, and you should leave `order` exactly as it is — + it rides along for the restore. 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. diff --git a/Kanban/Storage/BoardLoader.swift b/Kanban/Storage/BoardLoader.swift index d7b615b..316ef25 100644 --- a/Kanban/Storage/BoardLoader.swift +++ b/Kanban/Storage/BoardLoader.swift @@ -317,8 +317,10 @@ public enum BoardLoader: Sendable { var trash: [Card] = [] var trashedLanes: [TrashedLane] = [] /// Every trash entry as the dedupe needs it, kind-blind — the container is one flat list to - /// the identity rule, whatever the snapshot splits it into. - var trashEntries: [(id: ItemID, title: String?, order: Double)] = [] + /// the identity rule, whatever the snapshot splits it into. Carried with the two keys the + /// container's order is stated in (`Ranks.isOrderedForTrash`), never `order`: the trash is + /// sorted by `modified` descending since 2026-07-31. + var trashEntries: [(id: ItemID, title: String?, modified: Date?)] = [] var trashKinds: [ItemID: IntegrityRules.ObjectKind] = [:] for entryURL in trashCandidates(in: boardRoot) { let entryName = entryURL.lastPathComponent @@ -360,7 +362,7 @@ public enum BoardLoader: Sendable { ) let id = ItemID(rawValue: entryName) trashKinds[id] = kind - trashEntries.append((id: id, title: document.title.value, order: order)) + trashEntries.append((id: id, title: document.title.value, modified: document.modified.value)) switch kind { case .lane: @@ -372,6 +374,7 @@ public enum BoardLoader: Sendable { id: id, schema: schema, title: document.title, + modified: document.modified, order: order, heldCards: children().count, document: document @@ -407,12 +410,19 @@ public enum BoardLoader: Sendable { // Display order is settled here rather than in the `BoardModel` call below, because the // dedupe's last tie-break *is* traversal order and the rule needs the occurrences in it. let orderedLanes = Ranks.sortedForDisplay(walkedLanes, order: \.order, name: \.name) - let orderedTrash = Ranks.sortedForDisplay(trash, order: \.order, name: { $0.id.rawValue }) - let orderedTrashedLanes = Ranks.sortedForDisplay(trashedLanes, order: \.order, name: { $0.id.rawValue }) - // The trash's own display order, **both kinds at once** — the column interleaves them by rank + // **The trash's arrays are sorted by `modified` descending**, not by `order` (01-storage-format.md + // § Deletion, re-ruled 2026-07-31): the trash move rewrites no rank, so the stamp *is* the + // position. Each kind is sorted by the same comparator the merged `BoardModel.trashEntries` + // applies, which is what makes a kind-narrowed slice of the column agree with the column. + let orderedTrash = Ranks.sortedForTrash( + trash, modified: { $0.modified.value }, title: { $0.title.value }, name: { $0.id.rawValue }) + let orderedTrashedLanes = Ranks.sortedForTrash( + trashedLanes, modified: { $0.modified.value }, title: { $0.title.value }, name: { $0.id.rawValue }) + // The trash's own display order, **both kinds at once** — the column interleaves them // (03-board-ui.md § Trash), and the dedupe's last tie-break is stated in traversal order, so // the two kinds are merged before the rule sees them rather than after. - let orderedTrashEntries = Ranks.sortedForDisplay(trashEntries, order: \.order, name: { $0.id.rawValue }) + let orderedTrashEntries = Ranks.sortedForTrash( + trashEntries, modified: \.modified, title: \.title, name: { $0.id.rawValue }) let verdict = dedupeIdentities( inBoardAt: boardRoot, lanes: orderedLanes, @@ -496,7 +506,7 @@ public enum BoardLoader: Sendable { private static func dedupeIdentities( inBoardAt root: URL, lanes: [WalkedLane], - trash: [(id: ItemID, title: String?, order: Double)], + trash: [(id: ItemID, title: String?, modified: Date?)], historyRanker: IdentityHistoryRanker? ) -> IntegrityRules.DedupeVerdict { typealias Container = IntegrityRules.IdentityOccurrence.Container diff --git a/Kanban/Storage/BoardModel.swift b/Kanban/Storage/BoardModel.swift index ff4834d..e5973d6 100644 --- a/Kanban/Storage/BoardModel.swift +++ b/Kanban/Storage/BoardModel.swift @@ -118,16 +118,17 @@ public struct BoardModel: Sendable, Equatable { /// **The container's other kind is `trashedLanes`** (re-ruled 2026-07-29 — lanes trash too). /// The two are separate arrays rather than one list of a sum type because they are separate /// *things*: a trashed card is an ordinary card that every card-shaped surface already reads, - /// and a trashed lane is an opaque row that none of them may. Interleaving the two by trash - /// rank is a rendering question (03-board-ui.md § Trash: "lane rows and cards interleave in the - /// one trash column purely by trash rank"), and both arrays carry the `order` that answers it. + /// and a trashed lane is an opaque row that none of them may. Interleaving the two is a + /// rendering question (03-board-ui.md § Trash: "lane rows and cards interleave in the one trash + /// column by `modified` descending"), and both arrays carry the stamp that answers it — + /// `BoardModel.trashEntries` is the one merge. /// - /// **Display order is `order` ascending, like any lane's cards** — `Ranks.sortedForDisplay`, - /// same folder-name tie-break. Newest-first falls out of ordinary ranks rather than a - /// timestamp sort: every arrival mints a rank *above* the current topmost - /// (`Ranks.insertAtHead`), so the trash needs no sort rule of its own. There is deliberately - /// no `deleted:` key on anything in here — a trashed card is an ordinary card in a special - /// place. + /// **Display order is `modified` descending** — `Ranks.sortedForTrash`, tie-broken by title then + /// folder name (re-ruled 2026-07-31, retiring the arrival rank mint). The trash is the one + /// container in the app whose sequence is not a rank sequence: the delete move stamps, and that + /// stamp *is* the position, with each entry's `order` riding along untouched for its restore. + /// There is deliberately no `deleted:` key on anything in here — a trashed card is an ordinary + /// card in a special place. /// /// Empty when `.trash/` is absent (the overwhelmingly common case — the folder is minted by /// the first delete), and empty when it holds nothing the loader recognizes as a card. @@ -281,9 +282,17 @@ public struct TrashedLane: Identifiable, Sendable, Equatable { public let schema: Int public let title: FieldValue - /// Rank within the trash, ascending = top to bottom — the same required, strictly validated - /// field a live lane carries (`Lane.order`), and what interleaves this row among the trash's - /// cards. Newest-first falls out of it: every arrival mints a rank above the current topmost. + /// **When the row entered the trash** — and therefore *where it sits*: the trash sorts by + /// `modified` descending (01-storage-format.md § Deletion, re-ruled 2026-07-31), and the trash + /// move is the container-changing write that stamps it. Missing on a foreign mover that skipped + /// the restamp, which sorts it below every dated sibling (`Ranks.isOrderedForTrash`). + public let modified: FieldValue + + /// The lane's rank **among the board's lanes**, riding along untouched — the trash move rewrites + /// no `order` at all, so this is still the strip position a restore would want and the value the + /// undo of a restore puts back. It is deliberately *not* what orders this row in the column + /// (`modified` is), and the same required, strictly validated field a live lane carries + /// (`Lane.order`). public let order: Double /// **How many cards the lane is holding** — the row's whole other half ("Doing — 5 cards"). diff --git a/Kanban/Storage/BoardWriter.swift b/Kanban/Storage/BoardWriter.swift index 17cc47b..e6cf793 100644 --- a/Kanban/Storage/BoardWriter.swift +++ b/Kanban/Storage/BoardWriter.swift @@ -94,9 +94,16 @@ public enum BoardWriter: Sendable { /// `nil`, the default, derives it from position (`IntegrityRules.placement`), and stamps /// nothing when position has no answer: a guessed kind on disk would be worse than an absent /// one, because the trash's discriminator trusts what it finds. + /// - Parameter stampedAt: the value `modified` takes, for the **one** write whose stamp is not + /// "now": the legacy tombstone migration, which stamps from the `deleted:` timestamp it is + /// retiring so a board's real deletion order survives into the trash's `modified`-descending + /// sort (01-storage-format.md § Deletion). `nil`, the default, is `Date()` — every other + /// caller. It is a parameter rather than something the `edits` closure could do because the + /// stamps deliberately run *after* `edits` and outrank it. public static func updateIndex( inItemFolder folder: URL, kind: IntegrityRules.ObjectKind? = nil, + stampedAt: Date? = nil, operation: WriteOperation, edits: (inout FrontmatterDocument) -> Void ) throws(BoardWriteError) { @@ -117,7 +124,7 @@ public enum BoardWriter: Sendable { // 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.set(FrontmatterKeys.modified, to: .date(stampedAt ?? Date())) document.remove(FrontmatterKeys.modifiedBy) } @@ -1036,15 +1043,20 @@ public enum BoardWriter: Sendable { /// **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. /// - /// **Comment folders are deliberately outside this preflight** (added 2026-07-30 with the comment - /// storage, and worth stating because it is a *narrowing* of a 2026-07-29 ruling): 01's copy - /// transaction says "refuses whole, loudly, naming the offending item", and its enhanced-schema - /// section says "**comment defects never refuse the board** — worst case is the stray posture … - /// a broken leaf annotation must not brick a load; deliberate, proportionate divergence from card - /// fail-fast". A ⌘V that refuses because one comment on one card inside a pasted lane has - /// hand-broken frontmatter is that divergence read the other way round. So the walk stays - /// `identityDescendants`' — cards and lanes — and a comment the contract cannot be applied to is - /// copied verbatim with a log line instead (`stampCopiedComment`). + /// **The preflight reaches comment depth** (01-storage-format.md § Frontmatter and § Enhanced + /// schema, ruled 2026-07-31 — reversing the 2026-07-30 carve-out that kept comments out of it): + /// "a comment whose frontmatter cannot take the stamp refuses the copy exactly like a card or + /// lane — never-refuse … is a *load* posture, and a user-initiated copy is a transaction, not a + /// load". The board still always loads with a broken annotation on it; the *gesture* may refuse, + /// and refusing is what keeps the sever rule true — no copy carries a live `remote` claim or a + /// stale `modified-by` because one comment could not be rewritten. + /// + /// The walk is therefore `copyContractDescendants`' — every folder `remintDescendants` will + /// materialize and `stampCopiedDescendant` will then rewrite, comments included — and the + /// refusal names the comment's *card*, which is what `operation.withTitle` produces here: the + /// enrichment reads the offending file's own title, and a comment has none, so the operation + /// keeps the copy root's. (The error always carries the path, which is what points at the + /// annotation itself.) /// /// **Internal rather than `private`**: template instantiation preflights its own tree with this, /// for `remintDescendants`' reason — one definition of what a copy owes its descendants. @@ -1052,7 +1064,7 @@ public enum BoardWriter: Sendable { of folder: URL, operation: WriteOperation ) throws(BoardWriteError) { - for descendant in identityDescendants(of: folder) { + for descendant in copyContractDescendants(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) @@ -1062,9 +1074,9 @@ public enum BoardWriter: Sendable { /// Every **card or lane** beneath `folder`, depth first — `remintDescendants`' recursion with the /// renaming taken out, so the preflight and the remint cannot disagree about which folders a copy - /// materializes as *items*. Comment folders are not here, by the carve-out - /// `checkCopiedDescendantsAreStampable` states; it is also exactly the right reach for - /// `stripCommentTrash`, since a thread lives under a card and nowhere else. + /// materializes as *items*. Comment folders are not here (they hang off `comments/`, which is not + /// identity-shaped), which is exactly the right reach for `stripCommentTrash`, since a thread + /// lives under a card and nowhere else. private static func identityDescendants(of folder: URL) -> [URL] { var found: [URL] = [] for child in childCandidates(of: folder) { @@ -1074,6 +1086,26 @@ public enum BoardWriter: Sendable { return found } + /// **Every folder below `folder` whose `index.md` the copy contract rewrites** — the items *and* + /// their comments, which is `remintDescendants`' own reach read as a list. + /// + /// The root's own thread is in it: `checkIndexIsRewritable` preflights the copy root itself, but + /// nothing preflights the comments hanging off it, and the copy stamps those too. Two walks that + /// disagreed about which folders a copy touches is exactly the drift a mid-flight failure after a + /// clean preflight would be. + private static func copyContractDescendants(of folder: URL) -> [URL] { + func comments(of item: URL) -> [URL] { + childCandidates(of: item.appendingPathComponent( + IntegrityRules.commentsFolderName, isDirectory: true)) + } + var found = comments(of: folder) + for item in identityDescendants(of: folder) { + found.append(item) + found.append(contentsOf: comments(of: item)) + } + 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 @@ -1087,6 +1119,12 @@ public enum BoardWriter: Sendable { /// 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). /// + /// **A comment takes the same strict path as a card** (ruled 2026-07-31): the former lenient + /// branch — stamp best-effort, log, copy verbatim on failure — retired with the preflight's + /// comment carve-out, because the preflight now clears comments too and a failure here is a disk + /// failure at either depth. `kind` is left to position (`IntegrityRules.placement`), which + /// answers `.comment` for a folder under a card's `comments/`. + /// /// **Internal rather than `private`**, with `remintDescendants` and for its reason: an /// instantiated board's lanes and cards are stamped by this exact rule. static func stampCopiedDescendant( @@ -1098,35 +1136,11 @@ public enum BoardWriter: Sendable { let indexURL = folder.appendingPathComponent(BoardLoader.indexFileName) guard FileManager.default.fileExists(atPath: indexURL.path) else { return } - // The one lenient branch, and the preflight's own carve-out read from the write side: a - // comment was never checked, so a comment that cannot be stamped is a *tolerated* defect - // rather than a disk failure — it copies verbatim, keeping whatever `remote` and - // `modified-by` it carried, and says so in the log (01-storage-format.md § Enhanced schema, - // "comment defects never refuse … tolerated, logged"). - guard !isCommentFolder(folder) else { - do { - try updateIndex(inItemFolder: folder, kind: .comment, operation: operation) { document in - applyCopyContract(to: &document, stamps: stamps, now: now) - } - } catch { - logger.warning( - "\(folder.path, privacy: .public): copied comment left unstamped — \(error.description, privacy: .public)" - ) - } - return - } - try updateIndex(inItemFolder: folder, operation: operation) { document in applyCopyContract(to: &document, stamps: stamps, now: now) } } - /// Whether `folder` is a comment — its parent is a card's `comments/`. Position, like every other - /// kind question here (`IntegrityRules.placement`). - static func isCommentFolder(_ folder: URL) -> Bool { - IntegrityRules.placement(ofFolder: folder) == .comment - } - static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "writer") // MARK: - The materialized trash @@ -1157,22 +1171,23 @@ public enum BoardWriter: Sendable { /// meet it here. /// 4. **Move the folder.** Nothing beneath it is read or rewritten, so `attachments/`, strays /// and every byte arrive unchanged, exactly as in an ordinary move. - /// 5. **Rewrite `order` to `order`, and stamp.** + /// 5. **Stamp** — and nothing else. /// - /// **`order` is the caller's, always** — deliberately not defaulted and deliberately not - /// computed here. Entry is at the *top* ("every arrival … lands at the trash's topmost - /// position, minting an `order` rank above the current top"), which is - /// `Ranks.insertAtHead(ofVisible:)` over the trash's current ranks — a question about the - /// *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. + /// **No rank is minted, and `order` is not rewritten at all** (01-storage-format.md § Deletion, + /// re-ruled 2026-07-31, retiring the arrival rank mint; 03-board-ui.md § Trash): the trash sorts + /// by `modified` descending, "and that stamp *is* the position — newest-first falls out with no + /// `order` rewrite at all; a delete is a pure folder move plus the stamp, and the item's `order` + /// key rides along untouched (which is also what undo's move-back restores for free)". The + /// parameter this call used to take is gone with the rule, and so is the store-side ladder that + /// computed it: there is no snapshot question left for the caller to answer. /// - /// **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 + /// **The `modified` stamp is therefore the whole write** — 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. + /// story", so it stamps exactly as a cross-lane move does. It falls out of `updateIndex` rather + /// than being asked for, which is why step 5 carries no argument — and why the + /// reorders-don't-stamp rule needs no trash carve-out to coexist with this call. Its second job + /// is the `kind` the flat container needs, backfilled on the same touch. /// /// **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 @@ -1186,14 +1201,12 @@ public enum BoardWriter: Sendable { @discardableResult public static func deleteCardToTrash( at cardFolder: URL, - inBoard boardRoot: URL, - order: Double + inBoard boardRoot: URL ) throws(BoardWriteError) -> ItemID { try moveIntoTrash( at: cardFolder, inBoard: boardRoot, kind: .card, - order: order, operation: .delete(title: nil), removingLegacyKey: false ) @@ -1208,12 +1221,12 @@ public enum BoardWriter: Sendable { /// /// - **The guard is `checkIsLaneFolder`** — UUID-shaped directly under a board root, which /// refuses a card, a board root, a stray, and notably a folder already in `.trash/`. - /// - **`kind: lane` is stamped**, not derived. The rank rewrite is a `updateIndex` on a folder - /// that is by then *inside* `.trash/`, where position cannot answer and shape would answer - /// *wrongly* for the one lane that most needs the key: an **empty** lane is shape-identical to - /// a card (01's own "honest limit"). The caller knows what it moved, so it says so — which is - /// also the backfill the ruling asks of this write ("`kind: lane` … backfilled on touch when - /// absent … the trash move's rank mint included"). + /// - **`kind: lane` is stamped**, not derived. The stamping rewrite is an `updateIndex` on a + /// folder that is by then *inside* `.trash/`, where position cannot answer and shape would + /// answer *wrongly* for the one lane that most needs the key: an **empty** lane is + /// shape-identical to a card (01's own "honest limit"). The caller knows what it moved, so it + /// says so — which is also the backfill the ruling asks of this write ("`kind: lane` … + /// backfilled on touch when absent … the trash move's `modified` stamp included"). /// /// **The subtree rides along untouched**: nothing beneath the lane is read or rewritten, so its /// cards, their `attachments/` and every stray arrive byte-identical and come back with it on @@ -1224,14 +1237,12 @@ public enum BoardWriter: Sendable { @discardableResult public static func deleteLaneToTrash( at laneFolder: URL, - inBoard boardRoot: URL, - order: Double + inBoard boardRoot: URL ) throws(BoardWriteError) -> ItemID { try moveIntoTrash( at: laneFolder, inBoard: boardRoot, kind: .lane, - order: order, operation: .delete(title: nil), removingLegacyKey: false ) @@ -1249,19 +1260,25 @@ public enum BoardWriter: Sendable { /// The removal is `FrontmatterDocument.remove`, so it takes **every** occurrence of the key: /// a hand-duplicated `deleted:` line cannot leave a twin behind that would re-migrate the card /// on the next load. Nothing else in the file is touched — unknown keys, comments, blank lines, - /// line endings and the body are the same bytes they were, and `order` and the stamps are the - /// only writes, exactly as for an ordinary delete. + /// line endings and the body are the same bytes they were, and the stamps are the only other + /// writes, exactly as for an ordinary delete. + /// + /// **The stamp is the retiring key's own timestamp where it parses** (01-storage-format.md + /// § Deletion, re-ruled 2026-07-31: "the migration move stamps `modified` **from the legacy + /// `deleted:` timestamp** where parseable — the deletion time is when the card entered the + /// trash, so real deletion order survives into the `modified`-descending sort — and from + /// migration time otherwise"). It is the one write in the app whose `modified` is not "now", + /// and it is why the legacy value is read *before* the move rather than left to the closure: + /// `updateIndex`'s stamps deliberately outrank anything `edits` does. @discardableResult public static func migrateTombstonedCard( at cardFolder: URL, - inBoard boardRoot: URL, - order: Double + inBoard boardRoot: URL ) throws(BoardWriteError) -> ItemID { try moveIntoTrash( at: cardFolder, inBoard: boardRoot, kind: .card, - order: order, operation: .migrateTombstone(title: nil), removingLegacyKey: true ) @@ -1278,7 +1295,6 @@ public enum BoardWriter: Sendable { at itemFolder: URL, inBoard boardRoot: URL, kind: IntegrityRules.ObjectKind, - order: Double, operation initialOperation: WriteOperation, removingLegacyKey: Bool ) throws(BoardWriteError) -> ItemID { @@ -1293,7 +1309,17 @@ public enum BoardWriter: Sendable { case .lane: try checkIsLaneFolder(itemFolder, operation: operation) case .card, .board, .comment: try checkIsCardFolder(itemFolder, operation: operation) } - operation = try checkIndexIsRewritable(inItemFolder: itemFolder, operation: operation) + // `checkIndexIsRewritable`'s two checks, spelled out rather than called, for one reason: the + // migration needs the *document* this read produces — the legacy `deleted:` timestamp it is + // about to remove becomes the `modified` stamp (`migrateTombstonedCard`), and it has to be + // read while the file is still at its old path. + let sourceIndex = itemFolder.appendingPathComponent(BoardLoader.indexFileName) + let source = try readDocument(at: sourceIndex, operation: operation) + operation = operation.withTitle(source.title.value) + try checkEditable(source, at: sourceIndex, operation: operation) + // Parseable legacy stamp → the deletion time it recorded; malformed, absent, or an ordinary + // delete → `nil`, which is `updateIndex`'s "now". + let stamp = removingLegacyKey ? source.deleted.value : nil let trash = trashFolder(inBoard: boardRoot) do { @@ -1322,8 +1348,10 @@ public enum BoardWriter: Sendable { // absence where the item was, the shown-trash side sees an arrival where it went. EchoLedger.current?.recordMove(from: itemFolder, to: arrived) - try updateIndex(inItemFolder: arrived, kind: kind, operation: operation) { document in - document.set(FrontmatterKeys.order, to: .double(order)) + // The whole of the arrival write: the stamp (which is the row's position), the `kind` the + // flat container discriminates on, and — for the migration alone — the retiring key. `order` + // is deliberately absent; it rides along as the item left it. + try updateIndex(inItemFolder: arrived, kind: kind, stampedAt: stamp, operation: operation) { document in if removingLegacyKey { document.remove(FrontmatterKeys.deleted) } diff --git a/Kanban/Storage/Ranks.swift b/Kanban/Storage/Ranks.swift index df59d2d..44ac52e 100644 --- a/Kanban/Storage/Ranks.swift +++ b/Kanban/Storage/Ranks.swift @@ -143,6 +143,66 @@ enum Ranks: Sendable { items.sorted { isOrderedForDisplay($0, before: $1, order: order, name: name) } } + // MARK: - The trash's own order + + /// The trash's order: **`modified` descending**, ties broken by title + /// (case-insensitive), then folder name (01-storage-format.md § Deletion, + /// re-ruled 2026-07-31, retiring the arrival rank mint; 03-board-ui.md § + /// Trash: "the trash sorts by `modified` descending … Ties break by title + /// (case-insensitive), then folder name — the deterministic tail"). + /// + /// **`order` is not consulted at all in here**, which is the whole of the + /// ruling: a delete is a pure folder move plus the stamp, and the item's + /// `order` key rides along untouched for its eventual restore. The trash is + /// the one container in the app whose sequence is not a rank sequence. + /// + /// **An undated entry sorts after every dated one** — the comment thread's + /// own rule for a missing `created` ("ties and missing/malformed … sort + /// after dated siblings"), read one container over: a stamp the app always + /// writes is missing only on a hand-made or foreign entry, and a missing + /// value must never outrank a real deletion. The tail then separates them + /// exactly as it separates two equal stamps. + /// + /// Titles compare with `localizedCaseInsensitiveCompare`, the same + /// comparison the rest of the app's user-facing sorting uses, and an + /// untitled entry compares as the empty string — it sorts first among its + /// stamp-mates, which is deterministic and is all the tail owes. + static func isOrderedForTrash( + _ lhs: T, before rhs: T, + modified: (T) -> Date?, title: (T) -> String?, name: (T) -> String + ) -> Bool { + let lhsModified = modified(lhs) + let rhsModified = modified(rhs) + if lhsModified != rhsModified { + // Newest first; an absent stamp is older than every present one. + switch (lhsModified, rhsModified) { + case let (.some(left), .some(right)): return left > right + case (.some, .none): return true + case (.none, .some): return false + case (.none, .none): break + } + } + let lhsTitle = title(lhs) ?? "" + let rhsTitle = title(rhs) ?? "" + let comparison = lhsTitle.localizedCaseInsensitiveCompare(rhsTitle) + guard comparison == .orderedSame else { return comparison == .orderedAscending } + return name(lhs) < name(rhs) + } + + /// Sorts the trash's entries into column order — `isOrderedForTrash`'s rule, + /// applied by the loader to each kind's array and by `BoardModel.trashEntries` + /// to the merged sequence, so the two can never disagree about what "the row + /// below this one" is (03-board-ui.md § Trash: "The merged order is one + /// derivation"). + static func sortedForTrash( + _ items: [T], + modified: (T) -> Date?, + title: (T) -> String?, + name: (T) -> String + ) -> [T] { + items.sorted { isOrderedForTrash($0, before: $1, modified: modified, title: title, name: name) } + } + // MARK: - Tombstone exclusion /// `append(toVisible:)`, ignoring tombstoned siblings. Tombstones are diff --git a/Kanban/UI/Board/BoardDrops.swift b/Kanban/UI/Board/BoardDrops.swift index 2c36521..219c1e9 100644 --- a/Kanban/UI/Board/BoardDrops.swift +++ b/Kanban/UI/Board/BoardDrops.swift @@ -535,6 +535,23 @@ struct BoardDropContext { cancelDrop() return false } + // **A mixed-kind drag never leaves the trash** (04-interactions.md ▸ The trash, ruled + // 2026-07-31 with kind-blind trash selection): "pickup is allowed — the selection is legal — + // but every out-of-trash drop target refuses the mixed payload, and the release surfaces a + // notice explaining the rule … the refused drag ends like any refusal, rows staying put". + // + // One guard for every out-of-trash target, because every one of them funnels through this + // commit — the lane masonries, the strip, the cross-board arrivals, and the window fallback + // alike. A *within*-trash release cannot reach it: the column declines `.trash`-container + // sessions outright (`TrashDrop.accepts`), so a trash-origin drag never holds a `.trash` + // proposal, and "within-trash drops stay inert" needs no clause here. + // + // The notice is the destination board's strip, which is where the user is looking. + guard !(session.container == .trash && session.mixesKinds) else { + store.banners.postMixedTrashDrag() + cancelDrop() + return false + } let ids = survivors.map { session.members[$0] } let folders = survivors.map { session.folders[$0] } let within = DragLocality.isSameBoard(sourceRoot, store.rootURL) diff --git a/Kanban/UI/Board/BoardView.swift b/Kanban/UI/Board/BoardView.swift index 27ecea1..830269c 100644 --- a/Kanban/UI/Board/BoardView.swift +++ b/Kanban/UI/Board/BoardView.swift @@ -893,14 +893,20 @@ struct BoardView: View { return .handled } - /// **⇧-arrow extends, and stops at both boundaries** (04 ▸ The trash, settled): "a ⇧-arrow whose - /// next step would cross from live cards into the trash (or back), or from card entries onto a - /// lane entry within it, is simply inert". + /// **⇧-arrow extends, and stops at the container boundary** (04 ▸ The trash: "⇧-arrow extension + /// still stops at the container boundary") — a ⇧-arrow whose next step would cross from live + /// cards into the trash, or back, is simply inert. + /// + /// **The kind boundary is no longer one of the stops inside the trash** (re-ruled 2026-07-31): + /// "⇧-click ranges, ⇧-arrow extension … all sweep every row" there, so a ⇧-arrow from a trash + /// card onto a lane row extends across it. On the board the kind test stands — the live grammar + /// is still cards XOR lanes. /// /// The *step* that would cross is what goes inert — the crossing item is never stepped over in /// search of a legal one, because that would silently drop the held range for a longer reach /// than the user asked for. So the nearest neighbour is computed **unrestricted** and then - /// tested: a different side or a different kind means this press does nothing at all. + /// tested: a different side (or, on the board, a different kind) means this press does nothing + /// at all. private func extend(_ direction: NavigationMath.Direction, from head: ItemID) -> KeyPress.Result { guard let origin = marqueeTargets.targets[head], let nextID = NavigationMath.nearest( @@ -910,7 +916,7 @@ struct BoardView: View { ), let next = marqueeTargets.targets[nextID], next.container == origin.container, - next.kind == origin.kind + next.container == .trash || next.kind == origin.kind else { return .handled } // An extension with no anchor makes one of where it started — the keyboard's equivalent of diff --git a/Kanban/UI/Board/CardFaceView.swift b/Kanban/UI/Board/CardFaceView.swift index 52314ea..5fc7904 100644 --- a/Kanban/UI/Board/CardFaceView.swift +++ b/Kanban/UI/Board/CardFaceView.swift @@ -356,12 +356,19 @@ struct CardFaceView: View { /// every other card drop uses, committed by the same `moveCards`. /// /// **Multi-drag carries the whole trash selection**, in the column's own order — the order the - /// rows are drawn in, which is `order` ascending like any lane's. + /// rows are drawn in, which since 2026-07-31 is `modified` descending (03 § Trash). + /// + /// **A kind-blind selection can span both kinds, and the session says so** (04-interactions.md ▸ + /// The trash, ruled 2026-07-31): the lane rows in it cannot ride a `.cards` session, so rather + /// than let them fall silently out of the payload the flag travels and the *drop* refuses with + /// the notice (`DragSession.mixesKinds`). Pickup stays allowed — the selection is legal. private func startTrashCardDrag() -> NSItemProvider { guard !store.isReadOnly, !store.isEditingInline else { return NSItemProvider() } let ids = draggedIDs let rows = store.snapshot.trash.filter { ids.contains($0.id) } guard !rows.isEmpty else { return NSItemProvider() } + let mixesKinds = SelectionGrammar.mixesKinds( + ItemReferenceSet(ids: ids, container: .trash), in: store.snapshot) let root = store.rootURL let payload = DragPayload( @@ -381,7 +388,8 @@ struct CardFaceView: View { folders: payload.folders, heights: rows.map { drops.registry.heights[$0.id] ?? LaneDropRegistry.nominalCardHeight }, container: .trash, - source: store + source: store, + mixesKinds: mixesKinds ) return payload.itemProvider() } diff --git a/Kanban/UI/Board/DragSession.swift b/Kanban/UI/Board/DragSession.swift index e65f048..395dd02 100644 --- a/Kanban/UI/Board/DragSession.swift +++ b/Kanban/UI/Board/DragSession.swift @@ -60,12 +60,11 @@ enum TrashDrop { /// The row the shadow takes, always: **the topmost**. /// - /// Not arbitrary, and the ranks are what make it honest: "every trash arrival mints a rank - /// above the current top" (04-interactions.md ▸ The trash), so a fresh delete genuinely lands on - /// top. The drop - /// therefore still lands exactly where the shadow shows — the one positional promise every other - /// drop in this app makes — while being the only proposal on the board the *pointer* does not - /// choose. + /// Not arbitrary, and the stamp is what makes it honest: "every trash arrival stamps `modified` + /// and the trash sorts newest-first by that stamp" (04-interactions.md ▸ The trash, re-ruled + /// 2026-07-31), so a fresh delete genuinely lands on top. The drop therefore still lands exactly + /// where the shadow shows — the one positional promise every other drop in this app makes — + /// while being the only proposal on the board the *pointer* does not choose. static let landingIndex = 0 /// Whether the shown trash takes this session — the whole of the gate, and every clause is a @@ -284,6 +283,18 @@ final class DragSession { /// and that is the whole of what makes it a restore (04-interactions.md ▸ The trash). private(set) var container: ItemContainer = .board + /// **Whether the selection this drag was picked up from spanned both kinds** — only ever true in + /// the trash, whose selection went kind-blind (04-interactions.md ▸ The trash, ruled 2026-07-31). + /// + /// A session carries items of exactly one `kind` — the pasteboard type is per-kind and a + /// `DragPayload` names one — so a mixed selection dragged from a trash row starts a session over + /// *its* kind and silently leaves the other behind. That silence is what this flag exists to + /// prevent: "pickup is allowed — the selection is legal — but every out-of-trash drop target + /// refuses the mixed payload, and the release surfaces a notice explaining the rule". The refusal + /// is at the commit (`BoardDropContext.commitDrop`), where every drop out of the trash funnels, + /// rather than at hover — 04 puts the explanation at the release. + private(set) var mixesKinds = false + /// The dragged items in **flatten order** — the order they will land in. private(set) var members: [ItemID] = [] @@ -496,16 +507,33 @@ final class DragSession { folders: [URL], heights: [CGFloat], container: ItemContainer, - source: BoardStore + source: BoardStore, + mixesKinds: Bool = false ) { - begin(kind: .cards, members: members, folders: folders, container: container, source: source) + begin( + kind: .cards, members: members, folders: folders, + container: container, source: source, mixesKinds: mixesKinds + ) cardHeights = heights laneUnits = [] } /// Begins a lane session. - func beginLanes(_ members: [ItemID], folders: [URL], units: [Int], source: BoardStore) { - begin(kind: .lanes, members: members, folders: folders, container: .board, source: source) + /// + /// `container` is a parameter because a **trashed lane row**'s drag is a `.lanes` session in + /// `.trash` — the restore at the lane level (04-interactions.md ▸ The trash ▸ Drag-to-restore). + func beginLanes( + _ members: [ItemID], + folders: [URL], + units: [Int], + container: ItemContainer = .board, + source: BoardStore, + mixesKinds: Bool = false + ) { + begin( + kind: .lanes, members: members, folders: folders, + container: container, source: source, mixesKinds: mixesKinds + ) laneUnits = units cardHeights = [] } @@ -515,7 +543,8 @@ final class DragSession { members: [ItemID], folders: [URL], container: ItemContainer, - source: BoardStore + source: BoardStore, + mixesKinds: Bool ) { endHold() self.kind = kind @@ -523,6 +552,7 @@ final class DragSession { self.memberSet = Set(members) self.folders = folders self.container = container + self.mixesKinds = mixesKinds self.sourceStore = source self.sourceRoot = source.rootURL self.proposal = nil diff --git a/Kanban/UI/Board/TrashLaneRowView.swift b/Kanban/UI/Board/TrashLaneRowView.swift index f1a970c..467a09c 100644 --- a/Kanban/UI/Board/TrashLaneRowView.swift +++ b/Kanban/UI/Board/TrashLaneRowView.swift @@ -183,6 +183,11 @@ struct TrashLaneRowView: View { /// **Multi-drag carries the whole trash-side lane selection**, in the column's own order, and a /// row outside the selection drags alone — the card face's targeting rule at the other level. /// + /// **A kind-blind selection can span both kinds, and the session says so** (04-interactions.md ▸ + /// The trash, ruled 2026-07-31): a `.lanes` session cannot carry the cards in it, so rather than + /// drop them silently the flag travels and the release refuses with the notice + /// (`DragSession.mixesKinds`) — "pickup is allowed — the selection is legal". + /// /// Refused under the read-only lock and while an inline editor is focused, like every other /// mutating gesture; a refusal is an item provider carrying nothing, so no session begins. private func startDrag() -> NSItemProvider { @@ -190,6 +195,8 @@ struct TrashLaneRowView: View { let ids = draggedIDs let rows = store.snapshot.trashedLanes.filter { ids.contains($0.id) } guard !rows.isEmpty else { return NSItemProvider() } + let mixesKinds = SelectionGrammar.mixesKinds( + ItemReferenceSet(ids: ids, container: .trash), in: store.snapshot) let root = store.rootURL let payload = DragPayload( @@ -212,7 +219,12 @@ struct TrashLaneRowView: View { // standard width; the restored lane then draws at whatever `width` its untouched // frontmatter still says, on the next reload. units: rows.map { _ in 1 }, - source: store + // **The session's container is this row's**, matching the payload's — which is what + // routes the release to `BoardStore.restoreLanes` (the move *out* of `.trash/`) rather + // than to `moveLanes`' strip permutation, and what the mixed-payload exit keys on. + container: .trash, + source: store, + mixesKinds: mixesKinds ) return payload.itemProvider() } diff --git a/Kanban/UI/Board/TrashLaneView.swift b/Kanban/UI/Board/TrashLaneView.swift index 2bed6f1..6b6aa64 100644 --- a/Kanban/UI/Board/TrashLaneView.swift +++ b/Kanban/UI/Board/TrashLaneView.swift @@ -12,7 +12,7 @@ import SwiftUI /// /// **Its cards are ordinary cards in a special place**, so there is nothing to derive and nothing /// to draw differently: the column renders `store.snapshot.trash` — which the loader parsed with the -/// same card parse the lanes use and sorted by `order` like any lane's children — through the very +/// same card parse the lanes use and sorted by `modified` descending, the trash's own order — through the very /// same `CardFaceView` a lane renders. "A trashed card is an ordinary card in a special place — /// search, selection, rendering, styling, and clipboard all treat it exactly like any other card" /// (03 § Trash), and one view is the only way to make *rendering* literally true rather than @@ -24,11 +24,11 @@ import SwiftUI /// **A trashed lane is an opaque unit** — "one distinct dimmed row showing its title and held-card /// count … no styling accents, never expandable" (03 § Trash) — so it gets its own small view /// (`TrashLaneRowView`) rather than a third card-face role: what a card face draws is exactly what -/// the design says this row must not. The two kinds **interleave purely by trash rank**, which is +/// the design says this row must not. The two kinds **interleave by `modified` descending**, which is /// `BoardModel.trashEntries`' merge and not a second one written here. /// -/// Newest-first falls out of the ranks (every arrival of either kind mints one above the current -/// top), so there is no timestamp sort and no entry type here at all. +/// Newest-first falls out of the stamp (every arrival of either kind restamps `modified` on the way +/// in — 03 § Trash, re-ruled 2026-07-31), so no rank is minted anywhere in this container. /// /// ### What makes it a column and not a lane /// @@ -52,9 +52,9 @@ import SwiftUI /// trash, lanes extended 2026-07-29) — the drag becomes the pointer's delete gesture, and release /// moves the dragged item(s) into `.trash/`. So the column declares an `onDrop` /// (`TrashDropDelegate`), and it is the narrowest one on the board: a board drag from **this** board, -/// unmodified. It diverges from every other drop in one way, and the ranks are what make the -/// divergence honest: **the shadow always takes the topmost row**, because every arrival mints a rank -/// above the current top. +/// unmodified. It diverges from every other drop in one way, and the stamp is what makes the +/// divergence honest: **the shadow always takes the topmost row**, because every arrival restamps +/// `modified` and the column is sorted by it, newest first. /// /// The drag *out* is the restore, and it is deliberately not special at either level: a trash card's /// drag is an ordinary `.cards` session in the `.trash` container @@ -155,8 +155,9 @@ struct TrashLaneView: View { )) } - /// The rows the column shows — **both kinds, interleaved by rank** (03-board-ui.md § Trash: - /// "Lane rows and cards interleave in the one trash column purely by trash rank"). + /// The rows the column shows — **both kinds, interleaved by `modified` descending** + /// (03-board-ui.md § Trash: "Lane rows and cards interleave in the one trash column by `modified` + /// descending"). /// /// **Shown, the trash's contents "participate in the filter exactly like any other card"** /// (03 § Trash — "the point of the pivot"), so the search predicate narrows this collection diff --git a/KanbanTests/AgentGuideTests.swift b/KanbanTests/AgentGuideTests.swift index 5f5d422..ad7cd34 100644 --- a/KanbanTests/AgentGuideTests.swift +++ b/KanbanTests/AgentGuideTests.swift @@ -687,6 +687,24 @@ struct AgentGuideContentTests { #expect(content.contains("the trash is the recoverable path for both")) } + /// **v8: the arrival rank is retired** (08-agent-integration.md's own line, re-ruled 2026-07-31: + /// "move the card **or lane** folder into `/.trash/` and restamp `modified` (the trash + /// sorts newest-first by that stamp … no rank to mint)"). The guide has to teach the *stamp* as + /// the position and to leave `order` alone — and, just as load-bearing, it must no longer teach + /// the rank formula: an agent still computing "smallest `order` minus 1024" would be writing a + /// key the app now deliberately preserves for the restore. + @Test("v8 teaches the stamp as the trash's order, and the rank formula is gone") + func v8TrashOrderingVocabularyIsPresent() { + let content = AgentGuide.content + #expect(content.contains("sorts by `modified`, newest first**")) + #expect(content.contains("the stamp is also the position")) + #expect(content.contains("there is no rank to mint")) + #expect(content.contains("leave `order` exactly as it is")) + // The retired formula, in the two spellings the v7 literal used. + #expect(!content.contains("smallest `order` already in `.trash/`")) + #expect(!content.contains("Arrivals go on top")) + } + /// 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. diff --git a/KanbanTests/BodyWriteTests.swift b/KanbanTests/BodyWriteTests.swift index 831d37e..d932546 100644 --- a/KanbanTests/BodyWriteTests.swift +++ b/KanbanTests/BodyWriteTests.swift @@ -267,7 +267,7 @@ struct StoreWriteCardBodyTests { func aTrashedCardStillTakesTheFlush() throws { let fixture = try makeBoard() defer { fixture.tearDown() } - try BoardWriter.deleteCardToTrash(at: fixture.url(cardPath), inBoard: fixture.root, order: 1024) + try BoardWriter.deleteCardToTrash(at: fixture.url(cardPath), inBoard: fixture.root) let store = try BoardStore(rootURL: fixture.root) let trashedPath = ".trash/\(Ident.card1)" diff --git a/KanbanTests/CardWindowFateTests.swift b/KanbanTests/CardWindowFateTests.swift index aff2d2d..cfaeaaa 100644 --- a/KanbanTests/CardWindowFateTests.swift +++ b/KanbanTests/CardWindowFateTests.swift @@ -90,9 +90,7 @@ struct CardWindowFateTests { func aLaneTrashedDismissesItsCards() throws { let fixture = try makeBoard() defer { fixture.tearDown() } - try BoardWriter.deleteLaneToTrash( - at: fixture.url(Ident.lane1), inBoard: fixture.root, order: -1024 - ) + try BoardWriter.deleteLaneToTrash(at: fixture.url(Ident.lane1), inBoard: fixture.root) let snapshot = try BoardLoader.load(boardRoot: fixture.root).model #expect(snapshot.trashedLanes.map(\.id.rawValue) == [Ident.lane1]) diff --git a/KanbanTests/ClaimedNameHealTests.swift b/KanbanTests/ClaimedNameHealTests.swift index 873da2e..52f074f 100644 --- a/KanbanTests/ClaimedNameHealTests.swift +++ b/KanbanTests/ClaimedNameHealTests.swift @@ -194,8 +194,7 @@ struct ClaimedNameDisplacementTests { store.displaceClaimedNames() try BoardWriter.deleteCardToTrash( at: fixture.url("\(Ident.lane1)/\(Ident.card1)"), - inBoard: fixture.root, - order: 1024 + inBoard: fixture.root ) #expect(fixture.exists(".trash/\(Ident.card1)")) diff --git a/KanbanTests/ClipboardTests.swift b/KanbanTests/ClipboardTests.swift index 0b87069..8db9a95 100644 --- a/KanbanTests/ClipboardTests.swift +++ b/KanbanTests/ClipboardTests.swift @@ -669,6 +669,55 @@ struct ClipboardAvailabilityTests { == ItemReferenceSet(ids: [clipboardTrashedLane], container: .trash)) } + /// **The guard the trash's kind-blind selection moved to the exits** (04-interactions.md ▸ The + /// trash, ruled 2026-07-31): "the pasteboard's payload types are per-kind, so Cut and Copy grey + /// out via ordinary menu validation while a trash selection mixes kinds — no failed gesture, no + /// beep". + /// + /// It is also what keeps `ClipboardManifest.kind` honest: the manifest names one payload type, + /// and a set spanning both never reaches the capture. + @Test("A mixed trash selection greys out both Copy and Cut") + func mixedTrashSelectionClosesCopyAndCut() throws { + let harness = try makeTrashedLaneHarness() + defer { harness.tearDown() } + harness.store.transient.isTrashVisible = true + + // Each kind alone is fine — the selection is legal either way, and so is the gesture. + harness.store.select([clipboardCard3], in: .trash) + #expect(harness.clipboard.canCopy(from: harness.store)) + harness.store.select([clipboardTrashedLane], in: .trash) + #expect(harness.clipboard.canCopy(from: harness.store)) + + // Together — a selection the grammar now allows — the exits close. + harness.store.select([clipboardCard3, clipboardTrashedLane], in: .trash) + #expect(harness.store.selection.ids.count == 2, "the selection itself is legal") + #expect(harness.clipboard.canCopy(from: harness.store) == false) + #expect(harness.clipboard.canCut(from: harness.store) == false) + // Delete is deliberately *not* gated: it works on a mixed selection, the alert counting both + // kinds (04 ▸ The trash). + #expect(TrashModel.canDelete(selection: harness.store.selection, in: harness.store.snapshot)) + } + + /// The live board's own mixed set cannot be built by any gesture — but the predicate answers for + /// it anyway rather than assuming, so a future caller cannot smuggle one past the exits. + @Test("The mixed-kind predicate answers for the live board too") + func mixedKindPredicateCoversTheBoard() throws { + let harness = try makeClipboardHarness() + defer { harness.tearDown() } + let snapshot = harness.store.snapshot + let laneID = try #require(snapshot.lanes.first?.id) + + #expect(!SelectionGrammar.mixesKinds( + ItemReferenceSet(ids: [clipboardCard1], container: .board), in: snapshot)) + #expect(SelectionGrammar.mixesKinds( + ItemReferenceSet(ids: [clipboardCard1, laneID], container: .board), in: snapshot)) + // Rows the container no longer holds are ignored — a ghost must not grey out a menu item. + #expect(!SelectionGrammar.mixesKinds( + ItemReferenceSet(ids: [clipboardCard1, ItemID(rawValue: Ident.indexless)], container: .board), + in: snapshot)) + #expect(!SelectionGrammar.mixesKinds(.empty, in: snapshot)) + } + @Test("The read-only lock blocks cut but never copy") func lockBlocksCutOnly() throws { let harness = try makeClipboardHarness() diff --git a/KanbanTests/CommentSearchTests.swift b/KanbanTests/CommentSearchTests.swift index b3a8c02..d797e3a 100644 --- a/KanbanTests/CommentSearchTests.swift +++ b/KanbanTests/CommentSearchTests.swift @@ -232,6 +232,7 @@ struct CommentIndexMatchingTests { id: ItemID(rawValue: Ident.lane3), schema: 1, title: .valid("Retired"), + modified: .missing, order: 1024, heldCards: 2, document: FrontmatterDocument(body: "") diff --git a/KanbanTests/CommentWriteTests.swift b/KanbanTests/CommentWriteTests.swift index 1a57f91..66a519d 100644 --- a/KanbanTests/CommentWriteTests.swift +++ b/KanbanTests/CommentWriteTests.swift @@ -638,27 +638,63 @@ struct CommentCopyTests { #expect(!fixture.exists("\(copiedCard)/comments/.trash")) } - @Test("A comment nobody can stamp never refuses the copy — it travels verbatim") - func brokenCommentDoesNotRefuseACopy() throws { + /// **The preflight reaches comment depth** (01-storage-format.md § Identity lifecycle and + /// § Enhanced schema, ruled 2026-07-31 — reversing the 2026-07-30 carve-out this suite used to + /// pin): "a comment whose frontmatter cannot take the stamp refuses the copy exactly like a card + /// or lane — never-refuse … is a *load* posture, and a user-initiated copy is a transaction, not + /// a load". + /// + /// The board still loads with this comment on it — that is the other test in this file — and it + /// is the gesture that refuses. Whole, and with nothing materialized: a partial copy is the one + /// outcome the transaction rule exists to prevent. + @Test("A comment nobody can stamp refuses the whole copy, and nothing is materialized") + func brokenCommentRefusesACopy() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try fixture.item("", Item.board) try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo")) try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing")) try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Fix login")) - // Readable, uneditable — the shape that refuses a copy at card level. + // Readable, uneditable — the shape that refuses a copy at card level, now at comment depth. try fixture.item("\(Ident.lane1)/\(Ident.card1)/comments/\(CommentIdent.one)", Item.uneditable) - let copy = try BoardWriter.copyItem( - at: fixture.url("\(Ident.lane1)/\(Ident.card1)"), - toParent: fixture.url(Ident.lane2), - order: 1024, - stamps: .fork - ) - let copied = "\(Ident.lane2)/\(copy.rawValue)" - let names = try postedNames(fixture, inCard: copied) - #expect(names.count == 1, "the copy landed whole") - #expect(try fixture.indexText("\(copied)/comments/\(names[0])") == Item.uneditable, "verbatim") + var thrown: BoardWriteError? + do { + _ = try BoardWriter.copyItem( + at: fixture.url("\(Ident.lane1)/\(Ident.card1)"), + toParent: fixture.url(Ident.lane2), + order: 1024, + stamps: .fork + ) + } catch { + thrown = error + } + + let error = try #require(thrown) + if case .uneditableFrontmatter = error.reason {} else { + Issue.record("expected an uneditable-frontmatter refusal, got \(error.reason)") + } + #expect(error.path.hasSuffix("comments/\(CommentIdent.one)/index.md"), + "the path points at the annotation, which is where the fix is") + // Nothing landed, and the source is untouched. + #expect(try fixture.entryNames(Ident.lane2).filter { IntegrityRules.isIdentityShaped($0) }.isEmpty) + #expect(try fixture.indexText("\(Ident.lane1)/\(Ident.card1)/comments/\(CommentIdent.one)") + == Item.uneditable) + } + + /// The other side of "load-scoped": the very same board **loads**, and its card window's thread + /// read tolerates the annotation. Only the copy gesture refuses. + @Test("The same broken comment never refuses the board") + func brokenCommentNeverRefusesTheBoard() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + try fixture.item("", Item.board) + try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo")) + try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Fix login")) + try fixture.item("\(Ident.lane1)/\(Ident.card1)/comments/\(CommentIdent.one)", Item.uneditable) + + let model = try BoardLoader.load(boardRoot: fixture.root).model + #expect(model.lanes.first?.cards.map(\.id.rawValue) == [Ident.card1]) } @Test("Template instantiation is born-today at comment depth too") @@ -739,7 +775,7 @@ struct CommentCopyTests { try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo")) let card = try threadedCard(fixture, in: Ident.lane1, card: Ident.card1) - try BoardWriter.deleteCardToTrash(at: fixture.url(card), inBoard: fixture.root, order: 1024) + try BoardWriter.deleteCardToTrash(at: fixture.url(card), inBoard: fixture.root) let trashedCard = ".trash/\(Ident.card1)" #expect(fixture.exists("\(trashedCard)/comments/\(CommentIdent.one)")) diff --git a/KanbanTests/DragSessionTests.swift b/KanbanTests/DragSessionTests.swift index 29ef2ab..9d9ceb1 100644 --- a/KanbanTests/DragSessionTests.swift +++ b/KanbanTests/DragSessionTests.swift @@ -241,6 +241,80 @@ struct TrashDropTests { } } +// MARK: - The mixed-kind drag out of the trash + +/// **"A mixed-kind drag never leaves the trash"** (04-interactions.md ▸ The trash, ruled 2026-07-31 +/// with kind-blind trash selection): "pickup is allowed — the selection is legal — but every +/// out-of-trash drop target refuses the mixed payload, and the release surfaces a notice explaining +/// the rule … the refused drag ends like any refusal, rows staying put". +/// +/// The refusal itself lives in `BoardDropContext.commitDrop`, which needs a live window and is not +/// unit-testable — the same split every other drop suite makes. What is testable is the whole of +/// what the refusal is *made* of: the flag a pickup records, and the notice the release posts. +@MainActor +@Suite("The mixed-kind drag out of the trash") +struct MixedTrashDragTests { + + private static let card1 = ItemID(rawValue: Ident.card1) + private static let lane1 = ItemID(rawValue: Ident.lane1) + + /// One lane and one trashed card — enough for a store to exist and a session to name folders. + private func makeBoard() 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(".trash/\(Ident.card1)", Item.rich(order: "1024", title: "Trashed")) + return fixture + } + + /// Pickup is allowed, and the flag is what travels instead of the rows that cannot ride a + /// per-kind payload — so nothing falls silently out of the drag. + @Test("A pickup records whether its selection spanned both kinds") + func theFlagTravels() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + let session = DragSession() + let folder = store.rootURL.appendingPathComponent(Ident.card1, isDirectory: true) + + session.beginCards([Self.card1], folders: [folder], heights: [44], container: .trash, source: store) + #expect(!session.mixesKinds, "an ordinary trash-card drag carries no flag") + + session.beginCards( + [Self.card1], folders: [folder], heights: [44], + container: .trash, source: store, mixesKinds: true + ) + #expect(session.mixesKinds) + #expect(session.container == .trash) + + // The lane level records it the same way, and a trashed lane row's session is in `.trash` — + // which is what routes its release to the restore rather than to a strip permutation. + session.beginLanes( + [Self.lane1], folders: [folder], units: [1], + container: .trash, source: store, mixesKinds: true + ) + #expect(session.mixesKinds) + #expect(session.container == .trash) + + // And an ordinary strip drag is unaffected: board container, no flag. + session.beginLanes([Self.lane1], folders: [folder], units: [1], source: store) + #expect(!session.mixesKinds) + #expect(session.container == .board) + } + + /// The notice is 04's own sentence, and it is a **loss row** — nothing failed and no write was + /// attempted, but the gesture the user made did not happen (the `postSkippedFolders` register). + @Test("The release's notice is the rule, in the design's own words") + func theNoticeExplainsTheRule() { + let banners = BannerCenter() + banners.postMixedTrashDrag() + + #expect(banners.losses.map(\.message) + == ["Cards and lanes leave the trash separately \u{2014} restore one kind at a time"]) + #expect(banners.oneShots.isEmpty, "no write failed — this is not an error row") + } +} + // MARK: - The committed-overlay hold @Suite("CommittedHold") diff --git a/KanbanTests/DragWriteTests.swift b/KanbanTests/DragWriteTests.swift index 455ba02..dbf0f37 100644 --- a/KanbanTests/DragWriteTests.swift +++ b/KanbanTests/DragWriteTests.swift @@ -675,9 +675,14 @@ struct RestoreByMoveOutTests { try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First")) try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing")) try fixture.item("\(Ident.lane2)/\(Ident.card4)", Item.rich(order: "1024", title: "Fourth")) - // The trash's own order is its display order — `card3` above `card2`, newest-first. - try fixture.item(".trash/\(Ident.card2)", Item.rich(order: "2048", title: "TrashedA")) - try fixture.item(".trash/\(Ident.card3)", Item.rich(order: "1024", title: "TrashedB")) + // The trash's own order is its display order, and since 2026-07-31 that is `modified` + // descending (01-storage-format.md § Deletion) — `card3` above `card2`, newest-first. + try fixture.item( + ".trash/\(Ident.card2)", + "---\nschema: 1\ntitle: TrashedA\norder: 2048\nmodified: 2026-05-01T09:00:00Z\n---\n") + try fixture.item( + ".trash/\(Ident.card3)", + "---\nschema: 1\ntitle: TrashedB\norder: 1024\nmodified: 2026-05-03T09:00:00Z\n---\n") let store = try BoardStore(rootURL: fixture.root) store.moveCards([card3, card2], toLane: lane2, at: 0) @@ -829,7 +834,7 @@ struct RestoreLaneTests { #expect(try fixture.indexText(Ident.lane3).contains("project: lanework"), "unknown keys ride along") } - @Test("Its undo is the ordinary move back in, at the trash rank the row was holding") + @Test("Its undo is the ordinary move back in, at the `order` the row was carrying") func undoMovesItBackIn() throws { let fixture = try makeTrashedLaneBoard() defer { fixture.tearDown() } diff --git a/KanbanTests/EchoLedgerTests.swift b/KanbanTests/EchoLedgerTests.swift index 67bf747..606b5e3 100644 --- a/KanbanTests/EchoLedgerTests.swift +++ b/KanbanTests/EchoLedgerTests.swift @@ -271,7 +271,7 @@ struct EchoLedgerWriterTests { let from = fixture.url("\(lane1)/\(card1)") try EchoLedger.$current.withValue(ledger) { - _ = try BoardWriter.deleteCardToTrash(at: from, inBoard: fixture.root, order: 1024) + _ = try BoardWriter.deleteCardToTrash(at: from, inBoard: fixture.root) } let to = fixture.url(".trash/\(card1)") @@ -594,13 +594,11 @@ struct EchoLedgerHealMarkTests { try EchoLedger.$current.withValue(ledger) { _ = try BoardWriter.migrateTombstonedCard( at: fixture.url("\(lane1)/\(Ident.card3)"), - inBoard: fixture.root, - order: 1024 + inBoard: fixture.root ) _ = try BoardWriter.deleteCardToTrash( at: fixture.url("\(lane1)/\(card1)"), - inBoard: fixture.root, - order: 2048 + inBoard: fixture.root ) } diff --git a/KanbanTests/InertGitTests.swift b/KanbanTests/InertGitTests.swift index 99b5a5f..4b899a4 100644 --- a/KanbanTests/InertGitTests.swift +++ b/KanbanTests/InertGitTests.swift @@ -186,7 +186,7 @@ struct InertGitTests { // the pair most likely to notice a `.git` at the root: the first walks into `/.trash/` // and the second walks back out of it. try BoardWriter.deleteCardToTrash( - at: board.url("\(board.laneA)/\(board.card1)"), inBoard: board.root, order: 1024 + at: board.url("\(board.laneA)/\(board.card1)"), inBoard: board.root ) _ = try BoardWriter.moveItem( at: BoardWriter.trashFolder(inBoard: board.root).appendingPathComponent(board.card1), diff --git a/KanbanTests/IntegrityRulesTests.swift b/KanbanTests/IntegrityRulesTests.swift index 2d46063..c5f189b 100644 --- a/KanbanTests/IntegrityRulesTests.swift +++ b/KanbanTests/IntegrityRulesTests.swift @@ -470,8 +470,7 @@ struct ObjectKindWriteTests { try BoardWriter.deleteCardToTrash( at: fixture.url("\(Ident.lane1)/\(Ident.card1)"), - inBoard: fixture.root, - order: 1024 + inBoard: fixture.root ) #expect(try kind(of: ".trash/\(Ident.card1)", in: fixture) == .valid("card")) diff --git a/KanbanTests/SearchFilterTests.swift b/KanbanTests/SearchFilterTests.swift index 6c2bd7e..741aa93 100644 --- a/KanbanTests/SearchFilterTests.swift +++ b/KanbanTests/SearchFilterTests.swift @@ -25,10 +25,13 @@ private enum More { /// A card or lane whose title and body are **independently controlled** — which `Item.rich` cannot /// be, since its body quotes its title, and a title-or-body test needs the two to disagree. -private func item(order: String, title: String?, body: String) -> String { +private func item(order: String, title: String?, body: String, modified: String? = nil) -> String { var lines = ["---", "schema: 1"] if let title { lines.append("title: \(title)") } lines.append("order: \(order)") + // The trash sorts by `modified` descending (01-storage-format.md § Deletion, re-ruled + // 2026-07-31), so a trash fixture states its column order here rather than in `order`. + if let modified { lines.append("modified: \(modified)") } lines.append("---") return lines.joined(separator: "\n") + "\n" + body + "\n" } @@ -109,12 +112,17 @@ private func makeTrashBoard() throws -> WriterFixture { let fixture = try WriterFixture() try fixture.item("", Item.board) try fixture.item(Ident.lane1, item(order: "1024", title: "Todo", body: "Inbox lane.")) - try fixture.item(".trash/\(Ident.card1)", item(order: "1024", title: "Fix login", body: "Auth.")) + // The column reads [card1, laneX, card2] — by their stamps, both kinds interleaved. + try fixture.item( + ".trash/\(Ident.card1)", + item(order: "1024", title: "Fix login", body: "Auth.", modified: "2026-05-05T09:00:00Z")) try fixture.item( ".trash/\(More.laneX)", - "---\nschema: 1\ntitle: Archive\norder: 1536\nkind: lane\n---\nOld login notes.\n" + "---\nschema: 1\ntitle: Archive\norder: 1536\nmodified: 2026-05-03T09:00:00Z\nkind: lane\n---\nOld login notes.\n" ) - try fixture.item(".trash/\(Ident.card2)", item(order: "2048", title: "Polish", body: "Wording.")) + try fixture.item( + ".trash/\(Ident.card2)", + item(order: "2048", title: "Polish", body: "Wording.", modified: "2026-05-01T09:00:00Z")) return fixture } @@ -335,19 +343,20 @@ struct SearchFilterOrderTests { /// "The row matches the search filter by lane title only" (03-board-ui.md § Trash) — the opaque /// unit's own rule, and the one place a lane *is* filtered: `laneX`'s body carries the query and /// the row still leaves the column, because its body is not on screen. - @Test("A trashed lane row filters by title alone, and its own list is kind-scoped") + @Test("A trashed lane row filters by title alone") func trashLaneRowsFilterByTitle() throws { let fixture = try makeTrashBoard() defer { fixture.tearDown() } let model = try load(fixture) - #expect(SelectionGrammar.order(of: .lane, in: .trash, snapshot: model) == [laneX]) + #expect(SelectionGrammar.trashLanes(in: model) == [laneX]) #expect(SelectionGrammar.trashLanes(in: model, filter: SearchFilter(query: "archive")) == [laneX]) // The body says "login". The row does not. #expect(SelectionGrammar.trashLanes(in: model, filter: SearchFilter(query: "login")).isEmpty) - // And the kind-scoped lists are disjoint slices of one rank order, which is what makes a - // ⇧-range skip the other kind (04-interactions.md ▸ The trash). + // And the ranging grammar's list is the whole column, both kinds — kind-blind trash selection + // (04-interactions.md ▸ The trash, re-ruled 2026-07-31). #expect(SelectionGrammar.trashRows(in: model) == [card1, laneX, card2]) + #expect(SelectionGrammar.order(of: .lane, in: .trash, snapshot: model) == [card1, laneX, card2]) } @Test("The trash's visible universe is its matching rows, both kinds") @@ -371,7 +380,7 @@ struct SearchFilterOrderTests { defer { fixture.tearDown() } let model = try load(fixture) - // Both kinds, interleaved purely by rank (03-board-ui.md § Trash). + // Both kinds, interleaved by `modified` descending (03-board-ui.md § Trash). #expect(TrashLaneView.rendered(model, filter: .inactive).map(\.id) == [card1, laneX, card2]) #expect(TrashLaneView.rendered(model, filter: SearchFilter(query: "login")).map(\.id) == [card1]) // The column and the arrow grammar cannot disagree about what is on screen. @@ -431,7 +440,8 @@ struct SearchFilterStoreTests { store.transient.isTrashVisible = true store.select([card2], in: .trash) store.selectAll() - #expect(store.selection.ids == [card1, card2]) + // Every visible *row*, both kinds — kind-blind since 2026-07-31. + #expect(store.selection.ids == [card1, laneX, card2]) store.select([card1], in: .trash) store.searchQuery = "login" diff --git a/KanbanTests/SelectionGrammarTests.swift b/KanbanTests/SelectionGrammarTests.swift index 732018b..0ef4ea7 100644 --- a/KanbanTests/SelectionGrammarTests.swift +++ b/KanbanTests/SelectionGrammarTests.swift @@ -12,10 +12,11 @@ import Testing /// read card ordering and the trash container, and a hand-built `BoardModel` would let both drift /// from what the loader actually produces. /// -/// **Two homogeneity axes, and the kind one reaches into the trash** (resettled 2026-07-28; lanes -/// rejoined 2026-07-29): cards XOR lanes, and board XOR trash. The trash's rows are cards *and* -/// opaque lane units, so "a trash selection is either cards or lane rows" is the board's own kind -/// rule in a second container rather than a third axis. +/// **One container axis, and a kind axis that stops at it** (resettled 2026-07-28; kind-blind trash +/// re-ruled 2026-07-31): a selection never mixes trash rows with board items, and *on the board* it +/// is cards XOR lanes — but inside the trash "cards and lane rows select together", so the kind axis +/// does not reach into the second container. The guard that used to live there moved to the exits +/// (⌘C/⌘X validation and the mixed-payload drop refusal). /// /// `WriterFixture`, `Ident` and `Item` live in `WriterTestSupport.swift`. @@ -46,32 +47,50 @@ private func makeLiveBoard() throws -> WriterFixture { } /// A board with one lane and three cards in its `.trash/` — the container the trash-side grammar -/// walks, in `order` display order (`[card1, card2, card3]`, newest first by ordinary ranks). +/// walks, newest first by `modified` (`[card1, card2, card3]`). @MainActor private func makeTrashBoard() 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)/\(More.card6)", Item.rich(order: "1024", title: "Live")) - try fixture.item(".trash/\(Ident.card1)", Item.rich(order: "256", title: "First")) - try fixture.item(".trash/\(Ident.card2)", Item.rich(order: "512", title: "Second")) - try fixture.item(".trash/\(Ident.card3)", Item.rich(order: "1024", title: "Third")) + // **The trash's order is `modified` descending** (01-storage-format.md § Deletion, re-ruled + // 2026-07-31), so the stamps are what these fixtures state their sequence in; the `order` values + // ride along and are deliberately in the *opposite* direction, so nothing here can pass by + // accident of the retired rank rule. + try fixture.item(".trash/\(Ident.card1)", trashItem(order: "1024", title: "First", modified: "2026-05-05T09:00:00Z")) + try fixture.item(".trash/\(Ident.card2)", trashItem(order: "512", title: "Second", modified: "2026-05-03T09:00:00Z")) + try fixture.item(".trash/\(Ident.card3)", trashItem(order: "256", title: "Third", modified: "2026-05-01T09:00:00Z")) return fixture } +/// One trash entry, stated in the key the container actually sorts by. +private func trashItem(order: String, title: String, modified: String, kind: String? = nil) -> String { + """ + --- + schema: 1 + title: \(title) + order: \(order) + modified: \(modified) + \(kind.map { "kind: \($0)\n" } ?? "")--- + \(title) body. + + """ +} + /// The same trash with **two lane rows interleaved among its cards** (03-board-ui.md § Trash, lanes -/// rejoined 2026-07-29): the rank order is `[card1, lane2, card2, lane3, card3]`, so every -/// kind-boundary claim below has a row of the other kind sitting inside the span it asks about. +/// rejoined 2026-07-29): the column order is `[card1, lane2, card2, lane3, card3]`, so every +/// kind-crossing claim below has a row of the other kind sitting inside the span it asks about. @MainActor private func makeMixedTrashBoard() throws -> WriterFixture { let fixture = try makeTrashBoard() try fixture.item( ".trash/\(Ident.lane2)", - "---\nschema: 1\ntitle: Doing\norder: 384\nkind: lane\n---\n" + trashItem(order: "384", title: "Doing", modified: "2026-05-04T09:00:00Z", kind: "lane") ) try fixture.item( ".trash/\(Ident.lane3)", - "---\nschema: 1\ntitle: Done\norder: 768\nkind: lane\n---\n" + trashItem(order: "768", title: "Done", modified: "2026-05-02T09:00:00Z", kind: "lane") ) return fixture } @@ -135,7 +154,7 @@ struct SelectionOrderTests { #expect(SelectionGrammar.lanes(in: snapshot) == [lane1, lane2, lane3]) } - @Test("The trash's list is its cards, in `order`; a trash with no lane rows has no lane list") + @Test("The trash's list is its rows, newest first; a trash with no lane rows is all cards") func trashOrder() throws { let fixture = try makeTrashBoard() defer { fixture.tearDown() } @@ -143,21 +162,30 @@ struct SelectionOrderTests { #expect(SelectionGrammar.trashCards(in: snapshot) == [card1, card2, card3]) #expect(SelectionGrammar.order(of: .card, in: .trash, snapshot: snapshot) == [card1, card2, card3]) - #expect(SelectionGrammar.order(of: .lane, in: .trash, snapshot: snapshot).isEmpty) + // Kind-blind: with no lane rows in the container the two lists coincide, which is the point — + // there is only ever *one* trash list to walk (04 ▸ The trash, re-ruled 2026-07-31). + #expect(SelectionGrammar.order(of: .lane, in: .trash, snapshot: snapshot) == [card1, card2, card3]) + #expect(SelectionGrammar.trashLanes(in: snapshot).isEmpty) } - /// **The column's three lists** (03-board-ui.md § Trash; 04-interactions.md ▸ The trash): one - /// merged rank order for *navigation*, and two kind-scoped slices of it for *ranging*. The - /// slices are what make a ⇧-range skip the other kind without a rule that says so. - @Test("The trash's rows interleave by rank, and each kind's list is a slice of that order") + /// **The column has one list, and the kind argument does not narrow it** (04-interactions.md ▸ + /// The trash, re-ruled 2026-07-31 — kind-blind trash selection): navigation and ranging read the + /// same merged sequence, so a ⇧-range sweeps the rows of the other kind rather than skipping + /// them. The kind-scoped slices survive as `trashCards`/`trashLanes` for the consumers that + /// genuinely mean one kind, and they are still slices of the same order. + @Test("The trash's rows interleave by stamp, and `order(of:in:)` returns them whatever the kind") func trashRowsInterleave() throws { let fixture = try makeMixedTrashBoard() defer { fixture.tearDown() } let snapshot = try load(fixture) - #expect(SelectionGrammar.trashRows(in: snapshot) == [card1, lane2, card2, lane3, card3]) - #expect(SelectionGrammar.order(of: .card, in: .trash, snapshot: snapshot) == [card1, card2, card3]) - #expect(SelectionGrammar.order(of: .lane, in: .trash, snapshot: snapshot) == [lane2, lane3]) + let rows = [card1, lane2, card2, lane3, card3] + #expect(SelectionGrammar.trashRows(in: snapshot) == rows) + #expect(SelectionGrammar.order(of: .card, in: .trash, snapshot: snapshot) == rows) + #expect(SelectionGrammar.order(of: .lane, in: .trash, snapshot: snapshot) == rows) + // The kind-scoped slices are still slices of it. + #expect(SelectionGrammar.trashCards(in: snapshot) == [card1, card2, card3]) + #expect(SelectionGrammar.trashLanes(in: snapshot) == [lane2, lane3]) // One merge, one order: the path resolver batches in exactly the order the column draws. #expect(ItemPath.resolve([card2, lane2, lane3], in: .trash, snapshot: snapshot) == [.trashLane(lane2), .trashCard(card2), .trashLane(lane3)]) @@ -287,19 +315,21 @@ struct CommandClickTests { #expect(ontoCard.anchor == card1) } - /// The kind axis inside the trash: "a trash selection is either cards or lane rows, - /// kind-homogeneous like the live board's own grammar" (04-interactions.md ▸ The trash). - @Test("⌘-click across the kind boundary inside the trash replaces") - func acrossKindInTheTrashReplaces() throws { + /// **Inside the trash there is no kind boundary to cross** (04-interactions.md ▸ The trash, + /// re-ruled 2026-07-31, superseding the kind-homogeneous trash grammar): "within the trash cards + /// and lane rows select together — clicks, ⇧-click ranges, ⇧-arrow extension, and the rubber + /// band all sweep every row". So a ⌘-click that used to replace now *extends*. + @Test("⌘-click adds a lane row to a card selection inside the trash") + func acrossKindInTheTrashExtends() throws { let fixture = try makeMixedTrashBoard() defer { fixture.tearDown() } let snapshot = try load(fixture) let ontoRow = click(target(lane2, .lane, .trash), .command, selection: set([card1], .trash), anchor: card1, in: snapshot) - #expect(ontoRow.selection == set([lane2], .trash)) + #expect(ontoRow.selection == set([card1, lane2], .trash)) let ontoCard = click(target(card1, .card, .trash), .command, selection: set([lane2, lane3], .trash), anchor: lane3, in: snapshot) - #expect(ontoCard.selection == set([card1], .trash)) + #expect(ontoCard.selection == set([lane2, lane3, card1], .trash)) // Within one kind it still toggles, which is what makes the branch above a rule rather than // a refusal of ⌘ in the trash. @@ -421,28 +451,29 @@ struct ShiftClickTests { #expect(outcome.anchor == card1) } - /// "⇧-click ranges skip rows of the other kind (resurrecting the 2026-07-28 skip-by-kind ruling, - /// mooted when lanes left the trash and back with them)" — 04-interactions.md ▸ The trash. - @Test("A trash range skips rows of the other kind") - func trashRangeSkipsTheOtherKind() throws { + /// **A trash range sweeps every row** (04-interactions.md ▸ The trash, re-ruled 2026-07-31 — + /// superseding the skip-by-kind ruling this suite used to pin): "⇧-click ranges … all sweep every + /// row". The kinds are not a boundary inside the container any more; the container still is. + @Test("A trash range sweeps every row between its endpoints, both kinds") + func trashRangeSweepsEveryRow() throws { let fixture = try makeMixedTrashBoard() defer { fixture.tearDown() } let snapshot = try load(fixture) - // The column reads [card1, lane2, card2, lane3, card3]: a card range from the top to the - // bottom takes the three cards and steps over both lane rows. + // The column reads [card1, lane2, card2, lane3, card3]: a range from the top to the bottom + // now takes all five rows rather than stepping over the two lane rows. let cards = click(target(card3, .card, .trash), .shift, selection: set([card1], .trash), anchor: card1, in: snapshot) - #expect(cards.selection == set([card1, card2, card3], .trash)) + #expect(cards.selection == set([card1, lane2, card2, lane3, card3], .trash)) - // And a lane-row range takes the rows, skipping the card sitting between them. + // And a range anchored on a lane row picks up the card between them. let rows = click(target(lane3, .lane, .trash), .shift, selection: set([lane2], .trash), anchor: lane2, in: snapshot) - #expect(rows.selection == set([lane2, lane3], .trash)) + #expect(rows.selection == set([lane2, card2, lane3], .trash)) - // A range aimed across the kinds has no list holding both endpoints, so it degrades to a - // plain click — never a mixed selection. + // And a range aimed from a card to a lane row is now an ordinary range rather than a + // degraded plain click: both endpoints sit in the one list, so the anchor stays put. let crossed = click(target(lane3, .lane, .trash), .shift, selection: set([card1], .trash), anchor: card1, in: snapshot) - #expect(crossed.selection == set([lane3], .trash)) - #expect(crossed.anchor == lane3) + #expect(crossed.selection == set([card1, lane2, card2, lane3], .trash)) + #expect(crossed.anchor == card1) } @Test("A range never crosses the container boundary") @@ -483,12 +514,13 @@ struct MarqueeMathTests { #expect(ids == [card1, card2]) } - /// **The kind filter is the whole rule, and in the trash it is load-bearing.** A trashed lane row - /// registers its frame like a card does — the arrows navigate by those frames — so the band - /// genuinely sweeps over one and must still leave it out: "the rubber band … selects cards only - /// (as the board marquee does); lane rows join by click grammar" (04-interactions.md ▸ The trash). - @Test("On the trash side the band takes cards only, and stays on its own side") - func trashSideTakesItsOwnCards() { + /// **In the trash the band sweeps every row** (04-interactions.md ▸ The trash, re-ruled + /// 2026-07-31, superseding the card-only band): "the rubber band [sweeps] every row (the band's + /// full-height backdrop covers both kinds)". A trashed lane row registers its frame like a card + /// does, so this needed only the kind filter to come off on that side — the *container* filter + /// stays, and the band still never leaves the side it began on. + @Test("On the trash side the band takes every row, and stays on its own side") + func trashSideTakesEveryRow() { let targets = [ Self.card(card1, 0, container: .trash), MarqueeTarget(id: lane2, kind: .lane, container: .trash, @@ -498,7 +530,7 @@ struct MarqueeMathTests { ] let all = CGRect(x: 0, y: 0, width: 50, height: 200) - #expect(MarqueeMath.selection(rect: all, targets: targets, in: .trash) == [card1, card2]) + #expect(MarqueeMath.selection(rect: all, targets: targets, in: .trash) == [card1, lane2, card2]) #expect(MarqueeMath.selection(rect: all, targets: targets, in: .board) == [card3]) } @@ -637,17 +669,19 @@ struct SelectAllTests { store.transient.isTrashVisible = true // "With the trash visible and a non-empty trash selection, Select All selects all visible - // trash cards" (04 ▸ The map, resettled 2026-07-28). There is no kind clause left to honour. + // trash rows" (04 ▸ The map; kind-blind since 2026-07-31). This container holds only cards, + // so rows and cards coincide — the mixed case is the test below. store.select([card2], in: .trash) store.selectAll() #expect(store.selection == set([card1, card2, card3], .trash)) } - /// "Select All is card-scoped everywhere, never lane rows" (04-interactions.md ▸ The trash, - /// re-affirmed 2026-07-29): a lane-row selection is a *trash* selection, so the command reads the - /// column — and what it selects there is its cards. - @Test("A lane-row selection still selects the trash's cards, never the rows") - func trashBranchIsCardScopedWithLaneRows() throws { + /// **In the trash, "all" is all rows** (04-interactions.md ▸ The trash and 11-command-nexus.md ▸ + /// Select All, re-ruled 2026-07-31 with kind-blind trash selection: "Select All with a non-empty + /// trash selection selects **all visible trash rows**"). The live board's own Select All stays + /// card-scoped, which the board branch above pins. + @Test("Select All in the trash takes every row, lane rows included") + func trashBranchTakesEveryRow() throws { let fixture = try makeMixedTrashBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) @@ -656,9 +690,7 @@ struct SelectAllTests { store.select([lane2], in: .trash) store.selectAll() - #expect(store.selection == set([card1, card2, card3], .trash)) - #expect(!store.selection.ids.contains(lane2)) - #expect(!store.selection.ids.contains(lane3)) + #expect(store.selection == set([card1, lane2, card2, lane3, card3], .trash)) } @Test("The trash branch is narrow: hidden, live, empty, or ghost selections take the board") diff --git a/KanbanTests/TrashModelTests.swift b/KanbanTests/TrashModelTests.swift index 53b2d80..87fa56d 100644 --- a/KanbanTests/TrashModelTests.swift +++ b/KanbanTests/TrashModelTests.swift @@ -28,8 +28,11 @@ private enum More { static let cardF = "ffffffff-ffff-4fff-8fff-ffffffffffff" } -private func card(order: String, title: String) -> String { - "---\nschema: 1\ntitle: \(title)\norder: \(order)\n---\n\(title) body.\n" +private func card(order: String, title: String, modified: String? = nil) -> String { + // The trash sorts by `modified` descending (01-storage-format.md § Deletion, re-ruled + // 2026-07-31), so a trash fixture states its column position here rather than in `order`. + let stamp = modified.map { "modified: \($0)\n" } ?? "" + return "---\nschema: 1\ntitle: \(title)\norder: \(order)\n\(stamp)---\n\(title) body.\n" } private func untitled(order: String) -> String { @@ -48,10 +51,11 @@ private func makeBoard() throws -> WriterFixture { try fixture.item(More.laneA, card(order: "1024", title: "Todo")) try fixture.item("\(More.laneA)/\(Ident.card1)", card(order: "1024", title: "First")) try fixture.item("\(More.laneA)/\(Ident.card2)", card(order: "2048", title: "Second")) - // Newest-first by ordinary ranks: every arrival mints above the current top. - try fixture.item(".trash/\(More.cardD)", card(order: "1024", title: "Oldest")) - try fixture.item(".trash/\(More.cardE)", card(order: "512", title: "Middle")) - try fixture.item(".trash/\(More.cardF)", card(order: "256", title: "Newest")) + // Newest-first by `modified`; the ranks disagree on purpose, so nothing here can pass by + // accident of the retired arrival-rank rule. + try fixture.item(".trash/\(More.cardD)", card(order: "256", title: "Oldest", modified: "2026-05-01T09:00:00Z")) + try fixture.item(".trash/\(More.cardE)", card(order: "512", title: "Middle", modified: "2026-05-03T09:00:00Z")) + try fixture.item(".trash/\(More.cardF)", card(order: "1024", title: "Newest", modified: "2026-05-05T09:00:00Z")) return fixture } @@ -70,16 +74,16 @@ private let cardF = ItemID(rawValue: More.cardF) @Suite("The trash's contents are the container") struct TrashContentsTests { - @Test("The trash is `snapshot.trash`, newest first by ordinary ranks") + @Test("The trash is `snapshot.trash`, newest first by `modified`") func theContainerIsTheList() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let snapshot = try load(fixture) #expect(snapshot.trash.compactMap(\.title.value) == ["Newest", "Middle", "Oldest"], - "03 ▸ Trash: the trash sorts by `order` like any lane, and entry is at the top") + "03 ▸ Trash: the trash sorts by `modified` descending, and entry is at the top") #expect(snapshot.trash.allSatisfy { $0.deleted.isMissing }, - "there is no `deleted:` key and no timestamp sort") + "there is no `deleted:` key — the stamp is the position") } @Test("Lanes are never in it, whatever a hand-editor nests in there") @@ -306,8 +310,11 @@ struct TrashFreightTests { let fixture = try WriterFixture() try fixture.item("", Item.board) try fixture.item(More.laneA, card(order: "1024", title: "Todo")) - try fixture.item(".trash/\(More.cardD)", card(order: "1024", title: "A card")) - try fixture.item(".trash/\(Ident.lane2)", "---\nschema: 1\ntitle: Doing\norder: 512\nkind: lane\n---\n") + try fixture.item( + ".trash/\(More.cardD)", card(order: "1024", title: "A card", modified: "2026-05-01T09:00:00Z")) + try fixture.item( + ".trash/\(Ident.lane2)", + "---\nschema: 1\ntitle: Doing\norder: 512\nmodified: 2026-05-03T09:00:00Z\nkind: lane\n---\n") for index in 0 ..< held { try fixture.item( ".trash/\(Ident.lane2)/\(UUID().uuidString.lowercased())", @@ -396,15 +403,15 @@ struct TrashFreightTests { == [.trashLane(ItemID(rawValue: Ident.lane2))]) } - /// The column is one list: `resolve` hands back the trash's paths interleaved by rank, because - /// a batch's order is the column's order (03 § Trash). - @Test("Resolution interleaves the container's two kinds by rank") + /// The column is one list: `resolve` hands back the trash's paths interleaved by `modified`, + /// because a batch's order is the column's order (03 § Trash). + @Test("Resolution interleaves the container's two kinds by the column's own order") func resolutionInterleaves() throws { let fixture = try makeFreightBoard(held: 0) defer { fixture.tearDown() } let snapshot = try load(fixture) - // The lane sits at 512 and the card at 1024, so the lane row comes first. + // The lane's stamp is the newer of the two, so the lane row comes first. #expect(ItemPath.resolve( [ItemID(rawValue: Ident.lane2), cardD], in: .trash, snapshot: snapshot ) == [.trashLane(ItemID(rawValue: Ident.lane2)), .trashCard(cardD)]) diff --git a/KanbanTests/TrashStorageTests.swift b/KanbanTests/TrashStorageTests.swift index 048c540..e0d188f 100644 --- a/KanbanTests/TrashStorageTests.swift +++ b/KanbanTests/TrashStorageTests.swift @@ -57,11 +57,12 @@ private func uuidName() -> String { UUID().uuidString.lowercased() } @Suite("BoardLoader ▸ the .trash container") struct TrashContainerLoadTests { - /// The container's whole ordering story: ordinary `order` ranks, ascending, sorted exactly as a - /// lane's cards are — newest-first falls out of *minting* (each arrival takes a rank above the - /// current top), never out of a timestamp sort, so the loader has no trash-specific rule at all. - @Test("Trash cards load in rank order, in their own container, carrying no deleted key") - func trashCardsLoadInRankOrder() throws { + /// The container's whole ordering story: **`modified` descending** (01-storage-format.md + /// § Deletion, re-ruled 2026-07-31 — the arrival rank mint retired). `order` rides along + /// untouched and is deliberately *not* consulted, which is what this fixture proves by giving the + /// three entries ranks that disagree with their stamps in every direction. + @Test("Trash cards load newest-first by `modified`, in their own container, carrying no deleted key") + func trashCardsLoadNewestFirstByModified() throws { let fixture = try TrashFixture() defer { fixture.tearDown() } @@ -72,15 +73,23 @@ struct TrashContainerLoadTests { try fixture.index("", "schema: 1\n") try fixture.index(lane, "schema: 1\norder: 1024\n") - // Written oldest-first on disk; the ranks are what decides. - try fixture.index(".trash/\(oldest)", "schema: 1\norder: 1024\ntitle: Oldest\n") - try fixture.index(".trash/\(middle)", "schema: 1\norder: 0\ntitle: Middle\n") - try fixture.index(".trash/\(newest)", "schema: 1\norder: -1024\ntitle: Newest\n") + // The `order` values are scrambled against the stamps on purpose: were a rank still deciding + // anything, this fixture would read Newest, Middle, Oldest by accident of the old rule and + // the assertion would pass for the wrong reason. + try fixture.index( + ".trash/\(oldest)", "schema: 1\norder: -1024\ntitle: Oldest\nmodified: 2026-07-01T09:00:00Z\n") + try fixture.index( + ".trash/\(middle)", "schema: 1\norder: 1024\ntitle: Middle\nmodified: 2026-07-15T09:00:00Z\n") + try fixture.index( + ".trash/\(newest)", "schema: 1\norder: 0\ntitle: Newest\nmodified: 2026-07-30T09:00:00Z\n") let result = try BoardLoader.load(boardRoot: fixture.root) #expect(result.model.trash.map(\.id.rawValue) == [newest, middle, oldest]) #expect(result.model.trash.map(\.title.value) == ["Newest", "Middle", "Oldest"]) + // And the ranks really did ride along untouched — the loader read them, it just did not sort + // by them. + #expect(result.model.trash.map(\.order) == [0, 1024, -1024]) // The pivot in one assertion: a trashed card carries no flag, it is simply somewhere else. #expect(result.model.trash.allSatisfy { !$0.isDeleted }) #expect(result.model.lanes.map(\.id.rawValue) == [lane]) @@ -383,8 +392,7 @@ struct DeleteCardToTrashTests { #expect(!fixture.exists(".trash")) let id = try BoardWriter.deleteCardToTrash( at: fixture.url("\(Ident.lane1)/\(Ident.card1)"), - inBoard: fixture.root, - order: -1024 + inBoard: fixture.root ) #expect(id == ItemID(rawValue: Ident.card1)) @@ -394,8 +402,11 @@ struct DeleteCardToTrashTests { /// The move's whole edit: the new rank plus the stamps. `modified` is stamped **on purpose** — /// the one exception to moves-don't-stamp, and what a future age-based auto-purge reads. - @Test("Only order and the stamps are rewritten; every other byte survives") - func onlyOrderAndStampsChange() throws { + /// **The stamps are the *whole* rewrite** (01 § Deletion, re-ruled 2026-07-31): no rank is + /// minted on arrival, so `order` is one of the bytes that survives rather than one of the two + /// that change. + @Test("Only the stamps are rewritten; every other byte — `order` included — survives") + func onlyTheStampsChange() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } @@ -405,8 +416,7 @@ struct DeleteCardToTrashTests { try BoardWriter.deleteCardToTrash( at: fixture.url("\(Ident.lane1)/\(Ident.card1)"), - inBoard: fixture.root, - order: -1024 + inBoard: fixture.root ) let text = try fixture.indexText(".trash/\(Ident.card1)") @@ -421,7 +431,7 @@ struct DeleteCardToTrashTests { #expect(!text.contains("deleted:")) let document = try FrontmatterDocument.parse(text) - #expect(document.order == .valid(-1024)) + #expect(document.order == .valid(2048), "the rank rode along exactly as the card left its lane") } @Test("Attachments and strays travel byte-identical — nothing beneath the card is read") @@ -439,8 +449,7 @@ struct DeleteCardToTrashTests { try BoardWriter.deleteCardToTrash( at: fixture.url("\(Ident.lane1)/\(Ident.card1)"), - inBoard: fixture.root, - order: -1024 + inBoard: fixture.root ) #expect(try fixture.data(".trash/\(Ident.card1)/attachments/shot.png") == png) @@ -462,8 +471,7 @@ struct DeleteCardToTrashTests { let error = writeFailure { _ = try BoardWriter.deleteCardToTrash( at: fixture.url(target), - inBoard: fixture.root, - order: -1024 + inBoard: fixture.root ) } #expect(error != nil) @@ -486,8 +494,7 @@ struct DeleteCardToTrashTests { let error = writeFailure { _ = try BoardWriter.deleteCardToTrash( at: fixture.url("\(Ident.lane1)/\(Ident.card1)"), - inBoard: fixture.root, - order: -2048 + inBoard: fixture.root ) } #expect(error?.operation == .delete(title: "Live")) @@ -510,8 +517,7 @@ struct DeleteCardToTrashTests { let error = writeFailure { _ = try BoardWriter.deleteCardToTrash( at: fixture.url("\(Ident.lane1)/\(Ident.card1)"), - inBoard: fixture.root, - order: -1024 + inBoard: fixture.root ) } if case .uneditableFrontmatter = error?.reason {} else { @@ -550,8 +556,7 @@ struct TombstoneMigrationTests { try BoardWriter.migrateTombstonedCard( at: fixture.url("\(Ident.lane1)/\(Ident.card1)"), - inBoard: fixture.root, - order: -1024 + inBoard: fixture.root ) #expect(!fixture.exists("\(Ident.lane1)/\(Ident.card1)")) @@ -559,7 +564,7 @@ struct TombstoneMigrationTests { #expect(!text.contains("deleted:")) #expect(text.contains("project: lanework # agent overlay")) #expect(text.contains("Body kept.")) - #expect(try FrontmatterDocument.parse(text).order == .valid(-1024)) + #expect(try FrontmatterDocument.parse(text).order == .valid(2048), "no rank is minted") // And the board now loads it as an ordinary trash card. let result = try BoardLoader.load(boardRoot: fixture.root) @@ -567,6 +572,74 @@ struct TombstoneMigrationTests { #expect(result.model.trash.map(\.id.rawValue) == [Ident.card1]) } + /// **The migration stamps `modified` from the key it is retiring** (01 § Deletion, re-ruled + /// 2026-07-31): "the deletion time is when the card entered the trash, so real deletion order + /// survives into the `modified`-descending sort". Without it every migrated card would land at + /// migration time and the board's deletion history would flatten into one instant. + /// + /// The duplicate key is deliberate and its resolution is 01's own last-wins rule: the *final* + /// occurrence is the value, so 02-02 is the stamp and 01-01 is invisible. + @Test("The migration stamps `modified` from the legacy `deleted:` timestamp") + func migrationStampsFromTheLegacyTimestamp() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + + try fixture.item("", Item.board) + try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Lane")) + try fixture.item("\(Ident.lane1)/\(Ident.card1)", """ + --- + schema: 1 + title: Gone + order: 2048 + modified: 2026-06-06T00:00:00Z + deleted: 2026-01-01T00:00:00Z + deleted: 2026-02-02T00:00:00Z + --- + """) + + try BoardWriter.migrateTombstonedCard( + at: fixture.url("\(Ident.lane1)/\(Ident.card1)"), + inBoard: fixture.root + ) + + let document = try FrontmatterDocument.parse(fixture.indexText(".trash/\(Ident.card1)")) + #expect(document.modified == .valid(Date(timeIntervalSince1970: 1_769_990_400)), + "2026-02-02T00:00:00Z — the last `deleted:` occurrence, not migration time") + } + + /// The other half of the same sentence: "**and from migration time otherwise**". A stamp that + /// cannot be parsed is no evidence of when the card was deleted, so the honest answer is now — + /// which lands it among the freshest rather than inventing a date. + @Test("An unparseable legacy timestamp falls back to migration time") + func migrationFallsBackToNow() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + + try fixture.item("", Item.board) + try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Lane")) + try fixture.item("\(Ident.lane1)/\(Ident.card1)", """ + --- + schema: 1 + title: Gone + order: 2048 + modified: 2020-01-01T00:00:00Z + deleted: whenever + --- + """) + + let before = Date() + try BoardWriter.migrateTombstonedCard( + at: fixture.url("\(Ident.lane1)/\(Ident.card1)"), + inBoard: fixture.root + ) + + let document = try FrontmatterDocument.parse(fixture.indexText(".trash/\(Ident.card1)")) + let stamped = try #require(document.modified.value) + #expect(stamped >= before.addingTimeInterval(-1), "migration time, not the old `modified`") + // The key still goes — presence is what migrates a card, validity is not (`stillTombstoned`). + #expect(!(try fixture.indexText(".trash/\(Ident.card1)").contains("deleted:"))) + } + /// The lane half of this migration is **retired** (01 § Deletion, re-ruled 2026-07-29): there is /// no `migrateTombstonedLane` to call, and the loader hands the store no lane work to do — the /// lane simply loads live with the key inert (`LegacyTombstoneDetectionTests`). @@ -618,8 +691,7 @@ struct DeleteLaneToTrashTests { let id = try BoardWriter.deleteLaneToTrash( at: fixture.url(Ident.lane1), - inBoard: fixture.root, - order: -1024 + inBoard: fixture.root ) #expect(id == ItemID(rawValue: Ident.lane1), "a delete moves a folder, it does not rename one") @@ -634,8 +706,8 @@ struct DeleteLaneToTrashTests { /// The container-changing move stamps both provenance keys (01 § Frontmatter ▸ `modified`'s /// scope, refined 2026-07-30) — the same rule a card's trash move obeys. - @Test("The rank is rewritten, modified stamped and modified-by cleared") - func theRankRewriteStamps() throws { + @Test("`modified` is stamped and `modified-by` cleared, and the rank is left alone") + func theArrivalStamps() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } @@ -653,10 +725,10 @@ struct DeleteLaneToTrashTests { """) - try BoardWriter.deleteLaneToTrash(at: fixture.url(Ident.lane1), inBoard: fixture.root, order: -1024) + try BoardWriter.deleteLaneToTrash(at: fixture.url(Ident.lane1), inBoard: fixture.root) let document = try FrontmatterDocument.parse(fixture.indexText(".trash/\(Ident.lane1)")) - #expect(document.order == .valid(-1024)) + #expect(document.order == .valid(2048), "the strip rank rides along; no trash rank is minted") #expect(document.modifiedBy.isMissing) #expect(document.modified.value.map { $0 > Date(timeIntervalSince1970: 1_600_000_000) } == true) let text = try fixture.indexText(".trash/\(Ident.lane1)") @@ -664,7 +736,7 @@ struct DeleteLaneToTrashTests { #expect(text.contains("Lane notes.")) } - /// "`kind: lane` … backfilled on touch when absent … the trash move's rank mint included" + /// "`kind: lane` … backfilled on touch when absent … the trash move's `modified` stamp included" /// (01 § Deletion). The **empty** lane is the case that needs it: in a flat container it is /// shape-identical to a card, so a derived kind would answer wrongly and the row would come back /// as a card. @@ -676,7 +748,7 @@ struct DeleteLaneToTrashTests { try fixture.item("", Item.board) try fixture.item(Ident.lane1, "---\nschema: 1\ntitle: Empty\norder: 1024\n---\n") - try BoardWriter.deleteLaneToTrash(at: fixture.url(Ident.lane1), inBoard: fixture.root, order: -1024) + try BoardWriter.deleteLaneToTrash(at: fixture.url(Ident.lane1), inBoard: fixture.root) #expect(try fixture.indexText(".trash/\(Ident.lane1)").contains("kind: lane")) let result = try BoardLoader.load(boardRoot: fixture.root) @@ -695,7 +767,7 @@ struct DeleteLaneToTrashTests { try fixture.item("", Item.board) try fixture.item(Ident.lane1, "---\nschema: 1\norder: 1024\nkind: lane\n---\n") - try BoardWriter.deleteLaneToTrash(at: fixture.url(Ident.lane1), inBoard: fixture.root, order: -1024) + try BoardWriter.deleteLaneToTrash(at: fixture.url(Ident.lane1), inBoard: fixture.root) let text = try fixture.indexText(".trash/\(Ident.lane1)") #expect(text.components(separatedBy: "kind: lane").count == 2, "written once, not twice") @@ -719,8 +791,7 @@ struct DeleteLaneToTrashTests { writeFailure { try BoardWriter.deleteLaneToTrash( at: fixture.url(target), - inBoard: fixture.root, - order: -1024 + inBoard: fixture.root ) } != nil ) @@ -1038,11 +1109,11 @@ struct TrashKindDiscriminatorTests { } } - /// The column is one list interleaved by rank (03 § Trash), which the snapshot expresses as two - /// arrays carrying the ranks that interleave them — so a consumer merging by `order` gets the - /// column, and neither array is "after" the other. - @Test("Both kinds carry the ranks that interleave them") - func kindsInterleaveByRank() throws { + /// The column is one list interleaved by **`modified` descending** (03 § Trash, re-ruled + /// 2026-07-31), which the snapshot expresses as two arrays each sorted by the same comparator — + /// so `BoardModel.trashEntries`' merge is the column, and neither array is "after" the other. + @Test("Both kinds carry the stamps that interleave them") + func kindsInterleaveByStamp() throws { let fixture = try TrashFixture() defer { fixture.tearDown() } let newestLane = uuidName() @@ -1050,18 +1121,41 @@ struct TrashKindDiscriminatorTests { let oldestLane = uuidName() try fixture.index("", "schema: 1\n") - try fixture.index(".trash/\(oldestLane)", "schema: 1\norder: 3072\nkind: lane\n") - try fixture.index(".trash/\(middleCard)", "schema: 1\norder: 2048\n") - try fixture.index(".trash/\(newestLane)", "schema: 1\norder: 1024\nkind: lane\n") + try fixture.index(".trash/\(oldestLane)", "schema: 1\norder: 1024\nmodified: 2026-05-01T09:00:00Z\nkind: lane\n") + try fixture.index(".trash/\(middleCard)", "schema: 1\norder: 2048\nmodified: 2026-05-03T09:00:00Z\n") + try fixture.index(".trash/\(newestLane)", "schema: 1\norder: 3072\nmodified: 2026-05-05T09:00:00Z\nkind: lane\n") let result = try BoardLoader.load(boardRoot: fixture.root) #expect(result.model.trashedLanes.map(\.id.rawValue) == [newestLane, oldestLane]) #expect(result.model.trash.map(\.id.rawValue) == [middleCard]) - let column = (result.model.trash.map { (order: $0.order, id: $0.id) } - + result.model.trashedLanes.map { (order: $0.order, id: $0.id) }) - .sorted { $0.order < $1.order } - .map(\.id.rawValue) - #expect(column == [newestLane, middleCard, oldestLane]) + // The one merge, which every consumer of "the row below this one" reads. + #expect(result.model.trashEntries.map(\.id.rawValue) == [newestLane, middleCard, oldestLane]) + } + + /// The comparator's tail, and its undated rung — neither is stated anywhere else, and both are + /// what keeps a hand-made or foreign-moved entry from deciding the column's top + /// (01 § Deletion: "Ties break by title (case-insensitive), then folder name"). + @Test("Equal stamps fall to title then folder name, and an undated entry sorts last") + func theDeterministicTail() throws { + let fixture = try TrashFixture() + defer { fixture.tearDown() } + // Folder names in a fixed order, so the last rung is observable rather than incidental. + let alpha = "11111111-1111-4111-8111-111111111111" + let beta = "22222222-2222-4222-8222-222222222222" + let gamma = "33333333-3333-4333-8333-333333333333" + let undated = "44444444-4444-4444-8444-444444444444" + + try fixture.index("", "schema: 1\n") + // Same instant, different titles: "apple" before "Banana" — case-insensitively. + try fixture.index(".trash/\(beta)", "schema: 1\norder: 1024\ntitle: Banana\nmodified: 2026-05-05T09:00:00Z\n") + try fixture.index(".trash/\(gamma)", "schema: 1\norder: 1024\ntitle: apple\nmodified: 2026-05-05T09:00:00Z\n") + // Same instant *and* the same title: the folder name is the last word. + try fixture.index(".trash/\(alpha)", "schema: 1\norder: 1024\ntitle: apple\nmodified: 2026-05-05T09:00:00Z\n") + // No stamp at all — a foreign mover that skipped the restamp sorts below every dated row. + try fixture.index(".trash/\(undated)", "schema: 1\norder: 1024\ntitle: Zulu\n") + + let result = try BoardLoader.load(boardRoot: fixture.root) + #expect(result.model.trash.map(\.id.rawValue) == [alpha, gamma, beta, undated]) } } diff --git a/KanbanTests/TrashWriteTests.swift b/KanbanTests/TrashWriteTests.swift index 81df9e0..127dec5 100644 --- a/KanbanTests/TrashWriteTests.swift +++ b/KanbanTests/TrashWriteTests.swift @@ -23,12 +23,16 @@ private enum More { /// A lane already sitting in `/.trash/` — the opaque unit, `kind: lane` being the only thing /// that tells it from a card in the flat container (01-storage-format.md § Deletion). -private func trashedLane(order: String, title: String) -> String { +/// +/// `modified` is the row's **position** since 2026-07-31 (the trash sorts by it, descending), so it +/// is a fixture parameter rather than the afterthought it was while ranks did the ordering. +private func trashedLane(order: String, title: String, modified: String = "2026-05-01T09:00:00Z") -> String { """ --- schema: 1 title: \(title) order: \(order) + modified: \(modified) kind: lane project: lanework # agent overlay --- @@ -39,7 +43,7 @@ private func trashedLane(order: String, title: String) -> String { /// A card already sitting in `/.trash/` — an ordinary card in a special place, with an unknown /// key so the verbatim-preservation claims have something to preserve. -private func trashResident(order: String, title: String) -> String { +private func trashResident(order: String, title: String, modified: String = "2026-05-01T09:00:00Z") -> String { """ --- schema: 1 @@ -47,6 +51,7 @@ private func trashResident(order: String, title: String) -> String { order: \(order) project: lanework # agent overlay created: 2026-01-01T09:00:00Z + modified: \(modified) --- \(title) body. @@ -83,8 +88,14 @@ private func makeBoard() throws -> WriterFixture { try fixture.item("\(Ident.lane2)/\(Ident.card3)", Item.rich(order: "1024", title: "Third")) try fixture.item(Ident.lane3, Item.rich(order: "3072", title: "Done")) try fixture.item("\(Ident.lane3)/\(Ident.card4)", Item.rich(order: "1024", title: "Fourth")) - try fixture.item(".trash/\(Ident.indexless)", trashResident(order: "1024", title: "Trashed")) - try fixture.item(".trash/\(More.newer)", trashResident(order: "512", title: "Newer")) + // Their *stamps* are what orders them now — "Newer" is the newer one, and its `order` is the + // lane rank it carried in, deliberately disagreeing with the column position. + try fixture.item( + ".trash/\(Ident.indexless)", + trashResident(order: "1024", title: "Trashed", modified: "2026-05-01T09:00:00Z")) + try fixture.item( + ".trash/\(More.newer)", + trashResident(order: "512", title: "Newer", modified: "2026-05-02T09:00:00Z")) return fixture } @@ -172,34 +183,46 @@ struct DeleteCardTests { #expect(try document(fixture, ".trash/\(Ident.card1)").modified.value != nil) } - @Test("Entry is at the top: the rank is minted above the current topmost") + /// **Entry is at the top, and the stamp is what puts it there** (03 ▸ Trash, re-ruled + /// 2026-07-31): no rank is minted, so the card's `order` arrives exactly as it left its lane and + /// the fresh `modified` does the positioning. + @Test("Entry is at the top: the fresh stamp outranks every resident, and `order` is untouched") func entryIsAtTheTop() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) - // The trash's current top is `newer` at 512. + // The trash's current top is `newer`, by its stamp — its `order` (512) is the smaller of the + // two, which under the retired rule would have been the reason and now is a coincidence. #expect(try loaded(fixture).trash.map(\.id) == [newer, trashed]) store.delete([card1]) - let landed = try #require(try document(fixture, ".trash/\(Ident.card1)").order.value) - #expect(landed < 512, "03 ▸ Trash: every arrival mints an `order` rank above the current top") + let arrived = try document(fixture, ".trash/\(Ident.card1)") + #expect(arrived.order == .valid(1024), "the lane rank rides along; nothing is minted") + let stamp = try #require(arrived.modified.value) + let residentStamp = try #require(try document(fixture, ".trash/\(More.newer)").modified.value) + #expect(stamp > residentStamp) #expect(try loaded(fixture).trash.map(\.id) == [card1, newer, trashed], - "newest-first falls out of ordinary ranks — no timestamp sort") + "newest-first falls out of the stamp — no rank anywhere in the container") } - @Test("A multi-card delete is one bracket, each arrival above the one before it") - func batchLandsNewestOnTop() throws { + /// **A batch shares one instant, so the deterministic tail orders it** (01 § Deletion: "Ties + /// break by title (case-insensitive), then folder name — the deterministic tail"). `modified` + /// serializes at whole-second granularity, so two cards deleted in one bracket carry the same + /// stamp by construction; the run still sorts above every resident, and *within* the run the + /// titles decide — "First" before "Second". That is the tail doing exactly its job, and it is the + /// honest reading of two deletions that really did happen at the same time. + @Test("A multi-card delete is one bracket; the run lands on top and the tail orders it") + func batchLandsOnTopOrderedByTheTail() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) store.delete([card1, card2]) - let first = try #require(try document(fixture, ".trash/\(Ident.card1)").order.value) - let second = try #require(try document(fixture, ".trash/\(Ident.card2)").order.value) - #expect(second < first) - #expect(try loaded(fixture).trash.map(\.id) == [card2, card1, newer, trashed]) + #expect(try document(fixture, ".trash/\(Ident.card1)").order == .valid(1024)) + #expect(try document(fixture, ".trash/\(Ident.card2)").order == .valid(2048)) + #expect(try loaded(fixture).trash.map(\.id) == [card1, card2, newer, trashed]) } @Test("The selection moves to the successor sibling, immediately") @@ -312,27 +335,30 @@ struct DeleteLaneTests { #expect(store.banners.oneShots.isEmpty) } - /// "Every arrival lands at the trash's topmost position … regardless of kind" (03 § Trash): the - /// ladder the rank is minted against is the whole container, cards and lane rows alike. + /// "Every arrival lands at the trash's topmost position … regardless of kind" (03 § Trash) — + /// and since 2026-07-31 that is the **merged `modified` order** doing it, over both kinds at + /// once, rather than a ladder the store minted against. @Test("The lane lands at the top of the trash, above every existing entry") func laneLandsOnTop() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) - let topBefore = try #require(loaded(fixture).trash.map(\.order).min()) store.delete([lane3]) - let landed = try #require(loaded(fixture).trashedLanes.first?.order) - #expect(landed < topBefore) + #expect(try loaded(fixture).trashEntries.first?.id == lane3, "topmost row of the whole column") + // The lane's strip rank rode along untouched, which is what its restore reads. + #expect(try document(fixture, ".trash/\(Ident.lane3)").order == .valid(3072)) - // And the next card delete mints above *that* — the lane row is in the ladder the store - // mints against, which is the whole container rather than one array of it. The reload is - // what puts the new row in the snapshot; without it the store is still holding the - // pre-delete picture, as it is for two of any deletes in a row. + // And a *later* card delete lands above it — the merged order is the whole container, so a + // row of the other kind is exactly as sortable as one of its own. The reload is what puts + // the new row in the snapshot; without it the store is still holding the pre-delete picture, + // as it is for two of any deletes in a row. The one-second sleep is load-bearing: `modified` + // serializes at whole-second granularity, so without it the two deletes share an instant and + // the deterministic title tail — not recency — would decide. await reload(store) + try await Task.sleep(for: .seconds(1.1)) store.delete([card1]) - let card = try #require(loaded(fixture).trash.first { $0.id == card1 }?.order) - #expect(card < landed) + #expect(try loaded(fixture).trashEntries.map(\.id).prefix(2) == [card1, lane3]) } /// No dialog: "the move is recoverable, so nothing needs confirming" (03 § Trash). @@ -544,11 +570,19 @@ private func makeTrashedLaneBoard() throws -> 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(".trash/\(More.newer)", trashResident(order: "512", title: "Newer")) - try fixture.item(".trash/\(Ident.lane2)", trashedLane(order: "768", title: "Doing")) + // The column reads newest-first by `modified`: Newer, Doing (the lane row), Trashed — the two + // kinds interleaved by the one stamp, which is the merged order this suite navigates by. + try fixture.item( + ".trash/\(More.newer)", + trashResident(order: "512", title: "Newer", modified: "2026-05-03T09:00:00Z")) + try fixture.item( + ".trash/\(Ident.lane2)", + trashedLane(order: "768", title: "Doing", modified: "2026-05-02T09:00:00Z")) try fixture.item(".trash/\(Ident.lane2)/\(Ident.card3)", Item.rich(order: "1024", title: "Third")) try fixture.item(".trash/\(Ident.lane2)/\(Ident.card4)", Item.rich(order: "2048", title: "Fourth")) - try fixture.item(".trash/\(Ident.indexless)", trashResident(order: "1024", title: "Trashed")) + try fixture.item( + ".trash/\(Ident.indexless)", + trashResident(order: "1024", title: "Trashed", modified: "2026-05-01T09:00:00Z")) return fixture } @@ -618,10 +652,9 @@ struct PurgeTrashedLaneTests { #expect(!fixture.exists(".trash/\(Ident.lane2)")) } - /// **The interim successor** (`SelectionGrammar.successor`'s trash branch): 04 settles navigation - /// and extension for the column's two kinds but not the successor, so it follows *navigation* — - /// the next row down whatever its kind — rather than stranding the selection. Gap card - /// 7b5cbc90 tracks the ruling; this test is the interim's marker as much as its cover. + /// **The successor is kind-blind** (04-interactions.md ▸ The map, ruled 2026-07-31, ratifying + /// what stood here as an interim): "the next row of either kind, in the same all-rows order plain + /// arrows walk … repeated ⌘⌫ empties a mixed trash without dead-ends". @Test("The successor after purging a lane row is the next row down, kind notwithstanding") func successorCrossesKinds() throws { let fixture = try makeTrashedLaneBoard() @@ -764,21 +797,30 @@ struct StoreTombstoneMigrationTests { #expect(try loaded(fixture).lanes.map(\.id.rawValue).contains(Ident.lane2)) } - @Test("Cards migrate oldest-first, so the newest deletion ends up on top") - func migrationOrderIsOldestFirst() throws { + /// **The stamps do the ordering, not the batch** (01 § Deletion, re-ruled 2026-07-31): each + /// migrated card takes its own `deleted:` timestamp as its `modified`, so the board's real + /// deletion order survives whatever sequence the heal happens to run in. + @Test("Migrated cards keep their real deletion order — newest deletion on top") + func migrationKeepsRealDeletionOrder() throws { let fixture = try makeLegacyBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) store.migrateLegacyTombstones() - // Every arrival mints above the current top, so migrating oldest-first reproduces the - // newest-first column the tombstone model's timestamp sort used to render. + // 03-05 above 03-01, straight off the retired key — not off the order the batch ran in. #expect(try loaded(fixture).trash.compactMap(\.title.value) == ["Newer", "Older"]) + let newerStamp = try #require(try document(fixture, ".trash/\(Ident.card3)").modified.value) + #expect(newerStamp == Date(timeIntervalSince1970: 1_772_701_200), + "2026-03-05T09:00:00Z — the legacy stamp, carried over verbatim") } - @Test("The order is deterministic when the stamps are missing or unparseable") - func undatedSortsOldest() throws { + /// An **unparseable** legacy stamp is no evidence of when the card was deleted, so the migration + /// stamps it at migration time (01 § Deletion: "and from migration time otherwise") — which + /// lands it among the freshest rather than inventing a date for it. Deterministic either way, + /// which is what this pins. + @Test("A card whose legacy stamp cannot be read is migrated at migration time") + func unparseableStampMigratesAtNow() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try fixture.item("", Item.board) @@ -795,9 +837,8 @@ struct StoreTombstoneMigrationTests { store.migrateLegacyTombstones() - // "A corrupt stamp must not outrank fresh deletions for the trash's most prominent rows": - // undated sorts oldest, so it migrates first and ends up *below* the dated one. - #expect(try loaded(fixture).trash.compactMap(\.title.value) == ["Dated", "Corrupt"]) + // Migration time is today, which is newer than any legacy stamp a real board carries. + #expect(try loaded(fixture).trash.compactMap(\.title.value) == ["Corrupt", "Dated"]) } @Test("A board-level deleted: is never migrated — it is meaningless, ignored and logged") diff --git a/KanbanTests/UndoWriteTests.swift b/KanbanTests/UndoWriteTests.swift index 21f15a2..384a218 100644 --- a/KanbanTests/UndoWriteTests.swift +++ b/KanbanTests/UndoWriteTests.swift @@ -627,24 +627,30 @@ struct TrashUndoTests { #expect(!fixture.exists(card1Path)) } - @Test("The redo files the card under the rank the delete minted, not a fresh one") - func redoUsesTheCapturedTrashRank() throws { + /// **There is no rank to capture or replay** (01-storage-format.md § Deletion, re-ruled + /// 2026-07-31): a delete is a folder move plus a `modified` stamp, so the card's `order` rides + /// along untouched through delete, undo and redo alike, and the redo is just the forward write + /// run again. + @Test("The redo re-runs the delete, and `order` is untouched at every leg") + func redoRerunsTheDelete() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, history) = try makeStore(fixture) store.delete([card1]) - let minted = try #require(try document(fixture, ".trash/\(Ident.card1)").order.value) - #expect(minted < 1024, "entry is at the top: a rank above the current topmost (order 1024)") + #expect(try document(fixture, ".trash/\(Ident.card1)").order.value == 1024, + "the lane rank rode along; nothing was minted") history.undo() + #expect(try document(fixture, card1Path).order.value == 1024) history.redo() - #expect(try document(fixture, ".trash/\(Ident.card1)").order.value == minted, - "the redo replays the write's own captured rank") + #expect(try document(fixture, ".trash/\(Ident.card1)").order.value == 1024) + // And the redone delete restamps, which is what puts it back on top of the column. + #expect(try loadedTrash(fixture).map(\.id).first == card1) } - @Test("A multi-card delete is one step with a plural title, and lands newest-last on top") + @Test("A multi-card delete is one step with a plural title, and the run lands on top") func batchDeleteIsOneStep() throws { let fixture = try makeBoard() defer { fixture.tearDown() } @@ -653,9 +659,11 @@ struct TrashUndoTests { store.delete([card1, card2]) #expect(history.undoActionName == "Delete 2 Cards") - let first = try #require(try document(fixture, ".trash/\(Ident.card1)").order.value) - let second = try #require(try document(fixture, ".trash/\(Ident.card2)").order.value) - #expect(second < first, "each arrival in the run mints a rank above the one before it") + // Both cards keep the ranks they had in their lane — nothing is minted, at either end of the + // run — and both land above the board's existing resident by their fresh stamps. + #expect(try document(fixture, ".trash/\(Ident.card1)").order.value == 1024) + #expect(try document(fixture, ".trash/\(Ident.card2)").order.value == 2048) + #expect(try loadedTrash(fixture).map(\.id).suffix(1) == [trashed]) history.undo() #expect(fixture.exists(card1Path)) @@ -683,9 +691,8 @@ struct TrashUndoTests { #expect(try loadedTrash(fixture).map(\.id) == [trashed], "its cards are not trash cards") #expect(try loadedTrashedLanes(fixture).map(\.id) == [lane2]) #expect(history.undoActionName == "Delete Lane") - let trashRank = try #require(try document(fixture, ".trash/\(Ident.lane2)").order.value) - let resident = try #require(try document(fixture, trashedPath).order.value) - #expect(trashRank < resident, "entry is at the top — a rank above the current topmost") + #expect(try document(fixture, ".trash/\(Ident.lane2)").order.value == laneRank, + "the strip rank rides along; no trash rank is minted") history.undo() @@ -699,8 +706,8 @@ struct TrashUndoTests { history.redo() #expect(fixture.exists(".trash/\(Ident.lane2)")) - #expect(try document(fixture, ".trash/\(Ident.lane2)").order.value == trashRank, - "the redo replays the write's own captured rank") + #expect(try document(fixture, ".trash/\(Ident.lane2)").order.value == laneRank, + "the redo re-runs the forward write, which has no rank to replay") } @Test("A multi-lane delete is one step with a plural title") @@ -712,9 +719,9 @@ struct TrashUndoTests { store.delete([lane1, lane2]) #expect(history.undoActionName == "Delete 2 Lanes") - let first = try #require(try document(fixture, ".trash/\(Ident.lane1)").order.value) - let second = try #require(try document(fixture, ".trash/\(Ident.lane2)").order.value) - #expect(second < first, "each arrival in the run mints a rank above the one before it") + // Their strip ranks, untouched — a lane delete mints nothing either. + #expect(try document(fixture, ".trash/\(Ident.lane1)").order.value == 1024) + #expect(try document(fixture, ".trash/\(Ident.lane2)").order.value == 2048) history.undo() #expect(fixture.exists(Ident.lane1)) @@ -728,7 +735,9 @@ struct TrashUndoTests { let fixture = try makeBoard() defer { fixture.tearDown() } let (store, history) = try makeStore(fixture) - let trashRank = try #require(try document(fixture, trashedPath).order.value) + // The `order` the row was *carrying* while trashed — its old lane rank, which the trash move + // never rewrote and which this restore is about to overwrite. + let carriedOrder = try #require(try document(fixture, trashedPath).order.value) store.moveCards([trashed], toLane: lane2, at: 0) @@ -740,8 +749,8 @@ struct TrashUndoTests { history.undo() #expect(fixture.exists(trashedPath), "back in the trash it came out of") #expect(!fixture.exists("\(Ident.lane2)/\(Ident.card4)")) - #expect(try document(fixture, trashedPath).order.value == trashRank, - "at the rank it was filed under") + #expect(try document(fixture, trashedPath).order.value == carriedOrder, + "the undo puts back the rank the restore overwrote") history.redo() #expect(fixture.exists("\(Ident.lane2)/\(Ident.card4)")) diff --git a/KanbanTests/WriteFidelityTests.swift b/KanbanTests/WriteFidelityTests.swift index 9d665be..a001007 100644 --- a/KanbanTests/WriteFidelityTests.swift +++ b/KanbanTests/WriteFidelityTests.swift @@ -158,7 +158,7 @@ struct WriteFidelityMinimalTouchTests { // included, which is the point of this harness. try step("delete", targeting: [], departed: ["\(Ident.lane2)/\(Ident.card4)"]) { try BoardWriter.deleteCardToTrash( - at: fixture.url("\(Ident.lane2)/\(Ident.card4)"), inBoard: fixture.root, order: 1024 + at: fixture.url("\(Ident.lane2)/\(Ident.card4)"), inBoard: fixture.root ) } // The restore is an ordinary move out — "there is no restore-specific machinery and no Put @@ -189,7 +189,7 @@ struct WriteFidelityMinimalTouchTests { departed: [Ident.lane2, "\(Ident.lane2)/\(Ident.card3)", "\(Ident.lane2)/\(Ident.card4)"] ) { try BoardWriter.deleteLaneToTrash( - at: fixture.url(Ident.lane2), inBoard: fixture.root, order: 512 + at: fixture.url(Ident.lane2), inBoard: fixture.root ) } try step("restore lane", targeting: []) { @@ -370,7 +370,7 @@ struct WriteFidelityCompositeTests { // resettled 2026-07-28), so its unknown keys, its comment and its body must ride along // untouched through both legs. try BoardWriter.deleteCardToTrash( - at: lane1Folder.appendingPathComponent(card2.rawValue), inBoard: root, order: 1024 + at: lane1Folder.appendingPathComponent(card2.rawValue), inBoard: root ) _ = try BoardWriter.moveItem( at: BoardWriter.trashFolder(inBoard: root).appendingPathComponent(card2.rawValue), @@ -548,7 +548,7 @@ struct WriteFidelityStampingTests { defer { fixture.tearDown() } try BoardWriter.deleteCardToTrash( - at: fixture.url("\(Ident.lane1)/\(Ident.card1)"), inBoard: fixture.root, order: 1024 + at: fixture.url("\(Ident.lane1)/\(Ident.card1)"), inBoard: fixture.root ) let trashed = try stamps(fixture, ".trash/\(Ident.card1)") #expect(trashed.modified != Self.priorModified, "into the trash is a container change") @@ -578,7 +578,7 @@ struct WriteFidelityStampingTests { defer { fixture.tearDown() } try BoardWriter.deleteLaneToTrash( - at: fixture.url(Ident.lane1), inBoard: fixture.root, order: 1024 + at: fixture.url(Ident.lane1), inBoard: fixture.root ) let lane = try stamps(fixture, ".trash/\(Ident.lane1)")