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
+26 -44
View File
@@ -471,15 +471,18 @@ public final class BannerCenter {
/// **A loss row for `postRelocatedLooseFiles`' exact reason**, and it is the same shape of event:
/// the app moved the user's folders on its own initiative, on a board it opened rather than on a
/// gesture they made. That must be said out loud, must not evaporate unread, and must not rank
/// as an error, because no action failed. The one nuance worth naming: the *lane* half is a
/// resurrection rather than a removal cards nobody asked to see again may reappear on the
/// board which is exactly the kind of surprise this class exists to announce.
/// as an error, because no action failed.
///
/// `cards` and `lanes` are the migrated items' titles, in the order they were written, `nil` for
/// an untitled one "Untitled" is a rendering, never a value (03-board-ui.md § Card face), so
/// the phrasing layer decides what to call it. A migration that migrated nothing posts nothing.
public func postMigratedTombstones(cards: [String?], lanes: [String?]) {
guard let message = Self.migratedTombstonesMessage(cards: cards, lanes: lanes) else { return }
/// **Cards only** (01-storage-format.md § Deletion, lane clause re-ruled 2026-07-29): the lane
/// half of this notice retired with the lane migration itself a lane's `deleted:` is inert now,
/// nothing is written for it, and a row announcing an act the app did not perform would be worse
/// than silence.
///
/// `cards` holds the migrated cards' titles, in the order they were written, `nil` for an
/// untitled one "Untitled" is a rendering, never a value (03-board-ui.md § Card face), so the
/// phrasing layer decides what to call it. A migration that migrated nothing posts nothing.
public func postMigratedTombstones(cards: [String?]) {
guard let message = Self.migratedTombstonesMessage(cards: cards) else { return }
postLoss(message)
}
@@ -962,49 +965,28 @@ public final class BannerCenter {
return "Moved '\(name)' into attachments — \(subject)"
}
/// The legacy tombstone migration's line **one folded sentence for both halves**, written in
/// `relocatedLooseFilesMessage`'s voice because it is the same kind of notice: the act first,
/// the subject after an em dash, plurals folded, a sole item named.
/// The legacy tombstone migration's line, written in `relocatedLooseFilesMessage`'s voice
/// because it is the same kind of notice: the act first, the subject after an em dash, plurals
/// folded, a sole item named.
///
/// The two clauses are joined rather than posted as two rows, because it is **one migration**:
/// a board opened, its old deletion markers were resolved, and that is one thing that happened
/// to the user's files. Two rows would also mean two dismissals for one event, and would rank a
/// resurrection and a relocation against each other for no reason.
/// **One clause, since the lane half retired** (01-storage-format.md § Deletion, re-ruled
/// 2026-07-29 lanes ignore the key rather than migrating). The shapes:
///
/// The shapes, in the relocation's own idiom:
/// - **One card**: "Moved 'Fix login' to the trash it carried an old deleted marker".
/// - **Several**: "Moved 3 cards to the trash they carried old deleted markers".
///
/// - **One card**, no lanes: "Moved 'Fix login' to the trash it carried an old deleted marker".
/// - **Several cards**: "Moved 3 cards to the trash they carried old deleted markers".
/// - **One lane**, no cards: "Restored 'Doing' it carried an old deleted marker".
/// - **Both**: "Moved 3 cards to the trash and restored 2 lanes they carried old deleted markers".
///
/// **The tail names the cause once**, and it is the whole explanation the row owes: the user did
/// not delete anything just now, and without the clause the sentence would read as an action
/// they had somehow just taken. The singular/plural of the tail follows the *total*, so the
/// mixed case never has to spell a singular (two clauses carry at least two items).
/// **The tail names the cause**, and it is the whole explanation the row owes: the user did not
/// delete anything just now, and without the clause the sentence would read as an action they
/// had somehow just taken.
///
/// `nil` when nothing migrated a migration that migrated nothing is not news.
public nonisolated static func migratedTombstonesMessage(cards: [String?], lanes: [String?]) -> String? {
let total = cards.count + lanes.count
guard total > 0 else { return nil }
var clauses: [String] = []
if !cards.isEmpty {
let subject = cards.count == 1
? sole(cards[0])
: "\(cards.count) cards"
clauses.append("Moved \(subject) to the trash")
}
if !lanes.isEmpty {
let subject = lanes.count == 1
? sole(lanes[0])
: "\(lanes.count) lanes"
clauses.append(clauses.isEmpty ? "Restored \(subject)" : "restored \(subject)")
}
let tail = total == 1
public nonisolated static func migratedTombstonesMessage(cards: [String?]) -> String? {
guard !cards.isEmpty else { return nil }
let subject = cards.count == 1 ? sole(cards[0]) : "\(cards.count) cards"
let tail = cards.count == 1
? "it carried an old deleted marker"
: "they carried old deleted markers"
return "\(clauses.joined(separator: " and "))\(tail)"
return "Moved \(subject) to the trash\(tail)"
}
/// The claimed-name displacement's line the relocation's own voice (the act first, the subject
+8 -3
View File
@@ -277,9 +277,12 @@ public struct BoardDiff: Sendable, Equatable {
/// Whether anything the digest can see differs the whole value while the trash is being
/// walked, everything but the trash while it is not.
///
/// `trash` is the one `var` on `BoardModel`, which is what makes "everything except the trash"
/// expressible as value equality rather than as a second field-by-field list that would go
/// stale the moment the model grows a field.
/// `trash` and `trashedLanes` are the two `var`s on `BoardModel`, which is what makes "everything
/// except the trash" expressible as value equality rather than as a second field-by-field list
/// that would go stale the moment the model grows a field. Both are cleared, for one reason: a
/// trashed lane is trash, so churn among the rows must stay invisible while the column is hidden
/// a lane *entering* the trash is still seen, because it left `lanes`, which is the board side
/// and is exactly the "1 lane deleted" event the user watched happen.
private static func boardSideDiffers(
_ old: BoardModel,
_ new: BoardModel,
@@ -290,6 +293,8 @@ public struct BoardDiff: Sendable, Equatable {
var newSansTrash = new
oldSansTrash.trash = []
newSansTrash.trash = []
oldSansTrash.trashedLanes = []
newSansTrash.trashedLanes = []
return oldSansTrash != newSansTrash
}
}
+124 -91
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] = []
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)
var ladder = trashRanks
var ranks: [Double] = []
for _ in lanes {
let rank = Ranks.insertAtHead(ofVisible: ladder)
ranks.append(rank)
ladder.insert(rank, at: 0)
}
}
guard landed != nil, captures.count == folders.count 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.
let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
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 else { return false }
// 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,
+5 -4
View File
@@ -158,8 +158,9 @@ public final class HealScheduler {
case none
/// Loss row, warning tone: files were moved into a card's `attachments/`.
case relocatedLooseFiles([BannerCenter.Relocation])
/// Loss row, warning tone: legacy `deleted:` keys were resolved.
case migratedTombstones(cards: [String?], lanes: [String?])
/// Loss row, warning tone: legacy `deleted:` keys on **cards** were resolved (the lane half
/// retired 2026-07-29 a lane's key is inert, and nothing is written for it).
case migratedTombstones(cards: [String?])
/// Loss row, warning tone: a claimed name's squatter was moved aside, named old and new.
case displacedClaimedNames([BannerCenter.Displacement])
/// Loss row, warning tone: withheld duplicate ids were reminted, named by title.
@@ -178,8 +179,8 @@ public final class HealScheduler {
break
case let .relocatedLooseFiles(relocations):
banners.postRelocatedLooseFiles(relocations)
case let .migratedTombstones(cards, lanes):
banners.postMigratedTombstones(cards: cards, lanes: lanes)
case let .migratedTombstones(cards):
banners.postMigratedTombstones(cards: cards)
case let .displacedClaimedNames(displacements):
banners.postDisplacedClaimedNames(displacements)
case let .remintedDuplicateIDs(titles):
+57 -21
View File
@@ -17,9 +17,11 @@ import Foundation
/// single container rule replacing the old liveness law, because Delete would otherwise mean two
/// different things in one gesture (move-to-trash vs permanent)".
///
/// **Lanes live only on the board side.** "Cards only. Lanes are never trashed" (03-board-ui.md §
/// Trash), so the trash's universe is cards and nothing else which is why the trash needs no
/// kind axis of its own any more.
/// **Both kinds live on both sides** (03-board-ui.md § Trash, re-ruled 2026-07-29 "Lanes trash
/// too"): the trash's universe is its cards *and* its trashed lanes, flat and interleaved by rank.
/// What the tombstone model needed and this does not is the old two-sided machinery the ancestor
/// walk, effective liveness, the entry-vs-universe split not the kind axis, which is the board's
/// own cards-XOR-lanes rule reaching a second container.
///
/// `String`-backed and `Codable` because the clipboard manifest carries one: a manifest written
/// before a quit is decoded after the relaunch, so these raw spellings are pasteboard API, and they
@@ -39,11 +41,16 @@ extension ItemContainer {
/// held to (02-architecture.md § Live-reload resilience: "re-resolution matches UUID *and*
/// container side ... presence in the snapshot is the whole question").
///
/// One walk, no filtering: the board side is the lanes plus their cards, the trash side is
/// `snapshot.trash`. There is deliberately no liveness predicate anywhere in here a legacy
/// `deleted:` key still riding in from an unmigrated board (`BoardLoader`'s migration window)
/// names an ordinary board card until its folder actually moves, which is the safe direction and
/// the one the migration then takes (01-storage-format.md § Deletion).
/// One walk, no filtering: the board side is the lanes plus their cards, the trash side is its
/// cards **and its trashed lanes** every id the container holds, which is what a universe is.
/// A trashed lane's own cards are deliberately not in it: they are not in the snapshot at all
/// (the entry is opaque 03-board-ui.md § Trash), so a selected or cut card whose lane was
/// trashed leaves every referencing set by the ordinary vanish rule, with no clause of its own.
///
/// There is deliberately no liveness predicate anywhere in here a legacy `deleted:` key still
/// riding in from an unmigrated board (`BoardLoader`'s migration window) names an ordinary board
/// card until its folder actually moves, which is the safe direction and the one the migration
/// then takes (01-storage-format.md § Deletion).
public func ids(in snapshot: BoardModel) -> Set<ItemID> {
var universe: Set<ItemID> = []
switch self {
@@ -58,6 +65,9 @@ extension ItemContainer {
for card in snapshot.trash {
universe.insert(card.id)
}
for lane in snapshot.trashedLanes {
universe.insert(lane.id)
}
}
return universe
}
@@ -71,9 +81,15 @@ extension ItemContainer {
/// builds the URL off the store's *current* `rootURL`, so a board renamed or moved mid-session
/// writes at the new location (02-architecture.md § Write-failure surfacing).
///
/// **Three cases, because the board has exactly three places an identity-bearing folder can be**
/// `<root>/<lane>`, `<root>/<lane>/<card>`, and `<root>/.trash/<card>`. The old two-optional-fields
/// shape could spell a fourth thing that does not exist; this cannot.
/// **Four cases, because the board has exactly four kinds of place an identity-bearing folder can
/// be** `<root>/<lane>`, `<root>/<lane>/<card>`, and, since lanes rejoined the trash (2026-07-29),
/// `<root>/.trash/<card>` and `<root>/.trash/<lane>`. The old two-optional-fields shape could spell
/// things that do not exist; this cannot.
///
/// **The trash's two cases share a path *shape* and differ in kind**, which is exactly the fact
/// `kind:` exists to record (01-storage-format.md § Deletion): `.trash/` is flat, so the path alone
/// cannot say what an entry is, and a caller that has resolved one through the snapshot knows
/// which is why the kind is in the case rather than re-derived from disk at every use.
public enum ItemPath: Sendable, Equatable {
/// A lane: `<root>/<lane>/`.
@@ -85,25 +101,36 @@ public enum ItemPath: Sendable, Equatable {
/// A card in the board's trash: `<root>/.trash/<card>/`.
case trashCard(ItemID)
/// A lane in the board's trash: `<root>/.trash/<lane>/` an opaque unit, subtree intact
/// (03-board-ui.md § Trash).
case trashLane(ItemID)
/// The item this path names.
public var id: ItemID {
switch self {
case let .lane(id): id
case let .card(_, id): id
case let .trashCard(id): id
case let .trashLane(id): id
}
}
/// Whether this path names a lane, **in either container** the kind axis, which the board's
/// cards-XOR-lanes rule asks about on both sides of the container boundary.
public var isLane: Bool {
if case .lane = self { return true }
return false
switch self {
case .lane, .trashLane: true
case .card, .trashCard: false
}
}
/// Which container this path is in the board for a lane or a lane's card, the trash for a
/// trash card. Derived rather than stored: the case *is* the answer.
/// Which container this path is in the board for a lane or a lane's card, the trash for
/// either kind of trash entry. Derived rather than stored: the case *is* the answer.
public var container: ItemContainer {
if case .trashCard = self { return .trash }
return .board
switch self {
case .trashCard, .trashLane: .trash
case .lane, .card: .board
}
}
/// This path resolved under a board root.
@@ -115,7 +142,7 @@ public enum ItemPath: Sendable, Equatable {
root
.appendingPathComponent(lane.rawValue, isDirectory: true)
.appendingPathComponent(id.rawValue, isDirectory: true)
case let .trashCard(id):
case let .trashCard(id), let .trashLane(id):
BoardWriter.trashFolder(inBoard: root)
.appendingPathComponent(id.rawValue, isDirectory: true)
}
@@ -131,6 +158,11 @@ extension ItemPath {
/// fails partway must fail the same way twice (`BoardStore.styleSubjects` makes the same choice
/// for the same reason).
///
/// **The trash's order interleaves its two kinds by rank** (03-board-ui.md § Trash: "lane rows
/// and cards interleave in the one trash column purely by trash rank"), which is why the two
/// arrays are merged here rather than concatenated: the column's order is the batch's order, and
/// a selection is kind-homogeneous anyway, so the merge costs nothing the one time it matters.
///
/// 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.
public static func resolve(
@@ -149,8 +181,11 @@ extension ItemPath {
}
}
case .trash:
for card in snapshot.trash where ids.contains(card.id) {
result.append(.trashCard(card.id))
let entries = snapshot.trash.map { (order: $0.order, path: ItemPath.trashCard($0.id)) }
+ snapshot.trashedLanes.map { (order: $0.order, path: ItemPath.trashLane($0.id)) }
for entry in Ranks.sortedForDisplay(entries, order: \.order, name: { $0.path.id.rawValue })
where ids.contains(entry.path.id) {
result.append(entry.path)
}
}
return result
@@ -166,6 +201,7 @@ extension ItemPath {
if lane.id == id { return .lane(id) }
if lane.cards.contains(where: { $0.id == id }) { return .card(lane: lane.id, id: id) }
}
return snapshot.trash.contains { $0.id == id } ? .trashCard(id) : nil
if snapshot.trash.contains(where: { $0.id == id }) { return .trashCard(id) }
return snapshot.trashedLanes.contains { $0.id == id } ? .trashLane(id) : nil
}
}
+23 -5
View File
@@ -27,11 +27,14 @@ import Foundation
///
/// ### What is *not* the predicate's business
///
/// **Lanes are never hidden by a card query.** 04 filters *cards*; a lane whose cards all miss the
/// query stays on the board showing an empty body and a `0` badge, because the width division is
/// layout and the filter is content. There is no lane overload at all: the tombstone model's trash
/// held lane *entries* that had to be filtered like rows, and lanes are never trashed now
/// (03-board-ui.md § Trash), so a card predicate is the whole of the filter.
/// **A live lane is never hidden by a card query.** 04 filters *cards*; a lane whose cards all miss
/// the query stays on the board showing an empty body and a `0` badge, because the width division is
/// layout and the filter is content.
///
/// **A trashed lane row is the one lane that filters** (03-board-ui.md § Trash, re-ruled 2026-07-29
/// "The row matches the search filter by lane title only"), and the difference is exactly what an
/// opaque unit is: it is a row in a column, not a container the query can empty, so it either shows
/// or it does not. Hence one overload for it, deliberately title-only.
public struct SearchFilter: Sendable, Equatable {
/// The query exactly as typed kept so a caller can key a transaction or a test on it.
@@ -78,6 +81,16 @@ public struct SearchFilter: Sendable, Equatable {
matches(title: card.title.value, body: card.body)
}
/// A trashed lane row **by title only** (03-board-ui.md § Trash, re-ruled 2026-07-29: "The row
/// matches the search filter by lane title only").
///
/// It is the opaque unit's own rule rather than an omission: the row's cards "are invisible to
/// search and not individually addressable", and the lane's *body* is not on screen either, so
/// matching anything the row does not show would hand the user a hit they cannot see.
public func matches(_ lane: TrashedLane) -> Bool {
matches(title: lane.title.value, body: "")
}
// MARK: - The visible universe
/// Every id the filter leaves visible in `container` **the universe
@@ -102,6 +115,11 @@ public struct SearchFilter: Sendable, Equatable {
for card in snapshot.trash where matches(card) {
ids.insert(card.id)
}
// A trash lane row *is* filtered, unlike a live lane: it is a row in a column rather than
// a container the query empties, and it matches by title only (03-board-ui.md § Trash).
for lane in snapshot.trashedLanes where matches(lane) {
ids.insert(lane.id)
}
}
return ids
}
+18 -14
View File
@@ -59,12 +59,13 @@ public enum ClickModifier: Sendable, Equatable {
/// (`SelectionGrammarTests`).
///
/// **Homogeneity is the invariant, and it is enforced here or nowhere.** The selection is
/// homogeneous on **two** axes now cards XOR lanes (§ Selection) and board XOR trash (§ The
/// trash's "single container rule replacing the old liveness law") and the third, kind-inside-the
/// -trash, retired with the lane entries it separated: "Cards only. Lanes are never trashed".
/// Both surviving axes are a property of what a *click* is allowed to produce, so no outcome below
/// is ever mixed: a modifier that would cross an axis degrades to a replace, which is the only
/// answer that keeps the invariant true without silently dropping what the user asked for.
/// homogeneous on **two** axes cards XOR lanes (§ Selection) and board XOR trash (§ The trash's
/// "single container rule replacing the old liveness law") and since lanes rejoined the trash
/// (2026-07-29) the kind axis simply reaches into the second container too: "a trash selection is
/// either cards or lane rows, kind-homogeneous like the live board's own grammar". Both axes are a
/// property of what a *click* is allowed to produce, so no outcome below is ever mixed: a modifier
/// that would cross an axis degrades to a replace, which is the only answer that keeps the invariant
/// true without silently dropping what the user asked for.
///
/// **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,
@@ -270,9 +271,10 @@ public enum SelectionGrammar {
/// order, a lane-index lookup, the successor's container) read exactly as they did before the
/// filter existed; the callers that *are* the board's input grammar pass the store's query.
///
/// **The lane list takes no filter**, because a card query hides no lane see `SearchFilter`.
/// **`(.trash, .lane)` is empty by construction**: "Cards only. Lanes are never trashed"
/// (03-board-ui.md § Trash), so there is no such list to walk rather than a rule saying not to.
/// **The lane list takes no filter**, because a card query hides no *live* lane see
/// `SearchFilter`. **`(.trash, .lane)` is the trash's lane rows** (04-interactions.md The
/// trash, lanes rejoined 2026-07-29): the list is empty until the rows are addressable by the
/// grammar, which is where the pointer and keyboard vocabulary for them lands.
public static func order(
of kind: SelectionKind,
in container: ItemContainer,
@@ -337,8 +339,9 @@ public enum SelectionGrammar {
/// somehow was not. Members that name nothing are ignored, and a set of only such members reads
/// as empty: a selection the next reload will drop must not decide what a click does now.
///
/// **The trash answers `.card` or nothing**, because lanes are never trashed which is why the
/// trash's old kind axis (card entries XOR lane entries) has no code left anywhere.
/// **The trash answers `.card` or nothing** while its lane rows are not yet addressable the
/// kind axis reaches both containers (04-interactions.md The trash), and this is where the
/// second container's answer to it lands.
public static func kind(of selection: ItemReferenceSet, in snapshot: BoardModel) -> SelectionKind? {
guard !selection.isEmpty else { return nil }
switch selection.container {
@@ -453,9 +456,10 @@ public struct MarqueeTarget: Sendable, Equatable {
/// across lanes" across them, not over them). Lanes are simply never registered as targets, and
/// the filter below keeps the rule true even if one were.
///
/// **There is no kind rule any more.** Under the tombstone model the trash interleaved card rows and
/// lane rows in one column, so the band needed a topmost-wins tie-break to stay homogeneous by kind;
/// lanes are never trashed now, so both containers hold cards and the rule is one line for both.
/// **The kind filter is the whole of the rule, in both containers**: "the rubber band stays on the
/// side it started on and selects cards only (as the board marquee does); lane rows join by click
/// grammar" (04-interactions.md The trash, re-affirmed 2026-07-29). A band never has to break a
/// tie between a card and a lane row, because it never sweeps a lane row at all.
public enum MarqueeMath {
/// The ids `rect` sweeps.
+120 -26
View File
@@ -10,25 +10,95 @@ import Foundation
/// The tombstone model needed a whole derivation layer: an entry type, an absolute ancestor walk, a
/// returning-card count, a deterministic `deleted`-timestamp sort, and a paths function that had to
/// restate which items were addressable. All of it is gone. A trashed card is "an ordinary card in a
/// special place", so the trash's contents *are* `snapshot.trash` already parsed by the same card
/// parse the lanes use, already in `order` display order, already newest-first because every arrival
/// mints a rank above the current top. There is nothing to derive, and no second definition to keep
/// in step with the loader's.
/// special place", so the trash's contents *are* `snapshot.trash` and `snapshot.trashedLanes`
/// already parsed by the loader, already in `order` display order, already newest-first because
/// every arrival mints a rank above the current top. There is nothing to derive, and no second
/// definition to keep in step with the loader's.
///
/// What genuinely remains is what the *commands* need and no view can answer: the two purge
/// confirmations' phrasing, and the menu validation that stages Delete by place. Both are pure
/// functions of a snapshot and a selection (`TrashModelTests`), so an alert's sentence is testable
/// without an alert on screen.
/// confirmations' phrasing which since lanes rejoined the trash (2026-07-29) has to **count the
/// freight** a trashed lane carries and the menu validation that stages Delete by place. Both are
/// pure functions of a snapshot and a selection (`TrashModelTests`), so an alert's sentence is
/// testable without an alert on screen. The one number that is not derivable from the snapshot's
/// own shape is the freight itself, and it does not need deriving: the loader counted it at load
/// (`TrashedLane.heldCards`), because the subtree it counts is deliberately not in the snapshot.
public enum TrashModel {
// MARK: - Counts and phrasing
/// "41 cards", "1 card" 06-history-undo.md's **plural folding**, which is all the folding a
/// cards-only container can need ("Cards only. Lanes are never trashed" 03-board-ui.md).
/// "41 cards", "1 card" 06-history-undo.md's **plural folding**.
public static func phrase(_ count: Int) -> String {
"\(count) card\(count == 1 ? "" : "s")"
}
/// "2 lanes", "1 lane" the same folding for the container's other kind (03-board-ui.md
/// § Trash, lanes rejoined 2026-07-29).
public static func lanePhrase(_ count: Int) -> String {
"\(count) lane\(count == 1 ? "" : "s")"
}
/// **What a purge is about to destroy**, counted honestly: the entries themselves, and the cards
/// a trashed lane is carrying (03-board-ui.md § Trash: "Confirms name the freight honestly").
///
/// A value rather than three returns because every phrasing below asks the same three questions,
/// and because "how many cards does this lose" is `cards + freight` in one place rather than at
/// each call site.
public struct Freight: Sendable, Equatable {
/// Trash entries that are cards.
public let cards: Int
/// Trash entries that are lanes.
public let lanes: Int
/// The cards those lanes are holding `TrashedLane.heldCards`, summed.
public let heldCards: Int
public var isEmpty: Bool { cards == 0 && lanes == 0 }
}
/// The freight of a set of resolved trash paths.
public static func freight(of paths: [ItemPath], in snapshot: BoardModel) -> Freight {
var cards = 0
var lanes = 0
var heldCards = 0
for path in paths {
switch path {
case .trashCard:
cards += 1
case .trashLane:
lanes += 1
heldCards += snapshot.trashedLanes.first { $0.id == path.id }?.heldCards ?? 0
case .lane, .card:
// A board path is not this command's business; the callers resolve in `.trash` and
// never produce one. Counted as nothing rather than refused, the vanished-target
// shrug this file gives everywhere.
continue
}
}
return Freight(cards: cards, lanes: lanes, heldCards: heldCards)
}
/// **The aggregate subject both confirmations share** 03-board-ui.md § Trash's own example
/// phrasings, with 06-history-undo.md's plural folding: "41 cards", "41 cards and 2 lanes
/// containing 9 more cards".
///
/// The lane clause says **"more"** only when cards were already counted, because that is the
/// only reading in which the word means anything; a lane holding nothing contributes no clause
/// of its own, since "2 lanes containing 0 cards" says less than "2 lanes".
public static func subject(for freight: Freight) -> String {
var clauses: [String] = []
if freight.cards > 0 {
clauses.append(phrase(freight.cards))
}
if freight.lanes > 0 {
var clause = lanePhrase(freight.lanes)
if freight.heldCards > 0 {
let more = freight.cards > 0 ? "more " : ""
clause += " containing \(freight.heldCards) \(more)card\(freight.heldCards == 1 ? "" : "s")"
}
clauses.append(clause)
}
return clauses.joined(separator: " and ")
}
// MARK: - Confirmations
/// A purge confirmation's three strings, built once and rendered by the window's alert.
@@ -41,26 +111,40 @@ public enum TrashModel {
public let confirmTitle: String
}
/// The alert in front of a **permanent** card delete the trash's own / (03-board-ui.md §
/// The alert in front of a **permanent** delete the trash's own / (03-board-ui.md §
/// Trash: "confirms exactly where the loss is real: the alert stands between one keystroke and
/// unrecoverable deletion").
///
/// A sole card is **named**; several fold into a count. `nil` when the ids name nothing in the
/// trash, which is also the command's own refusal so the prompt and the action can never
/// disagree about whether there is anything to purge.
/// A sole entry is **named**; several fold into a count. A sole trashed **lane** names its
/// freight as well "Permanently delete lane 'Doing' and its 5 cards" is the design's own
/// phrasing, and the word *lane* is in it because an opaque row's title alone would not say what
/// the extra five cards are doing in the sentence.
///
/// **A mixed set is not a gesture this app can produce** a selection is kind-homogeneous on
/// both axes (04-interactions.md The trash) but the aggregate phrasing covers one anyway
/// rather than picking a kind to lie about: it is the same sentence Empty Trash builds.
///
/// `nil` when the ids name nothing in the trash, which is also the command's own refusal so
/// the prompt and the action can never disagree about whether there is anything to purge.
public static func purgePrompt(
for ids: Set<ItemID>,
snapshot: BoardModel,
unrecoverable: Bool
) -> PurgePrompt? {
let targets = ItemPath.resolve(ids, in: .trash, snapshot: snapshot).filter { !$0.isLane }
let targets = ItemPath.resolve(ids, in: .trash, snapshot: snapshot)
guard !targets.isEmpty else { return nil }
let subject: String
if targets.count == 1, let only = targets.first {
subject = "\u{201C}\(displayName(of: only, in: snapshot))\u{201D}"
let name = "\u{201C}\(displayName(of: only, in: snapshot))\u{201D}"
if case let .trashLane(id) = only {
let held = snapshot.trashedLanes.first { $0.id == id }?.heldCards ?? 0
subject = held > 0 ? "lane \(name) and its \(phrase(held))" : "lane \(name)"
} else {
subject = phrase(targets.count)
subject = name
}
} else {
subject = self.subject(for: freight(of: targets, in: snapshot))
}
return PurgePrompt(
title: "Permanently delete \(subject)?",
@@ -70,15 +154,23 @@ public enum TrashModel {
}
/// Empty Trash's alert **always shown** ("Empty Trash confirms everywhere"), and always
/// naming the **true count**: every card in `.trash/`, never the filtered view (03-board-ui.md §
/// Trash: "search-independent, the confirmation naming the card count").
/// naming the **true count**: every entry in `.trash/`, never the filtered view (03-board-ui.md §
/// Trash: "search-independent, the confirmation naming the full count").
///
/// Counts rather than names even for a single card, because the command is about the trash
/// rather than about an item: "Permanently delete 41 cards" is the design's own example phrasing.
/// rather than about an item: "Permanently delete 41 cards" and " 41 cards and 2 lanes
/// containing 9 more cards" are the design's own example phrasings, and the second is why the
/// lane freight is counted rather than left implied a bulk permanent delete must not
/// understate what it takes.
public static func emptyTrashPrompt(in snapshot: BoardModel, unrecoverable: Bool) -> PurgePrompt? {
guard !snapshot.trash.isEmpty else { return nil }
let freight = Freight(
cards: snapshot.trash.count,
lanes: snapshot.trashedLanes.count,
heldCards: snapshot.trashedLanes.reduce(0) { $0 + $1.heldCards }
)
guard !freight.isEmpty else { return nil }
return PurgePrompt(
title: "Permanently delete \(phrase(snapshot.trash.count))?",
title: "Permanently delete \(subject(for: freight))?",
message: message(unrecoverable: unrecoverable),
confirmTitle: "Delete"
)
@@ -87,9 +179,9 @@ public enum TrashModel {
/// The alert's body: whether any of it comes back.
///
/// The tombstone era's second sentence "Deleting a lane also deletes every card inside it"
/// is gone with the lane entries it warned about: no purge path reaches a lane any more
/// (`ItemPath.isLane` is filtered out above, and lane deletion is its own physical command with
/// undo as its net).
/// stays gone, and now for a better reason than "no purge reaches a lane": the *title* carries
/// the freight explicitly ("and its 5 cards"), which is 03's own phrasing and says the same
/// thing where the user is already reading.
private static func message(unrecoverable: Bool) -> String {
// m7-git: on a git board the content stays reachable in history, so the second sentence is
// the honest one and the trash's own Delete does not confirm there at all
@@ -99,9 +191,9 @@ public enum TrashModel {
: "The board\u{2019}s history still has them."
}
/// What to call a card in a prompt its title, or the "Untitled" rendering.
/// What to call an item in a prompt its title, or the "Untitled" rendering.
///
/// Total by construction: a path whose card has gone since the prompt was asked for reads
/// Total by construction: a path whose item has gone since the prompt was asked for reads
/// "Untitled" rather than failing, which is the same shrug every other vanished-target rule in
/// the app gives.
private static func displayName(of path: ItemPath, in snapshot: BoardModel) -> String {
@@ -113,6 +205,8 @@ public enum TrashModel {
.cards.first { $0.id == id }?.title.value ?? "Untitled"
case let .trashCard(id):
return snapshot.trash.first { $0.id == id }?.title.value ?? "Untitled"
case let .trashLane(id):
return snapshot.trashedLanes.first { $0.id == id }?.title.value ?? "Untitled"
}
}
+154 -75
View File
@@ -46,30 +46,34 @@ import os
///
/// ## The trash is a container, not a level
///
/// `<root>/.trash/` is a **reserved, app-claimed board-root name** holding card folders directly
/// (01-storage-format.md § Deletion, resettled 2026-07-28) "same shape as a lane's children, no
/// `index.md` of its own". The walk therefore treats it as a second card container beside the
/// lanes: `trashCards(in:)` parses its UUID-shaped children with exactly the card parse the lane
/// walk uses (same fail-fast on `schema`/`order`, same skip-and-warn rules), and the result lands
/// in `BoardModel.trash` rather than under any lane. Being reserved, it is **never a stray** and
/// never warns; absent, the trash is simply empty.
/// `<root>/.trash/` is a **reserved, app-claimed board-root name** holding card *and lane* folders
/// interleaved directly (01-storage-format.md § Deletion, resettled 2026-07-28; lanes joined
/// 2026-07-29) no `index.md` of its own. The walk therefore treats it as a second container beside
/// the lanes, parsing its UUID-shaped children with the same fail-fast on `schema`/`order` and the
/// same skip-and-warn rules the lane walk uses. Being reserved, it is **never a stray** and never
/// warns; absent, the trash is simply empty.
///
/// **`kind:` decides what each entry is** (`IntegrityRules.trashKind`): depth defines meaning on the
/// live board, but the trash is flat and an empty lane folder is shape-identical to a card folder.
/// A card lands in `BoardModel.trash` parsed like any card; a lane lands in
/// `BoardModel.trashedLanes` as an **opaque unit** title, rank, and a count of the cards it holds
/// and its subtree is never walked into the snapshot (03-board-ui.md § Trash).
///
/// ## The migration window
///
/// The tombstone model is retired: no `deleted:` key is ever written again, and a key found on
/// load is *migration input* a card relocates into `.trash/` with the key removed, a lane
/// returns live with the key removed, a board-level key stays meaningless (ignored + logged).
/// Detection is read-only here, the loose-file carve-out's posture exactly: this loader reports
/// what it found through `LoadResult.legacyTombstones` and the store schedules the Writer-mediated
/// fix.
/// The tombstone model is retired: no `deleted:` key is ever written again. A key found on a **card**
/// is *migration input* the card relocates into `.trash/` with the key removed. A key on a lane or
/// on the board is meaningless and stays where it is (ignored + logged, the tolerate tier: lanes
/// stopped migrating on 2026-07-29, when they gained a real trash of their own). Detection is
/// read-only here, the loose-file carve-out's posture exactly: this loader reports what it found
/// through `LoadResult.legacyTombstones` and the store schedules the Writer-mediated fix.
///
/// **Until that write lands, such items still load through the retiring tombstone path**
/// a `deleted:`-carrying lane or card stays filed under its lane with `isDeleted` set. That is a
/// deliberate intermediate, not an oversight: the migration's whole promise is "never destroy",
/// and the safe direction while the fix is still pending (it is deferred under any read-only lock,
/// and can be pending for a whole session) is for nothing to silently vanish from view before its
/// folder has actually moved. The window closes per board on the first successful migration write,
/// after which no `deleted:` key exists to read and the flag is permanently `false`.
/// **Until that write lands, such cards still load through the retiring tombstone path** a
/// `deleted:`-carrying card stays filed under its lane with `isDeleted` set. That is a deliberate
/// intermediate, not an oversight: the migration's whole promise is "never destroy", and the safe
/// direction while the fix is still pending (it is deferred under any read-only lock, and can be
/// pending for a whole session) is for nothing to silently vanish from view before its folder has
/// actually moved. The window closes per board on the first successful migration write.
public enum BoardLoader: Sendable {
/// Schema version this app understands; anything higher fails fast
@@ -281,7 +285,6 @@ public enum BoardLoader: Sendable {
// and the key's removal are the store's, through the Writer.
if card.isDeleted {
defects.append(.legacyTombstone(LegacyTombstone(
kind: .card,
laneID: ItemID(rawValue: laneName),
cardID: ItemID(rawValue: cardName),
title: card.title.value
@@ -292,14 +295,14 @@ public enum BoardLoader: Sendable {
cards.append(card)
}
// **A lane's legacy `deleted:` is tolerate-tier, not work** (01-storage-format.md
// § Deletion, lane clause re-ruled 2026-07-29): "a lane carrying `deleted:` simply loads
// live with the key ignored no migration machinery, no key-strip write, no notice
// (backward compatibility deliberately not funded ; the key is inert, preserved verbatim
// like any unhandled key, logged)". A warning, exactly like the board-level key: this was
// ignored, it is staying exactly where it is, there is nothing to do.
if !laneDocument.deleted.isMissing {
defects.append(.legacyTombstone(LegacyTombstone(
kind: .lane,
laneID: ItemID(rawValue: laneName),
cardID: nil,
title: laneDocument.title.value
)))
logger.info("\(laneName, privacy: .public): legacy 'deleted' key — lane to be returned live with the key removed")
warn(.laneLevelDeletedIgnored(path: laneName))
}
walkedLanes.append(WalkedLane(
@@ -312,30 +315,86 @@ public enum BoardLoader: Sendable {
}
var trash: [Card] = []
var trashedLanes: [TrashedLane] = []
/// Every trash entry as the dedupe needs it, kind-blind the container is one flat list to
/// the identity rule, whatever the snapshot splits it into.
var trashEntries: [(id: ItemID, title: String?, order: Double)] = []
var trashKinds: [ItemID: IntegrityRules.ObjectKind] = [:]
for cardURL in trashCandidates(in: boardRoot) {
let cardName = cardURL.lastPathComponent
let cardRelPath = trashFolderName + "/" + cardName
guard isUUIDShaped(cardName) else {
warn(.nonUUIDFolderIgnored(path: cardRelPath))
for entryURL in trashCandidates(in: boardRoot) {
let entryName = entryURL.lastPathComponent
let entryRelPath = trashFolderName + "/" + entryName
guard isUUIDShaped(entryName) else {
warn(.nonUUIDFolderIgnored(path: entryRelPath))
continue
}
guard hasIndex(cardURL) else {
warn(.missingIndex(path: cardRelPath))
guard hasIndex(entryURL) else {
warn(.missingIndex(path: entryRelPath))
continue
}
let entry = try parseCard(at: cardURL, path: cardRelPath)
noteCoercions(in: entry.document, at: cardRelPath + "/" + indexFileName)
// The two kinds are validated identically `schema` and `order` are required of both
// (`IntegrityRules.requiresOrder`) so the strict parse happens once, before the
// discriminator, and a malformed entry fails fast whichever kind it turns out to be.
let entryPath = entryRelPath + "/" + indexFileName
let document = try readDocument(at: entryURL.appendingPathComponent(indexFileName), path: entryPath)
let schema = try validatedSchema(in: document, path: entryPath)
let order = try validatedOrder(in: document, path: entryPath)
noteCoercions(in: document, at: entryPath)
// **The trash's discriminator, applied where the flat container needs it**
// (01-storage-format.md § Deletion, re-ruled 2026-07-29): the *value* is trusted
// outright, and only an unrecognized value or no key at all falls through to shape.
// Reading the shape half is one directory listing, and only when the value did not
// answer see `looksLikeALaneFolder(_:)`.
trashKinds[entry.id] = IntegrityRules.trashKind(
kindValue: entry.document.kind.value,
hasIdentityShapedChildIndex: looksLikeALaneFolder(cardURL)
//
// The children are listed at most once per entry and only where an answer needs them
// the shape fallback asks when `kind` did not, the lane arm asks for the count so a
// board of trashed cards written by this app pays for no directory reads at all.
var listed: [URL]?
func children() -> [URL] {
if let listed { return listed }
let found = identityShapedChildren(of: entryURL)
listed = found
return found
}
let kind = IntegrityRules.trashKind(
kindValue: document.kind.value,
hasIdentityShapedChildIndex: !children().isEmpty
)
trash.append(entry)
let id = ItemID(rawValue: entryName)
trashKinds[id] = kind
trashEntries.append((id: id, title: document.title.value, order: order))
switch kind {
case .lane:
// **The subtree is counted, never walked** (03-board-ui.md § Trash: an opaque unit
// showing its title and held-card count). The count is the same listing the shape
// fallback asks for, so a `kind: lane` entry pays for exactly one directory read and
// a kindless one pays for none extra.
trashedLanes.append(TrashedLane(
id: id,
schema: schema,
title: document.title,
order: order,
heldCards: children().count,
document: document
))
case .card, .board:
// `kind: board` never reaches here as itself `trashKind` treats it as unrecognized
// and answers by shape so this arm is the card answer and nothing else.
trash.append(Card(
id: id,
schema: schema,
title: document.title,
created: document.created,
modified: document.modified,
modifiedBy: document.modifiedBy,
deleted: document.deleted,
background: document.background,
icon: document.icon,
iconColor: document.iconColor,
order: order,
attachments: attachmentNames(in: entryURL),
document: document
))
}
}
// **The board-wide identity dedupe** (01-storage-format.md § Fractal layout Rules:
@@ -348,10 +407,15 @@ public enum BoardLoader: Sendable {
// dedupe's last tie-break *is* traversal order and the rule needs the occurrences in it.
let orderedLanes = Ranks.sortedForDisplay(walkedLanes, order: \.order, name: \.name)
let orderedTrash = Ranks.sortedForDisplay(trash, order: \.order, name: { $0.id.rawValue })
let orderedTrashedLanes = Ranks.sortedForDisplay(trashedLanes, order: \.order, name: { $0.id.rawValue })
// The trash's own display order, **both kinds at once** the column interleaves them by rank
// (03-board-ui.md § Trash), and the dedupe's last tie-break is stated in traversal order, so
// the two kinds are merged before the rule sees them rather than after.
let orderedTrashEntries = Ranks.sortedForDisplay(trashEntries, order: \.order, name: { $0.id.rawValue })
let verdict = dedupeIdentities(
inBoardAt: boardRoot,
lanes: orderedLanes,
trash: orderedTrash,
trash: orderedTrashEntries,
historyRanker: historyRanker
)
@@ -386,6 +450,7 @@ public enum BoardLoader: Sendable {
template: boardDocument.value(for: templateKey),
lanes: orderedLanes.compactMap { $0.rendered(withholding: withheld) },
trash: orderedTrash.filter { !withheld.contains(trashFolderName + "/" + $0.id.rawValue) },
trashedLanes: orderedTrashedLanes.filter { !withheld.contains(trashFolderName + "/" + $0.id.rawValue) },
document: boardDocument
)
@@ -430,7 +495,7 @@ public enum BoardLoader: Sendable {
private static func dedupeIdentities(
inBoardAt root: URL,
lanes: [WalkedLane],
trash: [Card],
trash: [(id: ItemID, title: String?, order: Double)],
historyRanker: IdentityHistoryRanker?
) -> IntegrityRules.DedupeVerdict {
typealias Container = IntegrityRules.IdentityOccurrence.Container
@@ -458,7 +523,7 @@ public enum BoardLoader: Sendable {
path: trashFolderName + "/" + entry.id.rawValue,
name: entry.id.rawValue,
container: .trashed,
title: entry.title.value
title: entry.title
))
}
@@ -559,26 +624,26 @@ public enum BoardLoader: Sendable {
}
}
/// Whether a folder in `.trash/` has the *shape* of a lane at least one identity-shaped child
/// holding its own `index.md` (01-storage-format.md § Deletion: "UUID-shaped children with
/// their own `index.md` lane else card").
/// A folder's **identity-shaped children holding their own `index.md`** the cards a trash
/// entry would render as, which is two answers in one listing (01-storage-format.md § Deletion:
/// "UUID-shaped children with their own `index.md` lane else card"; 03-board-ui.md § Trash:
/// the row's held-card count).
///
/// Only reached when `kind` did not answer (`IntegrityRules.trashKind`'s `@autoclosure`), and
/// deliberately not a parse: this asks what the folder *looks like*, not whether anything inside
/// it would load. A trashed lane's cards are never enumerated as levels the walk stops at a
/// trash entry exactly as it stops at a card under a lane.
private static func looksLikeALaneFolder(_ folder: URL) -> Bool {
/// Deliberately not a parse: this asks what the folder *holds*, not whether anything inside it
/// would load. A trashed lane's cards are never enumerated as levels the walk stops at a trash
/// entry exactly as it stops at a card under a lane so the count is a fact about the freight
/// and the entry stays opaque.
private static func identityShapedChildren(of folder: URL) -> [URL] {
let children = (try? directoryCandidates(in: folder)) ?? []
return children.contains { isUUIDShaped($0.lastPathComponent) && hasIndex($0) }
return children.filter { isUUIDShaped($0.lastPathComponent) && hasIndex($0) }
}
/// One card folder read into a `Card` **the card parse, shared by both containers**.
/// One card folder read into a `Card` the lane walk's card parse.
///
/// A trashed card is "an ordinary card in a special place" (03-board-ui.md § Trash), and this
/// function is what makes that literally true rather than a claim two code paths have to keep
/// agreeing on: the same strict `schema`/`order` validation, the same attachment listing, the
/// same verbatim document. Its callers keep what genuinely differs by container the
/// lane-keyed loose-file and legacy-tombstone channels outside it.
/// The trash's own walk reads its entries inline instead, because the container is flat and its
/// kind is `kind:`'s to answer before a `Card` can be built at all: the two share their strict
/// `schema`/`order` validation (`IntegrityRules`' rulebook, one rule) rather than sharing a
/// function that has already decided what it is reading.
///
/// `path` is root-relative and names the *folder*; the errors this throws name its `index.md`.
/// Callers guard `isUUIDShaped` and `hasIndex` first, exactly as the lane walk always has.
@@ -605,7 +670,7 @@ public enum BoardLoader: Sendable {
)
}
/// The candidate card folders inside `<root>/.trash/`, or `[]` when there is no trash.
/// The candidate entry folders inside `<root>/.trash/`, or `[]` when there is no trash.
///
/// **Absent is empty, not an error** the container is minted by the first delete, so most
/// boards never have one, and a board without a trash is a board with an empty trash.
@@ -620,12 +685,10 @@ public enum BoardLoader: Sendable {
/// the "window measured in one reload, not a standing state" the ruling accepts.
///
/// Entries are `directoryCandidates` hidden entries and symlinks already excluded, in
/// folder-name order so the trash gets the same stray tolerance every other container gets,
/// including the one that matters most here: a **lane-shaped nesting** inside `.trash` (a
/// whole lane folder dropped in by hand) is not a level. Its own UUID-shaped children are
/// never enumerated, because the walk stops at a card exactly as it does under a lane; the
/// outer folder either parses as a card (it has an `index.md`) or is skipped as
/// `.missingIndex`, and either way nothing below it renders.
/// folder-name order so the trash gets the same stray tolerance every other container gets.
/// **Nothing below an entry is ever enumerated as a level**: the walk stops at a trash entry
/// exactly as it stops at a card under a lane, so a lane-shaped folder here reads as one opaque
/// entry (a trashed lane, by `kind` or by shape) and its cards are counted rather than walked.
private static func trashCandidates(in boardRoot: URL) -> [URL] {
let trashURL = boardRoot.appendingPathComponent(trashFolderName, isDirectory: true)
guard let values = try? trashURL.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey]) else {
@@ -945,10 +1008,12 @@ public struct LoadResult: Sendable {
/// (`IntegrityRules.trashKind`; 01-storage-format.md § Deletion, re-ruled 2026-07-29): the
/// `kind` value trusted outright, falling through to shape only when it does not answer.
///
/// A *reading*, not a rendering: `BoardModel.trash` parses every entry through the one card
/// parse (a trashed card is "an ordinary card in a special place"), and the container is flat,
/// so this is where the answer to "which of these was a lane?" lives until the lanes-in-trash
/// surface consumes it. Keyed by identity, so it survives the display sort.
/// **The reading the snapshot's own split is made of**: `BoardModel.trash` holds the entries
/// this called a card and `BoardModel.trashedLanes` the ones it called a lane. It is carried out
/// of the load as well because it is the *verdict* rather than its consequence a suite pins
/// the discriminator directly, and a consumer asking "what did this entry read as" gets the
/// answer without inferring it from which array the entry landed in. Keyed by identity, so it
/// survives the display sort.
public var trashKinds: [ItemID: IntegrityRules.ObjectKind] = [:]
/// The cards this walk found holding loose files a **view over `defects`**, under the name it
@@ -959,10 +1024,12 @@ public struct LoadResult: Sendable {
defects.compactMap { if case let .looseCardFiles(work) = $0 { work } else { nil } }
}
/// The legacy `deleted:` keys this walk found the retired tombstone model's migration input,
/// as a **view over `defects`** (01-storage-format.md § Deletion).
/// The legacy `deleted:` keys this walk found on **cards** the retired tombstone model's
/// surviving migration input, as a **view over `defects`** (01-storage-format.md § Deletion).
///
/// Order is the walk's: a lane's tombstoned cards, then the lane itself, lane by lane.
/// A lane's key is not here and never will be: it is inert, tolerated, and reported as a
/// `LoadWarning.laneLevelDeletedIgnored` (lane clause re-ruled 2026-07-29). Order is the walk's,
/// lane by lane.
public var legacyTombstones: [LegacyTombstone] {
defects.compactMap { if case let .legacyTombstone(work) = $0 { work } else { nil } }
}
@@ -1018,6 +1085,16 @@ public enum LoadWarning: Sendable, Equatable, CustomStringConvertible {
/// (01-storage-format.md § Deletion) ignored, never tombstones the board.
case boardLevelDeletedIgnored
/// A **lane** carrying a legacy `deleted:` key (01-storage-format.md § Deletion, lane clause
/// re-ruled 2026-07-29): the lane loads live and the key is ignored "no migration machinery,
/// no key-strip write, no notice", preserved verbatim like any unhandled key.
///
/// Its home is here rather than in the defect stream because that is exactly the tolerate tier's
/// verdict on it: this was ignored, it is staying exactly where it is, there is nothing to do.
/// A *card*'s key is still work and still a `LegacyTombstone` defect the two halves of the old
/// migration parted company with the ruling. `path` is relative to the board root.
case laneLevelDeletedIgnored(path: String)
/// A folder whose name is a **case-spelled twin** of another occurrence of the same identity
/// one item typed two ways (01-storage-format.md § Fractal layout Rules): a spelling some *live*
/// occurrence carries wins over one only trash ghosts carry (the container preference, stated
@@ -1039,6 +1116,8 @@ public enum LoadWarning: Sendable, Equatable, CustomStringConvertible {
"\(path): folder name is not UUID-shaped, ignored as a stray"
case .boardLevelDeletedIgnored:
"index.md: board-level 'deleted' key is meaningless, ignored"
case let .laneLevelDeletedIgnored(path):
"\(path): lane-level 'deleted' key is inert, ignored — the lane loads live"
case let .caseTwinIgnored(path, winner):
"\(path): case-spelled twin of \(winner), ignored as a spelling artifact"
}
+78 -23
View File
@@ -98,21 +98,29 @@ public struct BoardModel: Sendable, Equatable {
/// Lanes in display order (`Ranks.sortedForDisplay`, folder-name tie-break).
///
/// **Legacy tombstones ride along flagged during the migration window.** A lane still
/// carrying a `deleted:` key loads here with `Lane.isDeleted` set, exactly as it did under
/// the retired tombstone model, *and* is reported through `LoadResult.legacyTombstones` see
/// `BoardLoader`'s "The migration window" note for why both happen at once and what ends it.
/// **A lane carrying a legacy `deleted:` key is here, live** (01-storage-format.md § Deletion,
/// lane clause re-ruled 2026-07-29): the key is ignored outright no migration, no notice, no
/// write so `Lane.isDeleted` describes the bytes and decides nothing. A *card* carrying one
/// still rides along flagged until its migration relocates it (`LoadResult.legacyTombstones`
/// see `BoardLoader`'s "The migration window" note).
public let lanes: [Lane]
/// The board's **materialized trash**: the card folders sitting directly in
/// The board's **materialized trash**, card side: the card folders sitting directly in
/// `<root>/.trash/`, in display order (01-storage-format.md § Deletion, resettled
/// 2026-07-28; 03-board-ui.md § Trash).
///
/// **A sibling container of `lanes`, not a lane.** `.trash/` is a reserved, app-claimed name
/// at board root that "holds card folders directly same shape as a lane's children, no
/// `index.md` of its own", so it has no identity, no title, no `order`, and no frontmatter to
/// model: the container *is* the list. That is why this is `[Card]` rather than a `Lane` or a
/// `Trash` struct there is nothing for either to carry that this array does not.
/// at board root that "holds card and lane folders interleaved directly, no `index.md` of its
/// own", so it has no identity, no title, no `order`, and no frontmatter to model: the
/// container *is* the list. That is why this is `[Card]` rather than a `Lane` or a `Trash`
/// struct there is nothing for either to carry that this array does not.
///
/// **The container's other kind is `trashedLanes`** (re-ruled 2026-07-29 lanes trash too).
/// The two are separate arrays rather than one list of a sum type because they are separate
/// *things*: a trashed card is an ordinary card that every card-shaped surface already reads,
/// and a trashed lane is an opaque row that none of them may. Interleaving the two by trash
/// rank is a rendering question (03-board-ui.md § Trash: "lane rows and cards interleave in the
/// one trash column purely by trash rank"), and both arrays carry the `order` that answers it.
///
/// **Display order is `order` ascending, like any lane's cards** `Ranks.sortedForDisplay`,
/// same folder-name tie-break. Newest-first falls out of ordinary ranks rather than a
@@ -129,6 +137,14 @@ public struct BoardModel: Sendable, Equatable {
/// usable from tests and future fixtures that have no trash to describe.
public var trash: [Card] = []
/// The board's materialized trash, **lane side**: the lane folders sitting directly in
/// `<root>/.trash/`, in display order (03-board-ui.md § Trash, re-ruled 2026-07-29 "Lanes
/// trash too", retiring the design's sole destructive delete).
///
/// See `TrashedLane` for why a trashed lane is not a `Lane`, and `trash` for why the container's
/// two kinds are two arrays.
public var trashedLanes: [TrashedLane] = []
/// The full parsed `index.md`. Unknown/reserved keys (`labels`, `assignees`, `due`,
/// `remote`, ) ride along uninterpreted via `document.unknownFields` so a future writer
/// can round-trip them without this model knowing what they mean.
@@ -175,18 +191,15 @@ public struct Lane: Identifiable, Sendable, Equatable {
/// The lane description / WIP policy / notes equivalent to `document.body`.
public var body: String { document.body }
/// A tombstoned lane: the `deleted` key is *present* (valid or malformed), not merely
/// absent. Deliberate: a malformed timestamp still hides the lane from the board the
/// key's presence is what encodes deletion intent, a missing key is the only thing that
/// means "not deleted" (`FieldValue.isMissing` already treats an explicit `deleted: null`
/// as absent, matching the engine's own null-is-missing rule).
/// Whether this lane carries a legacy `deleted:` key presence, not validity
/// (`FieldValue.isMissing` already treats an explicit `deleted: null` as absent).
///
/// **Retiring.** The tombstone model is retired (01-storage-format.md § Deletion, resettled
/// 2026-07-28): the app never writes a `deleted:` key again, and one found on load is
/// migration input reported through `LoadResult.legacyTombstones` (a lane's migration removes
/// the key and returns the lane live). This accessor survives only for the migration window,
/// during which the retiring tombstone renderers still read it; it becomes permanently
/// `false` for every board once the migration has run, and goes away with its last consumer.
/// **Retired, and on a lane the key is now inert** (01-storage-format.md § Deletion, lane clause
/// re-ruled 2026-07-29): the app never writes `deleted:`, and a lane found carrying one "simply
/// loads live with the key ignored no migration machinery, no key-strip write, no notice",
/// preserved verbatim like any unhandled key and logged (`LoadWarning.laneLevelDeletedIgnored`).
/// So this reads the key without meaning anything by it: nothing hides a lane, nothing rewrites
/// it, and the field survives only so the value round-trips.
public var isDeleted: Bool { !deleted.isMissing }
}
@@ -238,8 +251,50 @@ public struct Card: Identifiable, Sendable, Equatable {
public var body: String { document.body }
/// A tombstoned card. See `Lane.isDeleted`'s doc comment the same "presence, not
/// validity" rule applies here, and the same retirement: a trashed card lives in
/// `BoardModel.trash` and carries no `deleted:` key at all, so this reads `false` for every
/// card in the container that replaced the flag.
/// validity" rule applies here. A card's key is still migration input (a card carrying it
/// relocates into `.trash/` with the key removed), which is the one half of the legacy rule
/// that survives; a trashed card carries no `deleted:` key at all, so this reads `false` for
/// every card in the container that replaced the flag.
public var isDeleted: Bool { !deleted.isMissing }
}
/// A lane in the board's trash: `<root>/.trash/<guid>/index.md` **an opaque unit**
/// (03-board-ui.md § Trash, re-ruled 2026-07-29: "A trashed lane is an opaque unit: one distinct
/// dimmed row showing its title and held-card count never expandable; its cards are invisible to
/// search and not individually addressable it restores whole or purges whole").
///
/// ### Why it is not a `Lane`
///
/// A `Lane` carries its cards, its styling and its width because the board renders all three. None
/// of that is true here: the row shows a title and a count, takes no styling accents, and its
/// subtree is deliberately **not walked into the snapshot** the loader stops at the trash entry
/// exactly as it stops at a card under a lane. A `Lane` with an empty `cards` array would be a lie
/// the first consumer to read it would believe; this type can only answer what the design says the
/// row knows.
///
/// The kind itself is `kind:`'s to answer, never position's: the trash is flat, and an empty lane
/// folder is shape-identical to a card folder (01-storage-format.md § Deletion
/// `IntegrityRules.trashKind`).
public struct TrashedLane: Identifiable, Sendable, Equatable {
public let id: ItemID
public let schema: Int
public let title: FieldValue<String>
/// Rank within the trash, ascending = top to bottom the same required, strictly validated
/// field a live lane carries (`Lane.order`), and what interleaves this row among the trash's
/// cards. Newest-first falls out of it: every arrival mints a rank above the current topmost.
public let order: Double
/// **How many cards the lane is holding** the row's whole other half ("Doing 5 cards").
///
/// Counted from disk at load, never derived from a walked subtree: the count is the one fact
/// about the freight the snapshot carries, and counting is what keeps the entry opaque. The
/// unit counted is what the loader *would* render as a card an identity-shaped child holding
/// its own `index.md` so the row's number and a restore's outcome agree.
public let heldCards: Int
/// The full parsed `index.md`; unknown/reserved keys ride along uninterpreted, so a restore
/// (an ordinary move out) returns the lane exactly as it went in.
public let document: FrontmatterDocument
}
+107 -352
View File
@@ -1046,9 +1046,9 @@ public enum BoardWriter: Sendable {
/// The sequence, which is the contract:
///
/// 1. **`cardFolder` must be a card** (`checkIsCardFolder`, the stricter guard: UUID-shaped
/// *under* a UUID-shaped parent). This is what makes "lanes are never trashed" structural
/// rather than a policy the caller has to remember a lane, a board root and a stray are
/// all refused here, and lane deletion has its own call (`removeLane`).
/// *under* a UUID-shaped parent). A board root and a stray are refused here, and a lane takes
/// the sibling door (`deleteLaneToTrash`) same move, different guard and a different `kind`
/// to stamp, which is exactly the pair `.trash/`'s flat container needs told apart.
/// 2. **Pre-flight the card's `index.md`** (`checkIndexIsRewritable`) the move rewrites it
/// at the destination, so a file that cannot be round-tripped refuses *before* the folder
/// travels. `moveItem`'s discover-before-you-write rule, for its reason.
@@ -1088,9 +1088,48 @@ public enum BoardWriter: Sendable {
inBoard boardRoot: URL,
order: Double
) throws(BoardWriteError) -> ItemID {
try moveCardIntoTrash(
try moveIntoTrash(
at: cardFolder,
inBoard: boardRoot,
kind: .card,
order: order,
operation: .delete(title: nil),
removingLegacyKey: false
)
}
/// **Deleting a lane: the same physical move into `<board-root>/.trash/`** (01-storage-format.md
/// § Deletion, lanes joined 2026-07-29 "retiring the design's sole destructive delete";
/// 03-board-ui.md § Trash: "deleting a lane moves its folder subtree intact into `.trash/`,
/// exactly as a card moves").
///
/// `deleteCardToTrash`'s body with two differences, and they are the whole of what a lane is:
///
/// - **The guard is `checkIsLaneFolder`** UUID-shaped directly under a board root, which
/// refuses a card, a board root, a stray, and notably a folder already in `.trash/`.
/// - **`kind: lane` is stamped**, not derived. The rank rewrite is a `updateIndex` on a folder
/// that is by then *inside* `.trash/`, where position cannot answer and shape would answer
/// *wrongly* for the one lane that most needs the key: an **empty** lane is shape-identical to
/// a card (01's own "honest limit"). The caller knows what it moved, so it says so which is
/// also the backfill the ruling asks of this write ("`kind: lane` backfilled on touch when
/// absent the trash move's rank mint included").
///
/// **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
/// restore which is what makes the inverse an ordinary move rather than a replay of captured
/// bytes (13-native-undo.md Interaction with the trash).
///
/// - Returns: the lane's identity, unchanged.
@discardableResult
public static func deleteLaneToTrash(
at laneFolder: URL,
inBoard boardRoot: URL,
order: Double
) throws(BoardWriteError) -> ItemID {
try moveIntoTrash(
at: laneFolder,
inBoard: boardRoot,
kind: .lane,
order: order,
operation: .delete(title: nil),
removingLegacyKey: false
@@ -1117,29 +1156,39 @@ public enum BoardWriter: Sendable {
inBoard boardRoot: URL,
order: Double
) throws(BoardWriteError) -> ItemID {
try moveCardIntoTrash(
try moveIntoTrash(
at: cardFolder,
inBoard: boardRoot,
kind: .card,
order: order,
operation: .migrateTombstone(title: nil),
removingLegacyKey: true
)
}
/// The shared body of `deleteCardToTrash` and `migrateTombstonedCard` see the former for the
/// sequence and the latter for what `removingLegacyKey` adds.
private static func moveCardIntoTrash(
at cardFolder: URL,
/// The shared body of the three moves into `.trash/` `deleteCardToTrash`, `deleteLaneToTrash`
/// and `migrateTombstonedCard`. See the first for the sequence, the second for what a lane's
/// `kind` is doing here, and the third for what `removingLegacyKey` adds.
///
/// **`kind` is both the guard and the stamp**: it picks which shape check the item must pass on
/// the way out, and it is handed to `updateIndex` so the arrived entry's `kind` is written from
/// what the caller *moved* rather than guessed from what the flat container makes it look like.
private static func moveIntoTrash(
at itemFolder: URL,
inBoard boardRoot: URL,
kind: IntegrityRules.ObjectKind,
order: Double,
operation initialOperation: WriteOperation,
removingLegacyKey: Bool
) throws(BoardWriteError) -> ItemID {
var operation = initialOperation
try checkIsDirectory(cardFolder, describedAs: "card folder", operation: operation)
try checkIsDirectory(itemFolder, describedAs: kind == .lane ? "lane folder" : "card folder", operation: operation)
try checkIsDirectory(boardRoot, describedAs: "board folder", operation: operation)
try checkIsCardFolder(cardFolder, operation: operation)
operation = try checkIndexIsRewritable(inItemFolder: cardFolder, operation: operation)
switch kind {
case .lane: try checkIsLaneFolder(itemFolder, operation: operation)
case .card, .board: try checkIsCardFolder(itemFolder, operation: operation)
}
operation = try checkIndexIsRewritable(inItemFolder: itemFolder, operation: operation)
let trash = trashFolder(inBoard: boardRoot)
do {
@@ -1152,23 +1201,23 @@ public enum BoardWriter: Sendable {
)
}
let name = cardFolder.lastPathComponent
let name = itemFolder.lastPathComponent
let arrived = trash.appendingPathComponent(name, isDirectory: true)
do {
try FileManager.default.moveItem(at: cardFolder, to: arrived)
try FileManager.default.moveItem(at: itemFolder, to: arrived)
} catch {
throw BoardWriteError(
operation: operation,
path: cardFolder.path,
path: itemFolder.path,
reason: .io(message: "could not move folder into the trash: \(error.localizedDescription)")
)
}
// A delete is a move into `.trash/` on disk (01-storage-format.md § Deletion), so the
// receipt is the move pair and it reads correctly from either end: the board side sees an
// absence where the card was, the shown-trash side sees an arrival where it went.
EchoLedger.current?.recordMove(from: cardFolder, to: arrived)
// absence where the item was, the shown-trash side sees an arrival where it went.
EchoLedger.current?.recordMove(from: itemFolder, to: arrived)
try updateIndex(inItemFolder: arrived, operation: operation) { document in
try updateIndex(inItemFolder: arrived, kind: kind, operation: operation) { document in
document.set(FrontmatterKeys.order, to: .double(order))
if removingLegacyKey {
document.remove(FrontmatterKeys.deleted)
@@ -1185,134 +1234,80 @@ public enum BoardWriter: Sendable {
return ItemID(rawValue: name)
}
/// **Migrating a legacy tombstoned lane**: the `deleted:` key is removed and the lane returns
/// **live**, exactly where it always was (01-storage-format.md § Deletion: "a lane carrying
/// `deleted:` returns live with the key removed and a notice resurrection is the safe
/// direction, nothing is destroyed by migration").
///
/// **Nothing moves and nothing is removed.** There is no lane trash to move it into, and
/// destroying a lane the user may never have meant to lose is the one direction migration is
/// forbidden to take. Its cards come back with it; any of *them* carrying their own
/// `deleted:` key migrate on their own account, as ordinary tombstoned cards.
///
/// The lane's rank is untouched, so it returns to its own position among its siblings
/// position-perfect for the same reason the retired Put Back was: the folder never moved.
///
/// Refuses anything that is not a lane (`checkIsLaneFolder`): a card's migration is a move and
/// has its own call, and pointing this at one would strip the key while leaving the card
/// exactly where the tombstone had hidden it.
public static func migrateTombstonedLane(at laneFolder: URL) throws(BoardWriteError) {
let operation = WriteOperation.migrateTombstone(title: nil)
try checkIsDirectory(laneFolder, describedAs: "lane folder", operation: operation)
try checkIsLaneFolder(laneFolder, operation: operation)
try updateIndex(inItemFolder: laneFolder, operation: operation) { document in
document.remove(FrontmatterKeys.deleted)
}
EchoLedger.current?.markHeal(at: laneFolder.appendingPathComponent(BoardLoader.indexFileName))
}
/// **Deleting a lane is physical** the folder and everything under it are removed
/// (01-storage-format.md § Deletion; 03-board-ui.md § Trash: "Cards only. Lanes are never
/// trashed"). There is no lane trash and no tombstone; the recovery net is native undo
/// in-session and git history on git boards.
///
/// **Capture before you remove.** Undo restores a lane by replaying its bytes, which only
/// works if someone is holding them `captureSubtree(at:operation:)` is that primitive, and
/// the pairing is the caller's (the undo step captures, then calls this). Deliberately not
/// folded in here: a purge that always paid for a full tree read would make Empty Trash on a
/// large board slow for a recovery nothing was going to use.
///
/// **A folder that is already gone is success**, `purgeItem`'s rule and for its reason: a
/// Finder deletion converges on exactly the end state this produces, so there is nothing left
/// to distinguish.
///
/// Refuses anything that is not a lane (`checkIsLaneFolder`) a board root, a card, a stray,
/// and notably a *trash card*, whose parent is `.trash/` rather than the board root.
public static func removeLane(at laneFolder: URL) throws(BoardWriteError) {
let operation = WriteOperation.delete(title: nil)
guard FileManager.default.fileExists(atPath: laneFolder.path) else { return }
try checkIsLaneFolder(laneFolder, operation: operation)
do {
try FileManager.default.removeItem(at: laneFolder)
} catch {
throw BoardWriteError(
operation: operation,
path: laneFolder.path,
reason: .io(message: "could not remove folder: \(error.localizedDescription)")
)
}
// The absence marker and it takes the lane's cards' receipts with it, which is what makes
// the digest's implied-events rule and the ledger agree that this was one event.
EchoLedger.current?.recordDeletion(at: laneFolder)
}
/// Permanently removes one card from the trash the trash's own **Delete**
/// (03-board-ui.md § Trash: "on a trash card, Delete (/) is permanent").
/// Permanently removes one **entry** from the trash the trash's own **Delete**
/// (03-board-ui.md § Trash: "on a trash selection, Delete (/) is permanent in the trash it
/// removes the folder").
///
/// `purgeItem` with the container checked: the folder must actually sit in this board's
/// `.trash/`, so a mis-aimed permanent delete cannot reach a live card. `purgeItem`
/// `.trash/`, so a mis-aimed permanent delete cannot reach a live item. `purgeItem`
/// (unconstrained) is for the create-undo's own removal, not this call.
///
/// **A trashed lane purges whole, freight and all** (lanes joined the trash 2026-07-29): the
/// removal is recursive, so "permanent delete walks lane subtrees" needs no walk of its own
/// here what the confirmation must *count* before this runs is `TrashModel`'s job, off the
/// snapshot. Which kind the entry is therefore never comes up: the container and the shape are
/// the whole check, exactly as they were when only cards lived here.
///
/// An already-gone folder is success, `purgeItem`'s rule.
public static func purgeTrashCard(at cardFolder: URL, inBoard boardRoot: URL) throws(BoardWriteError) {
public static func purgeTrashEntry(at entryFolder: URL, inBoard boardRoot: URL) throws(BoardWriteError) {
let operation = WriteOperation.purge(title: nil)
guard FileManager.default.fileExists(atPath: cardFolder.path) else { return }
guard FileManager.default.fileExists(atPath: entryFolder.path) else { return }
try checkIsUUIDShaped(cardFolder, operation: operation)
guard isSameLocation(cardFolder.deletingLastPathComponent(), trashFolder(inBoard: boardRoot)) else {
try checkIsUUIDShaped(entryFolder, operation: operation)
guard isSameLocation(entryFolder.deletingLastPathComponent(), trashFolder(inBoard: boardRoot)) else {
throw BoardWriteError(
operation: operation,
path: cardFolder.path,
path: entryFolder.path,
reason: .unreadable(message: "folder is not in this board's trash")
)
}
do {
try FileManager.default.removeItem(at: cardFolder)
try FileManager.default.removeItem(at: entryFolder)
} catch {
throw BoardWriteError(
operation: operation,
path: cardFolder.path,
path: entryFolder.path,
reason: .io(message: "could not remove folder: \(error.localizedDescription)")
)
}
EchoLedger.current?.recordDeletion(at: cardFolder)
EchoLedger.current?.recordDeletion(at: entryFolder)
}
/// **Empty Trash** (, 03-board-ui.md § Trash): permanently removes every card in
/// `<board-root>/.trash/`. Returns what it removed, in folder-name order.
/// **Empty Trash** (, 03-board-ui.md § Trash): permanently removes every entry in
/// `<board-root>/.trash/` cards and trashed lanes alike, **lane subtrees walked** by the
/// recursive removal. Returns what it removed, in folder-name order.
///
/// **The card folders, not the container.** The design says it "purges the whole `.trash/`",
/// and the cards are the whole of it in every board the app produces but the container is a
/// **The entry folders, not the container.** The design says it "purges the whole `.trash/`",
/// and the entries are the whole of it in every board the app produces but the container is a
/// real folder a hand-editor can put things in, and stray tolerance ("preserved verbatim,
/// never rendered") does not stop applying because the folder is the app's. Removing only what
/// the loader recognizes as a card keeps the count honest (the confirmation names cards) and
/// keeps this command from being the one place in the app that destroys a file nobody ever
/// saw. The emptied container is left standing; the next delete would only recreate it.
/// the loader recognizes as an entry keeps the count honest (the confirmation names cards and
/// lane freight) and keeps this command from being the one place in the app that destroys a file
/// nobody ever saw. The emptied container is left standing; the next delete would only recreate
/// it.
///
/// **Search-independent**, by construction: this walks the folder, never a filtered view.
///
/// Removal is per card, in order, and a failure stops the batch and throws everything
/// Removal is per entry, in order, and a failure stops the batch and throws everything
/// already removed stays removed, `importAttachments`' rule. A board with no trash at all
/// removes nothing and returns `[]`.
@discardableResult
public static func emptyTrash(inBoard boardRoot: URL) throws(BoardWriteError) -> [ItemID] {
let operation = WriteOperation.purge(title: nil)
var purged: [ItemID] = []
for card in childCandidates(of: trashFolder(inBoard: boardRoot)) {
for entry in childCandidates(of: trashFolder(inBoard: boardRoot)) {
do {
try FileManager.default.removeItem(at: card)
try FileManager.default.removeItem(at: entry)
} catch {
throw BoardWriteError(
operation: operation,
path: card.path,
path: entry.path,
reason: .io(message: "could not remove folder: \(error.localizedDescription)")
)
}
EchoLedger.current?.recordDeletion(at: card)
purged.append(ItemID(rawValue: card.lastPathComponent))
EchoLedger.current?.recordDeletion(at: entry)
purged.append(ItemID(rawValue: entry.lastPathComponent))
}
return purged
}
@@ -1336,206 +1331,6 @@ public enum BoardWriter: Sendable {
}
}
// MARK: - Subtree capture and replay
/// Every byte of a folder tree, in memory the capture half of a physical removal's undo
/// (13-native-undo.md Rules; 03-board-ui.md § Trash, "the net is undo, not the trash").
///
/// **`readIndexText(ofItem:operation:)` widened from one file to a whole tree**, and for the
/// same reason: the inverse of a physical removal is a recreation, and the only way Z can put
/// a lane back *with its identity and its cards* is for the step to be holding what was there.
/// One `index.md` is enough to replay a create; a lane delete takes its nested cards, their
/// `attachments/`, and every stray with it.
///
/// **Everything, verbatim.** Unlike every other walk in this file, this one does *not* apply
/// the loader's stray exclusions: hidden files (`.DS_Store`, a dot-file a hand-editor left),
/// non-UUID folders, files with no meaning to the schema all captured, because the promise is
/// that undo restores what was removed rather than what the app would have rendered. Bytes are
/// carried as `Data` and never decoded, so encoding, line endings and BOMs are non-questions;
/// POSIX permissions ride along per entry.
///
/// **Symlinks are captured as links, never followed** (01-storage-format.md § Fractal layout
/// Rules) the destination string is recorded and recreated as a link, so a cyclic or
/// cross-volume link is neither traversed here nor materialized as a copy of its target.
///
/// Entries are sorted by name at every level, so a capture is a deterministic value: two
/// captures of one unchanged tree are `==`, which is what makes a round-trip assertable.
///
/// It reads the whole tree into memory, so it is for the sizes a board actually has (a lane
/// and its cards) not a general-purpose archiver.
public static func captureSubtree(
at folder: URL,
operation: WriteOperation
) throws(BoardWriteError) -> SubtreeSnapshot {
try checkIsDirectory(folder, describedAs: "item folder", operation: operation)
let entries: [URL]
do {
entries = try FileManager.default.contentsOfDirectory(
at: folder,
includingPropertiesForKeys: [.isDirectoryKey, .isSymbolicLinkKey],
options: []
)
} catch {
throw BoardWriteError(
operation: operation,
path: folder.path,
reason: .unreadable(message: "could not list folder: \(error.localizedDescription)")
)
}
var captured: [SubtreeSnapshot.Entry] = []
for entry in entries.sorted(by: { $0.lastPathComponent < $1.lastPathComponent }) {
let name = entry.lastPathComponent
let values = try? entry.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey])
if values?.isSymbolicLink == true {
guard let destination = try? FileManager.default.destinationOfSymbolicLink(atPath: entry.path) else {
throw BoardWriteError(
operation: operation,
path: entry.path,
reason: .unreadable(message: "could not read symbolic link")
)
}
captured.append(.symlink(name: name, destination: destination))
} else if values?.isDirectory == true {
captured.append(.folder(try captureSubtree(at: entry, operation: operation)))
} else {
let contents: Data
do {
contents = try Data(contentsOf: entry)
} catch {
throw BoardWriteError(
operation: operation,
path: entry.path,
reason: .unreadable(message: "could not read file: \(error.localizedDescription)")
)
}
captured.append(.file(name: name, contents: contents, permissions: posixPermissions(of: entry)))
}
}
return SubtreeSnapshot(
name: folder.lastPathComponent,
permissions: posixPermissions(of: folder),
entries: captured
)
}
/// Puts a captured tree back, at `folder`, byte for byte the replay half of
/// `captureSubtree(at:operation:)`, and `recreateItem`'s rules one level of nesting wider.
///
/// - **The parent must already exist** (`withIntermediateDirectories: false`): a redo whose
/// board root has since gone must fail rather than conjure a tree in mid-air.
/// - **It refuses to clobber**: anything at `folder` fails loudly rather than being written
/// over. Restoring on top of a folder someone recreated meanwhile would silently merge two
/// trees.
/// - **The bytes are written verbatim** nothing is stamped, nothing is re-serialized, no
/// `index.md` is parsed. This replays; it does not edit.
/// - **All-or-nothing**: any failure removes the partial tree best-effort and rethrows,
/// `copyItem`'s rule a half-restored lane is pure residue, since nothing was there.
///
/// `folder`'s own name governs, not `snapshot.name`: a caller restoring to the path it removed
/// passes the same URL, and the snapshot's name is carried for identification, not as an
/// instruction.
///
/// Permissions are applied **after** a folder's children are written, so a captured read-only
/// directory does not lock out its own contents on the way back in.
public static func recreateSubtree(
at folder: URL,
from snapshot: SubtreeSnapshot,
operation: WriteOperation
) throws(BoardWriteError) {
guard !FileManager.default.fileExists(atPath: folder.path) else {
throw BoardWriteError(
operation: operation,
path: folder.path,
reason: .io(message: "something already exists here")
)
}
do throws(BoardWriteError) {
try materialize(snapshot, at: folder, operation: operation, intermediates: false)
} catch {
try? FileManager.default.removeItem(at: folder)
throw error
}
}
/// `recreateSubtree`'s recursion, minus its clobber refusal and its cleanup both belong to
/// the top-level call, which is the only one with a partial tree to remove.
private static func materialize(
_ snapshot: SubtreeSnapshot,
at folder: URL,
operation: WriteOperation,
intermediates: Bool
) throws(BoardWriteError) {
do {
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: intermediates)
} catch {
throw BoardWriteError(
operation: operation,
path: folder.path,
reason: .io(message: "could not create folder: \(error.localizedDescription)")
)
}
for entry in snapshot.entries {
switch entry {
case let .file(name, contents, permissions):
let fileURL = folder.appendingPathComponent(name)
do {
try contents.write(to: fileURL)
// An undo restore recreates whole subtrees byte for byte; the bytes are already
// in hand, so every restored file gets its own receipt rather than only the
// `index.md` at the top.
EchoLedger.current?.recordWrite(at: fileURL, data: contents)
} catch {
throw BoardWriteError(
operation: operation,
path: fileURL.path,
reason: .io(message: "could not write file: \(error.localizedDescription)")
)
}
setPosixPermissions(permissions, of: fileURL)
case let .folder(child):
try materialize(
child,
at: folder.appendingPathComponent(child.name, isDirectory: true),
operation: operation,
intermediates: false
)
case let .symlink(name, destination):
let linkURL = folder.appendingPathComponent(name)
do {
try FileManager.default.createSymbolicLink(atPath: linkURL.path, withDestinationPath: destination)
} catch {
throw BoardWriteError(
operation: operation,
path: linkURL.path,
reason: .io(message: "could not create symbolic link: \(error.localizedDescription)")
)
}
}
}
// After the children, so a captured read-only folder cannot lock out its own contents.
setPosixPermissions(snapshot.permissions, of: folder)
}
/// An item's POSIX permission bits, or `nil` when they cannot be read `attributesOfItem`
/// rather than a `URLResourceValues` key because it is `lstat`-based, so a symlink's own
/// attributes are never its target's. Best-effort by design: permissions decorate a capture,
/// and a tree that restores with default modes is a far better outcome than one that refuses
/// to restore.
private static func posixPermissions(of url: URL) -> Int? {
(try? FileManager.default.attributesOfItem(atPath: url.path))?[.posixPermissions] as? Int
}
private static func setPosixPermissions(_ permissions: Int?, of url: URL) {
guard let permissions else { return }
try? FileManager.default.setAttributes([.posixPermissions: permissions], ofItemAtPath: url.path)
}
/// Physical removal the create-undo's own primitive (13-native-undo.md: "create remove the
/// created folder"): deletes the folder tree from disk. Irreversible, and distinct from the
/// ordinary delete, which is a *move* into `.trash/` this call does **not** require the item to
@@ -2569,46 +2364,6 @@ public struct MoveResult: Sendable, Equatable {
}
}
// MARK: - Subtree vocabulary
/// A folder tree captured whole, in memory what `BoardWriter.captureSubtree(at:operation:)`
/// produces and `recreateSubtree(at:from:operation:)` replays.
///
/// **A value, deliberately**: `Sendable` so an undo step can carry it across isolation domains,
/// and `Equatable` so a capture recreate capture round trip is one assertion. Equality is
/// exact names, bytes, link destinations, permissions, and order which holds because a capture
/// sorts every level by name.
///
/// It describes bytes, never meaning. There is no `index.md` here, no frontmatter, no identity:
/// a lane, a card, an `attachments/` folder and a hand-made `notes/` are all just folders with
/// entries, which is exactly what a byte-faithful restore needs and all it may assume.
public struct SubtreeSnapshot: Sendable, Equatable {
/// The captured folder's own name. Carried for identification and for nested folders' paths;
/// the top-level replay takes its path from the caller instead (see `recreateSubtree`).
public let name: String
/// POSIX permission bits as captured, `nil` when unreadable applied best-effort on replay.
public let permissions: Int?
/// The folder's direct children, sorted by name.
public let entries: [Entry]
public init(name: String, permissions: Int?, entries: [Entry]) {
self.name = name
self.permissions = permissions
self.entries = entries
}
/// One captured child. Symlinks are their own case rather than a file holding their target's
/// bytes "symlinks are never traversed" (01-storage-format.md § Fractal layout Rules), so
/// a capture records the link and a replay recreates the link.
public enum Entry: Sendable, Equatable {
case file(name: String, contents: Data, permissions: Int?)
case folder(SubtreeSnapshot)
case symlink(name: String, destination: String)
}
}
/// How a copy stamps the files it materializes the one axis on which the two kinds of copy
/// differ (01-storage-format.md § Fractal layout Rules; § Frontmatter).
public enum CopyStamps: Sendable {
@@ -2665,8 +2420,9 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
/// the embedded `index.md` is kept for now that it is never a materialization source), `nil` for
/// an untitled item.
case paste(title: String?)
/// / a card moving into `.trash/` (`deleteCardToTrash`) or a lane being removed
/// outright (`removeLane`). The word the user pressed, whichever staging it took.
/// / a card (`deleteCardToTrash`) or a lane (`deleteLaneToTrash`) moving into `.trash/`.
/// One word for one gesture: since lanes rejoined the trash (2026-07-29) both are the same
/// physical move, and neither is destructive.
///
/// **There is no `restore` case**: restoring is an ordinary move out (`moveItem`), so a failed
/// restore says the app couldn't *move* the card which is exactly what it couldn't do
@@ -2674,15 +2430,14 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
case delete(title: String?)
case purge(title: String?)
/// A legacy `deleted:` key being migrated away a card relocating into `.trash/` with the key
/// removed, or a lane getting the key stripped and returning live (01-storage-format.md
/// § Deletion, "Legacy `deleted:` keys migrate on load-and-write, never destroy").
/// A legacy `deleted:` key being migrated away a **card** relocating into `.trash/` with the
/// key removed (01-storage-format.md § Deletion: "cards migrate, lanes ignore"; the lane half
/// retired 2026-07-29 with the lane trash, so this operation has one performer left).
///
/// Its own case rather than a fold into `.delete` or `.move`, on the vocabulary's standing
/// reasoning and `.relocateLooseFile`'s in particular: this is work the *app* started on its
/// own, on a board an older version wrote, and a banner telling the user the app "couldn't
/// delete 'Fix login'" would name a gesture they never made on a lane, one whose outcome is
/// the opposite of deletion.
/// delete 'Fix login'" would name a gesture they never made.
case migrateTombstone(title: String?)
case style(title: String?) // updateIndex on behalf of styling flows (03-board-ui.md)
case resize(title: String?) // a lane's `width` the edge drag and the stepper alike (03-board-ui.md § Lane)
+18 -22
View File
@@ -502,7 +502,8 @@ public enum IntegrityRules: Sendable {
public enum Defect: Sendable, Equatable {
/// A card holding files that belong in its `attachments/` (the loose-file carve-out).
case looseCardFiles(LooseCardFiles)
/// An item carrying a legacy `deleted:` key (the retired tombstone model's migration input).
/// A **card** carrying a legacy `deleted:` key (the retired tombstone model's surviving
/// migration input; a lane's key is inert and tolerated instead).
case legacyTombstone(LegacyTombstone)
/// A claimed board-root name held by the wrong kind of node (ruled 2026-07-29).
case claimedNameSquatted(ClaimedNameSquatter)
@@ -567,7 +568,7 @@ public enum IntegrityRules: Sendable {
case let .looseCardFiles(work):
work.fileNames.map { "loose:\(work.laneID.rawValue)/\(work.cardID.rawValue)/\($0)" }
case let .legacyTombstone(work):
["tombstone:\(work.laneID.rawValue)/\(work.cardID?.rawValue ?? "")"]
["tombstone:\(work.laneID.rawValue)/\(work.cardID.rawValue)"]
case let .claimedNameSquatted(work):
// The node *kind* is part of the picture: a squatter replaced by a different kind
// of squatter is a new defect, and a heal that failed on one has no claim to have
@@ -846,35 +847,30 @@ public struct LooseCardFiles: Sendable, Equatable {
}
}
/// One item found carrying a legacy `deleted:` key everything its migration and notice need, and
/// nothing more (01-storage-format.md § Deletion: "Legacy `deleted:` keys migrate on load-and-write,
/// never destroy").
/// One **card** found carrying a legacy `deleted:` key everything its migration and notice need,
/// and nothing more (01-storage-format.md § Deletion: "a card carrying `deleted:` is relocated into
/// `.trash/` (key removed)").
///
/// **Cards only, since 2026-07-29.** The rule used to have two halves, and the lane half is retired
/// wholesale: "a lane carrying `deleted:` simply loads live with the key ignored no migration
/// machinery, no key-strip write, no notice". A lane's key is now the tolerate tier's business
/// (`LoadWarning.laneLevelDeletedIgnored`), which is why there is no `kind` here to switch on: the
/// one surviving act is a move, and a payload that could spell the other one would be a defect
/// nothing can heal.
///
/// The path is carried as its identity components rather than as a URL `LooseCardFiles`'
/// convention, for its reason. `title` is the item's as written, `nil` for an untitled one.
/// convention, for its reason. `title` is the card's as written, `nil` for an untitled one.
public struct LegacyTombstone: Sendable, Equatable {
/// Which migration this item takes the two are genuinely different acts, not one act at two
/// levels: a card *moves* (into `.trash/`, at a minted top-of-trash rank) and a lane stays
/// exactly where it is (the key is stripped and it returns live).
public enum Kind: Sendable, Equatable {
case card
case lane
}
public let kind: Kind
/// The lane's own identity for `.lane`; the card's **containing** lane for `.card` the context
/// the relocation needs to find the folder at all.
/// The card's **containing** lane the context the relocation needs to find the folder at all.
public let laneID: ItemID
/// The card's identity for `.card`, `nil` for `.lane`. Two fields rather than an enum payload so
/// the common "which folder is this" question is one path join at every call site.
public let cardID: ItemID?
/// The card itself.
public let cardID: ItemID
public let title: String?
public init(kind: Kind, laneID: ItemID, cardID: ItemID?, title: String?) {
self.kind = kind
public init(laneID: ItemID, cardID: ItemID, title: String?) {
self.laneID = laneID
self.cardID = cardID
self.title = title
+11 -10
View File
@@ -35,7 +35,7 @@ final class TrashConfirmations {
/// reachable inside the trash now: the trash's own staged Delete, and Empty Trash (which names
/// the whole container and re-derives its targets at the moment it runs).
enum Action: Equatable {
case deleteTrashCards(Set<ItemID>)
case deleteTrashEntries(Set<ItemID>)
case emptyTrash
}
}
@@ -43,8 +43,8 @@ final class TrashConfirmations {
/// **File Delete, staged by place** (04-interactions.md The map) with the confirmation the
/// trash side owes and the board side does not.
///
/// A board selection goes straight through: moving a card into the trash and deleting a lane are
/// both recoverable (the trash itself, and native undo 03-board-ui.md § Trash), so neither
/// A board selection goes straight through: a card and a lane alike move into the trash, and the
/// move is recoverable (the trash itself, and native undo 03-board-ui.md § Trash), so neither
/// stands an alert. A **trash** selection is the permanent one, and it "confirms exactly where
/// the loss is real": `purgeIsUnrecoverable` decides.
///
@@ -71,7 +71,7 @@ final class TrashConfirmations {
/// is what a board that keeps history does for every permanent delete (delete-never-forgets).
func requestTrashDelete(of ids: Set<ItemID>, in store: BoardStore) {
guard store.purgeIsUnrecoverable else {
store.deleteTrashCards(ids)
store.deleteTrashEntries(ids)
return
}
guard let prompt = TrashModel.purgePrompt(
@@ -79,7 +79,7 @@ final class TrashConfirmations {
snapshot: store.snapshot,
unrecoverable: true
) else { return }
pending = Pending(prompt: prompt, action: .deleteTrashCards(ids))
pending = Pending(prompt: prompt, action: .deleteTrashEntries(ids))
}
/// Raises Empty Trash's alert. **Always** it guards bulk scope rather than per-item
@@ -98,7 +98,7 @@ final class TrashConfirmations {
guard let pending else { return }
self.pending = nil
switch pending.action {
case let .deleteTrashCards(ids): store.deleteTrashCards(ids)
case let .deleteTrashEntries(ids): store.deleteTrashEntries(ids)
case .emptyTrash: store.emptyTrash()
}
}
@@ -168,9 +168,10 @@ struct TrashCommands: View {
extension BoardStore {
/// **Trash shown and non-empty** (11-command-nexus.md's own scope for the Empty Trash row)
/// where "non-empty" reads `.trash/` itself and never the filtered view (03-board-ui.md § Trash:
/// "menu validation's non-empty reads `.trash/`, not the filtered view", so a search that hides
/// every trash card leaves the command enabled and its confirmation still names the true count).
/// where "non-empty" reads `.trash/` itself **both kinds of entry**, and never the filtered
/// view (03-board-ui.md § Trash: "menu validation's non-empty reads `.trash/`, not the filtered
/// view", so a search that hides every trash row leaves the command enabled and its confirmation
/// still names the true count, lane freight included).
///
/// The visibility clause is 04-interactions.md's, stated for the whole column: "hidden, it is
/// invisible to every gesture".
@@ -179,7 +180,7 @@ extension BoardStore {
/// menu (`TrashModel`'s own reason for being a pure function of a snapshot).
var canEmptyTrash: Bool {
guard acceptsBoardMutations, transient.isTrashVisible else { return false }
return !snapshot.trash.isEmpty
return !snapshot.trash.isEmpty || !snapshot.trashedLanes.isEmpty
}
}
+24
View File
@@ -82,6 +82,30 @@ struct CardWindowFateTests {
#expect(CardWindowHost.cardWindowFate(cardID: Ident.card1, in: snapshot) == .dismisses)
}
/// The lane's own delete is a *move* now (03 § Trash, re-ruled 2026-07-29), and the dismissal
/// still falls out of the same absence rather than needing a rule: a trashed lane is an opaque
/// unit, so its cards are not in the snapshot at all not under a lane, not among the trash's
/// cards and the walk simply does not find them.
@Test("Trashing a card's lane dismisses its window too — the freight leaves the snapshot with it")
func aLaneTrashedDismissesItsCards() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try BoardWriter.deleteLaneToTrash(
at: fixture.url(Ident.lane1), inBoard: fixture.root, order: -1024
)
let snapshot = try BoardLoader.load(boardRoot: fixture.root).model
#expect(snapshot.trashedLanes.map(\.id.rawValue) == [Ident.lane1])
#expect(!snapshot.trash.contains { $0.id.rawValue == Ident.card1 })
#expect(CardWindowHost.cardWindowFate(cardID: Ident.card1, in: snapshot) == .dismisses)
// And the same absence is what ejects it from every item-referencing set selection, the
// pending cut, drag membership (02-architecture.md § Live-reload resilience).
#expect(!ItemContainer.board.ids(in: snapshot).contains(ItemID(rawValue: Ident.card1)))
#expect(!ItemContainer.trash.ids(in: snapshot).contains(ItemID(rawValue: Ident.card1)))
#expect(ItemContainer.trash.ids(in: snapshot).contains(ItemID(rawValue: Ident.lane1)),
"the lane row itself is in the trash's universe")
}
@Test("A card that is not in this board's snapshot dismisses — the cross-board move")
func anAbsentCardDismisses() throws {
let fixture = try makeBoard()
+3 -1
View File
@@ -269,7 +269,9 @@ struct FixtureTombstonesTests {
let cardUnderDeadLane = "50000000-0000-4000-8000-000000000005"
let result = try loadFixture("Valid/tombstones.kanban")
#expect(result.warnings.isEmpty)
// The lane's key is the tolerate tier's since 2026-07-29 ignored, logged, and the lane
// loads live (01 § Deletion, lane clause).
#expect(result.warnings == [.laneLevelDeletedIgnored(path: laneDead)])
#expect(result.model.lanes.map(\.id.rawValue) == [laneLive, laneDead])
let live = try #require(result.model.lanes.first { $0.id.rawValue == laneLive })
+4 -4
View File
@@ -292,7 +292,7 @@ struct IntegrityDefectTests {
LooseCardFiles(laneID: lane, cardID: card, title: nil, fileNames: ["a.txt"])
).healClass == .looseCardFiles)
#expect(IntegrityRules.Defect.legacyTombstone(
LegacyTombstone(kind: .lane, laneID: lane, cardID: nil, title: nil)
LegacyTombstone(laneID: lane, cardID: card, title: nil)
).healClass == .legacyTombstone)
#expect(IntegrityRules.Defect.claimedNameSquatted(
ClaimedNameSquatter(name: ".trash", found: .file, expected: .directory)
@@ -318,10 +318,10 @@ struct IntegrityDefectTests {
@Test("Signatures identify the work, not its display")
func signaturesIdentifyTheWork() {
let one = IntegrityRules.Defect.legacyTombstone(
LegacyTombstone(kind: .card, laneID: lane, cardID: card, title: "Before")
LegacyTombstone(laneID: lane, cardID: card, title: "Before")
)
let two = IntegrityRules.Defect.legacyTombstone(
LegacyTombstone(kind: .card, laneID: lane, cardID: card, title: "After")
LegacyTombstone(laneID: lane, cardID: card, title: "After")
)
#expect(one.signatures == two.signatures)
}
@@ -334,7 +334,7 @@ struct IntegrityDefectTests {
LooseCardFiles(laneID: lane, cardID: card, title: nil, fileNames: ["x"])
)
let tombstone = IntegrityRules.Defect.legacyTombstone(
LegacyTombstone(kind: .card, laneID: lane, cardID: card, title: nil)
LegacyTombstone(laneID: lane, cardID: card, title: nil)
)
let squatter = IntegrityRules.Defect.claimedNameSquatted(
ClaimedNameSquatter(name: ".trash", found: .symlink, expected: .directory)
+119
View File
@@ -292,6 +292,125 @@ struct TrashPhrasingTests {
}
}
// MARK: - Lane freight
/// "Confirms name the freight honestly a trashed lane's alert counts its cards" (03 § Trash,
/// re-ruled 2026-07-29). The count is `TrashedLane.heldCards`, which the loader took at load
/// precisely because the subtree it counts is deliberately not in the snapshot.
@MainActor
@Suite("TrashModel ▸ lane freight in the confirmations")
struct TrashFreightTests {
/// A board whose trash holds one card and one lane carrying `held` cards.
private func makeFreightBoard(held: Int) throws -> WriterFixture {
let fixture = try WriterFixture()
try fixture.item("", Item.board)
try fixture.item(More.laneA, card(order: "1024", title: "Todo"))
try fixture.item(".trash/\(More.cardD)", card(order: "1024", title: "A card"))
try fixture.item(".trash/\(Ident.lane2)", "---\nschema: 1\ntitle: Doing\norder: 512\nkind: lane\n---\n")
for index in 0 ..< held {
try fixture.item(
".trash/\(Ident.lane2)/\(UUID().uuidString.lowercased())",
card(order: "\((index + 1) * 1024)", title: "Freight \(index)")
)
}
return fixture
}
/// 03's own phrasing, verbatim: "Permanently delete lane 'Doing' and its 5 cards".
@Test("A sole trashed lane names itself and its freight")
func soleLaneNamesItsFreight() throws {
let fixture = try makeFreightBoard(held: 5)
defer { fixture.tearDown() }
let snapshot = try load(fixture)
let prompt = try #require(TrashModel.purgePrompt(
for: [ItemID(rawValue: Ident.lane2)], snapshot: snapshot, unrecoverable: true
))
#expect(prompt.title == "Permanently delete lane \u{201C}Doing\u{201D} and its 5 cards?")
#expect(prompt.message == "This can\u{2019}t be undone.")
}
/// An empty lane has no freight clause to add "2 lanes containing 0 cards" would say less
/// than naming the lane.
@Test("An empty trashed lane names only itself")
func emptyLaneNamesOnlyItself() throws {
let fixture = try makeFreightBoard(held: 0)
defer { fixture.tearDown() }
let snapshot = try load(fixture)
let prompt = try #require(TrashModel.purgePrompt(
for: [ItemID(rawValue: Ident.lane2)], snapshot: snapshot, unrecoverable: true
))
#expect(prompt.title == "Permanently delete lane \u{201C}Doing\u{201D}?")
}
@Test("A sole held card folds to the singular")
func oneHeldCardFoldsSingular() throws {
let fixture = try makeFreightBoard(held: 1)
defer { fixture.tearDown() }
let prompt = try #require(TrashModel.purgePrompt(
for: [ItemID(rawValue: Ident.lane2)], snapshot: try load(fixture), unrecoverable: true
))
#expect(prompt.title == "Permanently delete lane \u{201C}Doing\u{201D} and its 1 card?")
}
/// 03's other example phrasing: " 41 cards and 2 lanes containing 9 more cards".
@Test("Empty Trash counts both kinds and the lanes' freight")
func emptyTrashCountsFreight() throws {
let fixture = try makeFreightBoard(held: 5)
defer { fixture.tearDown() }
let prompt = try #require(TrashModel.emptyTrashPrompt(in: try load(fixture), unrecoverable: true))
#expect(prompt.title == "Permanently delete 1 card and 1 lane containing 5 more cards?")
}
/// The subject builder as a pure value, so the phrasing rules are pinned without a board:
/// **"more" only where cards precede it**, and no clause for freight that is not there.
@Test("The aggregate subject folds every shape")
func theSubjectFolds() {
func subject(cards: Int, lanes: Int, held: Int) -> String {
TrashModel.subject(for: TrashModel.Freight(cards: cards, lanes: lanes, heldCards: held))
}
#expect(subject(cards: 41, lanes: 0, held: 0) == "41 cards")
#expect(subject(cards: 1, lanes: 0, held: 0) == "1 card")
#expect(subject(cards: 41, lanes: 2, held: 9) == "41 cards and 2 lanes containing 9 more cards")
#expect(subject(cards: 0, lanes: 2, held: 9) == "2 lanes containing 9 cards",
"nothing precedes it, so nothing is 'more'")
#expect(subject(cards: 0, lanes: 1, held: 1) == "1 lane containing 1 card")
#expect(subject(cards: 3, lanes: 1, held: 0) == "3 cards and 1 lane")
}
/// A trashed lane is deletable from the trash like any entry the menu validation is by
/// container, and the kind never enters it.
@Test("Delete is enabled on a trash lane selection, and resolves to a purgeable path")
func laneRowsValidate() throws {
let fixture = try makeFreightBoard(held: 2)
defer { fixture.tearDown() }
let snapshot = try load(fixture)
let selection = ItemReferenceSet(ids: [ItemID(rawValue: Ident.lane2)], container: .trash)
#expect(TrashModel.canDelete(selection: selection, in: snapshot))
#expect(ItemPath.resolve(selection.ids, in: .trash, snapshot: snapshot)
== [.trashLane(ItemID(rawValue: Ident.lane2))])
}
/// The column is one list: `resolve` hands back the trash's paths interleaved by rank, because
/// a batch's order is the column's order (03 § Trash).
@Test("Resolution interleaves the container's two kinds by rank")
func resolutionInterleaves() throws {
let fixture = try makeFreightBoard(held: 0)
defer { fixture.tearDown() }
let snapshot = try load(fixture)
// The lane sits at 512 and the card at 1024, so the lane row comes first.
#expect(ItemPath.resolve(
[ItemID(rawValue: Ident.lane2), cardD], in: .trash, snapshot: snapshot
) == [.trashLane(ItemID(rawValue: Ident.lane2)), .trashCard(cardD)])
}
}
// MARK: - Menu validation
@MainActor
+287 -164
View File
@@ -140,10 +140,12 @@ struct TrashContainerLoadTests {
#expect(result.warnings.count == 2)
}
/// "A lane-shaped nesting inside `.trash` is a stray": the walk stops at a card in the trash
/// exactly as it does under a lane, so a UUID-shaped folder *inside* a trash card is content,
/// never a level invisible, preserved, and not warned about (a card's subfolders never are).
@Test("A lane-shaped nesting inside .trash renders as one card, its children invisible")
/// The walk stops at a trash entry exactly as it stops at a card under a lane, so a UUID-shaped
/// folder *inside* one is never a level. What that folder makes of its parent changed with the
/// lanes-in-trash ruling: a kindless entry holding identity-shaped children with their own
/// `index.md` reads as a **lane** by shape (01 § Deletion), one opaque row whose children are
/// counted rather than surfaced invisible, preserved, and not warned about.
@Test("A lane-shaped nesting inside .trash reads as one opaque lane row, its children counted")
func laneShapedNestingInsideTrash() throws {
let fixture = try TrashFixture()
defer { fixture.tearDown() }
@@ -157,7 +159,9 @@ struct TrashContainerLoadTests {
let result = try BoardLoader.load(boardRoot: fixture.root)
#expect(result.model.trash.map(\.id.rawValue) == [outer])
#expect(result.model.trashedLanes.map(\.id.rawValue) == [outer])
#expect(result.model.trashedLanes.first?.heldCards == 1)
#expect(result.model.trash.isEmpty)
#expect(result.warnings.isEmpty)
// Nowhere in the snapshot, and still on disk.
#expect(!result.model.lanes.contains { $0.cards.contains { $0.id.rawValue == nested } })
@@ -277,33 +281,43 @@ struct LegacyTombstoneDetectionTests {
let result = try BoardLoader.load(boardRoot: fixture.root)
#expect(result.legacyTombstones == [
LegacyTombstone(kind: .card, laneID: ItemID(rawValue: lane), cardID: ItemID(rawValue: card), title: "Gone")
LegacyTombstone(laneID: ItemID(rawValue: lane), cardID: ItemID(rawValue: card), title: "Gone")
])
// Still rendered by the retiring path read-only detection has moved nothing yet.
#expect(result.model.lanes[0].cards.map(\.isDeleted) == [true])
#expect(result.model.trash.isEmpty)
}
@Test("A tombstoned lane is reported with no card, and keeps loading flagged")
func tombstonedLaneIsReported() throws {
/// "A lane carrying `deleted:` simply loads **live** with the key ignored no migration
/// machinery, no key-strip write, no notice" (01 § Deletion, lane clause re-ruled 2026-07-29):
/// the tolerate tier's whole verdict, and the bytes are never touched.
@Test("A tombstoned lane loads live, is warned rather than migrated, and keeps its bytes")
func tombstonedLaneIsTolerated() throws {
let fixture = try TrashFixture()
defer { fixture.tearDown() }
let lane = uuidName()
let text = "schema: 1\norder: 1024\ntitle: Old lane\ndeleted: 2026-01-01T00:00:00Z\n"
try fixture.index("", "schema: 1\n")
try fixture.index(lane, "schema: 1\norder: 1024\ntitle: Old lane\ndeleted: 2026-01-01T00:00:00Z\n")
try fixture.index(lane, text)
let result = try BoardLoader.load(boardRoot: fixture.root)
#expect(result.legacyTombstones == [
LegacyTombstone(kind: .lane, laneID: ItemID(rawValue: lane), cardID: nil, title: "Old lane")
])
#expect(result.model.lanes[0].isDeleted)
#expect(result.legacyTombstones.isEmpty, "the lane half of the migration is retired")
#expect(result.warnings == [.laneLevelDeletedIgnored(path: lane)])
#expect(result.model.lanes.map(\.title.value) == ["Old lane"], "it loads live")
#expect(result.model.lanes[0].isDeleted, "the key is still on disk — it just decides nothing")
#expect(
try String(contentsOf: fixture.root.appendingPathComponent("\(lane)/index.md"), encoding: .utf8)
== "---\n" + text + "---\n",
"preserved verbatim, like any unhandled key"
)
}
/// Presence, not validity the same rule the flag has always read by, so a broken timestamp
/// still migrates rather than being left behind as the one key the pivot forgot.
@Test("A malformed deleted value is still migration input")
/// still migrates rather than being left behind as the one key the pivot forgot. The lane beside
/// it takes the tolerate posture whatever its value's condition.
@Test("A malformed deleted value is still migration input on a card, still inert on a lane")
func malformedDeletedIsStillReported() throws {
let fixture = try TrashFixture()
defer { fixture.tearDown() }
@@ -316,8 +330,8 @@ struct LegacyTombstoneDetectionTests {
let result = try BoardLoader.load(boardRoot: fixture.root)
#expect(result.legacyTombstones.map(\.kind) == [.card, .lane])
#expect(result.legacyTombstones.map(\.cardID) == [ItemID(rawValue: card), nil])
#expect(result.legacyTombstones.map(\.cardID) == [ItemID(rawValue: card)])
#expect(result.warnings == [.laneLevelDeletedIgnored(path: lane)])
}
/// "A `deleted:` key at board level remains meaningless ignored and logged, preserved
@@ -553,10 +567,11 @@ struct TombstoneMigrationTests {
#expect(result.model.trash.map(\.id.rawValue) == [Ident.card1])
}
/// "A lane carrying `deleted:` returns **live** with the key removed" resurrection is the
/// safe direction, and the folder does not move, so it returns to its own position.
@Test("A tombstoned lane keeps its place and returns live")
func laneMigratesInPlace() throws {
/// The lane half of this migration is **retired** (01 § Deletion, re-ruled 2026-07-29): there is
/// no `migrateTombstonedLane` to call, and the loader hands the store no lane work to do the
/// lane simply loads live with the key inert (`LegacyTombstoneDetectionTests`).
@Test("A tombstoned lane is no longer migration input at all")
func laneIsNotMigrationInput() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
@@ -572,74 +587,124 @@ struct TombstoneMigrationTests {
Lane notes.
""")
let cardBefore = Item.rich(order: "1024", title: "Kept")
try fixture.item("\(Ident.lane1)/\(Ident.card1)", cardBefore)
try BoardWriter.migrateTombstonedLane(at: fixture.url(Ident.lane1))
let text = try fixture.indexText(Ident.lane1)
#expect(!text.contains("deleted:"))
#expect(text.contains("width: 2"))
#expect(text.contains("Lane notes."))
#expect(try FrontmatterDocument.parse(text).order == .valid(2048))
// Its cards are none of the lane migration's business.
#expect(try fixture.indexText("\(Ident.lane1)/\(Ident.card1)") == cardBefore)
let result = try BoardLoader.load(boardRoot: fixture.root)
#expect(result.legacyTombstones.isEmpty)
#expect(result.model.lanes.map(\.isDeleted) == [false])
#expect(result.model.lanes.map(\.title.value) == ["Old lane"])
}
}
@Test("The lane migration refuses a card, whose migration is a move")
func laneMigrationRefusesACard() throws {
// MARK: - Writer: a lane delete is the same move
@Suite("BoardWriter ▸ deleteLaneToTrash")
struct DeleteLaneToTrashTests {
/// "Deleting a lane moves its folder subtree intact into `.trash/`, exactly as a card moves"
/// (03 § Trash, re-ruled 2026-07-29). Byte fidelity of the freight is the assertion: nothing
/// under the lane is read or rewritten, so a restore is an ordinary move back.
@Test("The lane folder moves whole, its subtree byte-identical")
func laneMovesWithItsSubtree() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let png = Data([0x89, 0x50, 0x4E, 0x47, 0x00, 0xFF])
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Lane"))
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Card"))
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Doing"))
let cardBefore = Item.rich(order: "1024", title: "Kept")
try fixture.item("\(Ident.lane1)/\(Ident.card1)", cardBefore)
try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.uneditable)
try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/shot.png", png)
try fixture.file("\(Ident.lane1)/notes/scratch.md", Data("scratch".utf8))
let error = writeFailure {
try BoardWriter.migrateTombstonedLane(at: fixture.url("\(Ident.lane1)/\(Ident.card1)"))
}
#expect(error?.operation == .migrateTombstone(title: nil))
}
}
// MARK: - Writer: lane delete is physical
@Suite("BoardWriter ▸ removeLane")
struct RemoveLaneTests {
@Test("The lane folder and everything under it go")
func laneIsRemovedWhole() 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)", Item.rich(order: "1024", title: "Card"))
try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/shot.png", Data([1]))
try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Other"))
try BoardWriter.removeLane(at: fixture.url(Ident.lane1))
let id = try BoardWriter.deleteLaneToTrash(
at: fixture.url(Ident.lane1),
inBoard: fixture.root,
order: -1024
)
#expect(id == ItemID(rawValue: Ident.lane1), "a delete moves a folder, it does not rename one")
#expect(!fixture.exists(Ident.lane1))
#expect(fixture.exists(Ident.lane2))
#expect(try fixture.indexText(".trash/\(Ident.lane1)/\(Ident.card1)") == cardBefore)
#expect(try fixture.indexText(".trash/\(Ident.lane1)/\(Ident.card2)") == Item.uneditable,
"even a card whose own index.md refuses writes — nothing below the root is touched")
#expect(try fixture.data(".trash/\(Ident.lane1)/\(Ident.card1)/attachments/shot.png") == png)
#expect(try fixture.data(".trash/\(Ident.lane1)/notes/scratch.md") == Data("scratch".utf8),
"strays ride along like everything else")
}
@Test("A lane that is already gone is success")
func absentLaneIsSuccess() throws {
/// The container-changing move stamps both provenance keys (01 § Frontmatter `modified`'s
/// scope, refined 2026-07-30) the same rule a card's trash move obeys.
@Test("The rank is rewritten, modified stamped and modified-by cleared")
func theRankRewriteStamps() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
try BoardWriter.removeLane(at: fixture.url(Ident.lane1))
try fixture.item(Ident.lane1, """
---
schema: 1
title: Doing
order: 2048
modified: 2020-01-01T00:00:00Z
modified-by: claude
width: 3
---
Lane notes.
""")
try BoardWriter.deleteLaneToTrash(at: fixture.url(Ident.lane1), inBoard: fixture.root, order: -1024)
let document = try FrontmatterDocument.parse(fixture.indexText(".trash/\(Ident.lane1)"))
#expect(document.order == .valid(-1024))
#expect(document.modifiedBy.isMissing)
#expect(document.modified.value.map { $0 > Date(timeIntervalSince1970: 1_600_000_000) } == true)
let text = try fixture.indexText(".trash/\(Ident.lane1)")
#expect(text.contains("width: 3"), "everything else round-trips")
#expect(text.contains("Lane notes."))
}
/// The guard has to tell a lane from a card *and* from a trash card, whose parent is `.trash/`
/// rather than a UUID otherwise a permanent delete would be reachable through this door.
@Test("A card, a trash card, a board root and a stray are all refused")
func onlyLanesAreRemoved() throws {
/// "`kind: lane` backfilled on touch when absent the trash move's rank mint included"
/// (01 § Deletion). The **empty** lane is the case that needs it: in a flat container it is
/// shape-identical to a card, so a derived kind would answer wrongly and the row would come back
/// as a card.
@Test("The move stamps kind: lane, even on an empty lane where shape would say card")
func kindIsStampedNotGuessed() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, "---\nschema: 1\ntitle: Empty\norder: 1024\n---\n")
try BoardWriter.deleteLaneToTrash(at: fixture.url(Ident.lane1), inBoard: fixture.root, order: -1024)
#expect(try fixture.indexText(".trash/\(Ident.lane1)").contains("kind: lane"))
let result = try BoardLoader.load(boardRoot: fixture.root)
#expect(result.trashKinds[ItemID(rawValue: Ident.lane1)] == .lane)
#expect(result.model.trashedLanes.map(\.id.rawValue) == [Ident.lane1])
#expect(result.model.trash.isEmpty)
}
/// A present `kind` is never rewritten and never corroborated the value names the kind
/// (01 § Frontmatter, the `kind` row).
@Test("An existing kind is left exactly as it was")
func existingKindIsLeftAlone() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, "---\nschema: 1\norder: 1024\nkind: lane\n---\n")
try BoardWriter.deleteLaneToTrash(at: fixture.url(Ident.lane1), inBoard: fixture.root, order: -1024)
let text = try fixture.indexText(".trash/\(Ident.lane1)")
#expect(text.components(separatedBy: "kind: lane").count == 2, "written once, not twice")
}
/// The guard is the mirror of `deleteCardToTrash`'s: a lane is `<root>/<lane>`, so a card, a
/// board root, a stray, and an entry already in `.trash/` are all refused.
@Test("A card, a trash entry, a board root and a stray are all refused")
func onlyLanesTakeThisDoor() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
@@ -650,107 +715,27 @@ struct RemoveLaneTests {
try fixture.item("notes", Item.rich(order: "1024", title: "Stray"))
for target in ["\(Ident.lane1)/\(Ident.card1)", ".trash/\(Ident.card2)", "", "notes"] {
#expect(writeFailure { try BoardWriter.removeLane(at: fixture.url(target)) } != nil)
#expect(
writeFailure {
try BoardWriter.deleteLaneToTrash(
at: fixture.url(target),
inBoard: fixture.root,
order: -1024
)
} != nil
)
}
#expect(fixture.exists("\(Ident.lane1)/\(Ident.card1)"))
#expect(fixture.exists(".trash/\(Ident.card2)"))
}
}
// MARK: - Writer: capture and replay
@Suite("BoardWriter ▸ subtree capture and replay")
struct SubtreeCaptureTests {
/// A lane delete's undo in one round trip: capture, remove, recreate, capture again. Equality
/// of the two captures is the byte-fidelity assertion names, bytes, nesting and order.
@Test("Capture → remove → recreate → capture is identical, nested cards and attachments included")
func roundTripIsByteIdentical() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let png = Data([0x89, 0x50, 0x4E, 0x47, 0x00, 0xFF, 0x0D, 0x0A])
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Lane"))
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "One"))
try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.uneditable)
try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/shot.png", png)
try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/sub/deep.bin", Data([7, 8, 9]))
// The verbatim promise covers what the loader would never render, too.
try fixture.file("\(Ident.lane1)/\(Ident.card1)/.DS_Store", Data([0, 1]))
try fixture.file("\(Ident.lane1)/notes/scratch.md", Data("scratch".utf8))
try FileManager.default.createDirectory(
at: fixture.url("\(Ident.lane1)/\(Ident.indexless)"),
withIntermediateDirectories: true
)
let lane = fixture.url(Ident.lane1)
let captured = try BoardWriter.captureSubtree(at: lane, operation: .delete(title: "Lane"))
try BoardWriter.removeLane(at: lane)
#expect(!fixture.exists(Ident.lane1))
try BoardWriter.recreateSubtree(at: lane, from: captured, operation: .delete(title: "Lane"))
let recaptured = try BoardWriter.captureSubtree(at: lane, operation: .delete(title: "Lane"))
#expect(recaptured == captured)
// and spot-checked against disk, so the equality is not two identical bugs.
#expect(try fixture.indexText("\(Ident.lane1)/\(Ident.card2)") == Item.uneditable)
#expect(try fixture.data("\(Ident.lane1)/\(Ident.card1)/attachments/shot.png") == png)
#expect(try fixture.data("\(Ident.lane1)/\(Ident.card1)/.DS_Store") == Data([0, 1]))
#expect(try fixture.data("\(Ident.lane1)/notes/scratch.md") == Data("scratch".utf8))
#expect(try fixture.entryNames(Ident.lane1).contains(Ident.indexless))
}
@Test("A symlink is captured as a link and recreated as one, never followed")
func symlinksAreNotFollowed() 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 FileManager.default.createSymbolicLink(
atPath: fixture.url(Ident.lane1).appendingPathComponent("link").path,
withDestinationPath: "../nowhere"
)
let lane = fixture.url(Ident.lane1)
let captured = try BoardWriter.captureSubtree(at: lane, operation: .delete(title: nil))
#expect(captured.entries.contains(.symlink(name: "link", destination: "../nowhere")))
try BoardWriter.removeLane(at: lane)
try BoardWriter.recreateSubtree(at: lane, from: captured, operation: .delete(title: nil))
let destination = try FileManager.default.destinationOfSymbolicLink(
atPath: lane.appendingPathComponent("link").path
)
#expect(destination == "../nowhere")
}
@Test("Recreating over something that exists refuses rather than merging")
func recreateRefusesToClobber() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Lane"))
let lane = fixture.url(Ident.lane1)
let captured = try BoardWriter.captureSubtree(at: lane, operation: .delete(title: nil))
let error = writeFailure {
try BoardWriter.recreateSubtree(at: lane, from: captured, operation: .delete(title: nil))
}
#expect(error?.reason == .io(message: "something already exists here"))
// The refusal left the folder that was there exactly as it was.
#expect(try fixture.indexText(Ident.lane1).contains("title: Lane"))
}
}
// MARK: - Writer: permanent removal
@Suite("BoardWriter ▸ purging the trash")
struct TrashContainerPurgeTests {
@Test("A trash card purges; a live card is unreachable through this door")
@Test("A trash entry purges; a live card is unreachable through this door")
func purgeIsScopedToTheTrash() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
@@ -760,11 +745,11 @@ struct TrashContainerPurgeTests {
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Live"))
try fixture.item(".trash/\(Ident.card2)", Item.rich(order: "1024", title: "Trashed"))
try BoardWriter.purgeTrashCard(at: fixture.url(".trash/\(Ident.card2)"), inBoard: fixture.root)
try BoardWriter.purgeTrashEntry(at: fixture.url(".trash/\(Ident.card2)"), inBoard: fixture.root)
#expect(!fixture.exists(".trash/\(Ident.card2)"))
let error = writeFailure {
try BoardWriter.purgeTrashCard(
try BoardWriter.purgeTrashEntry(
at: fixture.url("\(Ident.lane1)/\(Ident.card1)"),
inBoard: fixture.root
)
@@ -773,7 +758,7 @@ struct TrashContainerPurgeTests {
#expect(fixture.exists("\(Ident.lane1)/\(Ident.card1)"))
// Already gone is success, as everywhere else on the purge path.
try BoardWriter.purgeTrashCard(at: fixture.url(".trash/\(Ident.card2)"), inBoard: fixture.root)
try BoardWriter.purgeTrashEntry(at: fixture.url(".trash/\(Ident.card2)"), inBoard: fixture.root)
}
@Test("Empty Trash takes every card and leaves a hand-editor's strays standing")
@@ -801,6 +786,44 @@ struct TrashContainerPurgeTests {
try fixture.item("", Item.board)
#expect(try BoardWriter.emptyTrash(inBoard: fixture.root).isEmpty)
}
/// "Permanent deletion walks lane subtrees" (03 § Trash): the trashed lane's freight goes with
/// it, through the same recursive removal a card takes.
@Test("Purging a trashed lane takes its whole subtree")
func purgingALaneTakesItsFreight() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
try fixture.item(".trash/\(Ident.lane1)", "---\nschema: 1\norder: 1024\nkind: lane\n---\n")
try fixture.item(".trash/\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Freight"))
try fixture.file(".trash/\(Ident.lane1)/\(Ident.card1)/attachments/shot.png", Data([1]))
try fixture.item(".trash/\(Ident.card2)", Item.rich(order: "2048", title: "Beside it"))
try BoardWriter.purgeTrashEntry(at: fixture.url(".trash/\(Ident.lane1)"), inBoard: fixture.root)
#expect(!fixture.exists(".trash/\(Ident.lane1)"))
#expect(fixture.exists(".trash/\(Ident.card2)"), "its neighbour is untouched")
}
@Test("Empty Trash walks lane subtrees too")
func emptyTrashWalksLanes() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
try fixture.item(".trash/\(Ident.lane1)", "---\nschema: 1\norder: 1024\nkind: lane\n---\n")
try fixture.item(".trash/\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Freight"))
try fixture.item(".trash/\(Ident.card2)", Item.rich(order: "2048", title: "Card"))
try fixture.file(".trash/readme.txt", Data("mine".utf8))
let purged = try BoardWriter.emptyTrash(inBoard: fixture.root)
#expect(Set(purged) == [ItemID(rawValue: Ident.lane1), ItemID(rawValue: Ident.card2)])
#expect(!fixture.exists(".trash/\(Ident.lane1)"))
#expect(!fixture.exists(".trash/\(Ident.card2)"))
#expect(try fixture.data(".trash/readme.txt") == Data("mine".utf8), "strays still stand")
}
}
// MARK: - Board-wide uniqueness spans the trash
@@ -887,6 +910,9 @@ struct TrashKindDiscriminatorTests {
let result = try BoardLoader.load(boardRoot: fixture.root)
#expect(result.trashKinds[ItemID(rawValue: entry)] == .lane)
#expect(result.model.trashedLanes.map(\.id.rawValue) == [entry])
#expect(result.model.trashedLanes.first?.heldCards == 0, "an honored lane can be empty")
#expect(result.model.trash.isEmpty, "and it is not a card anywhere")
}
/// No key, or a value outside the schema's three, falls through to shape UUID-shaped children
@@ -938,7 +964,104 @@ struct TrashKindDiscriminatorTests {
) { $0.set(FrontmatterKeys.order, to: .double(4096)) }
let after = try BoardLoader.load(boardRoot: fixture.root)
#expect(after.model.trash.first?.document.kind == .valid("lane"))
#expect(after.model.trashedLanes.first?.document.kind == .valid("lane"))
#expect(after.trashKinds[ItemID(rawValue: entry)] == .lane)
}
/// **The opaque unit** (03 § Trash): a trashed lane is one row with a title, a rank and a count.
/// Its cards are not in the snapshot at all not as trash cards, not as anybody's lane's cards
/// and the count is what the loader would have rendered as cards, so the row's number and a
/// restore's outcome agree.
@Test("A trashed lane surfaces as one opaque row: title, rank, held-card count")
func trashedLaneIsOpaque() throws {
let fixture = try TrashFixture()
defer { fixture.tearDown() }
let lane = uuidName()
let held = [uuidName(), uuidName(), uuidName()]
try fixture.index("", "schema: 1\n")
try fixture.index(".trash/\(lane)", "schema: 1\norder: 1024\ntitle: Doing\nkind: lane\n")
for (index, card) in held.enumerated() {
try fixture.index(".trash/\(lane)/\(card)", "schema: 1\norder: \((index + 1) * 1024)\n")
}
// Not a card, so not counted: a stray folder and an identity-shaped one with no index.md.
try fixture.index(".trash/\(lane)/attachments", "schema: 1\norder: 1024\n")
try fixture.folder(".trash/\(lane)/\(uuidName())")
let result = try BoardLoader.load(boardRoot: fixture.root)
#expect(result.model.trashedLanes.count == 1)
let row = try #require(result.model.trashedLanes.first)
#expect(row.id == ItemID(rawValue: lane))
#expect(row.title.value == "Doing")
#expect(row.order == 1024)
#expect(row.heldCards == 3)
#expect(result.model.trash.isEmpty, "the freight is not surfaced as trash cards")
#expect(result.model.lanes.isEmpty)
#expect(result.warnings.isEmpty, "nothing inside an opaque entry is walked, so nothing strays")
}
/// The ruling's own **honest limit**, pinned so it stays a decision rather than a surprise: "a
/// kind-less trashed lane emptied of its children before any touch becomes indistinguishable
/// from a card" (01 § Deletion). The app never produces one `deleteLaneToTrash` stamps the
/// key so this is only reachable by a hand-editor.
@Test("A kindless empty lane folder reads as a card — the on-touch-only limit")
func kindlessEmptyLaneReadsAsACard() throws {
let fixture = try TrashFixture()
defer { fixture.tearDown() }
let entry = uuidName()
try fixture.index("", "schema: 1\n")
try fixture.index(".trash/\(entry)", "schema: 1\norder: 1024\ntitle: Was a lane\n")
let result = try BoardLoader.load(boardRoot: fixture.root)
#expect(result.trashKinds[ItemID(rawValue: entry)] == .card)
#expect(result.model.trash.map(\.id.rawValue) == [entry])
#expect(result.model.trashedLanes.isEmpty)
}
/// Both kinds are validated by the one rulebook: `schema` and `order` are required of a lane
/// exactly as of a card (`IntegrityRules.requiresOrder`), so a malformed entry fails fast
/// whichever kind the discriminator would have called it.
@Test("A trashed lane missing order fails the load, like any entry")
func trashedLaneFailsFastOnOrder() throws {
let fixture = try TrashFixture()
defer { fixture.tearDown() }
let lane = uuidName()
try fixture.index("", "schema: 1\n")
try fixture.index(".trash/\(lane)", "schema: 1\nkind: lane\n")
try fixture.index(".trash/\(lane)/\(uuidName())", "schema: 1\norder: 1024\n")
#expect(throws: BoardLoadError.self) {
try BoardLoader.load(boardRoot: fixture.root)
}
}
/// The column is one list interleaved by rank (03 § Trash), which the snapshot expresses as two
/// arrays carrying the ranks that interleave them so a consumer merging by `order` gets the
/// column, and neither array is "after" the other.
@Test("Both kinds carry the ranks that interleave them")
func kindsInterleaveByRank() throws {
let fixture = try TrashFixture()
defer { fixture.tearDown() }
let newestLane = uuidName()
let middleCard = uuidName()
let oldestLane = uuidName()
try fixture.index("", "schema: 1\n")
try fixture.index(".trash/\(oldestLane)", "schema: 1\norder: 3072\nkind: lane\n")
try fixture.index(".trash/\(middleCard)", "schema: 1\norder: 2048\n")
try fixture.index(".trash/\(newestLane)", "schema: 1\norder: 1024\nkind: lane\n")
let result = try BoardLoader.load(boardRoot: fixture.root)
#expect(result.model.trashedLanes.map(\.id.rawValue) == [newestLane, oldestLane])
#expect(result.model.trash.map(\.id.rawValue) == [middleCard])
let column = (result.model.trash.map { (order: $0.order, id: $0.id) }
+ result.model.trashedLanes.map { (order: $0.order, id: $0.id) })
.sorted { $0.order < $1.order }
.map(\.id.rawValue)
#expect(column == [newestLane, middleCard, oldestLane])
}
}
+78 -44
View File
@@ -267,28 +267,73 @@ struct DeleteCardTests {
}
}
// MARK: - Delete: a lane is physical
// MARK: - Delete: a lane takes the same move
@MainActor
@Suite("BoardStore ▸ delete a lane")
struct DeleteLaneTests {
@Test("Deleting a lane removes the folder and its contents — nothing is trashed")
func laneDeleteIsPhysical() throws {
/// 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 freight is inside the folder that
/// moved, so it is not filed separately and not surfaced.
@Test("Deleting a lane moves the folder into .trash/, subtree intact and opaque")
func laneDeleteIsAMoveIntoTheTrash() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.delete([lane3])
// 03-board-ui.md § Trash: "Cards only. Lanes are never trashed deleting a lane deletes it,
// folder and contents, physically."
#expect(!fixture.exists(Ident.lane3))
#expect(!fixture.exists(".trash/\(Ident.card4)"), "its cards go with it, not into the trash")
#expect(try loaded(fixture).trash.map(\.id) == [newer, trashed], "the trash is untouched")
#expect(fixture.exists(".trash/\(Ident.lane3)"))
#expect(fixture.exists(".trash/\(Ident.lane3)/\(Ident.card4)"), "its cards ride along inside it")
#expect(!fixture.exists(".trash/\(Ident.card4)"), "and are never filed as trash cards")
let model = try loaded(fixture)
#expect(model.trash.map(\.id) == [newer, trashed], "the trash's cards are unchanged")
#expect(model.trashedLanes.map(\.id.rawValue) == [Ident.lane3])
#expect(model.trashedLanes.first?.heldCards == 1)
#expect(store.banners.oneShots.isEmpty)
}
/// "Every arrival lands at the trash's topmost position regardless of kind" (03 § Trash): the
/// ladder the rank is minted against is the whole container, cards and lane rows alike.
@Test("The lane lands at the top of the trash, above every existing entry")
func laneLandsOnTop() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let topBefore = try #require(loaded(fixture).trash.map(\.order).min())
store.delete([lane3])
let landed = try #require(loaded(fixture).trashedLanes.first?.order)
#expect(landed < topBefore)
// And the next card delete mints above *that* the lane row is in the ladder the store
// mints against, which is the whole container rather than one array of it. The reload is
// what puts the new row in the snapshot; without it the store is still holding the
// pre-delete picture, as it is for two of any deletes in a row.
await reload(store)
store.delete([card1])
let card = try #require(loaded(fixture).trash.first { $0.id == card1 }?.order)
#expect(card < landed)
}
/// No dialog: "the move is recoverable, so nothing needs confirming" (03 § Trash).
@Test("A lane delete raises no confirmation and needs none")
func laneDeleteIsNotConfirmed() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let confirmations = TrashConfirmations()
store.select([lane3], in: .board)
confirmations.requestDelete(in: store)
#expect(confirmations.pending == nil)
#expect(fixture.exists(".trash/\(Ident.lane3)"), "it went straight through")
}
@Test("The selection moves to the successor lane")
func laneSuccessor() throws {
let fixture = try makeBoard()
@@ -344,7 +389,7 @@ struct StagedDeleteTests {
// The column's order is [newer, trashed].
store.select([newer], in: .trash)
store.deleteTrashCards([newer])
store.deleteTrashEntries([newer])
#expect(store.selection.ids == [trashed])
#expect(store.selection.container == .trash)
@@ -357,7 +402,7 @@ struct StagedDeleteTests {
let store = try BoardStore(rootURL: fixture.root)
store.select([newer, trashed], in: .trash)
store.deleteTrashCards([newer, trashed])
store.deleteTrashEntries([newer, trashed])
#expect(try loaded(fixture).trash.isEmpty)
#expect(store.selection.isEmpty)
@@ -369,7 +414,7 @@ struct StagedDeleteTests {
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.deleteTrashCards([card1])
store.deleteTrashEntries([card1])
#expect(fixture.exists("\(Ident.lane1)/\(Ident.card1)"))
#expect(store.banners.oneShots.isEmpty)
@@ -432,8 +477,8 @@ struct PurgeTests {
#expect(store.purgeIsUnrecoverable)
store.select([trashed], in: .trash)
store.deleteTrashCards([trashed])
store.deleteTrashCards([newer])
store.deleteTrashEntries([trashed])
store.deleteTrashEntries([newer])
store.emptyTrash()
// 13-native-undo.md Rules: "Permanently delete (the trash's Delete, Empty Trash)
@@ -472,10 +517,11 @@ struct StoreTombstoneMigrationTests {
return fixture
}
@Test("Cards relocate into .trash/ with the key removed; lanes return live in place")
func migrationMovesCardsAndResurrectsLanes() throws {
@Test("Cards relocate into .trash/ with the key removed; a lane's key is never written to")
func migrationMovesCardsAndLeavesLanesAlone() throws {
let fixture = try makeLegacyBoard()
defer { fixture.tearDown() }
let laneBefore = try fixture.indexText(Ident.lane2)
let store = try BoardStore(rootURL: fixture.root)
store.migrateLegacyTombstones()
@@ -488,12 +534,12 @@ struct StoreTombstoneMigrationTests {
#expect(!(try fixture.indexText(".trash/\(Ident.card2)").contains("deleted:")))
#expect(!(try fixture.indexText(".trash/\(Ident.card3)").contains("deleted:")))
// The lane stayed exactly where it was, key stripped "resurrection is the safe direction,
// nothing is destroyed by migration".
#expect(fixture.exists(Ident.lane2))
#expect(!(try fixture.indexText(Ident.lane2).contains("deleted:")))
#expect(fixture.exists("\(Ident.lane2)/\(Ident.card4)"), "its cards come back with it")
#expect(try document(fixture, Ident.lane2).order.value == 2048, "at its own position")
// "A lane carrying `deleted:` simply loads live with the key ignored no migration
// machinery, no key-strip write" (01 § Deletion, re-ruled 2026-07-29): its bytes are
// untouched, `modified` included, and it renders as an ordinary lane.
#expect(try fixture.indexText(Ident.lane2) == laneBefore, "not one byte written")
#expect(fixture.exists("\(Ident.lane2)/\(Ident.card4)"))
#expect(try loaded(fixture).lanes.map(\.id.rawValue).contains(Ident.lane2))
}
@Test("Cards migrate oldest-first, so the newest deletion ends up on top")
@@ -554,7 +600,7 @@ struct StoreTombstoneMigrationTests {
#expect(store.banners.losses.isEmpty, "and nothing to announce")
}
@Test("The notice is one folded warning-tone row naming both halves")
@Test("The notice is one warning-tone row, and names only what was moved")
func theNotice() throws {
let fixture = try makeLegacyBoard()
defer { fixture.tearDown() }
@@ -563,8 +609,10 @@ struct StoreTombstoneMigrationTests {
store.migrateLegacyTombstones()
#expect(store.banners.losses.count == 1, "one migration, one row")
// No lane clause: nothing happened to the lane, so a row claiming otherwise would announce
// an act the app did not perform.
#expect(store.banners.losses.first?.message
== "Moved 2 cards to the trash and restored 'Retired' — they carried old deleted markers")
== "Moved 2 cards to the trash — they carried old deleted markers")
}
@Test("A board with nothing legacy migrates nothing and says nothing")
@@ -628,7 +676,7 @@ struct StoreTombstoneMigrationTests {
// read, so the channel empties and stays empty.
#expect(store.legacyTombstones.isEmpty)
#expect(store.snapshot.trash.count == 2)
#expect(store.snapshot.lanes.count == 2, "the resurrected lane is an ordinary lane again")
#expect(store.snapshot.lanes.count == 2, "the key-carrying lane was always an ordinary lane")
}
@Test("It is armed by the reload seam, exactly like the loose-file relocation")
@@ -659,31 +707,17 @@ struct MigrationNoticeTests {
@Test("One card names it; several fold to a count")
func cardsFold() {
#expect(BannerCenter.migratedTombstonesMessage(cards: ["Fix login"], lanes: [])
#expect(BannerCenter.migratedTombstonesMessage(cards: ["Fix login"])
== "Moved 'Fix login' to the trash — it carried an old deleted marker")
#expect(BannerCenter.migratedTombstonesMessage(cards: ["A", "B", "C"], lanes: [])
#expect(BannerCenter.migratedTombstonesMessage(cards: ["A", "B", "C"])
== "Moved 3 cards to the trash — they carried old deleted markers")
}
@Test("A lane reads as a restoration, which is what it is")
func lanesRead() {
#expect(BannerCenter.migratedTombstonesMessage(cards: [], lanes: ["Doing"])
== "Restored 'Doing' — it carried an old deleted marker")
#expect(BannerCenter.migratedTombstonesMessage(cards: [], lanes: ["A", "B"])
== "Restored 2 lanes — they carried old deleted markers")
}
@Test("Both halves fold into one sentence — one migration, one row")
func bothFold() {
#expect(BannerCenter.migratedTombstonesMessage(cards: ["A", "B", "C"], lanes: ["D", "E"])
== "Moved 3 cards to the trash and restored 2 lanes — they carried old deleted markers")
}
@Test("An untitled item reads as a rendering, and nothing migrated is not news")
func edges() {
#expect(BannerCenter.migratedTombstonesMessage(cards: [nil], lanes: [])
#expect(BannerCenter.migratedTombstonesMessage(cards: [nil])
== "Moved an untitled item to the trash — it carried an old deleted marker")
#expect(BannerCenter.migratedTombstonesMessage(cards: [], lanes: []) == nil)
#expect(BannerCenter.migratedTombstonesMessage(cards: []) == nil)
}
}
@@ -712,7 +746,7 @@ struct TrashConfirmationsTests {
store.select([trashed], in: .trash)
confirmations.requestDelete(in: store)
let pending = try #require(confirmations.pending)
#expect(pending.action == .deleteTrashCards([trashed]))
#expect(pending.action == .deleteTrashEntries([trashed]))
#expect(fixture.exists(".trash/\(Ident.indexless)"), "nothing has happened yet")
confirmations.confirm(in: store)
@@ -844,7 +878,7 @@ struct TrashMenuValidationTests {
confirmations.requestDelete(in: store)
// The trash staging: permanent, and behind the alert.
let pending = try #require(confirmations.pending)
#expect(pending.action == .deleteTrashCards([trashed]))
#expect(pending.action == .deleteTrashEntries([trashed]))
}
/// A context menu names its target by where it was invoked, so the trash row's Delete must purge
@@ -862,7 +896,7 @@ struct TrashMenuValidationTests {
let pending = try #require(confirmations.pending)
#expect(pending.prompt.title == "Permanently delete \u{201C}Trashed\u{201D}?")
#expect(pending.action == .deleteTrashCards([trashed]))
#expect(pending.action == .deleteTrashEntries([trashed]))
confirmations.confirm(in: store)
#expect(!fixture.exists(".trash/\(Ident.indexless)"))
+55 -12
View File
@@ -664,29 +664,63 @@ struct TrashUndoTests {
#expect(history.canUndo == false)
}
@Test("A lane delete is physical, and its undo recreates the folder byte for byte")
func laneDeleteRecreatesTheSubtree() throws {
/// 13 Interaction with the trash: "a lane [returns] to its strip position (subtree intact it
/// never left the folder)". The inverse is the ordinary move back, so the capture/recreate
/// machinery is retired: the lane's bytes never left the disk to need replaying.
@Test("A lane delete is a move into the trash, and its undo is the move back to its strip rank")
func laneDeleteRoundTrip() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (store, history) = try makeStore(fixture)
let laneText = try fixture.indexText(Ident.lane2)
let laneBefore = try fixture.indexText(Ident.lane2)
let cardText = try fixture.indexText("\(Ident.lane2)/\(Ident.indexless)")
let laneRank = try #require(try document(fixture, Ident.lane2).order.value)
store.delete([lane2])
#expect(!fixture.exists(Ident.lane2), "03 ▸ Trash: deleting a lane deletes it, physically")
#expect(try loadedTrash(fixture).map(\.id) == [trashed], "lanes are never trashed")
#expect(!fixture.exists(Ident.lane2))
#expect(fixture.exists(".trash/\(Ident.lane2)"))
#expect(try loadedTrash(fixture).map(\.id) == [trashed], "its cards are not trash cards")
#expect(try loadedTrashedLanes(fixture).map(\.id) == [lane2])
#expect(history.undoActionName == "Delete Lane")
let trashRank = try #require(try document(fixture, ".trash/\(Ident.lane2)").order.value)
let resident = try #require(try document(fixture, trashedPath).order.value)
#expect(trashRank < resident, "entry is at the top — a rank above the current topmost")
history.undo()
#expect(fixture.exists(Ident.lane2))
#expect(try fixture.indexText(Ident.lane2) == laneText, "the capture replays bytes, it does not edit")
#expect(!fixture.exists(".trash/\(Ident.lane2)"))
#expect(try document(fixture, Ident.lane2).order.value == laneRank, "at its own strip position")
#expect(untouchedLines(try fixture.indexText(Ident.lane2)) == untouchedLines(laneBefore),
"byte-identical but for the stamps every container-changing move owns")
#expect(try fixture.indexText("\(Ident.lane2)/\(Ident.indexless)") == cardText,
"and the whole subtree comes back with it — nested cards included")
"the subtree never moved relative to its lane, so not one nested byte changed")
history.redo()
#expect(!fixture.exists(Ident.lane2))
#expect(fixture.exists(".trash/\(Ident.lane2)"))
#expect(try document(fixture, ".trash/\(Ident.lane2)").order.value == trashRank,
"the redo replays the write's own captured rank")
}
@Test("A multi-lane delete is one step with a plural title")
func batchLaneDeleteIsOneStep() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (store, history) = try makeStore(fixture)
store.delete([lane1, lane2])
#expect(history.undoActionName == "Delete 2 Lanes")
let first = try #require(try document(fixture, ".trash/\(Ident.lane1)").order.value)
let second = try #require(try document(fixture, ".trash/\(Ident.lane2)").order.value)
#expect(second < first, "each arrival in the run mints a rank above the one before it")
history.undo()
#expect(fixture.exists(Ident.lane1))
#expect(fixture.exists(Ident.lane2))
#expect(try loadedTrashedLanes(fixture).isEmpty)
#expect(history.canUndo == false)
}
@Test("A restore-by-move-out registers as an ordinary Move, with the ordinary move inverse")
@@ -721,6 +755,11 @@ private func loadedTrash(_ fixture: WriterFixture) throws -> [Card] {
try BoardLoader.load(boardRoot: fixture.root).model.trash
}
@MainActor
private func loadedTrashedLanes(_ fixture: WriterFixture) throws -> [TrashedLane] {
try BoardLoader.load(boardRoot: fixture.root).model.trashedLanes
}
// MARK: - The Edit session
@MainActor
@@ -820,7 +859,7 @@ struct NotUndoableTests {
let armed = try #require(history.undoActionName)
store.select([trashed], in: .trash)
store.deleteTrashCards([trashed])
store.deleteTrashEntries([trashed])
#expect(fixture.exists(trashedPath) == false)
#expect(store.purgeIsUnrecoverable)
@@ -963,7 +1002,9 @@ private enum Foreign {
try fixture.move(path, toTrash: id)
}
/// A foreign delete of a **lane** physical, since lanes are never trashed.
/// A **Finder deletion** of a lane: the folder disappears entirely, which "is also a delete"
/// (01 § Deletion) such items never enter the trash, so this is the shape a foreign writer's
/// destructive removal has, distinct from the app's own delete (a move into `.trash/`).
static func removeLane(_ fixture: WriterFixture, _ path: String) throws {
try FileManager.default.removeItem(at: fixture.url(path))
}
@@ -1093,8 +1134,10 @@ struct StaleStepTests {
#expect(store.banners.signposts.count == 1)
}
/// The card's own path is the check: a lane delete is physical, so a card under it is simply not
/// there any more no ancestor walk, which is what materializing the trash bought.
/// The card's own path is the check: its lane's folder is gone, so the card is simply not there
/// any more no ancestor walk, which is what materializing the trash bought. (The same holds
/// when the app trashes the lane: the card's path moves under `.trash/` and nothing is left at
/// the one the step named.)
@Test("A card whose lane a foreign writer deleted is stale too")
func aCardUnderARemovedLaneIsStale() throws {
let fixture = try makeBoard()
+43
View File
@@ -179,6 +179,29 @@ struct WriteFidelityMinimalTouchTests {
}
#expect(BoardLoader.isUUIDShaped(createdCard))
// A **lane** delete is the same move, one level up (03 § Trash, re-ruled 2026-07-29): the
// lane's folder leaves the visible tree carrying its whole subtree, and the cards inside it
// are neither read nor rewritten their mtimes prove it, since they are still tracked here
// under their new path only after the restore puts the lane back.
try step(
"delete lane",
targeting: [],
departed: [Ident.lane2, "\(Ident.lane2)/\(Ident.card3)", "\(Ident.lane2)/\(Ident.card4)"]
) {
try BoardWriter.deleteLaneToTrash(
at: fixture.url(Ident.lane2), inBoard: fixture.root, order: 512
)
}
try step("restore lane", targeting: []) {
_ = try BoardWriter.moveItem(
at: BoardWriter.trashFolder(inBoard: fixture.root).appendingPathComponent(Ident.lane2),
toParent: fixture.root,
sourceBoardRoot: fixture.root,
destinationBoardRoot: fixture.root,
order: 2048
)
}
try step("import attachment", targeting: []) {
// Dot-prefixed so it never becomes a board-root stray the loader has to warn about
// the source lives outside the board tree in spirit, just not in path.
@@ -547,6 +570,26 @@ struct WriteFidelityStampingTests {
#expect(restored.modifiedBy == nil, "out of the trash is a container change too")
}
/// A lane's trash move is the same container change, and its **subtree is not** the nested
/// card is neither read nor rewritten, so its stamps are exactly what they were.
@Test("A lane's trash move stamps the lane and nothing beneath it")
func aLaneTrashMoveStampsOnlyTheLane() throws {
let fixture = try twoLaneBoard()
defer { fixture.tearDown() }
try BoardWriter.deleteLaneToTrash(
at: fixture.url(Ident.lane1), inBoard: fixture.root, order: 1024
)
let lane = try stamps(fixture, ".trash/\(Ident.lane1)")
#expect(lane.modified != Self.priorModified, "into the trash is a container change")
#expect(lane.modifiedBy == nil)
let card = try stamps(fixture, ".trash/\(Ident.lane1)/\(Ident.card1)")
#expect(card.modified == Self.priorModified, "the freight's own container did not change")
#expect(card.modifiedBy == "claude", "so even a foreign stamp survives, untouched")
}
/// A cross-board arrival changes the container as surely as a cross-lane move does, and the
/// import boundary's remint does not change that: the arrived file is stamped either way.
@Test("A cross-board arrival stamps")