Lanes delete into the trash — storage, loader, writer, and undo

Phase 1 of the lanes-in-trash card (2026-07-29 ruling, docs led the
code): lane delete is a move into .trash/ with the subtree intact,
arriving at top trash rank — no destructive delete remains outside
the trash.

TrashedLane opaque unit (id/schema/title/order/heldCards) beside
trash cards — deliberately not a Lane, so no card-shaped surface can
believe an empty subtree. Loader's trash walk trusts the kind VALUE
(lane → opaque unit w/ held-card count counted at the loader's own
unit; card → ordinary card; absent/unrecognized → UUID-children
shape, empty-kindless falls to card per 01's honest limit). Writer:
moveIntoTrash generalized with kind passed never derived (an empty
lane would re-derive as card), deleteLaneToTrash mints against the
whole-container rank ladder. Retired: migrateTombstonedLane (lane
deleted: now ignored — loads live, bytes inert, tolerate-tier
warning), removeLane, captureSubtree/recreateSubtree and the
subtree-snapshot machinery. Undo inverse = move back to captured
strip position, redo replays at captured trash rank. Purge walks
lane subtrees; TrashModel.Freight phrases confirms with lane freight
("…and its 5 cards"). ItemPath gains .trashLane; resolve interleaves
the trash by rank; SearchFilter matches lane rows by title only.
Trashed-lane card windows dismiss and pending cuts void via the
ordinary vanish rule — no new plumbing.

Phase 2 (rendering, selection grammar, drag, a11y, agent guide)
follows. Both schemes 1858 tests / 318 suites green.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-30 16:16:57 -04:00
parent 785ef5fe14
commit 8014bde7c6
21 changed files with 1357 additions and 910 deletions
+122 -89
View File
@@ -1863,7 +1863,7 @@ public final class BoardStore: HealHost {
return snapshot.trash.contains { $0.id == id } ? .trashCard(id) : nil
}
/// A card's title at a resolved path, in either container the skip banner's quoted subject.
/// An item's title at a resolved path, in either container the skip banner's quoted subject.
nonisolated static func cardTitle(at path: ItemPath, in snapshot: BoardModel) -> String? {
switch path {
case let .card(lane, id):
@@ -1872,6 +1872,8 @@ public final class BoardStore: HealHost {
snapshot.trash.first { $0.id == id }?.title.value
case let .lane(id):
snapshot.lanes.first { $0.id == id }?.title.value
case let .trashLane(id):
snapshot.trashedLanes.first { $0.id == id }?.title.value
}
}
@@ -2227,11 +2229,7 @@ public final class BoardStore: HealHost {
/// The parent folder an inverse move returns it to.
func parent(under root: URL) -> URL {
switch path {
case let .card(lane, _): ItemPath.lane(lane).folder(under: root)
case .trashCard: BoardWriter.trashFolder(inBoard: root)
case .lane: root
}
BoardStore.parentFolder(of: path, under: root)
}
}
@@ -2402,10 +2400,13 @@ public final class BoardStore: HealHost {
}
/// The parent folder an item at `path` sits in the destination an inverse move returns it to.
///
/// Both trash cases answer the container itself: `.trash/` is flat, so a trashed lane's parent is
/// the same folder a trashed card's is (01-storage-format.md § Deletion).
nonisolated static func parentFolder(of path: ItemPath, under root: URL) -> URL {
switch path {
case let .card(lane, _): ItemPath.lane(lane).folder(under: root)
case .trashCard: BoardWriter.trashFolder(inBoard: root)
case .trashCard, .trashLane: BoardWriter.trashFolder(inBoard: root)
case .lane: root
}
}
@@ -3121,20 +3122,22 @@ public final class BoardStore: HealHost {
public func deleteSelection() {
switch selection.container {
case .board: delete(selection.ids)
case .trash: deleteTrashCards(selection.ids)
case .trash: deleteTrashEntries(selection.ids)
}
}
/// Deletes every **board** item in `ids` cards into `.trash/`, lanes outright in one bracket.
/// Deletes every **board** item in `ids` cards and lanes alike into `.trash/` in one bracket.
///
/// **Two writes, because they are two acts** (03-board-ui.md § Trash): "deleting a card moves its
/// folder into `<board-root>/.trash/`", while "Cards only. Lanes are never trashed deleting a
/// lane deletes it, folder and contents, physically. The net is undo, not the trash."
/// **One act at two levels now** (03-board-ui.md § Trash, re-ruled 2026-07-29): "deleting a lane
/// moves its folder subtree intact into `.trash/`, exactly as a card moves". The two writes
/// below differ only in which Writer door they take (`deleteCardToTrash` / `deleteLaneToTrash`),
/// which is a `kind` question and not a semantic one no dialog either way, because the move is
/// recoverable and "nothing needs confirming".
///
/// **A set naming both is not a gesture this app can produce** the selection is cards XOR lanes
/// (04-interactions.md § Selection) so the partition below never actually splits, and when a
/// caller hands one anyway the lanes win: a lane delete takes its cards with it, and running the
/// trash move as well would put a second step on the undo stack for one keystroke.
/// caller hands one anyway the lanes win: a lane takes its cards with it, and moving them
/// separately would file the same cards twice and put two steps on the stack for one keystroke.
///
/// **Ids that name nothing are silently skipped**, not refused: the paths are resolved against
/// the snapshot, so a selection the next reload will drop writes nothing. An empty resolution
@@ -3170,7 +3173,7 @@ public final class BoardStore: HealHost {
}
let landed = lanes.isEmpty
? moveToTrash(paths.compactMap(Self.cardMove(of:)))
: removeLanes(lanes)
: moveLanesToTrash(lanes)
guard landed else { return }
if let successor {
@@ -3246,7 +3249,7 @@ public final class BoardStore: HealHost {
/// **The rank is the store's to mint** (03-board-ui.md § Trash: "every arrival lands at the
/// trash's topmost position, minting an `order` rank above the current top"). That is a question
/// about the snapshot, which the stateless Writer does not have so `Ranks.insertAtHead` runs
/// here over `snapshot.trash`, and a multi-card delete threads the minted rank back through the
/// here over `trashRanks`, and a multi-card delete threads the minted rank back through the
/// running list so each card in the run lands above the one before it. Newest-first therefore
/// falls out of ordinary ranks, with no timestamp sort anywhere.
///
@@ -3265,7 +3268,7 @@ public final class BoardStore: HealHost {
// 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 = snapshot.trash.map(\.order)
var ladder = trashRanks
var ranks: [Double] = []
for _ in moves {
let rank = Ranks.insertAtHead(ofVisible: ladder)
@@ -3329,72 +3332,109 @@ public final class BoardStore: HealHost {
return true
}
/// **Deleting a lane is physical** the folder and its contents go (03-board-ui.md § Trash:
/// "Cards only. Lanes are never trashed The net is undo, not the trash").
/// **Deleting a lane is a move into `.trash/`, subtree intact** (03-board-ui.md § Trash,
/// re-ruled 2026-07-29: "deleting a lane moves its folder subtree intact into `.trash/`,
/// exactly as a card moves The no-dialog posture survives for a better reason: the move is
/// recoverable, so nothing needs confirming").
///
/// **Capture before you remove.** The undo replays the lane's bytes, which only works if the step
/// is holding them: `captureSubtree` reads the whole tree nested cards, their `attachments/`,
/// every stray, symlinks as links, POSIX modes inside the same bracket as the removal, so
/// nothing can change between the two. The capture is the reason this is a *destructive* delete
/// with a real inverse rather than an unrecoverable one.
/// **`moveToTrash`'s twin, and deliberately its mirror image**: the same head-of-the-trash rank
/// mint threaded through the run, the same one bracket, the same one step because on disk it is
/// the same write one level up (`BoardWriter.deleteLaneToTrash`, which differs only in the guard
/// it passes and the `kind` it stamps). What is *not* here any more is the whole capture layer:
/// the lane's bytes never leave the disk, so nothing has to hold them (13-native-undo.md
/// Interaction with the trash: "the recreate-from-capture inverse retires with the last
/// destructive delete").
///
/// **In-session only, and that is the accepted net** (13-native-undo.md Rules session-only
/// persistence): the bytes live on the stack, so closing the board loses them. Git boards keep
/// the lane reachable forever (06-history-undo.md's delete-never-forgets) a Pro difference,
/// stated honestly.
///
/// A capture that fails takes the whole bracket down and nothing is removed: better a delete that
/// visibly did not happen than one whose undo could not.
/// **The cards ride along and are not the store's business**: they are inside the folder that
/// moved, so they are neither read nor written, and they leave the snapshot with their lane
/// which is what dismisses their card windows and voids their pending cuts, through the ordinary
/// vanish rule and no clause of its own (02-architecture.md § Live-reload resilience).
@discardableResult
private func removeLanes(_ ids: [ItemID]) -> Bool {
private func moveLanesToTrash(_ ids: [ItemID]) -> Bool {
let lanes = ids.compactMap { id in snapshot.lanes.first { $0.id == id } }
guard !lanes.isEmpty else { return false }
let root = rootURL
let folders = lanes.map { ItemPath.lane($0.id).folder(under: root) }
var captures: [SubtreeSnapshot] = []
var ladder = trashRanks
var ranks: [Double] = []
for _ in lanes {
let rank = Ranks.insertAtHead(ofVisible: ladder)
ranks.append(rank)
ladder.insert(rank, at: 0)
}
let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
for folder in folders {
captures.append(try BoardWriter.captureSubtree(at: folder, operation: .delete(title: nil)))
try BoardWriter.removeLane(at: folder)
for (lane, rank) in zip(lanes, ranks) {
try BoardWriter.deleteLaneToTrash(
at: ItemPath.lane(lane.id).folder(under: root),
inBoard: root,
order: rank
)
}
}
guard landed != nil, captures.count == folders.count else { return false }
guard landed != nil else { return false }
// lane delete **recreate the folder from the registered inverse** (13 Rules). Values, not
// references: the capture is a `SubtreeSnapshot` of bytes taken before the removal, so the
// step means the same thing any number of reloads later.
// lane delete **the ordinary move back** (13-native-undo.md Interaction with the trash:
// "a lane [returns] to its strip position (subtree intact it never left the folder)"). The
// redo replays the forward write with its own captured trash rank, exactly as the card
// delete's does, so a redone delete lands the lane where the undo took it from rather than at
// whatever the top of the trash has become in the meantime.
//
// Its predicate is existence and nothing else the undo wants the paths still empty (a
// recreate refuses to clobber, so a lane somebody re-made at that id is not this step's to
// overwrite), the redo wants them back.
let steps = Array(zip(folders, captures))
// The expectations are one swap, and the container rides in the path (`HistoryStaleness`):
// the undo wants the lane in the trash holding the rank the delete gave it, the redo wants it
// back on the strip holding the rank it left. A foreign restore empties the trash path and
// the undo skips; a foreign re-delete empties the strip path and the redo skips.
let steps = zip(lanes, ranks).map { lane, rank in
(
trashed: ItemPath.trashLane(lane.id).folder(under: root),
origin: ItemPath.lane(lane.id).folder(under: root),
priorOrder: lane.order,
trashRank: rank
)
}
registerStep(
HistoryPhrase.name(.delete, kind: .lane, count: steps.count),
subject: lanes.count == 1 ? lanes[0].title.value : nil,
undoExpects: steps.map { .absent($0.0) },
redoExpects: steps.map { .present($0.0) }
undoExpects: steps.map { .present($0.trashed, .order($0.trashRank)) },
redoExpects: steps.map { .present($0.origin, .order($0.priorOrder)) }
) { _ in
for (folder, capture) in steps {
try BoardWriter.recreateSubtree(at: folder, from: capture, operation: .createLane)
for step in steps {
_ = try BoardWriter.moveItem(
at: step.trashed,
toParent: root,
sourceBoardRoot: root,
destinationBoardRoot: root,
order: step.priorOrder
)
}
} redo: { _ in
// Reversed, so a multi-lane delete unwinds in the mirror of the order it was made in.
for (folder, _) in steps.reversed() {
try BoardWriter.removeLane(at: folder)
for step in steps {
try BoardWriter.deleteLaneToTrash(at: step.origin, inBoard: root, order: step.trashRank)
}
}
return true
}
/// **The trash's own Delete permanent** (03-board-ui.md § Trash: "on a trash card, Delete
/// (/) is permanent; in the trash it removes the folder").
/// Every rank the trash currently holds, **both kinds** what a fresh arrival mints above
/// (03-board-ui.md § Trash: "Every arrival lands at the top regardless of kind", so the ladder
/// is the whole container and not one array of it).
private var trashRanks: [Double] {
snapshot.trash.map(\.order) + snapshot.trashedLanes.map(\.order)
}
/// **The trash's own Delete permanent** (03-board-ui.md § Trash: "on a trash selection, Delete
/// (/) is permanent in the trash it removes the folder").
///
/// Its own method rather than a flag on `delete(_:)` because it is a different act with a
/// different safety story: it registers **no undo step**, and `purgeIsUnrecoverable` stays `true`
/// 13-native-undo.md Rules settles this by name ("Permanently delete (the trash's Delete,
/// Empty Trash) the confirm *is* the safety"). A stack entry here would be a promise the
/// filesystem cannot keep.
/// Empty Trash) the confirm *is* the safety", "lanes and their freight included"). A stack
/// entry here would be a promise the filesystem cannot keep.
///
/// **Entries, not cards** (lanes rejoined the trash 2026-07-29): a trashed lane row purges whole,
/// its subtree with it, through the same Writer call the walk is the recursive removal's, and
/// the *counting* the confirmation needs is `TrashModel.freight`'s, off the snapshot before this
/// runs.
///
/// **The confirmation is the window's** (`TrashConfirmations`), for `deleteSelection`'s reason
/// and it is why this seam is explicit: the alert has to be able to name what this will purge
@@ -3403,7 +3443,7 @@ public final class BoardStore: HealHost {
/// The selection moves to the successor sibling **within the trash**: the permanent delete is as
/// deliberate a gesture as the move-to-trash, so repeated walks down the column exactly as it
/// walks down a lane (04-interactions.md The map).
public func deleteTrashCards(_ ids: Set<ItemID>) {
public func deleteTrashEntries(_ ids: Set<ItemID>) {
let paths = ItemPath.resolve(ids, in: .trash, snapshot: snapshot)
guard !paths.isEmpty else { return }
let successor = SelectionGrammar.successor(
@@ -3416,7 +3456,7 @@ public final class BoardStore: HealHost {
try? performWrite { () throws(BoardWriteError) -> Void in
for path in paths {
try BoardWriter.purgeTrashCard(at: path.folder(under: root), inBoard: root)
try BoardWriter.purgeTrashEntry(at: path.folder(under: root), inBoard: root)
}
}
@@ -3427,9 +3467,11 @@ public final class BoardStore: HealHost {
}
}
/// **Empty Trash ** purges every card in `<root>/.trash/`, in one bracket.
/// **Empty Trash ** purges every entry in `<root>/.trash/`, **lane subtrees walked**, in
/// one bracket (03-board-ui.md § Trash).
///
/// Not undoable, `deleteTrashCards`' ruling this is the other half of 13's "Permanently delete".
/// Not undoable, `deleteTrashEntries`' ruling this is the other half of 13's "Permanently
/// delete".
///
/// **Whole-trash scope, search-independent** (03-board-ui.md § Trash, settled): the writer walks
/// the folder itself, never a filtered view "a bulk command about the trash itself never
@@ -3437,7 +3479,7 @@ public final class BoardStore: HealHost {
/// is the strongest form of that guarantee, and strays a hand-editor left in the container are
/// preserved verbatim rather than swept up with the cards (`BoardWriter.emptyTrash`).
public func emptyTrash() {
guard !snapshot.trash.isEmpty else { return }
guard !snapshot.trash.isEmpty || !snapshot.trashedLanes.isEmpty else { return }
let root = rootURL
try? performWrite { () throws(BoardWriteError) -> Void in
@@ -3460,14 +3502,14 @@ public final class BoardStore: HealHost {
/// the tail hook, the lock deferral, the writability gate, the memo and the clear-on-success are
/// one implementation. What is this heal's own is below.
///
/// ### The two acts
/// ### One act, since 2026-07-29
///
/// - **A card relocates into `.trash/`** with the key removed (`BoardWriter.migrateTombstonedCard`).
/// - **A lane returns live** with the key stripped and nothing moved
/// (`BoardWriter.migrateTombstonedLane`) "resurrection is the safe direction, nothing is
/// destroyed by migration". A tombstoned lane's own cards come back with it; any of them
/// carrying their own key migrate on their own account, as ordinary tombstoned cards, in the
/// same pass.
/// **A card relocates into `.trash/`** with the key removed (`BoardWriter.migrateTombstonedCard`),
/// and that is the whole of it. The lane half is **retired wholesale** with the lane trash
/// (01-storage-format.md § Deletion: "cards migrate, lanes ignore" a lane carrying `deleted:`
/// "simply loads live with the key ignored no migration machinery, no key-strip write, no
/// notice"). Old tombstoned lanes reappearing is the accepted cost, stated in the ruling; a
/// tombstoned lane's own cards still migrate on their own account, as ordinary tombstoned cards.
///
/// ### The order among migrating cards is `deleted:`-ascending, deliberately
///
@@ -3481,44 +3523,35 @@ public final class BoardStore: HealHost {
///
/// ### The write half re-verifies against disk
///
/// Each item's `deleted:` key is re-read at write time (`stillTombstoned(at:)`) and a key that
/// Each card's `deleted:` key is re-read at write time (`stillTombstoned(at:)`) and a key that
/// has gone an agent removed it, another window migrated first skips silently: "losing the
/// race to a foreign fix is success, never an error" (§ Validation and healing, generalized from
/// the Repair-races-a-vanished-duplicate precedent). Without it a lane whose key vanished under
/// the write would be rewritten for nothing, stamping `modified` on a file with no defect left.
/// the Repair-races-a-vanished-duplicate precedent). Without it a card whose key vanished under
/// the write would be moved into the trash for nothing.
public func migrateLegacyTombstones() {
let work = legacyTombstones
let root = rootURL
let cards = Self.migrationOrder(of: work, in: snapshot)
let lanes = work.filter { $0.kind == .lane }
var movedCards: [String?] = []
var returnedLanes: [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 = snapshot.trash.map(\.order)
var ladder = trashRanks
heals.run(
.legacyTombstone,
signature: Self.signature(of: work.map(IntegrityRules.Defect.legacyTombstone)),
on: self
) { () throws(BoardWriteError) -> Void in
for card in cards {
guard let cardID = card.cardID else { continue }
let folder = ItemPath.card(lane: card.laneID, id: cardID).folder(under: root)
let folder = ItemPath.card(lane: card.laneID, id: card.cardID).folder(under: root)
guard Self.stillTombstoned(at: folder) else { continue }
let rank = Ranks.insertAtHead(ofVisible: ladder)
try BoardWriter.migrateTombstonedCard(at: folder, inBoard: root, order: rank)
ladder.insert(rank, at: 0)
movedCards.append(card.title)
}
for lane in lanes {
let folder = ItemPath.lane(lane.laneID).folder(under: root)
guard Self.stillTombstoned(at: folder) else { continue }
try BoardWriter.migrateTombstonedLane(at: folder)
returnedLanes.append(lane.title)
}
} posting: {
.migratedTombstones(cards: movedCards, lanes: returnedLanes)
.migratedTombstones(cards: movedCards)
}
}
@@ -3538,7 +3571,7 @@ public final class BoardStore: HealHost {
return !document.deleted.isMissing
}
/// The `.card` tombstones in the order they should be filed into the trash oldest `deleted:`
/// The tombstoned cards in the order they should be filed into the trash oldest `deleted:`
/// first, so the newest ends up on top (see `migrateLegacyTombstones`).
///
/// `sorted(by:)` is not stable in the standard library, so the walk position is folded into the
@@ -3556,10 +3589,9 @@ public final class BoardStore: HealHost {
}
return work
.enumerated()
.filter { $0.element.kind == .card }
.sorted { lhs, rhs in
let left = lhs.element.cardID.flatMap { stamps[$0] } ?? .distantPast
let right = rhs.element.cardID.flatMap { stamps[$0] } ?? .distantPast
let left = stamps[lhs.element.cardID] ?? .distantPast
let right = stamps[rhs.element.cardID] ?? .distantPast
return left == right ? lhs.offset < rhs.offset : left < right
}
.map(\.element)
@@ -3899,8 +3931,9 @@ public final class BoardStore: HealHost {
/// visible and a non-empty trash selection, Select All selects all visible trash cards; in every
/// other state, all visible live cards the container boundary decides which 'all' is meant").
/// A trash selection naming nothing (a foreign restore, a purge) falls through to the board
/// rather than selecting the trash wholesale on a guess. There is no kind clause any more:
/// lanes are never trashed, so every trash row is a card.
/// rather than selecting the trash wholesale on a guess. **Select All is card-scoped in both
/// containers, never lane rows** (04 The trash, re-affirmed 2026-07-29 with lanes back in the
/// trash), which is why the trash branch reads its cards and asks no kind question.
///
/// The 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,