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
+245 -21
View File
@@ -43,6 +43,33 @@ import os
/// Symlinks: a lane/card candidate that is itself a symlink is treated as a stray and never
/// followed, whether it points to a file or a directory this loader does not resolve
/// cross-volume or cyclic trees.
///
/// ## 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.
///
/// ## 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.
///
/// **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`.
public enum BoardLoader: Sendable {
/// Schema version this app understands; anything higher fails fast
@@ -61,6 +88,31 @@ public enum BoardLoader: Sendable {
/// the writer must never disagree about which file a folder's content lives in.
static let indexFileName = "index.md"
/// The materialized trash container at board root (01-storage-format.md § Deletion, resettled
/// 2026-07-28) **app-claimed, never a stray**, joining `CLAUDE.md`, `CLAUDE.user.md` and the
/// seeded `.gitignore` on the claimed list.
///
/// Dot-prefixed, which is doing real work rather than being decoration: `directoryCandidates`
/// skips hidden entries, so the container can never be mistaken for a lane candidate and can
/// never earn a `.nonUUIDFolderIgnored` warning. The lane walk skips it by name as well
/// (`reservedRootNames`) so the rule holds even where hidden-file semantics don't.
///
/// Internal rather than `private`: `BoardWriter` moves folders into and out of this exact
/// name, and a board can have only one trash.
static let trashFolderName = ".trash"
/// Board-root names the app claims, and therefore the names the lane walk skips **without a
/// stray warning** (01-storage-format.md § Fractal layout Rules: "Three board-root names are
/// app-claimed, not strays", plus `.trash/` from § Deletion).
///
/// Only `.trash` is a *folder* and so the only one the directory walk could ever reach; the
/// files are listed because the claim is about names, and a future check that needs the set
/// should find it complete rather than build a second one. Compared lowercased, like
/// `reservedCardChildNames` and for its reason the filesystem this runs on usually is.
static let reservedRootNames: Set<String> = [
trashFolderName, "claude.md", "claude.user.md", ".gitignore",
]
/// The card-level names the app claims, and therefore the three the loose-file carve-out
/// never touches (01-storage-format.md § Fractal layout Rules: "Reserved card-level names
/// untouched"): the card's own `index.md` plus the two reserved children. `comments` is
@@ -101,8 +153,15 @@ public enum BoardLoader: Sendable {
// stray-*tolerance* vocabulary (see `LoadResult.looseCardFiles`).
var looseCardFiles: [LooseCardFiles] = []
// The retired tombstone model's detection channel, on the same reasoning and in the same
// idiom (see `LoadResult.legacyTombstones`).
var legacyTombstones: [LegacyTombstone] = []
// Legal per the frontmatter table, meaningless at board level ignore and log, never
// tombstone (01-storage-format.md § Deletion).
// tombstone, and **never migrate**: "a `deleted:` key at board level remains meaningless
// ignored and logged, preserved verbatim" (01-storage-format.md § Deletion). It is
// deliberately absent from `legacyTombstones`: there is no item to relocate and no key
// the app has any business removing from a file it was told to leave alone.
if !boardDocument.deleted.isMissing {
warn(.boardLevelDeletedIgnored)
}
@@ -110,6 +169,10 @@ public enum BoardLoader: Sendable {
var lanes: [Lane] = []
for laneURL in try directoryCandidates(in: boardRoot) {
let laneName = laneURL.lastPathComponent
// The app-claimed board-root names are not strays and must not warn as such. Hidden
// entries never reach here anyway (`.trash` included), so this is the rule stated
// rather than the mechanism relied on.
guard !reservedRootNames.contains(laneName.lowercased()) else { continue }
guard isUUIDShaped(laneName) else {
warn(.nonUUIDFolderIgnored(path: laneName))
continue
@@ -137,10 +200,7 @@ public enum BoardLoader: Sendable {
continue
}
let cardPath = cardRelPath + "/" + indexFileName
let cardDocument = try readDocument(at: cardURL.appendingPathComponent(indexFileName), path: cardPath)
let cardSchema = try validatedSchema(in: cardDocument, path: cardPath)
let cardOrder = try validatedOrder(in: cardDocument, path: cardPath)
let card = try parseCard(at: cardURL, path: cardRelPath)
// Noticed, never acted on: the relocation is the store's, through the Writer.
let loose = looseFileNames(in: cardURL)
@@ -148,27 +208,35 @@ public enum BoardLoader: Sendable {
looseCardFiles.append(LooseCardFiles(
laneID: ItemID(rawValue: laneName),
cardID: ItemID(rawValue: cardName),
title: cardDocument.title.value,
title: card.title.value,
fileNames: loose
))
logger.info("\(cardRelPath, privacy: .public): \(loose.count, privacy: .public) loose file(s) beside index.md — to be relocated into attachments/")
}
cards.append(Card(
id: ItemID(rawValue: cardName),
schema: cardSchema,
title: cardDocument.title,
created: cardDocument.created,
modified: cardDocument.modified,
modifiedBy: cardDocument.modifiedBy,
deleted: cardDocument.deleted,
background: cardDocument.background,
icon: cardDocument.icon,
iconColor: cardDocument.iconColor,
order: cardOrder,
attachments: attachmentNames(in: cardURL),
document: cardDocument
// Detection only, the loose-file precedent exactly: the relocation into `.trash/`
// and the key's removal are the store's, through the Writer.
if card.isDeleted {
legacyTombstones.append(LegacyTombstone(
kind: .card,
laneID: ItemID(rawValue: laneName),
cardID: ItemID(rawValue: cardName),
title: card.title.value
))
logger.info("\(cardRelPath, privacy: .public): legacy 'deleted' key — card to be relocated into \(trashFolderName, privacy: .public)/")
}
cards.append(card)
}
if !laneDocument.deleted.isMissing {
legacyTombstones.append(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")
}
lanes.append(Lane(
@@ -189,6 +257,21 @@ public enum BoardLoader: Sendable {
))
}
var trash: [Card] = []
for cardURL in trashCandidates(in: boardRoot) {
let cardName = cardURL.lastPathComponent
let cardRelPath = trashFolderName + "/" + cardName
guard isUUIDShaped(cardName) else {
warn(.nonUUIDFolderIgnored(path: cardRelPath))
continue
}
guard hasIndex(cardURL) else {
warn(.missingIndex(path: cardRelPath))
continue
}
trash.append(try parseCard(at: cardURL, path: cardRelPath))
}
let model = BoardModel(
rootURL: boardRoot,
schema: boardSchema,
@@ -202,10 +285,80 @@ public enum BoardLoader: Sendable {
iconColor: boardDocument.iconColor,
template: boardDocument.value(for: templateKey),
lanes: Ranks.sortedForDisplay(lanes, order: \.order, name: { $0.id.rawValue }),
trash: Ranks.sortedForDisplay(trash, order: \.order, name: { $0.id.rawValue }),
document: boardDocument
)
return LoadResult(model: model, warnings: warnings, looseCardFiles: looseCardFiles)
return LoadResult(
model: model,
warnings: warnings,
looseCardFiles: looseCardFiles,
legacyTombstones: legacyTombstones
)
}
/// One card folder read into a `Card` **the card parse, shared by both containers**.
///
/// 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.
///
/// `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.
private static func parseCard(at cardURL: URL, path: String) throws(BoardLoadError) -> Card {
let cardPath = path + "/" + indexFileName
let document = try readDocument(at: cardURL.appendingPathComponent(indexFileName), path: cardPath)
let schema = try validatedSchema(in: document, path: cardPath)
let order = try validatedOrder(in: document, path: cardPath)
return Card(
id: ItemID(rawValue: cardURL.lastPathComponent),
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: cardURL),
document: document
)
}
/// The candidate card 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.
///
/// **A `.trash` that is not a plain directory yields nothing**: a file by that name, or a
/// *symlink* "symlinks are never traversed" (01-storage-format.md § Fractal layout Rules),
/// and a symlinked trash would render bytes living outside the board that FSEvents never
/// reports. Logged rather than warned: `LoadWarning` is the stray vocabulary and a claimed
/// name is not a stray, so there is no case here that fits and nothing for a user to do about
/// a name the app claims.
///
/// 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.
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 {
return []
}
guard values.isDirectory == true, values.isSymbolicLink != true else {
logger.warning("\(trashFolderName, privacy: .public): not a plain directory, treated as an empty trash")
return []
}
return (try? directoryCandidates(in: trashURL)) ?? []
}
// MARK: - Filesystem helpers
@@ -364,6 +517,11 @@ public enum BoardLoader: Sendable {
/// those to actual lane/card candidates by name shape (`isUUIDShaped`) before doing
/// anything else with them.
///
/// **`.trash/` is hidden, so it never appears among a board root's candidates** which is
/// exactly right: it is a container, not a level, and the walk reaches it by name instead
/// (`trashCandidates(in:)`). This same call then enumerates *inside* it, so the trash's own
/// children get identical stray tolerance.
///
/// An unreadable non-root folder (permission changed mid-walk, races) degrades to "no
/// candidates" rather than failing the whole load fail-fast is reserved for the board
/// root and for malformed `index.md` content, not transient directory-listing races below
@@ -512,7 +670,73 @@ public struct LoadResult: Sendable {
/// belongs on disk is a question about the *tree*, not about what the board is currently
/// rendering the same reason the loader flags a tombstoned card at all rather than dropping
/// it.
///
/// **Cards in `.trash/` are deliberately *not* walked for loose files in this version.** The
/// channel is keyed by lane (`LooseCardFiles.laneID`, the store's path key) and a trashed card
/// has no lane; widening the key is the store-side change that belongs with the store-side
/// scheduling. Loose files beside a trashed card's `index.md` therefore keep the ordinary
/// stray posture tolerated, preserved verbatim and are tidied the moment the card is
/// restored into a lane, which is the only state in which they matter.
public var looseCardFiles: [LooseCardFiles] = []
/// The legacy `deleted:` keys this walk found the retired tombstone model's **migration
/// input** (01-storage-format.md § Deletion, resettled 2026-07-28: "Legacy `deleted:` keys
/// migrate on load-and-write, never destroy").
///
/// **The `looseCardFiles` idiom, for the same reason it exists**: `warnings` is the
/// stray-*tolerance* vocabulary "this was ignored, it is staying exactly where it is, there
/// is nothing to do" and a legacy tombstone is the opposite, pending work the store acts on.
/// Folding it into the warning channel would also throw away everything the act needs (which
/// lane, which card, which title) and force it to be re-derived from a display string.
///
/// Nothing renders this. Its consumer is the store, which relocates each `.card` into
/// `.trash/` with the key removed, strips each `.lane`'s key in place (a lane returns **live**
/// resurrection is the safe direction), and posts the warning-tone notice. Like the
/// relocation it mirrors, the write is deferred under any read-only lock; the items stay
/// rendered through the retiring tombstone path until it lands (see this type's `BoardLoader`
/// note on the migration window).
///
/// Board-level `deleted:` never appears here it is meaningless, ignored and logged
/// (`LoadWarning.boardLevelDeletedIgnored`), and nothing about it is the app's to rewrite.
///
/// Order is the walk's: a lane's tombstoned cards, then the lane itself, lane by lane.
public var legacyTombstones: [LegacyTombstone] = []
}
/// One item found carrying a legacy `deleted:` key everything its migration and notice need,
/// and nothing more.
///
/// The path is carried as its identity components rather than as a URL `LooseCardFiles`'
/// convention, for its reason: the write derives its path from the store's *current* root, which
/// may have been re-resolved since the load. `title` is the item's as written, `nil` for an
/// untitled one, because "Untitled" is a rendering and never a value (03-board-ui.md § Card face).
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.
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?
public let title: String?
public init(kind: Kind, laneID: ItemID, cardID: ItemID?, title: String?) {
self.kind = kind
self.laneID = laneID
self.cardID = cardID
self.title = title
}
}
/// One card found holding files that belong in its `attachments/` everything the relocation and