Realign code with the 2026-07-31 rulings

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
This commit is contained in:
2026-07-31 18:35:07 -04:00
parent 542ab169a3
commit bec75e4282
37 changed files with 1200 additions and 551 deletions
+8
View File
@@ -227,8 +227,16 @@ public final class ClipboardStore {
/// the text (04 Grammar). The field consumes the selector natively, so this guard is belt over /// 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 /// braces but a board command that stayed armed under an editor would be exactly the kind of
/// fall-through 04 is careful about. /// 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 { public func canCopy(from store: BoardStore) -> Bool {
guard !store.isEditingInline else { return false } 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 return SelectionGrammar.kind(of: store.selection, in: store.snapshot) != nil
} }
+3 -6
View File
@@ -498,13 +498,10 @@ enum UITestLaunch {
// The delete goes last so the trashed card's identity is one the lanes above have already // 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 // 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] let doomed = cardURLs[trashedCardIndex.lane][trashedCardIndex.card]
try BoardWriter.deleteCardToTrash( try BoardWriter.deleteCardToTrash(at: doomed, inBoard: root)
at: doomed,
inBoard: root,
order: Ranks.append(toVisible: [] as [Double])
)
return root return root
} }
+21
View File
@@ -554,6 +554,27 @@ public final class BannerCenter {
postLoss(Self.skippedFoldersMessage(count: count)) 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 /// 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" /// 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. /// are different promises and a row that offers one must never quietly do the other.
+71 -98
View File
@@ -2343,8 +2343,10 @@ public final class BoardStore: HealHost {
let rendered = snapshot.lanes let rendered = snapshot.lanes
let target = min(max(0, index), rendered.count) 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. // What an undo puts back: the `order` the row was **carrying** while trashed its old strip
var arrivals: [(id: ItemID, order: Double, trashRank: Double, title: String?)] = [] // 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 let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
// The shared two-step; the arriving lanes are not among the renumbered children. // The shared two-step; the arriving lanes are not among the renumbered children.
guard let placed = try HealScheduler.placingRanks( guard let placed = try HealScheduler.placingRanks(
@@ -2365,29 +2367,31 @@ public final class BoardStore: HealHost {
arrivals.append(( arrivals.append((
id: member.id, id: member.id,
order: rank, order: rank,
trashRank: member.order, carriedOrder: member.order,
title: member.title.value title: member.title.value
)) ))
} }
} }
guard landed != nil, !arrivals.isEmpty else { return } guard landed != nil, !arrivals.isEmpty else { return }
// restore-by-move **move back in** (13-native-undo.md Interaction with the trash), at the // restore-by-move **move back in** (13-native-undo.md Interaction with the trash),
// trash rank the row was holding the delete's step read from the other end. // 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 trashFolder = Self.parentFolder(of: .trashLane(arrivals[0].id), under: root)
let steps = arrivals.map { arrival in let steps = arrivals.map { arrival in
( (
restored: ItemPath.lane(arrival.id).folder(under: root), restored: ItemPath.lane(arrival.id).folder(under: root),
trashed: ItemPath.trashLane(arrival.id).folder(under: root), trashed: ItemPath.trashLane(arrival.id).folder(under: root),
order: arrival.order, order: arrival.order,
trashRank: arrival.trashRank carriedOrder: arrival.carriedOrder
) )
} }
registerStep( registerStep(
HistoryPhrase.name(.move, kind: .lane, count: steps.count), HistoryPhrase.name(.move, kind: .lane, count: steps.count),
subject: arrivals.count == 1 ? arrivals[0].title : nil, subject: arrivals.count == 1 ? arrivals[0].title : nil,
undoExpects: steps.map { .present($0.restored, .order($0.order)) }, 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 ) { _ in
for step in steps { for step in steps {
_ = try BoardWriter.moveItem( _ = try BoardWriter.moveItem(
@@ -2395,7 +2399,7 @@ public final class BoardStore: HealHost {
toParent: trashFolder, toParent: trashFolder,
sourceBoardRoot: root, sourceBoardRoot: root,
destinationBoardRoot: root, destinationBoardRoot: root,
order: step.trashRank order: step.carriedOrder
) )
} }
} redo: { _ in } redo: { _ in
@@ -3492,18 +3496,17 @@ public final class BoardStore: HealHost {
return (lane, id) return (lane, id)
} }
/// **The delete write itself: a physical move into `<root>/.trash/`, at a freshly minted top /// **The delete write itself: a physical move into `<root>/.trash/`** one `performWrite`
/// rank** one `performWrite` bracket whatever the set's size and whichever gesture asked. /// 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; /// 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. /// 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 /// **There is no rank to mint** (03-board-ui.md § Trash, re-ruled 2026-07-31: "newest-first with
/// trash's topmost position, minting an `order` rank above the current top"). That is a question /// no `order` rewrite, no rank minting, the item's `order` key riding along untouched for its
/// about the snapshot, which the stateless Writer does not have so `Ranks.insertAtHead` runs /// eventual restore"). The trash sorts by `modified` descending and the move stamps it, so the
/// here over `trashRanks`, and a multi-card delete threads the minted rank back through the /// position is the Writer's own doing and the store has no snapshot question left to answer
/// running list so each card in the run lands above the one before it. Newest-first therefore /// the head-of-the-trash ladder this method used to thread through the run retired with the rule.
/// falls out of ordinary ranks, with no timestamp sort anywhere.
/// ///
/// - Returns: whether the write landed, so a caller can decide what to do with the selection. /// - Returns: whether the write landed, so a caller can decide what to do with the selection.
@discardableResult @discardableResult
@@ -3517,23 +3520,11 @@ public final class BoardStore: HealHost {
guard !moves.isEmpty else { return false } guard !moves.isEmpty else { return false }
let root = rootURL 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 let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
for (move, rank) in zip(moves, ranks) { for move in moves {
try BoardWriter.deleteCardToTrash( try BoardWriter.deleteCardToTrash(
at: ItemPath.card(lane: move.laneID, id: move.id).folder(under: root), at: ItemPath.card(lane: move.laneID, id: move.id).folder(under: root),
inBoard: root, inBoard: root
order: rank
) )
} }
} }
@@ -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, // 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"). // returning the card to its source lane and rank").
// //
// The redo replays the *forward* write with its own captured rank, exactly as every other // The redo replays the forward write, which now takes no values at all a delete is a folder
// redo in this file replays the values its gesture wrote so a redone delete lands the card // move plus a fresh stamp, and a redone delete lands where a first one would.
// back where the undo took it from, rather than at whatever the top of the trash has become
// in the meantime.
// //
// **The expectations are one swap, and the container rides in the path** (`HistoryStaleness`): // **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 // the undo wants the card **in the trash**, and that is the whole of it 13's field-level
// back in its lane holding the rank it left. A foreign restore empties the trash path and the // predicate compares "what its write set", and this write sets no field an expectation can
// undo skips; a foreign re-delete empties the lane path and the redo skips. // name (the `modified` stamp is a clock reading, not a value the step chose). Existence is
let steps = zip(moves, ranks).map { move, rank in // 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), trashed: ItemPath.trashCard(move.id).folder(under: root),
origin: ItemPath.card(lane: move.laneID, id: 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), laneFolder: ItemPath.lane(move.laneID).folder(under: root),
priorOrder: move.order, priorOrder: move.order
trashRank: rank
) )
} }
registerStep( registerStep(
HistoryPhrase.name(.delete, kind: .card, count: steps.count), HistoryPhrase.name(.delete, kind: .card, count: steps.count),
subject: moves.count == 1 ? moves[0].title : nil, 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)) } redoExpects: steps.map { .present($0.origin, .order($0.priorOrder)) }
) { _ in ) { _ in
for step in steps { for step in steps {
@@ -3578,7 +3570,7 @@ public final class BoardStore: HealHost {
} }
} redo: { _ in } redo: { _ in
for step in steps { 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 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 /// exactly as a card moves The no-dialog posture survives for a better reason: the move is
/// recoverable, so nothing needs confirming"). /// recoverable, so nothing needs confirming").
/// ///
/// **`moveToTrash`'s twin, and deliberately its mirror image**: the same head-of-the-trash rank /// **`moveToTrash`'s twin, and deliberately its mirror image**: no rank, the same one bracket,
/// mint threaded through the run, the same one bracket, the same one step because on disk it is /// the same one step because on disk it is
/// the same write one level up (`BoardWriter.deleteLaneToTrash`, which differs only in the guard /// 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: /// 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 /// 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 } guard !lanes.isEmpty else { return false }
let root = rootURL 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 let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
for (lane, rank) in zip(lanes, ranks) { for lane in lanes {
try BoardWriter.deleteLaneToTrash( try BoardWriter.deleteLaneToTrash(
at: ItemPath.lane(lane.id).folder(under: root), at: ItemPath.lane(lane.id).folder(under: root),
inBoard: root, inBoard: root
order: rank
) )
} }
} }
@@ -3628,26 +3611,24 @@ public final class BoardStore: HealHost {
// lane delete **the ordinary move back** (13-native-undo.md Interaction with the trash: // 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 // "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 // redo replays the forward write, which takes no values the card delete's shape exactly.
// 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.
// //
// The expectations are one swap, and the container rides in the path (`HistoryStaleness`): // 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 // the undo wants the lane **in the trash** (existence alone the delete sets no field an
// back on the strip holding the rank it left. A foreign restore empties the trash path and // expectation can name, `moveToTrash`' note), the redo wants it back on the strip holding the
// the undo skips; a foreign re-delete empties the strip path and the redo skips. // rank the undo put back. A foreign restore empties the trash path and the undo skips; a
let steps = zip(lanes, ranks).map { lane, rank in // 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), trashed: ItemPath.trashLane(lane.id).folder(under: root),
origin: ItemPath.lane(lane.id).folder(under: root), origin: ItemPath.lane(lane.id).folder(under: root),
priorOrder: lane.order, priorOrder: lane.order
trashRank: rank
) )
} }
registerStep( registerStep(
HistoryPhrase.name(.delete, kind: .lane, count: steps.count), HistoryPhrase.name(.delete, kind: .lane, count: steps.count),
subject: lanes.count == 1 ? lanes[0].title.value : nil, 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)) } redoExpects: steps.map { .present($0.origin, .order($0.priorOrder)) }
) { _ in ) { _ in
for step in steps { for step in steps {
@@ -3661,19 +3642,12 @@ public final class BoardStore: HealHost {
} }
} redo: { _ in } redo: { _ in
for step in steps { 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 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 /// **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"). /// (/) 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 /// 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. /// 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. /// **Each migrated card takes its own `deleted:` timestamp as its `modified`** where it parses
/// Migrating oldest-first therefore reproduces the newest-first column the tombstone model's /// (01-storage-format.md § Deletion, re-ruled 2026-07-31; `BoardWriter.migrateTombstonedCard`),
/// timestamp sort used to render the same board, read the same way, with ordinary ranks doing /// so the board's real deletion order survives into the trash's `modified`-descending sort no
/// the work. A card whose stamp is missing or unparseable sorts as **oldest** (the retired sort's /// matter what order the batch runs in the sequencing that used to *be* the ordering is now
/// own rule: "a corrupt stamp must not outrank fresh deletions"), and ties fall to the loader's /// only a batch order. It is kept, `deleted:`-ascending, for determinism: the notice's card list
/// walk order lane `order`, then card `order` which is the deterministic tie-break the whole /// and the commit's path order read the same way twice. A card whose stamp is missing or
/// corpus already uses. /// 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 /// ### The write half re-verifies against disk
/// ///
@@ -3786,9 +3762,6 @@ public final class BoardStore: HealHost {
let cards = Self.migrationOrder(of: work, in: snapshot) let cards = Self.migrationOrder(of: work, in: snapshot)
var movedCards: [String?] = [] 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( heals.run(
.legacyTombstone, .legacyTombstone,
signature: Self.signature(of: work.map(IntegrityRules.Defect.legacyTombstone)), signature: Self.signature(of: work.map(IntegrityRules.Defect.legacyTombstone)),
@@ -3797,9 +3770,7 @@ public final class BoardStore: HealHost {
for card in cards { for card in cards {
let folder = ItemPath.card(lane: card.laneID, id: card.cardID).folder(under: root) let folder = ItemPath.card(lane: card.laneID, id: card.cardID).folder(under: root)
guard Self.stillTombstoned(at: folder) else { continue } guard Self.stillTombstoned(at: folder) else { continue }
let rank = Ranks.insertAtHead(ofVisible: ladder) try BoardWriter.migrateTombstonedCard(at: folder, inBoard: root)
try BoardWriter.migrateTombstonedCard(at: folder, inBoard: root, order: rank)
ladder.insert(rank, at: 0)
movedCards.append(card.title) movedCards.append(card.title)
} }
} posting: { } posting: {
@@ -3823,8 +3794,9 @@ public final class BoardStore: HealHost {
return !document.deleted.isMissing return !document.deleted.isMissing
} }
/// The tombstoned cards in the order they should be filed into the trash oldest `deleted:` /// The tombstoned cards in the order the batch files them oldest `deleted:` first, a
/// first, so the newest ends up on top (see `migrateLegacyTombstones`). /// 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 /// `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 /// 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. /// 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**, /// 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 /// 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: "with the trash /// "all" could mean anything but the board (04 The map, resettled 2026-07-28: "the container
/// visible and a non-empty trash selection, Select All selects all visible trash cards; in every /// boundary decides which 'all' is meant"). A trash selection naming nothing (a foreign restore,
/// other state, all visible live cards the container boundary decides which 'all' is meant"). /// a purge) falls through to the board rather than selecting the trash wholesale on a guess.
/// 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 /// **In the trash "all" is all *rows*, both kinds** (04 The trash and 11-command-nexus.md
/// containers, never lane rows** (04 The trash, re-affirmed 2026-07-29 with lanes back in the /// Select All, re-ruled 2026-07-31 with kind-blind trash selection: "Select All with a non-empty
/// trash), which is why the trash branch reads its cards and asks no kind question. /// 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 /// 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, /// 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 let filter = searchFilter
if transient.isTrashVisible, selection.container == .trash, !selection.isEmpty, if transient.isTrashVisible, selection.container == .trash, !selection.isEmpty,
SelectionGrammar.kind(of: selection, in: snapshot) != nil { 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 return
} }
apply(Set(SelectionGrammar.boardCards(in: snapshot, filter: filter)), in: .board) apply(Set(SelectionGrammar.boardCards(in: snapshot, filter: filter)), in: .board)
+37 -15
View File
@@ -76,12 +76,12 @@ extension ItemContainer {
// MARK: - The trash's two kinds, in one order // 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, /// 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 /// re-ruled 2026-07-31: "Lane rows and cards interleave in the one trash column by `modified`
/// rank"). /// descending").
/// ///
/// **It exists so the interleave is written once.** The container's two kinds are two arrays on the /// **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 /// 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 /// 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 /// 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. /// 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 /// **The stamp that decides where this row sits among the others** the trash sorts by
/// for the same purpose. /// `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 { public var order: Double {
switch self { switch self {
case let .card(card): card.order case let .card(card): card.order
@@ -131,15 +151,17 @@ public enum TrashEntry: Identifiable, Sendable, Equatable {
extension BoardModel { extension BoardModel {
/// The trash's rows, top to bottom **the container's one order**, both kinds interleaved by /// 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 /// The tail is `Ranks.isOrderedForTrash`'s title case-insensitively, then folder name which
/// already applied within each kind: two rows minted the same rank by two writers order the same /// is what the loader already applied within each kind, so the merge of two sorted arrays and
/// way twice. /// each array alone agree everywhere they overlap.
public var trashEntries: [TrashEntry] { public var trashEntries: [TrashEntry] {
Ranks.sortedForDisplay( Ranks.sortedForTrash(
trash.map(TrashEntry.card) + trashedLanes.map(TrashEntry.lane), trash.map(TrashEntry.card) + trashedLanes.map(TrashEntry.lane),
order: \.order, modified: \.modified,
title: \.title,
name: { $0.id.rawValue } name: { $0.id.rawValue }
) )
} }
@@ -230,10 +252,10 @@ extension ItemPath {
/// fails partway must fail the same way twice (`BoardStore.styleSubjects` makes the same choice /// fails partway must fail the same way twice (`BoardStore.styleSubjects` makes the same choice
/// for the same reason). /// for the same reason).
/// ///
/// **The trash's order interleaves its two kinds by rank** (03-board-ui.md § Trash: "lane rows /// **The trash's order interleaves its two kinds by `modified`** (03-board-ui.md § Trash: "lane
/// and cards interleave in the one trash column purely by trash rank"), which is why the walk is /// rows and cards interleave in the one trash column by `modified` descending"), which is why the
/// `trashEntries` rather than the two arrays concatenated: the column's order is the batch's /// walk is `trashEntries` rather than the two arrays concatenated: the column's order is the
/// order, and it is stated in exactly one place. /// 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: /// 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. /// a selection the next reload will drop writes nothing rather than being refused.
+139 -69
View File
@@ -58,14 +58,20 @@ public enum ClickModifier: Sendable, Equatable {
/// function of the click, the current selection, the anchor, and the snapshot /// function of the click, the current selection, the anchor, and the snapshot
/// (`SelectionGrammarTests`). /// (`SelectionGrammarTests`).
/// ///
/// **Homogeneity is the invariant, and it is enforced here or nowhere.** The selection is /// **The container is the invariant, and it is enforced here or nowhere.** A selection never mixes
/// homogeneous on **two** axes cards XOR lanes (§ Selection) and board XOR trash (§ The trash's /// trash rows with board items (§ The trash's "single container rule replacing the old liveness
/// "single container rule replacing the old liveness law") and since lanes rejoined the trash /// law"), and *on the board* it is also cards XOR lanes (§ Selection). **Inside the trash it is
/// (2026-07-29) the kind axis simply reaches into the second container too: "a trash selection is /// kind-blind** (re-ruled 2026-07-31, superseding the lanes-rejoin pass's kind-homogeneous trash
/// either cards or lane rows, kind-homogeneous like the live board's own grammar". Both axes are a /// grammar): "within the trash cards and lane rows select together clicks, -click ranges,
/// property of what a *click* is allowed to produce, so no outcome below is ever mixed: a modifier /// -arrow extension, and the rubber band all sweep every row". The kind axis therefore stops at the
/// that would cross an axis degrades to a replace, which is the only answer that keeps the invariant /// container boundary rather than reaching through it, and the guard the trash used to need moved to
/// true without silently dropping what the user asked for. /// 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 /// **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, /// 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 /// **-click toggles** but only *within* a set it can legally join. Crossing the container (a
/// clicked while lanes are selected, a trash card clicked while board cards are) is not a mixed /// trash row clicked while board cards are selected) or, **on the board**, the kind (a card
/// selection and not a refusal: it is a **replace**, the same outcome a plain click would give, /// clicked while lanes are selected) is not a mixed selection and not a refusal: it is a
/// because the click unambiguously names a new set of one. /// **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 /// 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 /// 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( private static func command(
_ target: SelectionTarget, _ target: SelectionTarget,
selection: ItemReferenceSet, selection: ItemReferenceSet,
@@ -172,7 +184,7 @@ public enum SelectionGrammar {
) -> Outcome { ) -> Outcome {
guard selection.container == target.container, guard selection.container == target.container,
let current = kind(of: selection, in: snapshot), let current = kind(of: selection, in: snapshot),
current == target.kind target.container == .trash || current == target.kind
else { else {
return plain(target, selection: selection, togglesOnRepeat: false) return plain(target, selection: selection, togglesOnRepeat: false)
} }
@@ -196,8 +208,10 @@ public enum SelectionGrammar {
/// origin rather than walking it along. /// 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 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 /// the nil anchor, the vanished anchor, and every standing axis crossing into one test, since a
/// exactly one (container, kind) pair. An invalid anchor makes the click a plain one, never a no-op: /// 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 /// 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. /// click names an unambiguous target and so always has something to do.
private static func shift( private static func shift(
@@ -235,8 +249,8 @@ public enum SelectionGrammar {
/// pointer and the keyboard to disagree about what a range is. /// 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 /// **`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 /// anchor's caller-side absence, and every standing axis crossing into one test. The callers
/// (container, kind) pair. The callers differ on what they do with that: a click degrades to a /// 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 /// plain click (it names an unambiguous target), while a -arrow goes inert (its next step is
/// ambiguous). /// 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 /// 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. /// 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`, /// **The trash has one list, whatever the kind** (04-interactions.md The trash, re-ruled
/// 04-interactions.md The trash: "-click ranges skip rows of the other kind"), which is what /// 2026-07-31 superseding the kind-narrowed slices this returned while the trash's grammar was
/// makes a range skip the other kind rather than needing a rule that says so: a list is exactly /// kind-homogeneous): "-click ranges sweep every row". So a trash range walks
/// one (container, kind) pair, and the rows in between simply are not in it. /// `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( public static func order(
of kind: SelectionKind, of kind: SelectionKind,
in container: ItemContainer, in container: ItemContainer,
snapshot: BoardModel, snapshot: BoardModel,
filter: SearchFilter = .inactive filter: SearchFilter = .inactive
) -> [ItemID] { ) -> [ItemID] {
switch (container, kind) { switch container {
case (.board, .card): boardCards(in: snapshot, filter: filter) case .trash:
case (.board, .lane): lanes(in: snapshot) return trashRows(in: snapshot, filter: filter)
case (.trash, .card): trashCards(in: snapshot, filter: filter) case .board:
case (.trash, .lane): trashLanes(in: snapshot, filter: filter) 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 /// 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 /// by `modified` descending (03-board-ui.md § Trash, re-ruled 2026-07-31: "the trash sorts by
/// any lane", newest-first falling out of the ranks rather than a timestamp sort). /// `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 /// **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 /// filter exactly like any other card").
/// cards, so a trash-side range walks exactly what the column is showing. ///
/// **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] { public static func trashCards(in snapshot: BoardModel, filter: SearchFilter = .inactive) -> [ItemID] {
snapshot.trash.filter { filter.matches($0) }.map(\.id) 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 /// 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:)`). /// 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* /// Its own list rather than a kind flag on `trashCards`, and for the same reason that one
/// here: one (container, kind) pair, and the pair is what a -range walks. The rows' positions /// survives: some consumers mean the rows of one kind. The rows' positions among the cards are
/// among the cards are `trashRows`' business. /// `trashRows`' business.
public static func trashLanes(in snapshot: BoardModel, filter: SearchFilter = .inactive) -> [ItemID] { public static func trashLanes(in snapshot: BoardModel, filter: SearchFilter = .inactive) -> [ItemID] {
snapshot.trashedLanes.filter { filter.matches($0) }.map(\.id) snapshot.trashedLanes.filter { filter.matches($0) }.map(\.id)
} }
/// **Every row the trash column shows, both kinds, in rank order** what *navigation* walks /// **Every row the trash column shows, both kinds, in the column's own order** what
/// (04-interactions.md The trash: "inside, plain arrows walk every row, card and lane row /// *everything* in the trash walks (04-interactions.md The trash: "inside, plain arrows walk
/// alike (navigation crosses kinds)"). /// 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 /// Navigation and ranging read the same sequence, which is what the kind-blind ruling bought:
/// they stop at a kind boundary, while navigation is over the column as drawn and crosses it. /// there is no longer a per-kind list that could disagree with the column about "the row below
/// One merge for both `BoardModel.trashEntries`. /// this one". One merge for all of it `BoardModel.trashEntries`.
public static func trashRows(in snapshot: BoardModel, filter: SearchFilter = .inactive) -> [ItemID] { public static func trashRows(in snapshot: BoardModel, filter: SearchFilter = .inactive) -> [ItemID] {
snapshot.trashEntries.filter { filter.matches($0) }.map(\.id) 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. /// 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 /// **On the board any member answers, because the set is homogeneous there** but the walk is
/// rather than the set's iteration order, so the answer is deterministic even for a set that /// the snapshot's order rather than the set's iteration order, so the answer is deterministic
/// somehow was not. Members that name nothing are ignored, and a set of only such members reads /// even for a set that somehow was not. Members that name nothing are ignored, and a set of only
/// as empty: a selection the next reload will drop must not decide what a click does now. /// 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 /// **In the trash it answers for the topmost row, and a mixed set is legal** (04-interactions.md
/// 2026-07-29: "a trash selection is either cards or lane rows, kind-homogeneous like the live /// The trash, re-ruled 2026-07-31 trash selection is kind-blind). Two callers still want it
/// board's own grammar"), walked in the column's own rank order so a set that somehow held both /// there and neither is asking about homogeneity: the -click branch uses it as a liveness test
/// answers by what is topmost rather than by array iteration order. /// ("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? { public static func kind(of selection: ItemReferenceSet, in snapshot: BoardModel) -> SelectionKind? {
guard !selection.isEmpty else { return nil } guard !selection.isEmpty else { return nil }
switch selection.container { 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 // MARK: - Successor on delete
/// What selects after deleting `ids` 04-interactions.md The map's Finder-style /// 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 /// 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 /// 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. /// 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 /// **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 /// reload-survival rule: "the selection just shrinks"), which is why this is called by
@@ -430,15 +497,13 @@ public enum SelectionGrammar {
let siblings: [ItemID] let siblings: [ItemID]
switch (container, kind) { switch (container, kind) {
case (.trash, _): case (.trash, _):
// **The siblings are every row, both kinds** the interim answer to an open gap. 04 // **The siblings are every row, both kinds** (04-interactions.md The map, ruled
// The trash settles navigation (plain arrows cross kinds) and extension (-arrows stop // 2026-07-31 ratifying what stood here as an interim): "In the trash the successor walk
// at the kind boundary) for the trash's two kinds, but says nothing about which row the // is kind-blind: the next row of either kind, in the same all-rows order plain arrows
// *successor* lands on after a lane row is purged. Until that is ruled, this follows // walk a successor is a fresh singleton selection, so the landing violates no grammar,
// navigation rather than extension: the successor is the next row down the column // and repeated empties a mixed trash without dead-ends". The alternative kind-scoped
// whatever its kind, so a purge never strands the selection with nothing selected while // siblings clears the selection whenever the purged row was its kind's last, which is a
// rows the user can see sit right below it. The conservative direction the alternative // deselect wearing a successor's clothes.
// (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) siblings = trashRows(in: snapshot, filter: filter)
case (.board, .lane): case (.board, .lane):
siblings = lanes(in: snapshot) 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 /// - **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 /// `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. /// 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 /// - **On the board the band never selects lanes** (§ Selection gives it to cards: "click-drag
/// across lanes" across them, not over them). A live lane is never registered as a target at /// rubber-bands across lanes" across them, not over them). A live lane is never registered as a
/// all; a **trashed lane row is**, because the arrows navigate by the same frames /// target at all, so that half is true twice over.
/// (`NavigationMath`) and the band's begin guard reads them too so the kind filter below is /// - **In the trash it sweeps every row** (04-interactions.md The trash, re-ruled 2026-07-31
/// load-bearing rather than belt over braces. /// 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
/// **The kind filter is the whole of the rule, in both containers**: "the rubber band stays on the /// trashed lane row is registered as a target the arrows navigate by the same frames
/// side it started on and selects cards only (as the board marquee does); lane rows join by click /// (`NavigationMath`) and the band's begin guard reads them so dropping the kind filter on that
/// grammar" (04-interactions.md The trash, re-affirmed 2026-07-29). A band never has to break a /// side is the whole of the change.
/// tie between a card and a lane row, because a lane row is not in the answer whatever it sweeps.
public enum MarqueeMath { public enum MarqueeMath {
/// The ids `rect` sweeps. /// The ids `rect` sweeps.
@@ -509,7 +573,13 @@ public enum MarqueeMath {
) -> Set<ItemID> { ) -> Set<ItemID> {
Set( Set(
targets.lazy 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) .map(\.id)
) )
} }
+3 -3
View File
@@ -11,9 +11,9 @@ import Foundation
/// returning-card count, a deterministic `deleted`-timestamp sort, and a paths function that had to /// 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 /// 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` /// 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 /// already parsed by the loader, already in the column's own order, already newest-first because
/// every arrival mints a rank above the current top. There is nothing to derive, and no second /// every arrival stamps `modified` and the container sorts by it descending (re-ruled 2026-07-31).
/// definition to keep in step with the loader's. /// 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 /// 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 /// confirmations' phrasing which since lanes rejoined the trash (2026-07-29) has to **count the
+15 -8
View File
@@ -63,8 +63,13 @@ enum AgentGuide {
/// `modified-by` like any content edit, the trash move included, since it's the same rule and /// `modified-by` like any content edit, the trash move included, since it's the same rule and
/// not a special case and the card-level `attachments` claimed name /// 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 /// (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. /// app's own folder, so a *file* by that name is a defect the app displaces on sight. **v8
static let version = 7 /// 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 // MARK: - The version marker
@@ -450,12 +455,14 @@ enum AgentGuide {
- **Delete a card or a lane = move its folder into `<board>/.trash/`**: - **Delete a card or a lane = move its folder into `<board>/.trash/`**:
`mv <lane>/<card-uuid> <board>/.trash/`, or `mv <lane-uuid> `mv <lane>/<card-uuid> <board>/.trash/`, or `mv <lane-uuid>
<board>/.trash/` for a whole lane (create `.trash/` if missing). A lane <board>/.trash/` for a whole lane (create `.trash/` if missing). A lane
travels with its cards inside it. Arrivals go on top: set the moved travels with its cards inside it. It's a container change like any other
item's `order` to the smallest `order` already in `.trash/` minus 1024 move (Moving and reordering above): stamp `modified` and re-stamp
(empty trash: any number). It's a container change like any other move `modified-by` and here the stamp is also the position. **The trash
(Moving and reordering above): stamp `modified` and re-stamp sorts by `modified`, newest first**, so a restamped arrival lands on top;
`modified-by`. Restore is the same move in reverse a card into a lane, there is no rank to mint, and you should leave `order` exactly as it is
a lane back to board root, with a fresh `order`, stamped the same way. 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 - **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` flat, so an empty lane folder looks exactly like a card folder; the `kind`
value is what tells them apart in there. value is what tells them apart in there.
+18 -8
View File
@@ -317,8 +317,10 @@ public enum BoardLoader: Sendable {
var trash: [Card] = [] var trash: [Card] = []
var trashedLanes: [TrashedLane] = [] var trashedLanes: [TrashedLane] = []
/// Every trash entry as the dedupe needs it, kind-blind the container is one flat list to /// 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. /// the identity rule, whatever the snapshot splits it into. Carried with the two keys the
var trashEntries: [(id: ItemID, title: String?, order: Double)] = [] /// 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] = [:] var trashKinds: [ItemID: IntegrityRules.ObjectKind] = [:]
for entryURL in trashCandidates(in: boardRoot) { for entryURL in trashCandidates(in: boardRoot) {
let entryName = entryURL.lastPathComponent let entryName = entryURL.lastPathComponent
@@ -360,7 +362,7 @@ public enum BoardLoader: Sendable {
) )
let id = ItemID(rawValue: entryName) let id = ItemID(rawValue: entryName)
trashKinds[id] = kind 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 { switch kind {
case .lane: case .lane:
@@ -372,6 +374,7 @@ public enum BoardLoader: Sendable {
id: id, id: id,
schema: schema, schema: schema,
title: document.title, title: document.title,
modified: document.modified,
order: order, order: order,
heldCards: children().count, heldCards: children().count,
document: document document: document
@@ -407,12 +410,19 @@ public enum BoardLoader: Sendable {
// Display order is settled here rather than in the `BoardModel` call below, because the // 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. // 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 orderedLanes = Ranks.sortedForDisplay(walkedLanes, order: \.order, name: \.name)
let orderedTrash = Ranks.sortedForDisplay(trash, order: \.order, name: { $0.id.rawValue }) // **The trash's arrays are sorted by `modified` descending**, not by `order` (01-storage-format.md
let orderedTrashedLanes = Ranks.sortedForDisplay(trashedLanes, order: \.order, name: { $0.id.rawValue }) // § Deletion, re-ruled 2026-07-31): the trash move rewrites no rank, so the stamp *is* the
// The trash's own display order, **both kinds at once** the column interleaves them by rank // 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 // (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. // 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( let verdict = dedupeIdentities(
inBoardAt: boardRoot, inBoardAt: boardRoot,
lanes: orderedLanes, lanes: orderedLanes,
@@ -496,7 +506,7 @@ public enum BoardLoader: Sendable {
private static func dedupeIdentities( private static func dedupeIdentities(
inBoardAt root: URL, inBoardAt root: URL,
lanes: [WalkedLane], lanes: [WalkedLane],
trash: [(id: ItemID, title: String?, order: Double)], trash: [(id: ItemID, title: String?, modified: Date?)],
historyRanker: IdentityHistoryRanker? historyRanker: IdentityHistoryRanker?
) -> IntegrityRules.DedupeVerdict { ) -> IntegrityRules.DedupeVerdict {
typealias Container = IntegrityRules.IdentityOccurrence.Container typealias Container = IntegrityRules.IdentityOccurrence.Container
+21 -12
View File
@@ -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 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 /// 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, /// *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 /// and a trashed lane is an opaque row that none of them may. Interleaving the two is a
/// rank is a rendering question (03-board-ui.md § Trash: "lane rows and cards interleave in the /// rendering question (03-board-ui.md § Trash: "lane rows and cards interleave in the one trash
/// one trash column purely by trash rank"), and both arrays carry the `order` that answers it. /// 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`, /// **Display order is `modified` descending** `Ranks.sortedForTrash`, tie-broken by title then
/// same folder-name tie-break. Newest-first falls out of ordinary ranks rather than a /// folder name (re-ruled 2026-07-31, retiring the arrival rank mint). The trash is the one
/// timestamp sort: every arrival mints a rank *above* the current topmost /// container in the app whose sequence is not a rank sequence: the delete move stamps, and that
/// (`Ranks.insertAtHead`), so the trash needs no sort rule of its own. There is deliberately /// stamp *is* the position, with each entry's `order` riding along untouched for its restore.
/// no `deleted:` key on anything in here a trashed card is an ordinary card in a special /// There is deliberately no `deleted:` key on anything in here a trashed card is an ordinary
/// place. /// card in a special place.
/// ///
/// Empty when `.trash/` is absent (the overwhelmingly common case the folder is minted by /// 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. /// 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 schema: Int
public let title: FieldValue<String> public let title: FieldValue<String>
/// Rank within the trash, ascending = top to bottom the same required, strictly validated /// **When the row entered the trash** and therefore *where it sits*: the trash sorts by
/// field a live lane carries (`Lane.order`), and what interleaves this row among the trash's /// `modified` descending (01-storage-format.md § Deletion, re-ruled 2026-07-31), and the trash
/// cards. Newest-first falls out of it: every arrival mints a rank above the current topmost. /// 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<Date>
/// 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 public let order: Double
/// **How many cards the lane is holding** the row's whole other half ("Doing 5 cards"). /// **How many cards the lane is holding** the row's whole other half ("Doing 5 cards").
+100 -72
View File
@@ -94,9 +94,16 @@ public enum BoardWriter: Sendable {
/// `nil`, the default, derives it from position (`IntegrityRules.placement`), and stamps /// `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 /// 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. /// 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( public static func updateIndex(
inItemFolder folder: URL, inItemFolder folder: URL,
kind: IntegrityRules.ObjectKind? = nil, kind: IntegrityRules.ObjectKind? = nil,
stampedAt: Date? = nil,
operation: WriteOperation, operation: WriteOperation,
edits: (inout FrontmatterDocument) -> Void edits: (inout FrontmatterDocument) -> Void
) throws(BoardWriteError) { ) 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 // 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. // a standing `modified-by` survives a reorder, which is the pairing 01 spells out.
if !operation.rewritesOrderOnly { if !operation.rewritesOrderOnly {
document.set(FrontmatterKeys.modified, to: .date(Date())) document.set(FrontmatterKeys.modified, to: .date(stampedAt ?? Date()))
document.remove(FrontmatterKeys.modifiedBy) 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 /// **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. /// 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 /// **The preflight reaches comment depth** (01-storage-format.md § Frontmatter and § Enhanced
/// storage, and worth stating because it is a *narrowing* of a 2026-07-29 ruling): 01's copy /// schema, ruled 2026-07-31 reversing the 2026-07-30 carve-out that kept comments out of it):
/// transaction says "refuses whole, loudly, naming the offending item", and its enhanced-schema /// "a comment whose frontmatter cannot take the stamp refuses the copy exactly like a card or
/// section says "**comment defects never refuse the board** worst case is the stray posture /// lane never-refuse is a *load* posture, and a user-initiated copy is a transaction, not a
/// a broken leaf annotation must not brick a load; deliberate, proportionate divergence from card /// load". The board still always loads with a broken annotation on it; the *gesture* may refuse,
/// fail-fast". A V that refuses because one comment on one card inside a pasted lane has /// and refusing is what keeps the sever rule true no copy carries a live `remote` claim or a
/// hand-broken frontmatter is that divergence read the other way round. So the walk stays /// stale `modified-by` because one comment could not be rewritten.
/// `identityDescendants`' cards and lanes and a comment the contract cannot be applied to is ///
/// copied verbatim with a log line instead (`stampCopiedComment`). /// 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, /// **Internal rather than `private`**: template instantiation preflights its own tree with this,
/// for `remintDescendants`' reason one definition of what a copy owes its descendants. /// for `remintDescendants`' reason one definition of what a copy owes its descendants.
@@ -1052,7 +1064,7 @@ public enum BoardWriter: Sendable {
of folder: URL, of folder: URL,
operation: WriteOperation operation: WriteOperation
) throws(BoardWriteError) { ) throws(BoardWriteError) {
for descendant in identityDescendants(of: folder) { for descendant in copyContractDescendants(of: folder) {
let indexURL = descendant.appendingPathComponent(BoardLoader.indexFileName) let indexURL = descendant.appendingPathComponent(BoardLoader.indexFileName)
guard FileManager.default.fileExists(atPath: indexURL.path) else { continue } guard FileManager.default.fileExists(atPath: indexURL.path) else { continue }
let document = try readDocument(at: indexURL, operation: operation) 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 /// 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 /// 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 /// materializes as *items*. Comment folders are not here (they hang off `comments/`, which is not
/// `checkCopiedDescendantsAreStampable` states; it is also exactly the right reach for /// identity-shaped), which is exactly the right reach for `stripCommentTrash`, since a thread
/// `stripCommentTrash`, since a thread lives under a card and nowhere else. /// lives under a card and nowhere else.
private static func identityDescendants(of folder: URL) -> [URL] { private static func identityDescendants(of folder: URL) -> [URL] {
var found: [URL] = [] var found: [URL] = []
for child in childCandidates(of: folder) { for child in childCandidates(of: folder) {
@@ -1074,6 +1086,26 @@ public enum BoardWriter: Sendable {
return found 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 /// 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 /// 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 /// 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 /// 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). /// 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 /// **Internal rather than `private`**, with `remintDescendants` and for its reason: an
/// instantiated board's lanes and cards are stamped by this exact rule. /// instantiated board's lanes and cards are stamped by this exact rule.
static func stampCopiedDescendant( static func stampCopiedDescendant(
@@ -1098,35 +1136,11 @@ public enum BoardWriter: Sendable {
let indexURL = folder.appendingPathComponent(BoardLoader.indexFileName) let indexURL = folder.appendingPathComponent(BoardLoader.indexFileName)
guard FileManager.default.fileExists(atPath: indexURL.path) else { return } 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 try updateIndex(inItemFolder: folder, operation: operation) { document in
applyCopyContract(to: &document, stamps: stamps, now: now) 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") static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "writer")
// MARK: - The materialized trash // MARK: - The materialized trash
@@ -1157,22 +1171,23 @@ public enum BoardWriter: Sendable {
/// meet it here. /// meet it here.
/// 4. **Move the folder.** Nothing beneath it is read or rewritten, so `attachments/`, strays /// 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. /// 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 /// **No rank is minted, and `order` is not rewritten at all** (01-storage-format.md § Deletion,
/// computed here. Entry is at the *top* ("every arrival lands at the trash's topmost /// re-ruled 2026-07-31, retiring the arrival rank mint; 03-board-ui.md § Trash): the trash sorts
/// position, minting an `order` rank above the current top"), which is /// by `modified` descending, "and that stamp *is* the position newest-first falls out with no
/// `Ranks.insertAtHead(ofVisible:)` over the trash's current ranks a question about the /// `order` rewrite at all; a delete is a pure folder move plus the stamp, and the item's `order`
/// *snapshot*, which the store holds and this stateless layer does not. Value-passing keeps /// key rides along untouched (which is also what undo's move-back restores for free)". The
/// the seam: the Writer takes a rank, the store computes it. /// 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 /// **The `modified` stamp is therefore the whole write** and it needs no exception to earn it.
/// it. The trash move changes the card's *container*, which is the whole predicate /// 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 /// (`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 /// story", so it stamps exactly as a cross-lane move does. It falls out of `updateIndex` rather
/// age-based auto-purge reads. It falls out of `updateIndex` here rather than being asked for, /// than being asked for, which is why step 5 carries no argument and why the
/// which is why there is nothing extra in step 5 and why the reorders-don't-stamp rule needs no /// reorders-don't-stamp rule needs no trash carve-out to coexist with this call. Its second job
/// trash carve-out to coexist with this call. /// 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 /// **Collision inside `.trash/` is impossible by construction**, and it is checked anyway. The
/// card is a resident of this very board, and board-wide uniqueness now spans lanes *and* the /// card is a resident of this very board, and board-wide uniqueness now spans lanes *and* the
@@ -1186,14 +1201,12 @@ public enum BoardWriter: Sendable {
@discardableResult @discardableResult
public static func deleteCardToTrash( public static func deleteCardToTrash(
at cardFolder: URL, at cardFolder: URL,
inBoard boardRoot: URL, inBoard boardRoot: URL
order: Double
) throws(BoardWriteError) -> ItemID { ) throws(BoardWriteError) -> ItemID {
try moveIntoTrash( try moveIntoTrash(
at: cardFolder, at: cardFolder,
inBoard: boardRoot, inBoard: boardRoot,
kind: .card, kind: .card,
order: order,
operation: .delete(title: nil), operation: .delete(title: nil),
removingLegacyKey: false removingLegacyKey: false
) )
@@ -1208,12 +1221,12 @@ public enum BoardWriter: Sendable {
/// ///
/// - **The guard is `checkIsLaneFolder`** UUID-shaped directly under a board root, which /// - **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/`. /// 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 /// - **`kind: lane` is stamped**, not derived. The stamping rewrite is an `updateIndex` on a
/// that is by then *inside* `.trash/`, where position cannot answer and shape would answer /// folder that is by then *inside* `.trash/`, where position cannot answer and shape would
/// *wrongly* for the one lane that most needs the key: an **empty** lane is shape-identical to /// answer *wrongly* for the one lane that most needs the key: an **empty** lane is
/// a card (01's own "honest limit"). The caller knows what it moved, so it says so which is /// shape-identical to a card (01's own "honest limit"). The caller knows what it moved, so it
/// also the backfill the ruling asks of this write ("`kind: lane` backfilled on touch when /// says so which is also the backfill the ruling asks of this write ("`kind: lane`
/// absent the trash move's rank mint included"). /// 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 /// **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 /// cards, their `attachments/` and every stray arrive byte-identical and come back with it on
@@ -1224,14 +1237,12 @@ public enum BoardWriter: Sendable {
@discardableResult @discardableResult
public static func deleteLaneToTrash( public static func deleteLaneToTrash(
at laneFolder: URL, at laneFolder: URL,
inBoard boardRoot: URL, inBoard boardRoot: URL
order: Double
) throws(BoardWriteError) -> ItemID { ) throws(BoardWriteError) -> ItemID {
try moveIntoTrash( try moveIntoTrash(
at: laneFolder, at: laneFolder,
inBoard: boardRoot, inBoard: boardRoot,
kind: .lane, kind: .lane,
order: order,
operation: .delete(title: nil), operation: .delete(title: nil),
removingLegacyKey: false removingLegacyKey: false
) )
@@ -1249,19 +1260,25 @@ public enum BoardWriter: Sendable {
/// The removal is `FrontmatterDocument.remove`, so it takes **every** occurrence of the key: /// 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 /// 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, /// 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 /// line endings and the body are the same bytes they were, and the stamps are the only other
/// only writes, exactly as for an ordinary delete. /// 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 @discardableResult
public static func migrateTombstonedCard( public static func migrateTombstonedCard(
at cardFolder: URL, at cardFolder: URL,
inBoard boardRoot: URL, inBoard boardRoot: URL
order: Double
) throws(BoardWriteError) -> ItemID { ) throws(BoardWriteError) -> ItemID {
try moveIntoTrash( try moveIntoTrash(
at: cardFolder, at: cardFolder,
inBoard: boardRoot, inBoard: boardRoot,
kind: .card, kind: .card,
order: order,
operation: .migrateTombstone(title: nil), operation: .migrateTombstone(title: nil),
removingLegacyKey: true removingLegacyKey: true
) )
@@ -1278,7 +1295,6 @@ public enum BoardWriter: Sendable {
at itemFolder: URL, at itemFolder: URL,
inBoard boardRoot: URL, inBoard boardRoot: URL,
kind: IntegrityRules.ObjectKind, kind: IntegrityRules.ObjectKind,
order: Double,
operation initialOperation: WriteOperation, operation initialOperation: WriteOperation,
removingLegacyKey: Bool removingLegacyKey: Bool
) throws(BoardWriteError) -> ItemID { ) throws(BoardWriteError) -> ItemID {
@@ -1293,7 +1309,17 @@ public enum BoardWriter: Sendable {
case .lane: try checkIsLaneFolder(itemFolder, operation: operation) case .lane: try checkIsLaneFolder(itemFolder, operation: operation)
case .card, .board, .comment: try checkIsCardFolder(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) let trash = trashFolder(inBoard: boardRoot)
do { do {
@@ -1322,8 +1348,10 @@ public enum BoardWriter: Sendable {
// absence where the item was, the shown-trash side sees an arrival where it went. // absence where the item was, the shown-trash side sees an arrival where it went.
EchoLedger.current?.recordMove(from: itemFolder, to: arrived) EchoLedger.current?.recordMove(from: itemFolder, to: arrived)
try updateIndex(inItemFolder: arrived, kind: kind, operation: operation) { document in // The whole of the arrival write: the stamp (which is the row's position), the `kind` the
document.set(FrontmatterKeys.order, to: .double(order)) // 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 { if removingLegacyKey {
document.remove(FrontmatterKeys.deleted) document.remove(FrontmatterKeys.deleted)
} }
+60
View File
@@ -143,6 +143,66 @@ enum Ranks: Sendable {
items.sorted { isOrderedForDisplay($0, before: $1, order: order, name: name) } 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<T>(
_ 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<T>(
_ 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 // MARK: - Tombstone exclusion
/// `append(toVisible:)`, ignoring tombstoned siblings. Tombstones are /// `append(toVisible:)`, ignoring tombstoned siblings. Tombstones are
+17
View File
@@ -535,6 +535,23 @@ struct BoardDropContext {
cancelDrop() cancelDrop()
return false 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 ids = survivors.map { session.members[$0] }
let folders = survivors.map { session.folders[$0] } let folders = survivors.map { session.folders[$0] }
let within = DragLocality.isSameBoard(sourceRoot, store.rootURL) let within = DragLocality.isSameBoard(sourceRoot, store.rootURL)
+11 -5
View File
@@ -893,14 +893,20 @@ struct BoardView: View {
return .handled return .handled
} }
/// **-arrow extends, and stops at both boundaries** (04 The trash, settled): "a -arrow whose /// **-arrow extends, and stops at the container boundary** (04 The trash: "-arrow extension
/// next step would cross from live cards into the trash (or back), or from card entries onto a /// still stops at the container boundary") a -arrow whose next step would cross from live
/// lane entry within it, is simply inert". /// 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 /// 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 /// 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 /// 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 { private func extend(_ direction: NavigationMath.Direction, from head: ItemID) -> KeyPress.Result {
guard let origin = marqueeTargets.targets[head], guard let origin = marqueeTargets.targets[head],
let nextID = NavigationMath.nearest( let nextID = NavigationMath.nearest(
@@ -910,7 +916,7 @@ struct BoardView: View {
), ),
let next = marqueeTargets.targets[nextID], let next = marqueeTargets.targets[nextID],
next.container == origin.container, next.container == origin.container,
next.kind == origin.kind next.container == .trash || next.kind == origin.kind
else { return .handled } else { return .handled }
// An extension with no anchor makes one of where it started the keyboard's equivalent of // An extension with no anchor makes one of where it started the keyboard's equivalent of
+10 -2
View File
@@ -356,12 +356,19 @@ struct CardFaceView: View {
/// every other card drop uses, committed by the same `moveCards`. /// 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 /// **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 { private func startTrashCardDrag() -> NSItemProvider {
guard !store.isReadOnly, !store.isEditingInline else { return NSItemProvider() } guard !store.isReadOnly, !store.isEditingInline else { return NSItemProvider() }
let ids = draggedIDs let ids = draggedIDs
let rows = store.snapshot.trash.filter { ids.contains($0.id) } let rows = store.snapshot.trash.filter { ids.contains($0.id) }
guard !rows.isEmpty else { return NSItemProvider() } guard !rows.isEmpty else { return NSItemProvider() }
let mixesKinds = SelectionGrammar.mixesKinds(
ItemReferenceSet(ids: ids, container: .trash), in: store.snapshot)
let root = store.rootURL let root = store.rootURL
let payload = DragPayload( let payload = DragPayload(
@@ -381,7 +388,8 @@ struct CardFaceView: View {
folders: payload.folders, folders: payload.folders,
heights: rows.map { drops.registry.heights[$0.id] ?? LaneDropRegistry.nominalCardHeight }, heights: rows.map { drops.registry.heights[$0.id] ?? LaneDropRegistry.nominalCardHeight },
container: .trash, container: .trash,
source: store source: store,
mixesKinds: mixesKinds
) )
return payload.itemProvider() return payload.itemProvider()
} }
+41 -11
View File
@@ -60,12 +60,11 @@ enum TrashDrop {
/// The row the shadow takes, always: **the topmost**. /// The row the shadow takes, always: **the topmost**.
/// ///
/// Not arbitrary, and the ranks are what make it honest: "every trash arrival mints a rank /// Not arbitrary, and the stamp is what makes it honest: "every trash arrival stamps `modified`
/// above the current top" (04-interactions.md The trash), so a fresh delete genuinely lands on /// and the trash sorts newest-first by that stamp" (04-interactions.md The trash, re-ruled
/// top. The drop /// 2026-07-31), so a fresh delete genuinely lands on top. The drop therefore still lands exactly
/// therefore still lands exactly where the shadow shows the one positional promise every other /// where the shadow shows the one positional promise every other drop in this app makes
/// drop in this app makes while being the only proposal on the board the *pointer* does not /// while being the only proposal on the board the *pointer* does not choose.
/// choose.
static let landingIndex = 0 static let landingIndex = 0
/// Whether the shown trash takes this session the whole of the gate, and every clause is a /// 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). /// and that is the whole of what makes it a restore (04-interactions.md The trash).
private(set) var container: ItemContainer = .board 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. /// The dragged items in **flatten order** the order they will land in.
private(set) var members: [ItemID] = [] private(set) var members: [ItemID] = []
@@ -496,16 +507,33 @@ final class DragSession {
folders: [URL], folders: [URL],
heights: [CGFloat], heights: [CGFloat],
container: ItemContainer, 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 cardHeights = heights
laneUnits = [] laneUnits = []
} }
/// Begins a lane session. /// 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 laneUnits = units
cardHeights = [] cardHeights = []
} }
@@ -515,7 +543,8 @@ final class DragSession {
members: [ItemID], members: [ItemID],
folders: [URL], folders: [URL],
container: ItemContainer, container: ItemContainer,
source: BoardStore source: BoardStore,
mixesKinds: Bool
) { ) {
endHold() endHold()
self.kind = kind self.kind = kind
@@ -523,6 +552,7 @@ final class DragSession {
self.memberSet = Set(members) self.memberSet = Set(members)
self.folders = folders self.folders = folders
self.container = container self.container = container
self.mixesKinds = mixesKinds
self.sourceStore = source self.sourceStore = source
self.sourceRoot = source.rootURL self.sourceRoot = source.rootURL
self.proposal = nil self.proposal = nil
+13 -1
View File
@@ -183,6 +183,11 @@ struct TrashLaneRowView: View {
/// **Multi-drag carries the whole trash-side lane selection**, in the column's own order, and a /// **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. /// 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 /// 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. /// mutating gesture; a refusal is an item provider carrying nothing, so no session begins.
private func startDrag() -> NSItemProvider { private func startDrag() -> NSItemProvider {
@@ -190,6 +195,8 @@ struct TrashLaneRowView: View {
let ids = draggedIDs let ids = draggedIDs
let rows = store.snapshot.trashedLanes.filter { ids.contains($0.id) } let rows = store.snapshot.trashedLanes.filter { ids.contains($0.id) }
guard !rows.isEmpty else { return NSItemProvider() } guard !rows.isEmpty else { return NSItemProvider() }
let mixesKinds = SelectionGrammar.mixesKinds(
ItemReferenceSet(ids: ids, container: .trash), in: store.snapshot)
let root = store.rootURL let root = store.rootURL
let payload = DragPayload( let payload = DragPayload(
@@ -212,7 +219,12 @@ struct TrashLaneRowView: View {
// standard width; the restored lane then draws at whatever `width` its untouched // standard width; the restored lane then draws at whatever `width` its untouched
// frontmatter still says, on the next reload. // frontmatter still says, on the next reload.
units: rows.map { _ in 1 }, 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() return payload.itemProvider()
} }
+10 -9
View File
@@ -12,7 +12,7 @@ import SwiftUI
/// ///
/// **Its cards are ordinary cards in a special place**, so there is nothing to derive and nothing /// **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 /// 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 /// 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" /// 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 /// (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 /// **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 /// 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 /// (`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. /// `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 /// Newest-first falls out of the stamp (every arrival of either kind restamps `modified` on the way
/// top), so there is no timestamp sort and no entry type here at all. /// 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 /// ### 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 /// 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` /// 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, /// (`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 /// 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 mints a rank /// divergence honest: **the shadow always takes the topmost row**, because every arrival restamps
/// above the current top. /// `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 /// 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 /// 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: /// The rows the column shows **both kinds, interleaved by `modified` descending**
/// "Lane rows and cards interleave in the one trash column purely by trash rank"). /// (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"** /// **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 /// (03 § Trash "the point of the pivot"), so the search predicate narrows this collection
+18
View File
@@ -687,6 +687,24 @@ struct AgentGuideContentTests {
#expect(content.contains("the trash is the recoverable path for both")) #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 `<root>/.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 /// The pathfinder's guide taught `media/` and tombstone deletes; both are retired
/// (01-storage-format.md Changes from the pathfinder schema; Deletion). The one legitimate /// (01-storage-format.md Changes from the pathfinder schema; Deletion). The one legitimate
/// mention of `deleted:` is the warning never to write it. /// mention of `deleted:` is the warning never to write it.
+1 -1
View File
@@ -267,7 +267,7 @@ struct StoreWriteCardBodyTests {
func aTrashedCardStillTakesTheFlush() throws { func aTrashedCardStillTakesTheFlush() throws {
let fixture = try makeBoard() let fixture = try makeBoard()
defer { fixture.tearDown() } 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 store = try BoardStore(rootURL: fixture.root)
let trashedPath = ".trash/\(Ident.card1)" let trashedPath = ".trash/\(Ident.card1)"
+1 -3
View File
@@ -90,9 +90,7 @@ struct CardWindowFateTests {
func aLaneTrashedDismissesItsCards() throws { func aLaneTrashedDismissesItsCards() throws {
let fixture = try makeBoard() let fixture = try makeBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
try BoardWriter.deleteLaneToTrash( try BoardWriter.deleteLaneToTrash(at: fixture.url(Ident.lane1), inBoard: fixture.root)
at: fixture.url(Ident.lane1), inBoard: fixture.root, order: -1024
)
let snapshot = try BoardLoader.load(boardRoot: fixture.root).model let snapshot = try BoardLoader.load(boardRoot: fixture.root).model
#expect(snapshot.trashedLanes.map(\.id.rawValue) == [Ident.lane1]) #expect(snapshot.trashedLanes.map(\.id.rawValue) == [Ident.lane1])
+1 -2
View File
@@ -194,8 +194,7 @@ struct ClaimedNameDisplacementTests {
store.displaceClaimedNames() store.displaceClaimedNames()
try BoardWriter.deleteCardToTrash( try BoardWriter.deleteCardToTrash(
at: fixture.url("\(Ident.lane1)/\(Ident.card1)"), at: fixture.url("\(Ident.lane1)/\(Ident.card1)"),
inBoard: fixture.root, inBoard: fixture.root
order: 1024
) )
#expect(fixture.exists(".trash/\(Ident.card1)")) #expect(fixture.exists(".trash/\(Ident.card1)"))
+49
View File
@@ -669,6 +669,55 @@ struct ClipboardAvailabilityTests {
== ItemReferenceSet(ids: [clipboardTrashedLane], container: .trash)) == 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") @Test("The read-only lock blocks cut but never copy")
func lockBlocksCutOnly() throws { func lockBlocksCutOnly() throws {
let harness = try makeClipboardHarness() let harness = try makeClipboardHarness()
+1
View File
@@ -232,6 +232,7 @@ struct CommentIndexMatchingTests {
id: ItemID(rawValue: Ident.lane3), id: ItemID(rawValue: Ident.lane3),
schema: 1, schema: 1,
title: .valid("Retired"), title: .valid("Retired"),
modified: .missing,
order: 1024, order: 1024,
heldCards: 2, heldCards: 2,
document: FrontmatterDocument(body: "") document: FrontmatterDocument(body: "")
+45 -9
View File
@@ -638,27 +638,63 @@ struct CommentCopyTests {
#expect(!fixture.exists("\(copiedCard)/comments/.trash")) #expect(!fixture.exists("\(copiedCard)/comments/.trash"))
} }
@Test("A comment nobody can stamp never refuses the copy — it travels verbatim") /// **The preflight reaches comment depth** (01-storage-format.md § Identity lifecycle and
func brokenCommentDoesNotRefuseACopy() throws { /// § 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() let fixture = try WriterFixture()
defer { fixture.tearDown() } defer { fixture.tearDown() }
try fixture.item("", Item.board) try fixture.item("", Item.board)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo")) 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.lane2, Item.rich(order: "2048", title: "Doing"))
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Fix login")) 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) try fixture.item("\(Ident.lane1)/\(Ident.card1)/comments/\(CommentIdent.one)", Item.uneditable)
let copy = try BoardWriter.copyItem( var thrown: BoardWriteError?
do {
_ = try BoardWriter.copyItem(
at: fixture.url("\(Ident.lane1)/\(Ident.card1)"), at: fixture.url("\(Ident.lane1)/\(Ident.card1)"),
toParent: fixture.url(Ident.lane2), toParent: fixture.url(Ident.lane2),
order: 1024, order: 1024,
stamps: .fork stamps: .fork
) )
let copied = "\(Ident.lane2)/\(copy.rawValue)" } catch {
let names = try postedNames(fixture, inCard: copied) thrown = error
#expect(names.count == 1, "the copy landed whole") }
#expect(try fixture.indexText("\(copied)/comments/\(names[0])") == Item.uneditable, "verbatim")
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") @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")) try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
let card = try threadedCard(fixture, in: Ident.lane1, card: Ident.card1) 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)" let trashedCard = ".trash/\(Ident.card1)"
#expect(fixture.exists("\(trashedCard)/comments/\(CommentIdent.one)")) #expect(fixture.exists("\(trashedCard)/comments/\(CommentIdent.one)"))
+74
View File
@@ -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 // MARK: - The committed-overlay hold
@Suite("CommittedHold") @Suite("CommittedHold")
+9 -4
View File
@@ -675,9 +675,14 @@ struct RestoreByMoveOutTests {
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First")) 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, Item.rich(order: "2048", title: "Doing"))
try fixture.item("\(Ident.lane2)/\(Ident.card4)", Item.rich(order: "1024", title: "Fourth")) 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. // The trash's own order is its display order, and since 2026-07-31 that is `modified`
try fixture.item(".trash/\(Ident.card2)", Item.rich(order: "2048", title: "TrashedA")) // descending (01-storage-format.md § Deletion) `card3` above `card2`, newest-first.
try fixture.item(".trash/\(Ident.card3)", Item.rich(order: "1024", title: "TrashedB")) 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) let store = try BoardStore(rootURL: fixture.root)
store.moveCards([card3, card2], toLane: lane2, at: 0) 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") #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 { func undoMovesItBackIn() throws {
let fixture = try makeTrashedLaneBoard() let fixture = try makeTrashedLaneBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
+3 -5
View File
@@ -271,7 +271,7 @@ struct EchoLedgerWriterTests {
let from = fixture.url("\(lane1)/\(card1)") let from = fixture.url("\(lane1)/\(card1)")
try EchoLedger.$current.withValue(ledger) { 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)") let to = fixture.url(".trash/\(card1)")
@@ -594,13 +594,11 @@ struct EchoLedgerHealMarkTests {
try EchoLedger.$current.withValue(ledger) { try EchoLedger.$current.withValue(ledger) {
_ = try BoardWriter.migrateTombstonedCard( _ = try BoardWriter.migrateTombstonedCard(
at: fixture.url("\(lane1)/\(Ident.card3)"), at: fixture.url("\(lane1)/\(Ident.card3)"),
inBoard: fixture.root, inBoard: fixture.root
order: 1024
) )
_ = try BoardWriter.deleteCardToTrash( _ = try BoardWriter.deleteCardToTrash(
at: fixture.url("\(lane1)/\(card1)"), at: fixture.url("\(lane1)/\(card1)"),
inBoard: fixture.root, inBoard: fixture.root
order: 2048
) )
} }
+1 -1
View File
@@ -186,7 +186,7 @@ struct InertGitTests {
// the pair most likely to notice a `.git` at the root: the first walks into `<root>/.trash/` // the pair most likely to notice a `.git` at the root: the first walks into `<root>/.trash/`
// and the second walks back out of it. // and the second walks back out of it.
try BoardWriter.deleteCardToTrash( 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( _ = try BoardWriter.moveItem(
at: BoardWriter.trashFolder(inBoard: board.root).appendingPathComponent(board.card1), at: BoardWriter.trashFolder(inBoard: board.root).appendingPathComponent(board.card1),
+1 -2
View File
@@ -470,8 +470,7 @@ struct ObjectKindWriteTests {
try BoardWriter.deleteCardToTrash( try BoardWriter.deleteCardToTrash(
at: fixture.url("\(Ident.lane1)/\(Ident.card1)"), at: fixture.url("\(Ident.lane1)/\(Ident.card1)"),
inBoard: fixture.root, inBoard: fixture.root
order: 1024
) )
#expect(try kind(of: ".trash/\(Ident.card1)", in: fixture) == .valid("card")) #expect(try kind(of: ".trash/\(Ident.card1)", in: fixture) == .valid("card"))
+20 -10
View File
@@ -25,10 +25,13 @@ private enum More {
/// A card or lane whose title and body are **independently controlled** which `Item.rich` cannot /// 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. /// 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"] var lines = ["---", "schema: 1"]
if let title { lines.append("title: \(title)") } if let title { lines.append("title: \(title)") }
lines.append("order: \(order)") 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("---") lines.append("---")
return lines.joined(separator: "\n") + "\n" + body + "\n" return lines.joined(separator: "\n") + "\n" + body + "\n"
} }
@@ -109,12 +112,17 @@ private func makeTrashBoard() throws -> WriterFixture {
let fixture = try WriterFixture() let fixture = try WriterFixture()
try fixture.item("", Item.board) try fixture.item("", Item.board)
try fixture.item(Ident.lane1, item(order: "1024", title: "Todo", body: "Inbox lane.")) 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( try fixture.item(
".trash/\(More.laneX)", ".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 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 /// "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 /// 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. /// 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 { func trashLaneRowsFilterByTitle() throws {
let fixture = try makeTrashBoard() let fixture = try makeTrashBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
let model = try load(fixture) 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]) #expect(SelectionGrammar.trashLanes(in: model, filter: SearchFilter(query: "archive")) == [laneX])
// The body says "login". The row does not. // The body says "login". The row does not.
#expect(SelectionGrammar.trashLanes(in: model, filter: SearchFilter(query: "login")).isEmpty) #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 // And the ranging grammar's list is the whole column, both kinds kind-blind trash selection
// -range skip the other kind (04-interactions.md The trash). // (04-interactions.md The trash, re-ruled 2026-07-31).
#expect(SelectionGrammar.trashRows(in: model) == [card1, laneX, card2]) #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") @Test("The trash's visible universe is its matching rows, both kinds")
@@ -371,7 +380,7 @@ struct SearchFilterOrderTests {
defer { fixture.tearDown() } defer { fixture.tearDown() }
let model = try load(fixture) 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: .inactive).map(\.id) == [card1, laneX, card2])
#expect(TrashLaneView.rendered(model, filter: SearchFilter(query: "login")).map(\.id) == [card1]) #expect(TrashLaneView.rendered(model, filter: SearchFilter(query: "login")).map(\.id) == [card1])
// The column and the arrow grammar cannot disagree about what is on screen. // The column and the arrow grammar cannot disagree about what is on screen.
@@ -431,7 +440,8 @@ struct SearchFilterStoreTests {
store.transient.isTrashVisible = true store.transient.isTrashVisible = true
store.select([card2], in: .trash) store.select([card2], in: .trash)
store.selectAll() 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.select([card1], in: .trash)
store.searchQuery = "login" store.searchQuery = "login"
+88 -56
View File
@@ -12,10 +12,11 @@ import Testing
/// read card ordering and the trash container, and a hand-built `BoardModel` would let both drift /// read card ordering and the trash container, and a hand-built `BoardModel` would let both drift
/// from what the loader actually produces. /// from what the loader actually produces.
/// ///
/// **Two homogeneity axes, and the kind one reaches into the trash** (resettled 2026-07-28; lanes /// **One container axis, and a kind axis that stops at it** (resettled 2026-07-28; kind-blind trash
/// rejoined 2026-07-29): cards XOR lanes, and board XOR trash. The trash's rows are cards *and* /// re-ruled 2026-07-31): a selection never mixes trash rows with board items, and *on the board* it
/// opaque lane units, so "a trash selection is either cards or lane rows" is the board's own kind /// is cards XOR lanes but inside the trash "cards and lane rows select together", so the kind axis
/// rule in a second container rather than a third 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`. /// `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 /// 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 @MainActor
private func makeTrashBoard() throws -> WriterFixture { private func makeTrashBoard() throws -> WriterFixture {
let fixture = try WriterFixture() let fixture = try WriterFixture()
try fixture.item("", Item.board) try fixture.item("", Item.board)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo")) 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("\(Ident.lane1)/\(More.card6)", Item.rich(order: "1024", title: "Live"))
try fixture.item(".trash/\(Ident.card1)", Item.rich(order: "256", title: "First")) // **The trash's order is `modified` descending** (01-storage-format.md § Deletion, re-ruled
try fixture.item(".trash/\(Ident.card2)", Item.rich(order: "512", title: "Second")) // 2026-07-31), so the stamps are what these fixtures state their sequence in; the `order` values
try fixture.item(".trash/\(Ident.card3)", Item.rich(order: "1024", title: "Third")) // 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 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 /// 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 /// rejoined 2026-07-29): the column 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. /// kind-crossing claim below has a row of the other kind sitting inside the span it asks about.
@MainActor @MainActor
private func makeMixedTrashBoard() throws -> WriterFixture { private func makeMixedTrashBoard() throws -> WriterFixture {
let fixture = try makeTrashBoard() let fixture = try makeTrashBoard()
try fixture.item( try fixture.item(
".trash/\(Ident.lane2)", ".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( try fixture.item(
".trash/\(Ident.lane3)", ".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 return fixture
} }
@@ -135,7 +154,7 @@ struct SelectionOrderTests {
#expect(SelectionGrammar.lanes(in: snapshot) == [lane1, lane2, lane3]) #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 { func trashOrder() throws {
let fixture = try makeTrashBoard() let fixture = try makeTrashBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
@@ -143,21 +162,30 @@ struct SelectionOrderTests {
#expect(SelectionGrammar.trashCards(in: snapshot) == [card1, card2, card3]) #expect(SelectionGrammar.trashCards(in: snapshot) == [card1, card2, card3])
#expect(SelectionGrammar.order(of: .card, in: .trash, snapshot: 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 /// **The column has one list, and the kind argument does not narrow it** (04-interactions.md
/// merged rank order for *navigation*, and two kind-scoped slices of it for *ranging*. The /// The trash, re-ruled 2026-07-31 kind-blind trash selection): navigation and ranging read the
/// slices are what make a -range skip the other kind without a rule that says so. /// same merged sequence, so a -range sweeps the rows of the other kind rather than skipping
@Test("The trash's rows interleave by rank, and each kind's list is a slice of that order") /// 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 { func trashRowsInterleave() throws {
let fixture = try makeMixedTrashBoard() let fixture = try makeMixedTrashBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
let snapshot = try load(fixture) let snapshot = try load(fixture)
#expect(SelectionGrammar.trashRows(in: snapshot) == [card1, lane2, card2, lane3, card3]) let rows = [card1, lane2, card2, lane3, card3]
#expect(SelectionGrammar.order(of: .card, in: .trash, snapshot: snapshot) == [card1, card2, card3]) #expect(SelectionGrammar.trashRows(in: snapshot) == rows)
#expect(SelectionGrammar.order(of: .lane, in: .trash, snapshot: snapshot) == [lane2, lane3]) #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. // 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) #expect(ItemPath.resolve([card2, lane2, lane3], in: .trash, snapshot: snapshot)
== [.trashLane(lane2), .trashCard(card2), .trashLane(lane3)]) == [.trashLane(lane2), .trashCard(card2), .trashLane(lane3)])
@@ -287,19 +315,21 @@ struct CommandClickTests {
#expect(ontoCard.anchor == card1) #expect(ontoCard.anchor == card1)
} }
/// The kind axis inside the trash: "a trash selection is either cards or lane rows, /// **Inside the trash there is no kind boundary to cross** (04-interactions.md The trash,
/// kind-homogeneous like the live board's own grammar" (04-interactions.md The trash). /// re-ruled 2026-07-31, superseding the kind-homogeneous trash grammar): "within the trash cards
@Test("⌘-click across the kind boundary inside the trash replaces") /// and lane rows select together clicks, -click ranges, -arrow extension, and the rubber
func acrossKindInTheTrashReplaces() throws { /// 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() let fixture = try makeMixedTrashBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
let snapshot = try load(fixture) let snapshot = try load(fixture)
let ontoRow = click(target(lane2, .lane, .trash), .command, selection: set([card1], .trash), anchor: card1, in: snapshot) 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) 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 // Within one kind it still toggles, which is what makes the branch above a rule rather than
// a refusal of in the trash. // a refusal of in the trash.
@@ -421,28 +451,29 @@ struct ShiftClickTests {
#expect(outcome.anchor == card1) #expect(outcome.anchor == card1)
} }
/// "-click ranges skip rows of the other kind (resurrecting the 2026-07-28 skip-by-kind ruling, /// **A trash range sweeps every row** (04-interactions.md The trash, re-ruled 2026-07-31
/// mooted when lanes left the trash and back with them)" 04-interactions.md The trash. /// superseding the skip-by-kind ruling this suite used to pin): "-click ranges all sweep every
@Test("A trash range skips rows of the other kind") /// row". The kinds are not a boundary inside the container any more; the container still is.
func trashRangeSkipsTheOtherKind() throws { @Test("A trash range sweeps every row between its endpoints, both kinds")
func trashRangeSweepsEveryRow() throws {
let fixture = try makeMixedTrashBoard() let fixture = try makeMixedTrashBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
let snapshot = try load(fixture) let snapshot = try load(fixture)
// The column reads [card1, lane2, card2, lane3, card3]: a card range from the top to the // The column reads [card1, lane2, card2, lane3, card3]: a range from the top to the bottom
// bottom takes the three cards and steps over both lane rows. // 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) 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) 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 // And a range aimed from a card to a lane row is now an ordinary range rather than a
// plain click never a mixed selection. // 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) let crossed = click(target(lane3, .lane, .trash), .shift, selection: set([card1], .trash), anchor: card1, in: snapshot)
#expect(crossed.selection == set([lane3], .trash)) #expect(crossed.selection == set([card1, lane2, card2, lane3], .trash))
#expect(crossed.anchor == lane3) #expect(crossed.anchor == card1)
} }
@Test("A range never crosses the container boundary") @Test("A range never crosses the container boundary")
@@ -483,12 +514,13 @@ struct MarqueeMathTests {
#expect(ids == [card1, card2]) #expect(ids == [card1, card2])
} }
/// **The kind filter is the whole rule, and in the trash it is load-bearing.** A trashed lane row /// **In the trash the band sweeps every row** (04-interactions.md The trash, re-ruled
/// registers its frame like a card does the arrows navigate by those frames so the band /// 2026-07-31, superseding the card-only band): "the rubber band [sweeps] every row (the band's
/// genuinely sweeps over one and must still leave it out: "the rubber band selects cards only /// full-height backdrop covers both kinds)". A trashed lane row registers its frame like a card
/// (as the board marquee does); lane rows join by click grammar" (04-interactions.md The trash). /// does, so this needed only the kind filter to come off on that side the *container* filter
@Test("On the trash side the band takes cards only, and stays on its own side") /// stays, and the band still never leaves the side it began on.
func trashSideTakesItsOwnCards() { @Test("On the trash side the band takes every row, and stays on its own side")
func trashSideTakesEveryRow() {
let targets = [ let targets = [
Self.card(card1, 0, container: .trash), Self.card(card1, 0, container: .trash),
MarqueeTarget(id: lane2, kind: .lane, 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) 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]) #expect(MarqueeMath.selection(rect: all, targets: targets, in: .board) == [card3])
} }
@@ -637,17 +669,19 @@ struct SelectAllTests {
store.transient.isTrashVisible = true store.transient.isTrashVisible = true
// "With the trash visible and a non-empty trash selection, Select All selects all visible // "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.select([card2], in: .trash)
store.selectAll() store.selectAll()
#expect(store.selection == set([card1, card2, card3], .trash)) #expect(store.selection == set([card1, card2, card3], .trash))
} }
/// "Select All is card-scoped everywhere, never lane rows" (04-interactions.md The trash, /// **In the trash, "all" is all rows** (04-interactions.md The trash and 11-command-nexus.md
/// re-affirmed 2026-07-29): a lane-row selection is a *trash* selection, so the command reads the /// Select All, re-ruled 2026-07-31 with kind-blind trash selection: "Select All with a non-empty
/// column and what it selects there is its cards. /// trash selection selects **all visible trash rows**"). The live board's own Select All stays
@Test("A lane-row selection still selects the trash's cards, never the rows") /// card-scoped, which the board branch above pins.
func trashBranchIsCardScopedWithLaneRows() throws { @Test("Select All in the trash takes every row, lane rows included")
func trashBranchTakesEveryRow() throws {
let fixture = try makeMixedTrashBoard() let fixture = try makeMixedTrashBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root) let store = try BoardStore(rootURL: fixture.root)
@@ -656,9 +690,7 @@ struct SelectAllTests {
store.select([lane2], in: .trash) store.select([lane2], in: .trash)
store.selectAll() store.selectAll()
#expect(store.selection == set([card1, card2, card3], .trash)) #expect(store.selection == set([card1, lane2, card2, lane3, card3], .trash))
#expect(!store.selection.ids.contains(lane2))
#expect(!store.selection.ids.contains(lane3))
} }
@Test("The trash branch is narrow: hidden, live, empty, or ghost selections take the board") @Test("The trash branch is narrow: hidden, live, empty, or ghost selections take the board")
+22 -15
View File
@@ -28,8 +28,11 @@ private enum More {
static let cardF = "ffffffff-ffff-4fff-8fff-ffffffffffff" static let cardF = "ffffffff-ffff-4fff-8fff-ffffffffffff"
} }
private func card(order: String, title: String) -> String { private func card(order: String, title: String, modified: String? = nil) -> String {
"---\nschema: 1\ntitle: \(title)\norder: \(order)\n---\n\(title) body.\n" // 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 { 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, card(order: "1024", title: "Todo"))
try fixture.item("\(More.laneA)/\(Ident.card1)", card(order: "1024", title: "First")) try fixture.item("\(More.laneA)/\(Ident.card1)", card(order: "1024", title: "First"))
try fixture.item("\(More.laneA)/\(Ident.card2)", card(order: "2048", title: "Second")) try fixture.item("\(More.laneA)/\(Ident.card2)", card(order: "2048", title: "Second"))
// Newest-first by ordinary ranks: every arrival mints above the current top. // Newest-first by `modified`; the ranks disagree on purpose, so nothing here can pass by
try fixture.item(".trash/\(More.cardD)", card(order: "1024", title: "Oldest")) // accident of the retired arrival-rank rule.
try fixture.item(".trash/\(More.cardE)", card(order: "512", title: "Middle")) try fixture.item(".trash/\(More.cardD)", card(order: "256", title: "Oldest", modified: "2026-05-01T09:00:00Z"))
try fixture.item(".trash/\(More.cardF)", card(order: "256", title: "Newest")) 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 return fixture
} }
@@ -70,16 +74,16 @@ private let cardF = ItemID(rawValue: More.cardF)
@Suite("The trash's contents are the container") @Suite("The trash's contents are the container")
struct TrashContentsTests { 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 { func theContainerIsTheList() throws {
let fixture = try makeBoard() let fixture = try makeBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
let snapshot = try load(fixture) let snapshot = try load(fixture)
#expect(snapshot.trash.compactMap(\.title.value) == ["Newest", "Middle", "Oldest"], #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 }, #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") @Test("Lanes are never in it, whatever a hand-editor nests in there")
@@ -306,8 +310,11 @@ struct TrashFreightTests {
let fixture = try WriterFixture() let fixture = try WriterFixture()
try fixture.item("", Item.board) try fixture.item("", Item.board)
try fixture.item(More.laneA, card(order: "1024", title: "Todo")) 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(
try fixture.item(".trash/\(Ident.lane2)", "---\nschema: 1\ntitle: Doing\norder: 512\nkind: lane\n---\n") ".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 { for index in 0 ..< held {
try fixture.item( try fixture.item(
".trash/\(Ident.lane2)/\(UUID().uuidString.lowercased())", ".trash/\(Ident.lane2)/\(UUID().uuidString.lowercased())",
@@ -396,15 +403,15 @@ struct TrashFreightTests {
== [.trashLane(ItemID(rawValue: Ident.lane2))]) == [.trashLane(ItemID(rawValue: Ident.lane2))])
} }
/// The column is one list: `resolve` hands back the trash's paths interleaved by rank, because /// The column is one list: `resolve` hands back the trash's paths interleaved by `modified`,
/// a batch's order is the column's order (03 § Trash). /// because a batch's order is the column's order (03 § Trash).
@Test("Resolution interleaves the container's two kinds by rank") @Test("Resolution interleaves the container's two kinds by the column's own order")
func resolutionInterleaves() throws { func resolutionInterleaves() throws {
let fixture = try makeFreightBoard(held: 0) let fixture = try makeFreightBoard(held: 0)
defer { fixture.tearDown() } defer { fixture.tearDown() }
let snapshot = try load(fixture) 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( #expect(ItemPath.resolve(
[ItemID(rawValue: Ident.lane2), cardD], in: .trash, snapshot: snapshot [ItemID(rawValue: Ident.lane2), cardD], in: .trash, snapshot: snapshot
) == [.trashLane(ItemID(rawValue: Ident.lane2)), .trashCard(cardD)]) ) == [.trashLane(ItemID(rawValue: Ident.lane2)), .trashCard(cardD)])
+145 -51
View File
@@ -57,11 +57,12 @@ private func uuidName() -> String { UUID().uuidString.lowercased() }
@Suite("BoardLoader ▸ the .trash container") @Suite("BoardLoader ▸ the .trash container")
struct TrashContainerLoadTests { struct TrashContainerLoadTests {
/// The container's whole ordering story: ordinary `order` ranks, ascending, sorted exactly as a /// The container's whole ordering story: **`modified` descending** (01-storage-format.md
/// lane's cards are newest-first falls out of *minting* (each arrival takes a rank above the /// § Deletion, re-ruled 2026-07-31 the arrival rank mint retired). `order` rides along
/// current top), never out of a timestamp sort, so the loader has no trash-specific rule at all. /// untouched and is deliberately *not* consulted, which is what this fixture proves by giving the
@Test("Trash cards load in rank order, in their own container, carrying no deleted key") /// three entries ranks that disagree with their stamps in every direction.
func trashCardsLoadInRankOrder() throws { @Test("Trash cards load newest-first by `modified`, in their own container, carrying no deleted key")
func trashCardsLoadNewestFirstByModified() throws {
let fixture = try TrashFixture() let fixture = try TrashFixture()
defer { fixture.tearDown() } defer { fixture.tearDown() }
@@ -72,15 +73,23 @@ struct TrashContainerLoadTests {
try fixture.index("", "schema: 1\n") try fixture.index("", "schema: 1\n")
try fixture.index(lane, "schema: 1\norder: 1024\n") try fixture.index(lane, "schema: 1\norder: 1024\n")
// Written oldest-first on disk; the ranks are what decides. // The `order` values are scrambled against the stamps on purpose: were a rank still deciding
try fixture.index(".trash/\(oldest)", "schema: 1\norder: 1024\ntitle: Oldest\n") // anything, this fixture would read Newest, Middle, Oldest by accident of the old rule and
try fixture.index(".trash/\(middle)", "schema: 1\norder: 0\ntitle: Middle\n") // the assertion would pass for the wrong reason.
try fixture.index(".trash/\(newest)", "schema: 1\norder: -1024\ntitle: Newest\n") 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) let result = try BoardLoader.load(boardRoot: fixture.root)
#expect(result.model.trash.map(\.id.rawValue) == [newest, middle, oldest]) #expect(result.model.trash.map(\.id.rawValue) == [newest, middle, oldest])
#expect(result.model.trash.map(\.title.value) == ["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. // 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.trash.allSatisfy { !$0.isDeleted })
#expect(result.model.lanes.map(\.id.rawValue) == [lane]) #expect(result.model.lanes.map(\.id.rawValue) == [lane])
@@ -383,8 +392,7 @@ struct DeleteCardToTrashTests {
#expect(!fixture.exists(".trash")) #expect(!fixture.exists(".trash"))
let id = try BoardWriter.deleteCardToTrash( let id = try BoardWriter.deleteCardToTrash(
at: fixture.url("\(Ident.lane1)/\(Ident.card1)"), at: fixture.url("\(Ident.lane1)/\(Ident.card1)"),
inBoard: fixture.root, inBoard: fixture.root
order: -1024
) )
#expect(id == ItemID(rawValue: Ident.card1)) #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 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. /// 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") /// **The stamps are the *whole* rewrite** (01 § Deletion, re-ruled 2026-07-31): no rank is
func onlyOrderAndStampsChange() throws { /// 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() let fixture = try WriterFixture()
defer { fixture.tearDown() } defer { fixture.tearDown() }
@@ -405,8 +416,7 @@ struct DeleteCardToTrashTests {
try BoardWriter.deleteCardToTrash( try BoardWriter.deleteCardToTrash(
at: fixture.url("\(Ident.lane1)/\(Ident.card1)"), at: fixture.url("\(Ident.lane1)/\(Ident.card1)"),
inBoard: fixture.root, inBoard: fixture.root
order: -1024
) )
let text = try fixture.indexText(".trash/\(Ident.card1)") let text = try fixture.indexText(".trash/\(Ident.card1)")
@@ -421,7 +431,7 @@ struct DeleteCardToTrashTests {
#expect(!text.contains("deleted:")) #expect(!text.contains("deleted:"))
let document = try FrontmatterDocument.parse(text) 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") @Test("Attachments and strays travel byte-identical — nothing beneath the card is read")
@@ -439,8 +449,7 @@ struct DeleteCardToTrashTests {
try BoardWriter.deleteCardToTrash( try BoardWriter.deleteCardToTrash(
at: fixture.url("\(Ident.lane1)/\(Ident.card1)"), at: fixture.url("\(Ident.lane1)/\(Ident.card1)"),
inBoard: fixture.root, inBoard: fixture.root
order: -1024
) )
#expect(try fixture.data(".trash/\(Ident.card1)/attachments/shot.png") == png) #expect(try fixture.data(".trash/\(Ident.card1)/attachments/shot.png") == png)
@@ -462,8 +471,7 @@ struct DeleteCardToTrashTests {
let error = writeFailure { let error = writeFailure {
_ = try BoardWriter.deleteCardToTrash( _ = try BoardWriter.deleteCardToTrash(
at: fixture.url(target), at: fixture.url(target),
inBoard: fixture.root, inBoard: fixture.root
order: -1024
) )
} }
#expect(error != nil) #expect(error != nil)
@@ -486,8 +494,7 @@ struct DeleteCardToTrashTests {
let error = writeFailure { let error = writeFailure {
_ = try BoardWriter.deleteCardToTrash( _ = try BoardWriter.deleteCardToTrash(
at: fixture.url("\(Ident.lane1)/\(Ident.card1)"), at: fixture.url("\(Ident.lane1)/\(Ident.card1)"),
inBoard: fixture.root, inBoard: fixture.root
order: -2048
) )
} }
#expect(error?.operation == .delete(title: "Live")) #expect(error?.operation == .delete(title: "Live"))
@@ -510,8 +517,7 @@ struct DeleteCardToTrashTests {
let error = writeFailure { let error = writeFailure {
_ = try BoardWriter.deleteCardToTrash( _ = try BoardWriter.deleteCardToTrash(
at: fixture.url("\(Ident.lane1)/\(Ident.card1)"), at: fixture.url("\(Ident.lane1)/\(Ident.card1)"),
inBoard: fixture.root, inBoard: fixture.root
order: -1024
) )
} }
if case .uneditableFrontmatter = error?.reason {} else { if case .uneditableFrontmatter = error?.reason {} else {
@@ -550,8 +556,7 @@ struct TombstoneMigrationTests {
try BoardWriter.migrateTombstonedCard( try BoardWriter.migrateTombstonedCard(
at: fixture.url("\(Ident.lane1)/\(Ident.card1)"), at: fixture.url("\(Ident.lane1)/\(Ident.card1)"),
inBoard: fixture.root, inBoard: fixture.root
order: -1024
) )
#expect(!fixture.exists("\(Ident.lane1)/\(Ident.card1)")) #expect(!fixture.exists("\(Ident.lane1)/\(Ident.card1)"))
@@ -559,7 +564,7 @@ struct TombstoneMigrationTests {
#expect(!text.contains("deleted:")) #expect(!text.contains("deleted:"))
#expect(text.contains("project: lanework # agent overlay")) #expect(text.contains("project: lanework # agent overlay"))
#expect(text.contains("Body kept.")) #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. // And the board now loads it as an ordinary trash card.
let result = try BoardLoader.load(boardRoot: fixture.root) let result = try BoardLoader.load(boardRoot: fixture.root)
@@ -567,6 +572,74 @@ struct TombstoneMigrationTests {
#expect(result.model.trash.map(\.id.rawValue) == [Ident.card1]) #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 /// 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 /// 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`). /// lane simply loads live with the key inert (`LegacyTombstoneDetectionTests`).
@@ -618,8 +691,7 @@ struct DeleteLaneToTrashTests {
let id = try BoardWriter.deleteLaneToTrash( let id = try BoardWriter.deleteLaneToTrash(
at: fixture.url(Ident.lane1), at: fixture.url(Ident.lane1),
inBoard: fixture.root, inBoard: fixture.root
order: -1024
) )
#expect(id == ItemID(rawValue: Ident.lane1), "a delete moves a folder, it does not rename one") #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 /// 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. /// 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") @Test("`modified` is stamped and `modified-by` cleared, and the rank is left alone")
func theRankRewriteStamps() throws { func theArrivalStamps() throws {
let fixture = try WriterFixture() let fixture = try WriterFixture()
defer { fixture.tearDown() } 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)")) 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.modifiedBy.isMissing)
#expect(document.modified.value.map { $0 > Date(timeIntervalSince1970: 1_600_000_000) } == true) #expect(document.modified.value.map { $0 > Date(timeIntervalSince1970: 1_600_000_000) } == true)
let text = try fixture.indexText(".trash/\(Ident.lane1)") let text = try fixture.indexText(".trash/\(Ident.lane1)")
@@ -664,7 +736,7 @@ struct DeleteLaneToTrashTests {
#expect(text.contains("Lane notes.")) #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 /// (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 /// shape-identical to a card, so a derived kind would answer wrongly and the row would come back
/// as a card. /// as a card.
@@ -676,7 +748,7 @@ struct DeleteLaneToTrashTests {
try fixture.item("", Item.board) try fixture.item("", Item.board)
try fixture.item(Ident.lane1, "---\nschema: 1\ntitle: Empty\norder: 1024\n---\n") 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")) #expect(try fixture.indexText(".trash/\(Ident.lane1)").contains("kind: lane"))
let result = try BoardLoader.load(boardRoot: fixture.root) let result = try BoardLoader.load(boardRoot: fixture.root)
@@ -695,7 +767,7 @@ struct DeleteLaneToTrashTests {
try fixture.item("", Item.board) try fixture.item("", Item.board)
try fixture.item(Ident.lane1, "---\nschema: 1\norder: 1024\nkind: lane\n---\n") 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)") let text = try fixture.indexText(".trash/\(Ident.lane1)")
#expect(text.components(separatedBy: "kind: lane").count == 2, "written once, not twice") #expect(text.components(separatedBy: "kind: lane").count == 2, "written once, not twice")
@@ -719,8 +791,7 @@ struct DeleteLaneToTrashTests {
writeFailure { writeFailure {
try BoardWriter.deleteLaneToTrash( try BoardWriter.deleteLaneToTrash(
at: fixture.url(target), at: fixture.url(target),
inBoard: fixture.root, inBoard: fixture.root
order: -1024
) )
} != nil } != nil
) )
@@ -1038,11 +1109,11 @@ struct TrashKindDiscriminatorTests {
} }
} }
/// The column is one list interleaved by rank (03 § Trash), which the snapshot expresses as two /// The column is one list interleaved by **`modified` descending** (03 § Trash, re-ruled
/// arrays carrying the ranks that interleave them so a consumer merging by `order` gets the /// 2026-07-31), which the snapshot expresses as two arrays each sorted by the same comparator
/// column, and neither array is "after" the other. /// so `BoardModel.trashEntries`' merge is the column, and neither array is "after" the other.
@Test("Both kinds carry the ranks that interleave them") @Test("Both kinds carry the stamps that interleave them")
func kindsInterleaveByRank() throws { func kindsInterleaveByStamp() throws {
let fixture = try TrashFixture() let fixture = try TrashFixture()
defer { fixture.tearDown() } defer { fixture.tearDown() }
let newestLane = uuidName() let newestLane = uuidName()
@@ -1050,18 +1121,41 @@ struct TrashKindDiscriminatorTests {
let oldestLane = uuidName() let oldestLane = uuidName()
try fixture.index("", "schema: 1\n") try fixture.index("", "schema: 1\n")
try fixture.index(".trash/\(oldestLane)", "schema: 1\norder: 3072\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\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: 1024\nkind: lane\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) let result = try BoardLoader.load(boardRoot: fixture.root)
#expect(result.model.trashedLanes.map(\.id.rawValue) == [newestLane, oldestLane]) #expect(result.model.trashedLanes.map(\.id.rawValue) == [newestLane, oldestLane])
#expect(result.model.trash.map(\.id.rawValue) == [middleCard]) #expect(result.model.trash.map(\.id.rawValue) == [middleCard])
let column = (result.model.trash.map { (order: $0.order, id: $0.id) } // The one merge, which every consumer of "the row below this one" reads.
+ result.model.trashedLanes.map { (order: $0.order, id: $0.id) }) #expect(result.model.trashEntries.map(\.id.rawValue) == [newestLane, middleCard, oldestLane])
.sorted { $0.order < $1.order } }
.map(\.id.rawValue)
#expect(column == [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])
} }
} }
+83 -42
View File
@@ -23,12 +23,16 @@ private enum More {
/// A lane already sitting in `<root>/.trash/` the opaque unit, `kind: lane` being the only thing /// A lane already sitting in `<root>/.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). /// 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 schema: 1
title: \(title) title: \(title)
order: \(order) order: \(order)
modified: \(modified)
kind: lane kind: lane
project: lanework # agent overlay project: lanework # agent overlay
--- ---
@@ -39,7 +43,7 @@ private func trashedLane(order: String, title: String) -> String {
/// A card already sitting in `<root>/.trash/` an ordinary card in a special place, with an unknown /// A card already sitting in `<root>/.trash/` an ordinary card in a special place, with an unknown
/// key so the verbatim-preservation claims have something to preserve. /// 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 schema: 1
@@ -47,6 +51,7 @@ private func trashResident(order: String, title: String) -> String {
order: \(order) order: \(order)
project: lanework # agent overlay project: lanework # agent overlay
created: 2026-01-01T09:00:00Z created: 2026-01-01T09:00:00Z
modified: \(modified)
--- ---
\(title) body. \(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.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, Item.rich(order: "3072", title: "Done"))
try fixture.item("\(Ident.lane3)/\(Ident.card4)", Item.rich(order: "1024", title: "Fourth")) try fixture.item("\(Ident.lane3)/\(Ident.card4)", Item.rich(order: "1024", title: "Fourth"))
try fixture.item(".trash/\(Ident.indexless)", trashResident(order: "1024", title: "Trashed")) // Their *stamps* are what orders them now "Newer" is the newer one, and its `order` is the
try fixture.item(".trash/\(More.newer)", trashResident(order: "512", title: "Newer")) // 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 return fixture
} }
@@ -172,34 +183,46 @@ struct DeleteCardTests {
#expect(try document(fixture, ".trash/\(Ident.card1)").modified.value != nil) #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 { func entryIsAtTheTop() throws {
let fixture = try makeBoard() let fixture = try makeBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root) 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]) #expect(try loaded(fixture).trash.map(\.id) == [newer, trashed])
store.delete([card1]) store.delete([card1])
let landed = try #require(try document(fixture, ".trash/\(Ident.card1)").order.value) let arrived = try document(fixture, ".trash/\(Ident.card1)")
#expect(landed < 512, "03 ▸ Trash: every arrival mints an `order` rank above the current top") #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], #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") /// **A batch shares one instant, so the deterministic tail orders it** (01 § Deletion: "Ties
func batchLandsNewestOnTop() throws { /// 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() let fixture = try makeBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root) let store = try BoardStore(rootURL: fixture.root)
store.delete([card1, card2]) store.delete([card1, card2])
let first = try #require(try document(fixture, ".trash/\(Ident.card1)").order.value) #expect(try document(fixture, ".trash/\(Ident.card1)").order == .valid(1024))
let second = try #require(try document(fixture, ".trash/\(Ident.card2)").order.value) #expect(try document(fixture, ".trash/\(Ident.card2)").order == .valid(2048))
#expect(second < first) #expect(try loaded(fixture).trash.map(\.id) == [card1, card2, newer, trashed])
#expect(try loaded(fixture).trash.map(\.id) == [card2, card1, newer, trashed])
} }
@Test("The selection moves to the successor sibling, immediately") @Test("The selection moves to the successor sibling, immediately")
@@ -312,27 +335,30 @@ struct DeleteLaneTests {
#expect(store.banners.oneShots.isEmpty) #expect(store.banners.oneShots.isEmpty)
} }
/// "Every arrival lands at the trash's topmost position regardless of kind" (03 § Trash): the /// "Every arrival lands at the trash's topmost position regardless of kind" (03 § Trash)
/// ladder the rank is minted against is the whole container, cards and lane rows alike. /// 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") @Test("The lane lands at the top of the trash, above every existing entry")
func laneLandsOnTop() async throws { func laneLandsOnTop() async throws {
let fixture = try makeBoard() let fixture = try makeBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root) let store = try BoardStore(rootURL: fixture.root)
let topBefore = try #require(loaded(fixture).trash.map(\.order).min())
store.delete([lane3]) store.delete([lane3])
let landed = try #require(loaded(fixture).trashedLanes.first?.order) #expect(try loaded(fixture).trashEntries.first?.id == lane3, "topmost row of the whole column")
#expect(landed < topBefore) // 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 // And a *later* card delete lands above it the merged order is the whole container, so a
// mints against, which is the whole container rather than one array of it. The reload is // row of the other kind is exactly as sortable as one of its own. The reload is what puts
// what puts the new row in the snapshot; without it the store is still holding the // the new row in the snapshot; without it the store is still holding the pre-delete picture,
// pre-delete picture, as it is for two of any deletes in a row. // 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) await reload(store)
try await Task.sleep(for: .seconds(1.1))
store.delete([card1]) store.delete([card1])
let card = try #require(loaded(fixture).trash.first { $0.id == card1 }?.order) #expect(try loaded(fixture).trashEntries.map(\.id).prefix(2) == [card1, lane3])
#expect(card < landed)
} }
/// No dialog: "the move is recoverable, so nothing needs confirming" (03 § Trash). /// 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("", Item.board)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo")) try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First")) try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First"))
try fixture.item(".trash/\(More.newer)", trashResident(order: "512", title: "Newer")) // The column reads newest-first by `modified`: Newer, Doing (the lane row), Trashed the two
try fixture.item(".trash/\(Ident.lane2)", trashedLane(order: "768", title: "Doing")) // 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.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.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 return fixture
} }
@@ -618,10 +652,9 @@ struct PurgeTrashedLaneTests {
#expect(!fixture.exists(".trash/\(Ident.lane2)")) #expect(!fixture.exists(".trash/\(Ident.lane2)"))
} }
/// **The interim successor** (`SelectionGrammar.successor`'s trash branch): 04 settles navigation /// **The successor is kind-blind** (04-interactions.md The map, ruled 2026-07-31, ratifying
/// and extension for the column's two kinds but not the successor, so it follows *navigation* /// what stood here as an interim): "the next row of either kind, in the same all-rows order plain
/// the next row down whatever its kind rather than stranding the selection. Gap card /// arrows walk repeated empties a mixed trash without dead-ends".
/// 7b5cbc90 tracks the ruling; this test is the interim's marker as much as its cover.
@Test("The successor after purging a lane row is the next row down, kind notwithstanding") @Test("The successor after purging a lane row is the next row down, kind notwithstanding")
func successorCrossesKinds() throws { func successorCrossesKinds() throws {
let fixture = try makeTrashedLaneBoard() let fixture = try makeTrashedLaneBoard()
@@ -764,21 +797,30 @@ struct StoreTombstoneMigrationTests {
#expect(try loaded(fixture).lanes.map(\.id.rawValue).contains(Ident.lane2)) #expect(try loaded(fixture).lanes.map(\.id.rawValue).contains(Ident.lane2))
} }
@Test("Cards migrate oldest-first, so the newest deletion ends up on top") /// **The stamps do the ordering, not the batch** (01 § Deletion, re-ruled 2026-07-31): each
func migrationOrderIsOldestFirst() throws { /// 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() let fixture = try makeLegacyBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root) let store = try BoardStore(rootURL: fixture.root)
store.migrateLegacyTombstones() store.migrateLegacyTombstones()
// Every arrival mints above the current top, so migrating oldest-first reproduces the // 03-05 above 03-01, straight off the retired key not off the order the batch ran in.
// newest-first column the tombstone model's timestamp sort used to render.
#expect(try loaded(fixture).trash.compactMap(\.title.value) == ["Newer", "Older"]) #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") /// An **unparseable** legacy stamp is no evidence of when the card was deleted, so the migration
func undatedSortsOldest() throws { /// 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() let fixture = try WriterFixture()
defer { fixture.tearDown() } defer { fixture.tearDown() }
try fixture.item("", Item.board) try fixture.item("", Item.board)
@@ -795,9 +837,8 @@ struct StoreTombstoneMigrationTests {
store.migrateLegacyTombstones() store.migrateLegacyTombstones()
// "A corrupt stamp must not outrank fresh deletions for the trash's most prominent rows": // Migration time is today, which is newer than any legacy stamp a real board carries.
// undated sorts oldest, so it migrates first and ends up *below* the dated one. #expect(try loaded(fixture).trash.compactMap(\.title.value) == ["Corrupt", "Dated"])
#expect(try loaded(fixture).trash.compactMap(\.title.value) == ["Dated", "Corrupt"])
} }
@Test("A board-level deleted: is never migrated — it is meaningless, ignored and logged") @Test("A board-level deleted: is never migrated — it is meaningless, ignored and logged")
+30 -21
View File
@@ -627,24 +627,30 @@ struct TrashUndoTests {
#expect(!fixture.exists(card1Path)) #expect(!fixture.exists(card1Path))
} }
@Test("The redo files the card under the rank the delete minted, not a fresh one") /// **There is no rank to capture or replay** (01-storage-format.md § Deletion, re-ruled
func redoUsesTheCapturedTrashRank() throws { /// 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() let fixture = try makeBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
let (store, history) = try makeStore(fixture) let (store, history) = try makeStore(fixture)
store.delete([card1]) store.delete([card1])
let minted = try #require(try document(fixture, ".trash/\(Ident.card1)").order.value) #expect(try document(fixture, ".trash/\(Ident.card1)").order.value == 1024,
#expect(minted < 1024, "entry is at the top: a rank above the current topmost (order 1024)") "the lane rank rode along; nothing was minted")
history.undo() history.undo()
#expect(try document(fixture, card1Path).order.value == 1024)
history.redo() history.redo()
#expect(try document(fixture, ".trash/\(Ident.card1)").order.value == minted, #expect(try document(fixture, ".trash/\(Ident.card1)").order.value == 1024)
"the redo replays the write's own captured rank") // 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 { func batchDeleteIsOneStep() throws {
let fixture = try makeBoard() let fixture = try makeBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
@@ -653,9 +659,11 @@ struct TrashUndoTests {
store.delete([card1, card2]) store.delete([card1, card2])
#expect(history.undoActionName == "Delete 2 Cards") #expect(history.undoActionName == "Delete 2 Cards")
let first = try #require(try document(fixture, ".trash/\(Ident.card1)").order.value) // Both cards keep the ranks they had in their lane nothing is minted, at either end of the
let second = try #require(try document(fixture, ".trash/\(Ident.card2)").order.value) // run and both land above the board's existing resident by their fresh stamps.
#expect(second < first, "each arrival in the run mints a rank above the one before it") #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() history.undo()
#expect(fixture.exists(card1Path)) #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 loadedTrash(fixture).map(\.id) == [trashed], "its cards are not trash cards")
#expect(try loadedTrashedLanes(fixture).map(\.id) == [lane2]) #expect(try loadedTrashedLanes(fixture).map(\.id) == [lane2])
#expect(history.undoActionName == "Delete Lane") #expect(history.undoActionName == "Delete Lane")
let trashRank = try #require(try document(fixture, ".trash/\(Ident.lane2)").order.value) #expect(try document(fixture, ".trash/\(Ident.lane2)").order.value == laneRank,
let resident = try #require(try document(fixture, trashedPath).order.value) "the strip rank rides along; no trash rank is minted")
#expect(trashRank < resident, "entry is at the top — a rank above the current topmost")
history.undo() history.undo()
@@ -699,8 +706,8 @@ struct TrashUndoTests {
history.redo() history.redo()
#expect(fixture.exists(".trash/\(Ident.lane2)")) #expect(fixture.exists(".trash/\(Ident.lane2)"))
#expect(try document(fixture, ".trash/\(Ident.lane2)").order.value == trashRank, #expect(try document(fixture, ".trash/\(Ident.lane2)").order.value == laneRank,
"the redo replays the write's own captured rank") "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") @Test("A multi-lane delete is one step with a plural title")
@@ -712,9 +719,9 @@ struct TrashUndoTests {
store.delete([lane1, lane2]) store.delete([lane1, lane2])
#expect(history.undoActionName == "Delete 2 Lanes") #expect(history.undoActionName == "Delete 2 Lanes")
let first = try #require(try document(fixture, ".trash/\(Ident.lane1)").order.value) // Their strip ranks, untouched a lane delete mints nothing either.
let second = try #require(try document(fixture, ".trash/\(Ident.lane2)").order.value) #expect(try document(fixture, ".trash/\(Ident.lane1)").order.value == 1024)
#expect(second < first, "each arrival in the run mints a rank above the one before it") #expect(try document(fixture, ".trash/\(Ident.lane2)").order.value == 2048)
history.undo() history.undo()
#expect(fixture.exists(Ident.lane1)) #expect(fixture.exists(Ident.lane1))
@@ -728,7 +735,9 @@ struct TrashUndoTests {
let fixture = try makeBoard() let fixture = try makeBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
let (store, history) = try makeStore(fixture) 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) store.moveCards([trashed], toLane: lane2, at: 0)
@@ -740,8 +749,8 @@ struct TrashUndoTests {
history.undo() history.undo()
#expect(fixture.exists(trashedPath), "back in the trash it came out of") #expect(fixture.exists(trashedPath), "back in the trash it came out of")
#expect(!fixture.exists("\(Ident.lane2)/\(Ident.card4)")) #expect(!fixture.exists("\(Ident.lane2)/\(Ident.card4)"))
#expect(try document(fixture, trashedPath).order.value == trashRank, #expect(try document(fixture, trashedPath).order.value == carriedOrder,
"at the rank it was filed under") "the undo puts back the rank the restore overwrote")
history.redo() history.redo()
#expect(fixture.exists("\(Ident.lane2)/\(Ident.card4)")) #expect(fixture.exists("\(Ident.lane2)/\(Ident.card4)"))
+5 -5
View File
@@ -158,7 +158,7 @@ struct WriteFidelityMinimalTouchTests {
// included, which is the point of this harness. // included, which is the point of this harness.
try step("delete", targeting: [], departed: ["\(Ident.lane2)/\(Ident.card4)"]) { try step("delete", targeting: [], departed: ["\(Ident.lane2)/\(Ident.card4)"]) {
try BoardWriter.deleteCardToTrash( 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 // 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)"] departed: [Ident.lane2, "\(Ident.lane2)/\(Ident.card3)", "\(Ident.lane2)/\(Ident.card4)"]
) { ) {
try BoardWriter.deleteLaneToTrash( 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: []) { 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 // resettled 2026-07-28), so its unknown keys, its comment and its body must ride along
// untouched through both legs. // untouched through both legs.
try BoardWriter.deleteCardToTrash( try BoardWriter.deleteCardToTrash(
at: lane1Folder.appendingPathComponent(card2.rawValue), inBoard: root, order: 1024 at: lane1Folder.appendingPathComponent(card2.rawValue), inBoard: root
) )
_ = try BoardWriter.moveItem( _ = try BoardWriter.moveItem(
at: BoardWriter.trashFolder(inBoard: root).appendingPathComponent(card2.rawValue), at: BoardWriter.trashFolder(inBoard: root).appendingPathComponent(card2.rawValue),
@@ -548,7 +548,7 @@ struct WriteFidelityStampingTests {
defer { fixture.tearDown() } defer { fixture.tearDown() }
try BoardWriter.deleteCardToTrash( 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)") let trashed = try stamps(fixture, ".trash/\(Ident.card1)")
#expect(trashed.modified != Self.priorModified, "into the trash is a container change") #expect(trashed.modified != Self.priorModified, "into the trash is a container change")
@@ -578,7 +578,7 @@ struct WriteFidelityStampingTests {
defer { fixture.tearDown() } defer { fixture.tearDown() }
try BoardWriter.deleteLaneToTrash( 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)") let lane = try stamps(fixture, ".trash/\(Ident.lane1)")