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
+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