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"
}
+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
}
+107 -352
View File
@@ -1046,9 +1046,9 @@ public enum BoardWriter: Sendable {
/// The sequence, which is the contract:
///
/// 1. **`cardFolder` must be a card** (`checkIsCardFolder`, the stricter guard: UUID-shaped
/// *under* a UUID-shaped parent). This is what makes "lanes are never trashed" structural
/// rather than a policy the caller has to remember a lane, a board root and a stray are
/// all refused here, and lane deletion has its own call (`removeLane`).
/// *under* a UUID-shaped parent). A board root and a stray are refused here, and a lane takes
/// the sibling door (`deleteLaneToTrash`) same move, different guard and a different `kind`
/// to stamp, which is exactly the pair `.trash/`'s flat container needs told apart.
/// 2. **Pre-flight the card's `index.md`** (`checkIndexIsRewritable`) the move rewrites it
/// at the destination, so a file that cannot be round-tripped refuses *before* the folder
/// travels. `moveItem`'s discover-before-you-write rule, for its reason.
@@ -1088,9 +1088,48 @@ public enum BoardWriter: Sendable {
inBoard boardRoot: URL,
order: Double
) throws(BoardWriteError) -> ItemID {
try moveCardIntoTrash(
try moveIntoTrash(
at: cardFolder,
inBoard: boardRoot,
kind: .card,
order: order,
operation: .delete(title: nil),
removingLegacyKey: false
)
}
/// **Deleting a lane: the same physical move into `<board-root>/.trash/`** (01-storage-format.md
/// § Deletion, lanes joined 2026-07-29 "retiring the design's sole destructive delete";
/// 03-board-ui.md § Trash: "deleting a lane moves its folder subtree intact into `.trash/`,
/// exactly as a card moves").
///
/// `deleteCardToTrash`'s body with two differences, and they are the whole of what a lane is:
///
/// - **The guard is `checkIsLaneFolder`** UUID-shaped directly under a board root, which
/// refuses a card, a board root, a stray, and notably a folder already in `.trash/`.
/// - **`kind: lane` is stamped**, not derived. The rank rewrite is a `updateIndex` on a folder
/// that is by then *inside* `.trash/`, where position cannot answer and shape would answer
/// *wrongly* for the one lane that most needs the key: an **empty** lane is shape-identical to
/// a card (01's own "honest limit"). The caller knows what it moved, so it says so which is
/// also the backfill the ruling asks of this write ("`kind: lane` backfilled on touch when
/// absent the trash move's rank mint included").
///
/// **The subtree rides along untouched**: nothing beneath the lane is read or rewritten, so its
/// cards, their `attachments/` and every stray arrive byte-identical and come back with it on
/// restore which is what makes the inverse an ordinary move rather than a replay of captured
/// bytes (13-native-undo.md Interaction with the trash).
///
/// - Returns: the lane's identity, unchanged.
@discardableResult
public static func deleteLaneToTrash(
at laneFolder: URL,
inBoard boardRoot: URL,
order: Double
) throws(BoardWriteError) -> ItemID {
try moveIntoTrash(
at: laneFolder,
inBoard: boardRoot,
kind: .lane,
order: order,
operation: .delete(title: nil),
removingLegacyKey: false
@@ -1117,29 +1156,39 @@ public enum BoardWriter: Sendable {
inBoard boardRoot: URL,
order: Double
) throws(BoardWriteError) -> ItemID {
try moveCardIntoTrash(
try moveIntoTrash(
at: cardFolder,
inBoard: boardRoot,
kind: .card,
order: order,
operation: .migrateTombstone(title: nil),
removingLegacyKey: true
)
}
/// The shared body of `deleteCardToTrash` and `migrateTombstonedCard` see the former for the
/// sequence and the latter for what `removingLegacyKey` adds.
private static func moveCardIntoTrash(
at cardFolder: URL,
/// The shared body of the three moves into `.trash/` `deleteCardToTrash`, `deleteLaneToTrash`
/// and `migrateTombstonedCard`. See the first for the sequence, the second for what a lane's
/// `kind` is doing here, and the third for what `removingLegacyKey` adds.
///
/// **`kind` is both the guard and the stamp**: it picks which shape check the item must pass on
/// the way out, and it is handed to `updateIndex` so the arrived entry's `kind` is written from
/// what the caller *moved* rather than guessed from what the flat container makes it look like.
private static func moveIntoTrash(
at itemFolder: URL,
inBoard boardRoot: URL,
kind: IntegrityRules.ObjectKind,
order: Double,
operation initialOperation: WriteOperation,
removingLegacyKey: Bool
) throws(BoardWriteError) -> ItemID {
var operation = initialOperation
try checkIsDirectory(cardFolder, describedAs: "card folder", operation: operation)
try checkIsDirectory(itemFolder, describedAs: kind == .lane ? "lane folder" : "card folder", operation: operation)
try checkIsDirectory(boardRoot, describedAs: "board folder", operation: operation)
try checkIsCardFolder(cardFolder, operation: operation)
operation = try checkIndexIsRewritable(inItemFolder: cardFolder, operation: operation)
switch kind {
case .lane: try checkIsLaneFolder(itemFolder, operation: operation)
case .card, .board: try checkIsCardFolder(itemFolder, operation: operation)
}
operation = try checkIndexIsRewritable(inItemFolder: itemFolder, operation: operation)
let trash = trashFolder(inBoard: boardRoot)
do {
@@ -1152,23 +1201,23 @@ public enum BoardWriter: Sendable {
)
}
let name = cardFolder.lastPathComponent
let name = itemFolder.lastPathComponent
let arrived = trash.appendingPathComponent(name, isDirectory: true)
do {
try FileManager.default.moveItem(at: cardFolder, to: arrived)
try FileManager.default.moveItem(at: itemFolder, to: arrived)
} catch {
throw BoardWriteError(
operation: operation,
path: cardFolder.path,
path: itemFolder.path,
reason: .io(message: "could not move folder into the trash: \(error.localizedDescription)")
)
}
// A delete is a move into `.trash/` on disk (01-storage-format.md § Deletion), so the
// receipt is the move pair and it reads correctly from either end: the board side sees an
// absence where the card was, the shown-trash side sees an arrival where it went.
EchoLedger.current?.recordMove(from: cardFolder, to: arrived)
// absence where the item was, the shown-trash side sees an arrival where it went.
EchoLedger.current?.recordMove(from: itemFolder, to: arrived)
try updateIndex(inItemFolder: arrived, operation: operation) { document in
try updateIndex(inItemFolder: arrived, kind: kind, operation: operation) { document in
document.set(FrontmatterKeys.order, to: .double(order))
if removingLegacyKey {
document.remove(FrontmatterKeys.deleted)
@@ -1185,134 +1234,80 @@ public enum BoardWriter: Sendable {
return ItemID(rawValue: name)
}
/// **Migrating a legacy tombstoned lane**: the `deleted:` key is removed and the lane returns
/// **live**, exactly where it always was (01-storage-format.md § Deletion: "a lane carrying
/// `deleted:` returns live with the key removed and a notice resurrection is the safe
/// direction, nothing is destroyed by migration").
///
/// **Nothing moves and nothing is removed.** There is no lane trash to move it into, and
/// destroying a lane the user may never have meant to lose is the one direction migration is
/// forbidden to take. Its cards come back with it; any of *them* carrying their own
/// `deleted:` key migrate on their own account, as ordinary tombstoned cards.
///
/// The lane's rank is untouched, so it returns to its own position among its siblings
/// position-perfect for the same reason the retired Put Back was: the folder never moved.
///
/// Refuses anything that is not a lane (`checkIsLaneFolder`): a card's migration is a move and
/// has its own call, and pointing this at one would strip the key while leaving the card
/// exactly where the tombstone had hidden it.
public static func migrateTombstonedLane(at laneFolder: URL) throws(BoardWriteError) {
let operation = WriteOperation.migrateTombstone(title: nil)
try checkIsDirectory(laneFolder, describedAs: "lane folder", operation: operation)
try checkIsLaneFolder(laneFolder, operation: operation)
try updateIndex(inItemFolder: laneFolder, operation: operation) { document in
document.remove(FrontmatterKeys.deleted)
}
EchoLedger.current?.markHeal(at: laneFolder.appendingPathComponent(BoardLoader.indexFileName))
}
/// **Deleting a lane is physical** the folder and everything under it are removed
/// (01-storage-format.md § Deletion; 03-board-ui.md § Trash: "Cards only. Lanes are never
/// trashed"). There is no lane trash and no tombstone; the recovery net is native undo
/// in-session and git history on git boards.
///
/// **Capture before you remove.** Undo restores a lane by replaying its bytes, which only
/// works if someone is holding them `captureSubtree(at:operation:)` is that primitive, and
/// the pairing is the caller's (the undo step captures, then calls this). Deliberately not
/// folded in here: a purge that always paid for a full tree read would make Empty Trash on a
/// large board slow for a recovery nothing was going to use.
///
/// **A folder that is already gone is success**, `purgeItem`'s rule and for its reason: a
/// Finder deletion converges on exactly the end state this produces, so there is nothing left
/// to distinguish.
///
/// Refuses anything that is not a lane (`checkIsLaneFolder`) a board root, a card, a stray,
/// and notably a *trash card*, whose parent is `.trash/` rather than the board root.
public static func removeLane(at laneFolder: URL) throws(BoardWriteError) {
let operation = WriteOperation.delete(title: nil)
guard FileManager.default.fileExists(atPath: laneFolder.path) else { return }
try checkIsLaneFolder(laneFolder, operation: operation)
do {
try FileManager.default.removeItem(at: laneFolder)
} catch {
throw BoardWriteError(
operation: operation,
path: laneFolder.path,
reason: .io(message: "could not remove folder: \(error.localizedDescription)")
)
}
// The absence marker and it takes the lane's cards' receipts with it, which is what makes
// the digest's implied-events rule and the ledger agree that this was one event.
EchoLedger.current?.recordDeletion(at: laneFolder)
}
/// Permanently removes one card from the trash the trash's own **Delete**
/// (03-board-ui.md § Trash: "on a trash card, Delete (/) is permanent").
/// Permanently removes one **entry** from the trash the trash's own **Delete**
/// (03-board-ui.md § Trash: "on a trash selection, Delete (/) is permanent in the trash it
/// removes the folder").
///
/// `purgeItem` with the container checked: the folder must actually sit in this board's
/// `.trash/`, so a mis-aimed permanent delete cannot reach a live card. `purgeItem`
/// `.trash/`, so a mis-aimed permanent delete cannot reach a live item. `purgeItem`
/// (unconstrained) is for the create-undo's own removal, not this call.
///
/// **A trashed lane purges whole, freight and all** (lanes joined the trash 2026-07-29): the
/// removal is recursive, so "permanent delete walks lane subtrees" needs no walk of its own
/// here what the confirmation must *count* before this runs is `TrashModel`'s job, off the
/// snapshot. Which kind the entry is therefore never comes up: the container and the shape are
/// the whole check, exactly as they were when only cards lived here.
///
/// An already-gone folder is success, `purgeItem`'s rule.
public static func purgeTrashCard(at cardFolder: URL, inBoard boardRoot: URL) throws(BoardWriteError) {
public static func purgeTrashEntry(at entryFolder: URL, inBoard boardRoot: URL) throws(BoardWriteError) {
let operation = WriteOperation.purge(title: nil)
guard FileManager.default.fileExists(atPath: cardFolder.path) else { return }
guard FileManager.default.fileExists(atPath: entryFolder.path) else { return }
try checkIsUUIDShaped(cardFolder, operation: operation)
guard isSameLocation(cardFolder.deletingLastPathComponent(), trashFolder(inBoard: boardRoot)) else {
try checkIsUUIDShaped(entryFolder, operation: operation)
guard isSameLocation(entryFolder.deletingLastPathComponent(), trashFolder(inBoard: boardRoot)) else {
throw BoardWriteError(
operation: operation,
path: cardFolder.path,
path: entryFolder.path,
reason: .unreadable(message: "folder is not in this board's trash")
)
}
do {
try FileManager.default.removeItem(at: cardFolder)
try FileManager.default.removeItem(at: entryFolder)
} catch {
throw BoardWriteError(
operation: operation,
path: cardFolder.path,
path: entryFolder.path,
reason: .io(message: "could not remove folder: \(error.localizedDescription)")
)
}
EchoLedger.current?.recordDeletion(at: cardFolder)
EchoLedger.current?.recordDeletion(at: entryFolder)
}
/// **Empty Trash** (, 03-board-ui.md § Trash): permanently removes every card in
/// `<board-root>/.trash/`. Returns what it removed, in folder-name order.
/// **Empty Trash** (, 03-board-ui.md § Trash): permanently removes every entry in
/// `<board-root>/.trash/` cards and trashed lanes alike, **lane subtrees walked** by the
/// recursive removal. Returns what it removed, in folder-name order.
///
/// **The card folders, not the container.** The design says it "purges the whole `.trash/`",
/// and the cards are the whole of it in every board the app produces but the container is a
/// **The entry folders, not the container.** The design says it "purges the whole `.trash/`",
/// and the entries are the whole of it in every board the app produces but the container is a
/// real folder a hand-editor can put things in, and stray tolerance ("preserved verbatim,
/// never rendered") does not stop applying because the folder is the app's. Removing only what
/// the loader recognizes as a card keeps the count honest (the confirmation names cards) and
/// keeps this command from being the one place in the app that destroys a file nobody ever
/// saw. The emptied container is left standing; the next delete would only recreate it.
/// the loader recognizes as an entry keeps the count honest (the confirmation names cards and
/// lane freight) and keeps this command from being the one place in the app that destroys a file
/// nobody ever saw. The emptied container is left standing; the next delete would only recreate
/// it.
///
/// **Search-independent**, by construction: this walks the folder, never a filtered view.
///
/// Removal is per card, in order, and a failure stops the batch and throws everything
/// Removal is per entry, in order, and a failure stops the batch and throws everything
/// already removed stays removed, `importAttachments`' rule. A board with no trash at all
/// removes nothing and returns `[]`.
@discardableResult
public static func emptyTrash(inBoard boardRoot: URL) throws(BoardWriteError) -> [ItemID] {
let operation = WriteOperation.purge(title: nil)
var purged: [ItemID] = []
for card in childCandidates(of: trashFolder(inBoard: boardRoot)) {
for entry in childCandidates(of: trashFolder(inBoard: boardRoot)) {
do {
try FileManager.default.removeItem(at: card)
try FileManager.default.removeItem(at: entry)
} catch {
throw BoardWriteError(
operation: operation,
path: card.path,
path: entry.path,
reason: .io(message: "could not remove folder: \(error.localizedDescription)")
)
}
EchoLedger.current?.recordDeletion(at: card)
purged.append(ItemID(rawValue: card.lastPathComponent))
EchoLedger.current?.recordDeletion(at: entry)
purged.append(ItemID(rawValue: entry.lastPathComponent))
}
return purged
}
@@ -1336,206 +1331,6 @@ public enum BoardWriter: Sendable {
}
}
// MARK: - Subtree capture and replay
/// Every byte of a folder tree, in memory the capture half of a physical removal's undo
/// (13-native-undo.md Rules; 03-board-ui.md § Trash, "the net is undo, not the trash").
///
/// **`readIndexText(ofItem:operation:)` widened from one file to a whole tree**, and for the
/// same reason: the inverse of a physical removal is a recreation, and the only way Z can put
/// a lane back *with its identity and its cards* is for the step to be holding what was there.
/// One `index.md` is enough to replay a create; a lane delete takes its nested cards, their
/// `attachments/`, and every stray with it.
///
/// **Everything, verbatim.** Unlike every other walk in this file, this one does *not* apply
/// the loader's stray exclusions: hidden files (`.DS_Store`, a dot-file a hand-editor left),
/// non-UUID folders, files with no meaning to the schema all captured, because the promise is
/// that undo restores what was removed rather than what the app would have rendered. Bytes are
/// carried as `Data` and never decoded, so encoding, line endings and BOMs are non-questions;
/// POSIX permissions ride along per entry.
///
/// **Symlinks are captured as links, never followed** (01-storage-format.md § Fractal layout
/// Rules) the destination string is recorded and recreated as a link, so a cyclic or
/// cross-volume link is neither traversed here nor materialized as a copy of its target.
///
/// Entries are sorted by name at every level, so a capture is a deterministic value: two
/// captures of one unchanged tree are `==`, which is what makes a round-trip assertable.
///
/// It reads the whole tree into memory, so it is for the sizes a board actually has (a lane
/// and its cards) not a general-purpose archiver.
public static func captureSubtree(
at folder: URL,
operation: WriteOperation
) throws(BoardWriteError) -> SubtreeSnapshot {
try checkIsDirectory(folder, describedAs: "item folder", operation: operation)
let entries: [URL]
do {
entries = try FileManager.default.contentsOfDirectory(
at: folder,
includingPropertiesForKeys: [.isDirectoryKey, .isSymbolicLinkKey],
options: []
)
} catch {
throw BoardWriteError(
operation: operation,
path: folder.path,
reason: .unreadable(message: "could not list folder: \(error.localizedDescription)")
)
}
var captured: [SubtreeSnapshot.Entry] = []
for entry in entries.sorted(by: { $0.lastPathComponent < $1.lastPathComponent }) {
let name = entry.lastPathComponent
let values = try? entry.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey])
if values?.isSymbolicLink == true {
guard let destination = try? FileManager.default.destinationOfSymbolicLink(atPath: entry.path) else {
throw BoardWriteError(
operation: operation,
path: entry.path,
reason: .unreadable(message: "could not read symbolic link")
)
}
captured.append(.symlink(name: name, destination: destination))
} else if values?.isDirectory == true {
captured.append(.folder(try captureSubtree(at: entry, operation: operation)))
} else {
let contents: Data
do {
contents = try Data(contentsOf: entry)
} catch {
throw BoardWriteError(
operation: operation,
path: entry.path,
reason: .unreadable(message: "could not read file: \(error.localizedDescription)")
)
}
captured.append(.file(name: name, contents: contents, permissions: posixPermissions(of: entry)))
}
}
return SubtreeSnapshot(
name: folder.lastPathComponent,
permissions: posixPermissions(of: folder),
entries: captured
)
}
/// Puts a captured tree back, at `folder`, byte for byte the replay half of
/// `captureSubtree(at:operation:)`, and `recreateItem`'s rules one level of nesting wider.
///
/// - **The parent must already exist** (`withIntermediateDirectories: false`): a redo whose
/// board root has since gone must fail rather than conjure a tree in mid-air.
/// - **It refuses to clobber**: anything at `folder` fails loudly rather than being written
/// over. Restoring on top of a folder someone recreated meanwhile would silently merge two
/// trees.
/// - **The bytes are written verbatim** nothing is stamped, nothing is re-serialized, no
/// `index.md` is parsed. This replays; it does not edit.
/// - **All-or-nothing**: any failure removes the partial tree best-effort and rethrows,
/// `copyItem`'s rule a half-restored lane is pure residue, since nothing was there.
///
/// `folder`'s own name governs, not `snapshot.name`: a caller restoring to the path it removed
/// passes the same URL, and the snapshot's name is carried for identification, not as an
/// instruction.
///
/// Permissions are applied **after** a folder's children are written, so a captured read-only
/// directory does not lock out its own contents on the way back in.
public static func recreateSubtree(
at folder: URL,
from snapshot: SubtreeSnapshot,
operation: WriteOperation
) throws(BoardWriteError) {
guard !FileManager.default.fileExists(atPath: folder.path) else {
throw BoardWriteError(
operation: operation,
path: folder.path,
reason: .io(message: "something already exists here")
)
}
do throws(BoardWriteError) {
try materialize(snapshot, at: folder, operation: operation, intermediates: false)
} catch {
try? FileManager.default.removeItem(at: folder)
throw error
}
}
/// `recreateSubtree`'s recursion, minus its clobber refusal and its cleanup both belong to
/// the top-level call, which is the only one with a partial tree to remove.
private static func materialize(
_ snapshot: SubtreeSnapshot,
at folder: URL,
operation: WriteOperation,
intermediates: Bool
) throws(BoardWriteError) {
do {
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: intermediates)
} catch {
throw BoardWriteError(
operation: operation,
path: folder.path,
reason: .io(message: "could not create folder: \(error.localizedDescription)")
)
}
for entry in snapshot.entries {
switch entry {
case let .file(name, contents, permissions):
let fileURL = folder.appendingPathComponent(name)
do {
try contents.write(to: fileURL)
// An undo restore recreates whole subtrees byte for byte; the bytes are already
// in hand, so every restored file gets its own receipt rather than only the
// `index.md` at the top.
EchoLedger.current?.recordWrite(at: fileURL, data: contents)
} catch {
throw BoardWriteError(
operation: operation,
path: fileURL.path,
reason: .io(message: "could not write file: \(error.localizedDescription)")
)
}
setPosixPermissions(permissions, of: fileURL)
case let .folder(child):
try materialize(
child,
at: folder.appendingPathComponent(child.name, isDirectory: true),
operation: operation,
intermediates: false
)
case let .symlink(name, destination):
let linkURL = folder.appendingPathComponent(name)
do {
try FileManager.default.createSymbolicLink(atPath: linkURL.path, withDestinationPath: destination)
} catch {
throw BoardWriteError(
operation: operation,
path: linkURL.path,
reason: .io(message: "could not create symbolic link: \(error.localizedDescription)")
)
}
}
}
// After the children, so a captured read-only folder cannot lock out its own contents.
setPosixPermissions(snapshot.permissions, of: folder)
}
/// An item's POSIX permission bits, or `nil` when they cannot be read `attributesOfItem`
/// rather than a `URLResourceValues` key because it is `lstat`-based, so a symlink's own
/// attributes are never its target's. Best-effort by design: permissions decorate a capture,
/// and a tree that restores with default modes is a far better outcome than one that refuses
/// to restore.
private static func posixPermissions(of url: URL) -> Int? {
(try? FileManager.default.attributesOfItem(atPath: url.path))?[.posixPermissions] as? Int
}
private static func setPosixPermissions(_ permissions: Int?, of url: URL) {
guard let permissions else { return }
try? FileManager.default.setAttributes([.posixPermissions: permissions], ofItemAtPath: url.path)
}
/// Physical removal the create-undo's own primitive (13-native-undo.md: "create remove the
/// created folder"): deletes the folder tree from disk. Irreversible, and distinct from the
/// ordinary delete, which is a *move* into `.trash/` this call does **not** require the item to
@@ -2569,46 +2364,6 @@ public struct MoveResult: Sendable, Equatable {
}
}
// MARK: - Subtree vocabulary
/// A folder tree captured whole, in memory what `BoardWriter.captureSubtree(at:operation:)`
/// produces and `recreateSubtree(at:from:operation:)` replays.
///
/// **A value, deliberately**: `Sendable` so an undo step can carry it across isolation domains,
/// and `Equatable` so a capture recreate capture round trip is one assertion. Equality is
/// exact names, bytes, link destinations, permissions, and order which holds because a capture
/// sorts every level by name.
///
/// It describes bytes, never meaning. There is no `index.md` here, no frontmatter, no identity:
/// a lane, a card, an `attachments/` folder and a hand-made `notes/` are all just folders with
/// entries, which is exactly what a byte-faithful restore needs and all it may assume.
public struct SubtreeSnapshot: Sendable, Equatable {
/// The captured folder's own name. Carried for identification and for nested folders' paths;
/// the top-level replay takes its path from the caller instead (see `recreateSubtree`).
public let name: String
/// POSIX permission bits as captured, `nil` when unreadable applied best-effort on replay.
public let permissions: Int?
/// The folder's direct children, sorted by name.
public let entries: [Entry]
public init(name: String, permissions: Int?, entries: [Entry]) {
self.name = name
self.permissions = permissions
self.entries = entries
}
/// One captured child. Symlinks are their own case rather than a file holding their target's
/// bytes "symlinks are never traversed" (01-storage-format.md § Fractal layout Rules), so
/// a capture records the link and a replay recreates the link.
public enum Entry: Sendable, Equatable {
case file(name: String, contents: Data, permissions: Int?)
case folder(SubtreeSnapshot)
case symlink(name: String, destination: String)
}
}
/// How a copy stamps the files it materializes the one axis on which the two kinds of copy
/// differ (01-storage-format.md § Fractal layout Rules; § Frontmatter).
public enum CopyStamps: Sendable {
@@ -2665,8 +2420,9 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
/// the embedded `index.md` is kept for now that it is never a materialization source), `nil` for
/// an untitled item.
case paste(title: String?)
/// / a card moving into `.trash/` (`deleteCardToTrash`) or a lane being removed
/// outright (`removeLane`). The word the user pressed, whichever staging it took.
/// / a card (`deleteCardToTrash`) or a lane (`deleteLaneToTrash`) moving into `.trash/`.
/// One word for one gesture: since lanes rejoined the trash (2026-07-29) both are the same
/// physical move, and neither is destructive.
///
/// **There is no `restore` case**: restoring is an ordinary move out (`moveItem`), so a failed
/// restore says the app couldn't *move* the card which is exactly what it couldn't do
@@ -2674,15 +2430,14 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
case delete(title: String?)
case purge(title: String?)
/// A legacy `deleted:` key being migrated away a card relocating into `.trash/` with the key
/// removed, or a lane getting the key stripped and returning live (01-storage-format.md
/// § Deletion, "Legacy `deleted:` keys migrate on load-and-write, never destroy").
/// A legacy `deleted:` key being migrated away a **card** relocating into `.trash/` with the
/// key removed (01-storage-format.md § Deletion: "cards migrate, lanes ignore"; the lane half
/// retired 2026-07-29 with the lane trash, so this operation has one performer left).
///
/// Its own case rather than a fold into `.delete` or `.move`, on the vocabulary's standing
/// reasoning and `.relocateLooseFile`'s in particular: this is work the *app* started on its
/// own, on a board an older version wrote, and a banner telling the user the app "couldn't
/// delete 'Fix login'" would name a gesture they never made on a lane, one whose outcome is
/// the opposite of deletion.
/// delete 'Fix login'" would name a gesture they never made.
case migrateTombstone(title: String?)
case style(title: String?) // updateIndex on behalf of styling flows (03-board-ui.md)
case resize(title: String?) // a lane's `width` the edge drag and the stepper alike (03-board-ui.md § Lane)
+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