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
@@ -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()