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