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