Materialize the trash — store, undo, and the container universe

Phase 2 swaps every consumer: Liveness and its ancestor walk are gone,
replaced by ItemContainer — a UUID set plus the container side it
lives on, presence the whole test, one selection boundary instead of
the old liveness law. Deletion stages by place: board cards move to
the trash at a store-minted head rank, trash-side delete is permanent
behind its confirmation, Delete Immediately skips the trash from
anywhere, lane delete captures the subtree and removes the folder.
Restore has no method at all — moveCards resolves members in either
container, so drag-out and cut-paste are the ordinary moves 13 calls
them, registering ordinary Move steps. The delete inverse moves the
card back to its captured lane and rank; redo replays the captured
trash rank, a value the gesture actually wrote; lane undo recreates
the subtree byte-faithfully in session. Purges register nothing —
where 13's trash section contradicts its own Rules on that, Rules
wins, filed for ruling. Staleness collapsed to present-or-absent: a
container is a path, so a foreign restore fails the delete step's
expectation structurally. Legacy tombstones migrate on the loose-file
tail hook, cards oldest-first so minting above top reproduces the
retired newest-first column, lanes returning live, one folded loss
row naming both directions. Put Back, restoreByDrag,
receiveRestoredCards, TrashEntry, and the kind machinery are deleted;
the trash column renders the container correctly with its full face
rework left to phase 3.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-28 17:47:56 -04:00
parent 16c10d61c3
commit 53bc71f7fb
53 changed files with 3459 additions and 3655 deletions
+13 -23
View File
@@ -110,7 +110,7 @@ extension BoardStore {
// gone is not one to pop: the stack is about to be cleared with the session anyway.
guard let store else { return .failed }
guard HistoryStaleness.isCurrent(expectations, under: store.rootURL) else {
guard HistoryStaleness.isCurrent(expectations) else {
store.banners.postSkippedStep(direction, subject: subject)
return .skipped
}
@@ -157,11 +157,11 @@ extension BoardStore {
/// Registers a create's step: undo removes the folders, redo puts them back byte-for-byte.
///
/// **Removal, not a tombstone**, exactly as 13 words it: an undone create leaves *no trace*,
/// because the item was born of the gesture being undone a tombstone would leave a trash row
/// for a card the user never really made. `purgeIsUnrecoverable` is untouched by this: that flag
/// is about Delete Immediately, whose loss is the user's own final gesture, while this loss is
/// one Z away.
/// **Removal, not a move into the trash**, exactly as 13 words it: an undone create leaves *no
/// trace*, because the item was born of the gesture being undone filing it in the trash would
/// leave a card the user never really made for them to find. `purgeIsUnrecoverable` is untouched
/// by this: that flag is about Delete Immediately, whose loss is the user's own final gesture,
/// while this loss is one Z away.
///
/// The honest edge, recorded rather than papered over: anything that happened *inside* the
/// created folder through an operation that registers no step of its own an attachment added by
@@ -169,19 +169,19 @@ extension BoardStore {
/// 13's own two rulings (remove the folder; attachment operations register nothing in v1) rather
/// than from anything decided here.
///
/// **Its staleness predicate is existence and liveness, and deliberately nothing else** (13:
/// "existence/liveness for create/delete/restore steps"). A create's after-value *is* the item's
/// being there, so the undo validates that the folders are still there and still live and the
/// redo that they are still gone. The same honest edge follows: a foreign *edit* inside a created
/// card does not stop Z from removing it, because the create never wrote that field while a
/// foreign *delete* does, since the trash row the user is looking at is not this step's to purge.
/// **Its staleness predicate is existence, and deliberately nothing else** (13: "existence
/// for create/delete/restore steps"). A create's after-value *is* the item's being there, so the
/// undo validates that the folders are still at their paths and the redo that they are still
/// gone. The same honest edge follows: a foreign *edit* inside a created card does not stop Z
/// from removing it, because the create never wrote that field while a foreign *delete* does,
/// since the card the user is looking at in the trash is at a different path now.
func registerCreation(_ items: [CreatedItem], kind: HistoryPhrase.Kind, subject: String? = nil) {
guard !items.isEmpty else { return }
let operation: WriteOperation = kind == .lane ? .createLane : .createCard
registerStep(
HistoryPhrase.name(.add, kind: kind, count: items.count),
subject: subject,
undoExpects: items.map { .live($0.folder) },
undoExpects: items.map { .present($0.folder) },
redoExpects: items.map { .absent($0.folder) }
) { _ in
// Reversed, so a lane and a card created by one gesture unwind child-first the same
@@ -282,14 +282,4 @@ extension BoardStore {
}
}
/// Puts an item's tombstone back **with the timestamp it carried**, not with `now`.
///
/// The inverse of Put Back is the item returning to the trash exactly where it was, and the trash
/// sorts by `deleted` (03-board-ui.md § Trash Contents): re-stamping would file the row under
/// today and quietly reorder a list the user was reading. A prior that was malformed (or, by
/// construction impossibly, missing) falls back to `now` the item has to be tombstoned, and an
/// unreadable timestamp is not a value to preserve.
static func restoreTombstone(_ prior: FieldValue<Date>, in document: inout FrontmatterDocument) {
document.set(FrontmatterKeys.deleted, to: .date(prior.value ?? Date()))
}
}
+40 -88
View File
@@ -43,40 +43,42 @@ public enum ExpectedField: Sendable, Equatable {
/// What one folder must currently hold for a step to be safe to cross the state that step's write
/// left it in.
///
/// Two halves, both of them 13's: **existence and liveness** ("target folder gone ... the step is
/// skipped"; "existence/liveness for create/delete/restore steps"), and the **field-level**
/// comparison above. A step carries one of these per item it touched, so a multi-card move validates
/// three targets and a single rename validates one which is the whole of "a foreign change to an
/// unrelated item must not skip anything": an item no step named is an item no expectation mentions.
/// Two halves, both of them 13's: **existence** ("target folder gone ... the step is skipped"),
/// and the **field-level** comparison above. A step carries one of these per item it touched, so a
/// multi-card move validates three targets and a single rename validates one which is the whole of
/// "a foreign change to an unrelated item must not skip anything": an item no step named is an item
/// no expectation mentions.
///
/// **The folder's *path* is the parent check.** A move's step expects the card at its destination
/// path; a card that a foreign writer moved elsewhere leaves nothing at that path, so the ordinary
/// existence half already answers "moved away" without a parent field of its own.
/// ### The container side rides in the folder path
///
/// **The folder's *path* is the parent check**, and since the trash was materialized that check is
/// also the container check (03-board-ui.md § Trash, resettled 2026-07-28). A delete step's undo
/// expects its card at `<root>/.trash/<id>`; a foreign restore moves the folder out, so nothing is
/// at that path and the existence half already answers "the card is not in the trash any more".
/// The mirror holds: the redo expects it back at `<root>/<lane>/<id>`, where a foreign re-delete
/// leaves nothing. That is why `Presence` is a two-case answer rather than the tombstone era's
/// three-way live/tombstoned/absent reading of a `deleted:` key there is no key to read, and no
/// ancestor to walk to find one.
public struct HistoryExpectation: Sendable, Equatable {
/// Where the item this step wrote to should be the destination for a move, the item's own
/// folder for everything else, and the board root for the board's own rename and styling.
public let folder: URL
/// Whether the item should be there, and if so on which side of the tombstone.
/// Whether the item should be there.
public let presence: Presence
/// The fields the step's write set, with the values it set them to. Empty for a step whose
/// whole subject *is* existence a create, a delete, a Put Back.
/// whole subject *is* existence a create, a lane delete.
public let fields: [ExpectedField]
/// Where an item stands, as the trash's own three-way reading of it.
/// Whether anything is at this path.
public enum Presence: Sendable, Equatable {
/// There, and rendered: no `deleted:` on the item **or on any ancestor**. Liveness is
/// effective, the same ancestor walk `BoardStore.liveItem` and the card windows' fate rule
/// apply a card under a tombstoned lane renders nowhere, so it is as gone as a deleted one.
case live
/// There, with a readable `index.md`. Which container that is, is the path's own answer.
case present
/// There, and tombstoned a trash row, or a card hidden under a tombstoned lane.
case tombstoned
/// Not there at all: the folder is gone. What an undone create leaves, and what a redone one
/// expects to find before putting it back.
/// Not there at all: the folder is gone. What an undone create and an undone lane delete
/// leave, and what a redone one expects to find before putting it back.
case absent
}
@@ -86,37 +88,21 @@ public struct HistoryExpectation: Sendable, Equatable {
self.fields = fields
}
/// The item is live and its fields say what the step set them to.
public static func live(_ folder: URL, _ fields: ExpectedField...) -> HistoryExpectation {
HistoryExpectation(folder: folder, presence: .live, fields: fields)
/// The item is at this path and its fields say what the step set them to.
public static func present(_ folder: URL, _ fields: ExpectedField...) -> HistoryExpectation {
HistoryExpectation(folder: folder, presence: .present, fields: fields)
}
/// The same, for a caller whose field list is computed the styling gesture's, which varies per
/// dimension. A label rather than a second variadic, so `.live(folder)` stays unambiguous.
public static func live(_ folder: URL, fields: [ExpectedField]) -> HistoryExpectation {
HistoryExpectation(folder: folder, presence: .live, fields: fields)
}
/// The item is tombstoned and its fields say what the step set them to.
public static func tombstoned(_ folder: URL, _ fields: ExpectedField...) -> HistoryExpectation {
HistoryExpectation(folder: folder, presence: .tombstoned, fields: fields)
/// dimension. A label rather than a second variadic, so `.present(folder)` stays unambiguous.
public static func present(_ folder: URL, fields: [ExpectedField]) -> HistoryExpectation {
HistoryExpectation(folder: folder, presence: .present, fields: fields)
}
/// Nothing is at this path.
public static func absent(_ folder: URL) -> HistoryExpectation {
HistoryExpectation(folder: folder, presence: .absent, fields: [])
}
/// The variant for a step whose target's liveness is not known until the gesture runs the Edit
/// session's, which is registered against a card that may have been tombstoned out from under
/// the buffer (05-card-window.md Deletion & lifecycle).
public static func item(
_ folder: URL,
tombstoned: Bool,
_ fields: ExpectedField...
) -> HistoryExpectation {
HistoryExpectation(folder: folder, presence: tombstoned ? .tombstoned : .live, fields: fields)
}
}
// MARK: - HistoryStaleness
@@ -140,29 +126,29 @@ public struct HistoryExpectation: Sendable, Equatable {
/// (settled ruled 2026-07-27): staleness is discovered at Z time, never by background pruning ...
/// The stack always looks full". This type has exactly one caller, `BoardStore.cross`, one line
/// before the inverse would have been written.
///
/// ### It needs no board root
///
/// The tombstone era's liveness half walked a folder's ancestors looking for a `deleted:` key, and
/// needed the root to know where to stop. Materializing the trash removed the walk: an item's
/// container is its path, and a path is checked by asking the filesystem whether anything is there.
public enum HistoryStaleness {
/// Whether every target a step named still holds what that step left there.
///
/// `root` is the board's current root, which the liveness walk stops at a lane's parent.
public static func isCurrent(_ expectations: [HistoryExpectation], under root: URL) -> Bool {
expectations.allSatisfy { isCurrent($0, under: root) }
public static func isCurrent(_ expectations: [HistoryExpectation]) -> Bool {
expectations.allSatisfy(isCurrent)
}
/// One target's answer.
///
/// A file that cannot be read or parsed fails every expectation but `.absent`: an `index.md`
/// somebody has just broken is not one holding this step's after-value, and the honest reading of
/// "the field no longer holds it" covers a field that can no longer be read at all.
public static func isCurrent(_ expectation: HistoryExpectation, under root: URL) -> Bool {
/// A file that cannot be read or parsed fails a `.present` expectation: an `index.md` somebody
/// has just broken is not one holding this step's after-value, and the honest reading of "the
/// field no longer holds it" covers a field that can no longer be read at all.
public static func isCurrent(_ expectation: HistoryExpectation) -> Bool {
guard expectation.presence != .absent else {
return !FileManager.default.fileExists(atPath: expectation.folder.path)
}
guard let document = index(at: expectation.folder) else { return false }
let tombstoned = isEffectivelyTombstoned(expectation.folder, document: document, under: root)
guard tombstoned == (expectation.presence == .tombstoned) else { return false }
return expectation.fields.allSatisfy { matches($0, in: document) }
}
@@ -192,40 +178,6 @@ public enum HistoryStaleness {
}
}
// MARK: Liveness
/// Whether the item at `folder` renders **presence of `deleted:`, not its validity**
/// (`Lane.isDeleted`'s rule), walked up through the ancestors the way every other liveness
/// question in this app is.
///
/// The board root is never tombstoned however its own frontmatter reads: a board-level `deleted:`
/// is a tolerated load *warning* (01-storage-format.md § Deletion), not a state that hides the
/// board from itself.
private static func isEffectivelyTombstoned(
_ folder: URL,
document: FrontmatterDocument,
under root: URL
) -> Bool {
guard !isRoot(folder, root) else { return false }
guard document.deleted.isMissing else { return true }
// Lane and card are the only levels below the root, so this walks at most twice; the bound
// is there so a folder that is not under this root at all (a step registered before a
// mid-session root change) ends rather than climbing to `/`.
var parent = folder.deletingLastPathComponent()
for _ in 0 ..< 4 {
guard !isRoot(parent, root) else { return false }
guard let ancestor = index(at: parent) else { return false }
if !ancestor.deleted.isMissing { return true }
parent = parent.deletingLastPathComponent()
}
return false
}
private static func isRoot(_ folder: URL, _ root: URL) -> Bool {
folder.standardizedFileURL.path == root.standardizedFileURL.path
}
// MARK: Reading
/// The item's `index.md` as the app reads it, or `nil` when there is no readable, parseable one