Unify the trashed universe on the trash rows

02 rules the trashed side has exactly one definition: the set with
trash rows — a card carrying its own deleted: under a tombstoned lane
is in neither universe, so an anchor or selection can never survive on
an item that renders nowhere. The membership rule now lives once, in
Liveness.walk, and ItemReferenceSet.idUniverse, TrashModel.entries,
paths, and emptyTrashTargets all derive from it — the old
lane-OR-card logic that admitted subsumed cards to the trashed side is
gone, and the two universes deliberately no longer partition the
board. Put Back, Delete Immediately, and Empty Trash outcomes are
unchanged: a tombstoned lane still moves and purges whole, its nested
tombstones with it.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-28 07:23:18 -04:00
parent 88364b20c0
commit 7be4eec9fd
5 changed files with 214 additions and 66 deletions
+4 -4
View File
@@ -1936,10 +1936,10 @@ public final class BoardStore {
/// carrying their own `deleted:` stay tombstoned and their rows reappear in the trash, which
/// is exactly the two-step recovery the design settled on.
///
/// A card whose lane is itself tombstoned cannot be reached this way through the UI (it has no
/// row `TrashModel.entries`), but the path resolution admits it, and the outcome is the pure
/// view's honest one: the key is removed, and the card still renders nowhere because its lane
/// is still tombstoned. Putting the lane back then shows it.
/// A card whose lane is itself tombstoned is **not reachable here at all**, by construction
/// rather than by the UI happening not to offer it: it has no trash row, and the trashed side of
/// `TrashModel.paths` is that row set exactly (`Liveness.walk`). Recovering it stays the two-step
/// the design settled on put the lane back, then put the card back from the row it regains.
///
/// The selection is deliberately left alone: the restored items flip liveness, and the reload's
/// resolve rule ejects them from a `.trashed` set as a vanish the same silent shrink an
+61 -18
View File
@@ -26,6 +26,54 @@ public enum Liveness: String, Codable, Sendable, Equatable {
}
}
// MARK: - The one definition of a side
extension Liveness {
/// Every item `snapshot` has on this side, visited in **board order** lanes left to right,
/// each lane immediately before its own cards as the lane it lives under and, for a card, the
/// card itself.
///
/// **This is the definition, and it is the only one** (02-architecture.md § Changes from Kanban,
/// settled: "universe and rows are one function, never a broader set with a pointer-side
/// subset"). Everything that asks what is on a side is this walk with a different accumulator:
/// `ItemReferenceSet.idUniverse` collects ids, `TrashModel.entries` builds the trash's rows,
/// `TrashModel.paths` and `emptyTrashTargets` build folders. Two spellings of the rule would be
/// two things to keep in step, and the one they would eventually disagree about is precisely the
/// item below.
///
/// **The whole rule is the `continue`: a tombstoned lane subsumes its subtree.** It contributes
/// one item to the trashed side its own row and its cards contribute nothing to *either*
/// side, whatever their own flags say. That is 03-board-ui.md § Trash's absolute ancestor walk
/// stated as code: "a card that carries its own `deleted:` under a tombstoned lane has **no row
/// of its own**".
///
/// **So the two sides do not partition the board, and that is the point.** A card beneath a
/// tombstoned lane is in *neither* universe, because it renders nowhere no row, no membership.
/// A selection, a drag, a pending cut, a range anchor or a navigation head can therefore never
/// survive a reload sitting on something no surface would draw, and 04-interactions.md § Search's
/// hidden-cards-leave-the-selection rule and 02's reload-survival rule stay one rule rather than
/// two that happen to agree.
///
/// Non-escaping and accumulator-driven rather than array-returning: the callers below run on
/// every reload and on every menu validation, and none of them wants a board-sized copy of the
/// model to throw away.
func walk(_ snapshot: BoardModel, visiting visit: (Lane, Card?) -> Void) {
for lane in snapshot.lanes {
if lane.isDeleted {
// The subsumption, both halves of it: the lane is a trash row, and its cards are
// nobody's so the loop below never runs for them.
if self == .trashed { visit(lane, nil) }
continue
}
if self == .live { visit(lane, nil) }
for card in lane.cards where Liveness(isDeleted: card.isDeleted) == self {
visit(lane, card)
}
}
}
}
// MARK: - ItemReferenceSet
/// A set of UUIDs over the snapshot plus the liveness side it lives on **the one shape every
@@ -98,32 +146,27 @@ public struct ItemReferenceSet: Sendable, Equatable {
/// item that is tombstoned or vanishes externally before paste drops out of the pending
/// cut"), and so does drag membership (04 Drag and drop's emptied-drag rule).
///
/// The liveness that is matched is **effective ancestor-walked** (settled): a card counts as
/// trashed if its own flag *or its lane's* says so. Tombstoning a lane therefore ejects its
/// cards from a live set even though their own flags never changed the card renders nowhere
/// once 03-board-ui.md collapses the lane to a single trash entry, and nothing invisible may
/// stay selected, drag-included, or pending-cut.
/// The liveness that is matched is **effective ancestor-walked** (settled), and the trashed
/// side is exactly the trash's rows: `Liveness.walk` is the one definition both sides read.
/// Tombstoning a lane therefore ejects its cards from a live set even though their own flags
/// never changed and does **not** hand them to a trashed set, because the lane's single entry
/// subsumes them (03-board-ui.md). A card under a tombstoned lane renders nowhere on either
/// side, and nothing invisible may stay selected, drag-included, or pending-cut.
public func resolved(against snapshot: BoardModel) -> ItemReferenceSet {
guard !ids.isEmpty else { return self }
return constrained(to: Self.idUniverse(of: snapshot, on: liveness))
}
/// Every id in `snapshot` whose **effective** liveness is `side` the reload direction's
/// universe, and the only place the ancestor walk lives.
/// Every id in `snapshot` on `side` the reload direction's universe.
///
/// A lane contributes itself on the side its own flag names, and each of its cards on the side
/// `lane.isDeleted || card.isDeleted` names the walk being one level deep is the whole of it,
/// because the tree is (01-storage-format.md § Fractal layout: board lane card).
/// One line over `Liveness.walk`, which is where the rule itself lives and is stated: the live
/// side is the live lanes and their unflagged cards, and the trashed side is *exactly* the trash's
/// rows tombstoned lanes, plus cards carrying their own `deleted:` under a live lane. Nothing
/// else is in either, so a card hidden beneath a tombstoned lane belongs to no universe and no
/// set may go on referencing it.
static func idUniverse(of snapshot: BoardModel, on side: Liveness) -> Set<ItemID> {
var universe: Set<ItemID> = []
for lane in snapshot.lanes {
if Liveness(isDeleted: lane.isDeleted) == side {
universe.insert(lane.id)
}
for card in lane.cards where Liveness(isDeleted: lane.isDeleted || card.isDeleted) == side {
universe.insert(card.id)
}
}
side.walk(snapshot) { lane, card in universe.insert(card?.id ?? lane.id) }
return universe
}
}
+51 -38
View File
@@ -77,7 +77,11 @@ public enum TrashEntry: Identifiable, Sendable, Equatable {
///
/// 1. **The absolute ancestor walk.** A tombstoned lane's entry subsumes everything beneath it "a
/// card that carries its own `deleted:` under a tombstoned lane has **no row of its own**". There
/// is no trash carve-out from 01-storage-format.md's consumer rule.
/// is no trash carve-out from 01-storage-format.md's consumer rule. This rule is not spelled
/// here: it is `Liveness.walk`'s, because the trashed **universe** every item-referencing set is
/// held to *is* this row set "universe and rows are one function" (02-architecture.md § Changes
/// from Kanban, settled). Everything below that asks what is in the trash asks that walk, so the
/// rows a user sees and the ids a selection may hold cannot drift apart.
/// 2. **The returning count.** A lane entry's number "counts what Put Back returns to the board
/// cards without their own flag; individually tombstoned descendants aren't in that number, since
/// they come back to the *trash*".
@@ -121,13 +125,23 @@ public enum TrashModel {
/// The trash's rows, in the order the quasi-lane shows them.
///
/// The walk is one level deep because the tree is (01-storage-format.md § Fractal layout), and
/// the branch on `lane.isDeleted` *is* the ancestor walk: a tombstoned lane contributes exactly
/// one entry and its cards contribute none, whatever their own flags say.
/// **Which items are rows is not decided here** it is `Liveness.trashed.walk`, the same walk
/// `ItemReferenceSet.idUniverse(of:on:)` reads, because the trashed universe and this row set are
/// one function. Its `continue` on a tombstoned lane is the absolute ancestor walk: such a lane
/// contributes exactly one entry and its cards contribute none, whatever their own flags say.
///
/// What is left for this function is what a row *carries* the returning count and the sort
/// inputs and the order it shows in, neither of which any other caller of the walk wants.
public static func entries(of snapshot: BoardModel) -> [TrashEntry] {
var rows: [Row] = []
for lane in snapshot.lanes {
if lane.isDeleted {
Liveness.trashed.walk(snapshot) { lane, card in
if let card {
rows.append(Row(
entry: .card(card, laneID: lane.id),
deleted: card.deleted.value,
name: card.id.rawValue
))
} else {
// Rule 2: only the cards *without* their own flag come back with the lane. The ones
// that carry a flag stay tombstoned and get their rows back in the trash which is
// why Put Back on such a card is deliberately two steps.
@@ -137,14 +151,6 @@ public enum TrashModel {
deleted: lane.deleted.value,
name: lane.id.rawValue
))
continue
}
for card in lane.cards where card.isDeleted {
rows.append(Row(
entry: .card(card, laneID: lane.id),
deleted: card.deleted.value,
name: card.id.rawValue
))
}
}
return rows.sorted(by: isOrdered).map(\.entry)
@@ -153,7 +159,11 @@ public enum TrashModel {
/// Whether the board has anything in its trash at all the "non-empty" half of Empty Trash's
/// menu validation, which "reads the board's tombstones, not the filtered view" (03 Trash).
///
/// Cheaper than building the entries and used where only the answer matters.
/// The walk's non-emptiness in **short-circuit form**, which is the one place the rule is
/// restated and only because stopping early is the whole point: a tombstoned lane is a row
/// outright, and under a live lane any own-flagged card is one. There is deliberately no third
/// clause for a card beneath a tombstoned lane the lane has already answered `true` for it.
/// `TrashModelTests` pins the equivalence to `entries(of:).isEmpty` so the shortcut cannot drift.
public static func isEmpty(_ snapshot: BoardModel) -> Bool {
!snapshot.lanes.contains { lane in
lane.isDeleted || lane.cards.contains(where: \.isDeleted)
@@ -193,27 +203,31 @@ public enum TrashModel {
// MARK: - Paths for the trash's writes
/// The folders `ids` names, in display order, restricted to one **effective** liveness side.
/// The folders `ids` names, in display order, restricted to one liveness side.
///
/// The universe rule is `ItemReferenceSet.idUniverse`'s, spelled here in terms of paths rather
/// than ids because a write needs to know *where* the item is: a lane contributes itself on the
/// side its own flag names, and a card on the side `lane.isDeleted || card.isDeleted` names.
/// **The membership rule is not restated here** it is `Liveness.walk`'s, the same one
/// `ItemReferenceSet.idUniverse` and `entries(of:)` read spelled in *paths* rather than ids
/// because a write needs to know where the item is. So the trashed side is the trash's rows and
/// nothing besides: a card beneath a tombstoned lane is not individually addressable, which costs
/// nothing (it has no row for a user to act on, so no command can name it) and buys the guarantee
/// that every path this hands a writer names something the board would draw.
///
/// **A tombstoned lane still takes its subtree with it**, and that is subsumption rather than
/// omission: its path is the lane *folder*, and removing a folder removes what is inside it. Put
/// Back on it restores the lane and every card that rode along; Delete Immediately on it purges
/// the whole tree, own-flag cards included the outcome the lane entry's confirmation sentence
/// exists to warn about (`message(lanes:unrecoverable:)`).
///
/// Display order lanes left to right, each lane then its cards rather than the caller's set
/// iteration order, which is not an order at all: a batch that fails partway must fail the same
/// way twice (`BoardStore.styleSubjects` makes the same choice for the same reason).
/// way twice (`BoardStore.styleSubjects` makes the same choice for the same reason). The walk
/// visits in exactly that order, so this is a filter over it and never a sort.
public static func paths(of ids: Set<ItemID>, on side: Liveness, in snapshot: BoardModel) -> [ItemPath] {
guard !ids.isEmpty else { return [] }
var result: [ItemPath] = []
for lane in snapshot.lanes {
let laneSide = Liveness(isDeleted: lane.isDeleted)
if laneSide == side, ids.contains(lane.id) {
result.append(ItemPath(laneID: lane.id, cardID: nil))
}
for card in lane.cards
where Liveness(isDeleted: lane.isDeleted || card.isDeleted) == side && ids.contains(card.id) {
result.append(ItemPath(laneID: lane.id, cardID: card.id))
}
side.walk(snapshot) { lane, card in
guard ids.contains(card?.id ?? lane.id) else { return }
result.append(ItemPath(laneID: lane.id, cardID: card?.id))
}
return result
}
@@ -221,20 +235,19 @@ public enum TrashModel {
/// Every folder Empty Trash removes "emptying purges every tombstone on the board, filter or
/// no filter" (03 Trash).
///
/// `paths(of:on:in:)` on the trashed side with **no id filter at all**, which is the strongest
/// form of that guarantee: the command's scope is the trashed universe itself, so it cannot
/// narrow to a selection any more than it can narrow to the search.
///
/// **A tombstoned lane contributes only itself**, and that is not an omission: removing the lane
/// folder removes the tree beneath it, own-flag cards included. Listing those cards as well would
/// be redundant purges of paths the first removal already took (harmless `purgeItem` treats a
/// folder that is already gone as success but noise).
/// folder that is already gone as success but noise) and would require a second, broader
/// definition of "in the trash" than the one every other caller reads.
public static func emptyTrashTargets(in snapshot: BoardModel) -> [ItemPath] {
var result: [ItemPath] = []
for lane in snapshot.lanes {
if lane.isDeleted {
result.append(ItemPath(laneID: lane.id, cardID: nil))
continue
}
for card in lane.cards where card.isDeleted {
result.append(ItemPath(laneID: lane.id, cardID: card.id))
}
Liveness.trashed.walk(snapshot) { lane, card in
result.append(ItemPath(laneID: lane.id, cardID: card?.id))
}
return result
}
@@ -137,6 +137,56 @@ struct TransientBoardStateTests {
#expect(store.transient.pendingCut.ids == [card3], "card3's lane is untouched, so card3 stays cut")
}
@Test("A card with its own deleted: under a tombstoned lane is in neither universe")
func ownFlaggedCardUnderATombstonedLaneIsInNeitherUniverse() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
// Everything that can point at an item, all aimed at card3 and on the *trashed* side, as
// if the user had clicked its trash row a moment before its lane went too.
store.transient.select([card3], liveness: .trashed, anchor: card3, head: card3)
store.transient.dragMembers = ItemReferenceSet(ids: [card3], liveness: .trashed)
store.transient.pendingCut = ItemReferenceSet(ids: [card3], liveness: .trashed)
store.transient.beginRename(of: card3, currentTitle: "Third")
// Both flags at once: the card carries its own `deleted:` *and* an agent tombstones its lane.
try fixture.item("\(Ident.lane2)/\(Ident.card3)", tombstoned(order: "1024", title: "Third"))
try fixture.item(Ident.lane2, tombstoned(order: "2048", title: "Doing"))
await reload(store)
let lane = try #require(store.snapshot.lanes.first { $0.id == lane2 })
#expect(lane.isDeleted)
#expect(lane.cards.first { $0.id == card3 }?.isDeleted == true, "the card's own flag is on disk")
// The lane's single entry subsumes it (03-board-ui.md's absolute ancestor walk), so it has
// no row and "the trashed side has exactly one definition: the set with trash rows"
// (02-architecture.md, settled). No row, no membership, on either side.
#expect(!TrashModel.entries(of: store.snapshot).map(\.id).contains(card3))
#expect(!ItemReferenceSet.idUniverse(of: store.snapshot, on: .trashed).contains(card3))
#expect(!ItemReferenceSet.idUniverse(of: store.snapshot, on: .live).contains(card3))
// So every set holding it is ejected from the trashed side here, and from the live side
// for the same reason, which the value function says directly since a set has one side.
#expect(store.transient.selection.ids.isEmpty)
#expect(store.transient.dragMembers.ids.isEmpty)
#expect(store.transient.pendingCut.ids.isEmpty)
#expect(ItemReferenceSet(ids: [card3], liveness: .live).resolved(against: store.snapshot).isEmpty)
// And no cursor or editor survives on it: an anchor that ranges from somewhere the board
// draws nowhere would be a range the user cannot see the origin of.
#expect(store.transient.selectionAnchor == nil)
#expect(store.transient.selectionHead == nil)
#expect(store.transient.renameEditor == nil)
// Menu validation agrees, which is the point of the sets and the commands reading one rule:
// neither twin offers to act on it.
let stale = ItemReferenceSet(ids: [card3], liveness: .trashed)
#expect(!TrashModel.canActOnTrash(selection: stale, in: store.snapshot))
#expect(!TrashModel.canDelete(selection: ItemReferenceSet(ids: [card3], liveness: .live),
in: store.snapshot))
}
@Test("Every set resolves to empty against a board whose lanes all vanished")
func everythingResolvesToNothingOnAnEmptyBoard() async throws {
let fixture = try makeBoard()
+48 -6
View File
@@ -213,7 +213,7 @@ struct TrashModelContentsTests {
#expect(!TrashModel.isEmpty(try load(dirty)))
}
@Test("Paths resolve on the effective liveness side, in display order")
@Test("Paths resolve on the row set's liveness side, in display order")
func pathsResolveByEffectiveLiveness() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
@@ -224,22 +224,64 @@ struct TrashModelContentsTests {
]
// Live: the live lane and its live card. `card3` is live by its own flag but its lane is
// tombstoned, so the ancestor walk puts it on the other side.
// tombstoned, so the ancestor walk takes it off this side.
let liveSide = TrashModel.paths(of: everything, on: .live, in: model)
#expect(liveSide.map { ($0.laneID.rawValue, $0.cardID?.rawValue) }.map { "\($0.0)/\($0.1 ?? "-")" }
== ["\(Ident.lane1)/-", "\(Ident.lane1)/\(Ident.card2)"])
// Trashed: the tombstoned card, the tombstoned lane, and everything the lane hides in
// display order, lanes left to right and each lane before its cards.
// Trashed: the tombstoned card and the tombstoned lane the trash's two rows, in display
// order, lanes left to right and each lane before its cards. Nothing under `lane2` is here:
// its cards have no rows, and the lane's *folder* is what takes them (Put Back returns them
// with it; Delete Immediately purges them with it).
let trashedSide = TrashModel.paths(of: everything, on: .trashed, in: model)
#expect(trashedSide.map { "\($0.laneID.rawValue)/\($0.cardID?.rawValue ?? "-")" }
== ["\(Ident.lane1)/\(Ident.card1)", "\(Ident.lane2)/-",
"\(Ident.lane2)/\(Ident.card3)", "\(Ident.lane2)/\(More.cardD)"])
== ["\(Ident.lane1)/\(Ident.card1)", "\(Ident.lane2)/-"])
#expect(TrashModel.paths(of: [], on: .live, in: model).isEmpty)
#expect(TrashModel.paths(of: [ItemID(rawValue: Ident.indexless)], on: .trashed, in: model).isEmpty)
}
@Test("The trashed universe is exactly the trash's rows — one function, not two")
func trashedUniverseIsTheRowSet() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let model = try load(fixture)
// The guarantee 02-architecture.md settles: "universe and rows are one function, never a
// broader set with a pointer-side subset". Anything a set may reference on the trashed side
// is something the quasi-lane draws.
let rows = Set(TrashModel.entries(of: model).map(\.id))
let trashed = ItemReferenceSet.idUniverse(of: model, on: .trashed)
#expect(trashed == rows)
// And the two universes therefore do **not** partition the board. `cardD` carries its own
// `deleted:` under a tombstoned lane and `card3` rides along unflagged under the same one;
// both render nowhere, so neither is on either side.
let liveUniverse = ItemReferenceSet.idUniverse(of: model, on: .live)
for hidden in [ItemID(rawValue: More.cardD), ItemID(rawValue: Ident.card3)] {
#expect(!rows.contains(hidden))
#expect(!trashed.contains(hidden))
#expect(!liveUniverse.contains(hidden))
#expect(ItemReferenceSet(ids: [hidden], liveness: .trashed).resolved(against: model).isEmpty)
#expect(ItemReferenceSet(ids: [hidden], liveness: .live).resolved(against: model).isEmpty)
}
// Paths are the same walk in folder form, so they name the same things one per row.
#expect(TrashModel.paths(of: rows, on: .trashed, in: model).count == rows.count)
#expect(TrashModel.emptyTrashTargets(in: model).count == rows.count)
// And `isEmpty`'s short-circuit is that walk's non-emptiness, on both a dirty board and a
// clean one.
#expect(TrashModel.isEmpty(model) == rows.isEmpty)
let clean = try WriterFixture()
defer { clean.tearDown() }
try clean.item("", Item.board)
try clean.item(Ident.lane1, live(order: "1024", title: "Todo"))
let cleanModel = try load(clean)
#expect(TrashModel.isEmpty(cleanModel) == TrashModel.entries(of: cleanModel).isEmpty)
#expect(ItemReferenceSet.idUniverse(of: cleanModel, on: .trashed).isEmpty)
}
@Test("Empty Trash targets every tombstone, a tombstoned lane contributing only its own folder")
func emptyTrashTargets() throws {
let fixture = try makeBoard()