Materialize the trash — storage layer

Phase 1 of the trash pivot: the file format learns .trash/. The loader
parses the reserved root container — cards only, one shared parseCard
for both containers so fail-fast, attachments, and verbatim documents
are literally the same code; absent means empty; symlinks and
lane-shaped nestings fall out as strays by construction. BoardModel
grows snapshot.trash as a plain rank-ordered card list — the container
has no identity to carry. Legacy deleted: keys keep flowing through
the retiring flag path so every tombstone consumer stays green, and
are additionally reported through LoadResult.legacyTombstones in the
loose-file idiom for phase 2's migration scheduling — nothing vanishes
from view before its folder has actually moved, which is also 01's
lock-deferral posture. Writer primitives land value-passing: move to
trash with caller-minted rank and the deliberate modified stamp,
tombstone migrations that surgically remove the key, physical lane
removal, per-card and whole-container purge that leaves strays
verbatim, and byte-faithful whole-subtree capture/recreate for lane
undo. Board-wide identity now spans the trash, so an import colliding
with a trashed UUID remints instead of colliding. The watcher already
delivered .trash events — isGitInternal tests a component, not a dot —
now stated and pinned rather than relied on.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-28 15:55:40 -04:00
parent 96c4014fef
commit 4cf5f09d93
8 changed files with 1746 additions and 30 deletions
+44 -7
View File
@@ -92,11 +92,39 @@ public struct BoardModel: Sendable, Equatable {
/// parsed into a dedicated Swift shape, so future subkeys need no model change.
public let template: YAMLValue?
/// Lanes in display order (`Ranks.sortedForDisplay`, folder-name tie-break) **including
/// tombstoned lanes**, which stay in the snapshot flagged (`Lane.isDeleted`) for the trash
/// view (01-storage-format.md § Deletion).
/// 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.
public let lanes: [Lane]
/// The board's **materialized trash**: 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.
///
/// **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
/// timestamp sort: every arrival mints a rank *above* the current topmost
/// (`Ranks.insertAtHead`), so the trash needs no sort rule of its own. There is deliberately
/// no `deleted:` key on anything in here a trashed card is an ordinary card in a special
/// place.
///
/// Empty when `.trash/` is absent (the overwhelmingly common case the folder is minted by
/// the first delete), and empty when it holds nothing the loader recognizes as a card.
///
/// **Defaulted so a snapshot can be built without one.** The one construction site is
/// `BoardLoader.load`, which always supplies it; the default keeps the memberwise initializer
/// usable from tests and future fixtures that have no trash to describe.
public var trash: [Card] = []
/// 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.
@@ -132,9 +160,9 @@ public struct Lane: Identifiable, Sendable, Equatable {
/// failing the load (01-storage-format.md § Frontmatter).
public let width: FieldValue<Int>
/// Cards in this lane, in display order (`Ranks.sortedForDisplay`, folder-name tie-break)
/// **including tombstoned cards**, which stay in the snapshot flagged (`Card.isDeleted`)
/// for the trash view.
/// Cards in this lane, in display order (`Ranks.sortedForDisplay`, folder-name tie-break).
/// A card still carrying a legacy `deleted:` key rides along flagged (`Card.isDeleted`) until
/// its migration relocates it into `BoardModel.trash` see `BoardModel.lanes`.
public let cards: [Card]
/// The full parsed `index.md`; unknown/reserved keys ride along uninterpreted.
@@ -148,6 +176,13 @@ public struct Lane: Identifiable, Sendable, Equatable {
/// 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).
///
/// **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.
public var isDeleted: Bool { !deleted.isMissing }
}
@@ -199,6 +234,8 @@ 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.
/// 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.
public var isDeleted: Bool { !deleted.isMissing }
}