Every comment-trash purge kneels to the ownership gate — the container-whole retirement retires
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
@@ -17,7 +17,7 @@ The undo/redo substrate for **every board without app-managed git, in every tier
|
||||
|
||||
⌫'s undo is the move back — a delete is a move into `.trash/` (cards resettled 2026-07-28; lanes rejoined 2026-07-29), so its undo is the ordinary inverse move, returning a card to its source lane and rank, a lane to its strip position (subtree intact — it never left the folder); a restore-by-move undoes the same way in reverse. The stack and the trash never conflict — they are the same folder moves addressed by recency instead of by selection. The old lane-delete recreate-from-capture inverse is **retired** — no destructive delete remains outside a trash, so nothing needs byte capture. A **permanent delete registers no step** — the trash's Delete and Empty Trash are not undoable (Rules above), lanes and their freight included; the confirm is the safety.
|
||||
|
||||
**Comments keep the no-capture rule true — on the window stack** (re-ruled 2026-07-31, superseding the board-stack routing): a comment delete is a move into the card's `comments/.trash/` (01-storage-format.md ▸ Enhanced schema — the materialized-trash pattern one level down), its inverse the ordinary move back, and the step lives on the **card window's own stack** (Rules above) — the board stack never carries a granular comment step, so the old stale-after-close skip scenario cannot arise. **The purge of `comments/.trash/` defers with the coarse step** (re-ruled 2026-07-31, superseding purge-at-close): the coarse close step's undo restores deleted comments, so their backing lives as long as the step does — the purge runs when the coarse step leaves the board stack **cleanly** — undone-and-superseded, or dropped off the end — or when the board session ends; **a stale-skipped step's backing instead survives to board-session end** (ruled 2026-07-31, decoupling skip from purge): the skip banner says nothing was applied, and an irreversible purge riding that gesture would be surprise loss — the skip is exactly when the user may want to inspect what the collision left; crash residue still sweeps at the next card-window open — **and residue is defined by the purge-deferral condition itself** (ruled 2026-07-31): `comments/.trash/` content referenced by a live coarse step on the board stack is a step's backing, not residue — the open-time sweep consults the stack and skips owned content, re-arming when the owning step leaves the stack (which is exactly when the deferred purge wanted to run; one condition, two consumers). Reopening a window can therefore never destroy its prior session's undo backing. Unowned content sweeps as before, armed-then-cleared like every heal memo. On Pro the substrate is history: the close commit nets delete-plus-purge to a removal, revert restores it, so purge rides the close flush there as before — purge timing follows the undo substrate's need.
|
||||
**Comments keep the no-capture rule true — on the window stack** (re-ruled 2026-07-31, superseding the board-stack routing): a comment delete is a move into the card's `comments/.trash/` (01-storage-format.md ▸ Enhanced schema — the materialized-trash pattern one level down), its inverse the ordinary move back, and the step lives on the **card window's own stack** (Rules above) — the board stack never carries a granular comment step, so the old stale-after-close skip scenario cannot arise. **The purge of `comments/.trash/` defers with the coarse step** (re-ruled 2026-07-31, superseding purge-at-close): the coarse close step's undo restores deleted comments, so their backing lives as long as the step does — the purge runs when the coarse step leaves the board stack **cleanly** — undone-and-superseded, or dropped off the end — or when the board session ends; **a stale-skipped step's backing instead survives to board-session end** (ruled 2026-07-31, decoupling skip from purge): the skip banner says nothing was applied, and an irreversible purge riding that gesture would be surprise loss — the skip is exactly when the user may want to inspect what the collision left; crash residue still sweeps at the next card-window open — **and residue is defined by the purge-deferral condition itself** (ruled 2026-07-31): `comments/.trash/` content referenced by a live coarse step on the board stack is a step's backing, not residue — the open-time sweep consults the stack and skips owned content, re-arming when the owning step leaves the stack (which is exactly when the deferred purge wanted to run; one condition, two consumers). Reopening a window can therefore never destroy its prior session's undo backing. Unowned content sweeps as before, armed-then-cleared like every heal memo. **Every purge of `comments/.trash/` is per-entry behind the ownership gate** (ruled 2026-08-06 — the container-whole retirement purge retires): a step's retirement and a no-step close remove only entries no live step still backs — the same `backedContent` inventory the sweep consults, making it one condition, *three* consumers. The container-whole purge assumed one owning step per card's comment trash, and two sessions over the same card broke it: the second step's retirement — or a mere reopen-and-close that registered nothing — emptied the first step's backing out from under it, silently killing an undo the stack still promised. Under the gate a purge cannot stale a live step by construction; an entry that outlives its owner is collected by whichever consumer runs next (the next retirement, close, open-time sweep, or session end — convergence, not a leak). One carve-out: **an open card window is itself an owner of its card's comment trash** — a retirement firing while the card's window is open defers its purge to that window's close, because entries deleted in the live session are backed by the window's fine steps, which the board-stack inventory cannot see; the close then settles by the same gate (its coarse step becomes the owner, or the no-step close purges the unowned). On Pro the substrate is history: the close commit nets delete-plus-purge to a removal, revert restores it, so purge rides the close flush there as before — purge timing follows the undo substrate's need.
|
||||
|
||||
## Out of scope
|
||||
|
||||
|
||||
@@ -105,6 +105,21 @@ final class CardWindowSession: CardSessionFlushing {
|
||||
/// object's to run, which is also true.
|
||||
var registerSessionStep: (@MainActor (CardWindowUndo, @escaping @MainActor () -> Void) -> Bool)?
|
||||
|
||||
/// **Where the close gives up this window's ownership of its card's comment trash** —
|
||||
/// `BoardStore.cardWindowDidClose(inCard:)`, wired by the host beside `registerSessionStep`
|
||||
/// (13-native-undo.md ▸ Interaction with the trash, ruled 2026-08-06):
|
||||
///
|
||||
/// > "an open card window is itself an owner of its card's comment trash — a retirement firing
|
||||
/// > while the card's window is open defers its purge to that window's close ... the close then
|
||||
/// > settles by the same gate."
|
||||
///
|
||||
/// A seam rather than a store reference, `registerSessionStep`'s reason: this object is a
|
||||
/// lifecycle and stays testable by having no idea what a board is. `nil` — a window that never
|
||||
/// joined its board — owns nothing on any store, which is also true.
|
||||
///
|
||||
/// Its **order** is the load-bearing part, and `endSession()` is where it is spelled.
|
||||
var endCardWindowOwnership: (@MainActor () -> Void)?
|
||||
|
||||
/// The Edit buffer's dirty text, a typed-in raw-source outlet, or an inline comment edit session
|
||||
/// holding keystrokes its file has not got — see `CardSessionFlushing`.
|
||||
///
|
||||
@@ -199,6 +214,15 @@ final class CardWindowSession: CardSessionFlushing {
|
||||
// still restores comments out of it. Registering answers whether the step took the purge on —
|
||||
// and a board whose substrate keeps no steps has already run it by the time that answer comes
|
||||
// back, which is how Pro keeps purging at the close flush without a word about tiers here.
|
||||
//
|
||||
// **This window stops owning its card's comment trash first** (ruled 2026-08-06 — the
|
||||
// open-window carve-out). While the window is open the store defers every purge of this
|
||||
// card's trash, because the entries this session deleted are backed by *this* stack's fine
|
||||
// steps and the board-stack inventory cannot see them. The close is where that stops being
|
||||
// true, and it has to stop being true **before** either branch below runs: a no-step close
|
||||
// that unmarked itself afterwards would defer its own purge into a no-op, and a coarse step
|
||||
// registered here can retire in the same breath (its registration clears the redo stack).
|
||||
endCardWindowOwnership?()
|
||||
let purge: @MainActor () -> Void = { [comments] in comments.purgeTrashNow() }
|
||||
if registerSessionStep?(undo, purge) != true {
|
||||
purge()
|
||||
@@ -609,7 +633,17 @@ struct CardWindowHost: View {
|
||||
/// The pane's two window-scoped facts are set *before* the read, because both of them are things
|
||||
/// the read's results are resolved against: the folder every comment's attachments hang off, and
|
||||
/// whether the lock is on.
|
||||
///
|
||||
/// **And the window takes ownership of its card's comment trash here** (13-native-undo.md ▸
|
||||
/// Interaction with the trash, ruled 2026-08-06): "an open card window is itself an owner of its
|
||||
/// card's comment trash". From this line until `CardWindowSession.endSession` gives it back, a
|
||||
/// purge of this card's trash — a foreign step's retirement, another window's close — defers
|
||||
/// entirely, because the fine steps backing this session's deletes live on a stack the board's
|
||||
/// inventory cannot see. The sweep below is unaffected and runs as it always has: it is gated on
|
||||
/// `backedContent`, and a window that has not made a gesture yet holds nothing that gate is
|
||||
/// missing.
|
||||
private func openCommentThread(store: BoardStore) {
|
||||
store.cardWindowDidOpen(inCard: ref.cardIdentity)
|
||||
if case let .shows(placement) = Self.cardWindowFate(cardID: ref.cardID, in: store.snapshot) {
|
||||
session.comments.cardFolder = Self.cardFolder(root: store.rootURL, placement: placement)
|
||||
}
|
||||
@@ -689,6 +723,11 @@ struct CardWindowHost: View {
|
||||
session.registerSessionStep = { [weak store] undo, purge in
|
||||
store?.registerCardSession(undo, inCard: cardID, retiring: purge) ?? false
|
||||
}
|
||||
// The close half of the open-window carve-out (13 ▸ Interaction with the trash, ruled
|
||||
// 2026-08-06). Its open half is `CardWindowHost.openCommentThread`, beside the residue sweep;
|
||||
// the pair has to be wired from the two places the window already calls into the store, and
|
||||
// these are they. A released store owns nothing to give up, which is what `weak` says.
|
||||
session.endCardWindowOwnership = { [weak store] in store?.cardWindowDidClose(inCard: cardID) }
|
||||
}
|
||||
|
||||
/// Points the comments pane at its card — **the one place every comment gesture learns which card
|
||||
|
||||
@@ -541,6 +541,48 @@ public final class BoardStore: HealHost {
|
||||
@ObservationIgnored
|
||||
public var commitSeam: HistoryCommitSeam?
|
||||
|
||||
// MARK: The open card windows
|
||||
|
||||
/// **Which of this board's cards have a card window open right now** — one fact, kept for one
|
||||
/// consumer: the `comments/.trash/` purge's ownership gate (13-native-undo.md ▸ Interaction with
|
||||
/// the trash, ruled 2026-08-06).
|
||||
///
|
||||
/// > "One carve-out: **an open card window is itself an owner of its card's comment trash** — a
|
||||
/// > retirement firing while the card's window is open defers its purge to that window's close,
|
||||
/// > because entries deleted in the live session are backed by the window's fine steps, which the
|
||||
/// > board-stack inventory cannot see; the close then settles by the same gate."
|
||||
///
|
||||
/// A window's fine comment steps live on the *window's* stack (`CardWindowUndo`), which
|
||||
/// `history.backedContent` — the board's stack — cannot see. So while a window is open the
|
||||
/// inventory is knowingly incomplete for that card, and the only honest answer to a purge is *not
|
||||
/// yet*. `purgeCommentTrash(inCard:)` is the one reader.
|
||||
///
|
||||
/// **Deliberately not a reference to the window.** The store is the *board's*, a card window is a
|
||||
/// scene, and 02-architecture.md § Components has the ownership pointing the other way; what the
|
||||
/// gate needs is an identity, not an object. It is also why this is a `Set` rather than a count:
|
||||
/// a card window's identity is `(board, card)` (`CardWindowRef`), so a card has at most one.
|
||||
///
|
||||
/// A window that never unmarked — a store torn down under an open window — costs nothing beyond a
|
||||
/// purge that did not run, which the next open's residue sweep collects. Convergence, not a leak.
|
||||
@ObservationIgnored
|
||||
public private(set) var openCardWindows: Set<ItemID> = []
|
||||
|
||||
/// **A card window opened over this card** — called from the window's open, beside the crash-residue
|
||||
/// sweep it already runs there (`CardWindowHost.openCommentThread`).
|
||||
public func cardWindowDidOpen(inCard id: ItemID) {
|
||||
openCardWindows.insert(id)
|
||||
}
|
||||
|
||||
/// **A card window over this card is closing** — called from the window's session end, and
|
||||
/// **before** that close's own purge (`CardWindowSession.endSession`).
|
||||
///
|
||||
/// The order is the whole of the carve-out's honesty: the close is the moment the window stops
|
||||
/// being an owner, so a close that unmarked itself *after* running its purge would defer that
|
||||
/// purge into a no-op and hand the work to nobody.
|
||||
public func cardWindowDidClose(inCard id: ItemID) {
|
||||
openCardWindows.remove(id)
|
||||
}
|
||||
|
||||
// MARK: Reload machinery
|
||||
|
||||
/// Monotonic id of the most recently *started* reload — and therefore also the number of tree
|
||||
|
||||
@@ -57,11 +57,12 @@ public enum CommentTarget: Sendable, Equatable {
|
||||
/// - **`saveCommentDraft` registers nothing**: a draft is a durable file being edited in place, with
|
||||
/// no session boundary to coalesce at and no meaning for "undo" that emptying it does not already
|
||||
/// have (05-card-window.md ▸ The comments column).
|
||||
/// - **`purgeCommentTrash` registers nothing** — the permanent-delete posture. It is also what makes
|
||||
/// the interaction with the stack correct for free: leftover comment steps go stale after a purge
|
||||
/// and skip with the ordinary info-tone banner, because the folders their expectations name are
|
||||
/// gone. **The expectations validate disk, never the snapshot** (`HistoryStaleness`), which is
|
||||
/// exactly why this works — comments are not in the snapshot at all.
|
||||
/// - **`purgeCommentTrash` registers nothing** — the permanent-delete posture. What it *does* consult
|
||||
/// is the stack: since "every purge of `comments/.trash/` is per-entry behind the ownership gate"
|
||||
/// (13 ▸ Interaction with the trash, ruled 2026-08-06) a purge removes only what no live step backs,
|
||||
/// so it can no longer stale a step by construction. Lazy invalidation still stands for the
|
||||
/// staleness a *foreign* hand leaves — **the expectations validate disk, never the snapshot**
|
||||
/// (`HistoryStaleness`), which is exactly why that works: comments are not in the snapshot at all.
|
||||
@MainActor
|
||||
extension BoardStore {
|
||||
|
||||
@@ -309,30 +310,65 @@ extension BoardStore {
|
||||
|
||||
// MARK: The purge, and the crash residue it leaves
|
||||
|
||||
/// **Empties one card's `comments/.trash/`** — **when undo no longer needs it** (01-storage-format.md
|
||||
/// § Enhanced schema, re-ruled 2026-07-31; 13-native-undo.md ▸ Interaction with the trash).
|
||||
/// **Purges one card's `comments/.trash/`, entry by entry, of everything undo no longer needs**
|
||||
/// (01-storage-format.md § Enhanced schema, re-ruled 2026-07-31; 13-native-undo.md ▸ Interaction
|
||||
/// with the trash, re-ruled 2026-08-06).
|
||||
///
|
||||
/// The call site moved with that re-ruling and this method did not change: the window's close no
|
||||
/// longer purges on its own, it hands this work to the coarse close step as that step's
|
||||
/// **retirement** (`HistoryStep.Retirement`), and the purge runs when the step leaves the board
|
||||
/// stack **cleanly** — undone-and-superseded, or dropped off the end — or when the board session
|
||||
/// ends. A **stale skip is not a clean exit** and does not purge (the decoupling ruled
|
||||
/// 2026-07-31): that step's backing survives to session end instead, where `clear()` retires it
|
||||
/// and this method finally runs. On a git board the step is never kept, so the retirement fires at
|
||||
/// the close flush, which is where the purge always ran there ("purge rides the close flush").
|
||||
/// The call site moved with the 2026-07-31 re-ruling: the window's close no longer purges on its
|
||||
/// own, it hands this work to the coarse close step as that step's **retirement**
|
||||
/// (`HistoryStep.Retirement`), and the purge runs when the step leaves the board stack **cleanly**
|
||||
/// — undone-and-superseded, or dropped off the end — or when the board session ends. A **stale
|
||||
/// skip is not a clean exit** and does not purge (the decoupling ruled 2026-07-31): that step's
|
||||
/// backing survives to session end instead, where `clear()` retires it and this method finally
|
||||
/// runs. On a git board the step is never kept, so the retirement fires at the close flush, which
|
||||
/// is where the purge always ran there ("purge rides the close flush").
|
||||
///
|
||||
/// **It empties the folder whole, and its two callers are exactly the moments that is right**: a
|
||||
/// close that registered no step (nothing took the hold), and the retirement of the step that
|
||||
/// did (the hold has just ended). The *sweep* cannot assume either, which is why it purges per
|
||||
/// entry behind the ownership gate — `sweepCommentTrashResidue(inCard:)`.
|
||||
/// ### Entries, not the container — the ownership gate, on this side too
|
||||
///
|
||||
/// One bracket, no step. Leftover comment steps on a stack are not pruned here and must not be:
|
||||
/// invalidation is lazy (13 ▸ Rules), so they stay on the stack, look full, and skip with the
|
||||
/// ordinary info-tone banner the first time one is crossed.
|
||||
/// What changed on 2026-08-06 is *what* a purge is allowed to remove:
|
||||
///
|
||||
/// > "**Every purge of `comments/.trash/` is per-entry behind the ownership gate** (ruled
|
||||
/// > 2026-08-06 — the container-whole retirement purge retires): a step's retirement and a no-step
|
||||
/// > close remove only entries no live step still backs — the same `backedContent` inventory the
|
||||
/// > sweep consults, making it one condition, *three* consumers. The container-whole purge assumed
|
||||
/// > one owning step per card's comment trash, and two sessions over the same card broke it: the
|
||||
/// > second step's retirement — or a mere reopen-and-close that registered nothing — emptied the
|
||||
/// > first step's backing out from under it, silently killing an undo the stack still promised.
|
||||
/// > Under the gate a purge cannot stale a live step by construction; an entry that outlives its
|
||||
/// > owner is collected by whichever consumer runs next (the next retirement, close, open-time
|
||||
/// > sweep, or session end — convergence, not a leak)."
|
||||
///
|
||||
/// So this reads the same inventory `sweepCommentTrashResidue(inCard:)` reads, through the same
|
||||
/// filter (`unownedTrashedComments`), and removes with the same per-entry primitive. The two
|
||||
/// consumers differ only in their bracket and their trigger, which is all they ever should have.
|
||||
///
|
||||
/// ### And the carve-out the inventory cannot cover
|
||||
///
|
||||
/// > "One carve-out: **an open card window is itself an owner of its card's comment trash** — a
|
||||
/// > retirement firing while the card's window is open defers its purge to that window's close,
|
||||
/// > because entries deleted in the live session are backed by the window's fine steps, which the
|
||||
/// > board-stack inventory cannot see; the close then settles by the same gate."
|
||||
///
|
||||
/// `openCardWindows` is that ownership, and the deferral is total rather than per entry: a live
|
||||
/// session's fine steps are on the *window's* stack, so `backedContent` is not merely incomplete
|
||||
/// about them, it is silent. The close is where the answer becomes knowable again — its coarse
|
||||
/// step becomes the owner, or the no-step close arrives here with the window already unmarked
|
||||
/// (`BoardStore.cardWindowDidClose(inCard:)`, run first by `CardWindowSession.endSession`).
|
||||
///
|
||||
/// **One bracket, no step — and no bracket at all for an empty remainder**, which is every close
|
||||
/// on a card nothing was deleted in. Leftover comment steps on a stack are not pruned here and
|
||||
/// must not be: invalidation is lazy (13 ▸ Rules), so a step whose backing something *else* removed
|
||||
/// stays on the stack, looks full, and skips with the ordinary info-tone banner the first time it
|
||||
/// is crossed. Under the gate this method is no longer one of those somethings.
|
||||
public func purgeCommentTrash(inCard id: ItemID) {
|
||||
// The carve-out, before anything else: an open window owns its card's whole comment trash.
|
||||
guard !openCardWindows.contains(id) else { return }
|
||||
guard let card = commentSubject(id) else { return }
|
||||
let folder = card.folder
|
||||
let unowned = unownedTrashedComments(inCard: id, at: folder)
|
||||
guard !unowned.isEmpty else { return }
|
||||
try? performWrite { () throws(BoardWriteError) -> Void in
|
||||
_ = try BoardWriter.purgeCommentTrash(inCard: card.folder)
|
||||
try Self.purgeTrashedComments(unowned, inCard: folder)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -365,16 +401,22 @@ extension BoardStore {
|
||||
/// **A step a stale skip popped still counts as an owner** (the skip-purge decoupling, ruled
|
||||
/// 2026-07-31): "a stale-skipped step's backing instead survives to board-session end ... the skip
|
||||
/// is exactly when the user may want to inspect what the collision left". Nothing here says so —
|
||||
/// that is `backedContent`'s answer, and keeping it there is what makes the pair one condition
|
||||
/// read twice rather than two conditions kept in step by hand.
|
||||
/// see `unownedTrashedComments(inCard:at:)`, which is where the whole gate now lives.
|
||||
///
|
||||
/// ### Entries, not the container
|
||||
/// ### Entries, not the container — which is now every purge's posture
|
||||
///
|
||||
/// Which is why this no longer calls `BoardWriter.purgeCommentTrash(inCard:)` — that empties the
|
||||
/// folder whole, and the whole folder is exactly what this may not assume it owns. The per-entry
|
||||
/// primitive is the same one an undone create removes its folder with, over the entries the
|
||||
/// listing already narrowed to identity shape; a hand-editor's stray in there keeps the verbatim
|
||||
/// posture either way.
|
||||
/// This was the first consumer to purge per entry, because the whole folder is exactly what it may
|
||||
/// not assume it owns. It is no longer the only one: **"every purge of `comments/.trash/` is
|
||||
/// per-entry behind the ownership gate ... the same `backedContent` inventory the sweep consults,
|
||||
/// making it one condition, *three* consumers"** (13 ▸ Interaction with the trash, ruled
|
||||
/// 2026-08-06). The container-whole `BoardWriter.purgeCommentTrash(inCard:)` retired from
|
||||
/// production with that ruling; what this and `purgeCommentTrash(inCard:)` share is the filter
|
||||
/// (`unownedTrashedComments`) and the removal (`purgeTrashedComments`), so the two can differ only
|
||||
/// in bracket and trigger.
|
||||
///
|
||||
/// The per-entry primitive is the same one an undone create removes its folder with, over the
|
||||
/// entries the listing already narrowed to identity shape; a hand-editor's stray in there keeps
|
||||
/// the verbatim posture either way.
|
||||
///
|
||||
/// The rest is the same six steps every scheduled heal gets, through the same engine: **rest**
|
||||
/// when there is nothing unowned (which is every open on a board that closed cleanly, and costs no
|
||||
@@ -392,21 +434,48 @@ extension BoardStore {
|
||||
public func sweepCommentTrashResidue(inCard id: ItemID) {
|
||||
guard let card = commentSubject(id) else { return }
|
||||
let folder = card.folder
|
||||
// The board's stack, never a window's: a window's own fine comment steps die with the window
|
||||
// that owns them, and the window this sweep runs for has not made a gesture yet. What can
|
||||
// outlive a close is the coarse step the close folded the session into, and that is here.
|
||||
let backed = history?.backedContent ?? []
|
||||
let residue = CommentThread.trashedCommentIDs(inCard: folder).filter { commentID in
|
||||
!backed.contains(.trashedComment(commentID, inCard: id))
|
||||
}
|
||||
let residue = unownedTrashedComments(inCard: id, at: folder)
|
||||
heals.run(
|
||||
.commentTrashResidue,
|
||||
signature: Set(residue.map { "comment-trash:\(card.path)/\($0.rawValue)" }),
|
||||
on: self
|
||||
) { () throws(BoardWriteError) -> Void in
|
||||
for commentID in residue {
|
||||
try BoardWriter.purgeItem(at: CommentThread.trashedCommentFolder(commentID, inCard: folder))
|
||||
}
|
||||
try Self.purgeTrashedComments(residue, inCard: folder)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: The one condition the three consumers read
|
||||
|
||||
/// **The entries in one card's `comments/.trash/` no live step is holding** — the ownership gate,
|
||||
/// as a list (13-native-undo.md ▸ Interaction with the trash, ruled 2026-07-31 and widened
|
||||
/// 2026-08-06 to "every purge").
|
||||
///
|
||||
/// **The board's stack, never a window's**: a window's own fine comment steps die with the window
|
||||
/// that owns them, and what can outlive a close is the coarse step the close folded the session
|
||||
/// into, which is here. That is also exactly why an *open* window is given ownership of its card's
|
||||
/// whole trash by `purgeCommentTrash(inCard:)` rather than by anything in this list — a live
|
||||
/// session's holds are not merely missing from `backedContent`, they are invisible to it.
|
||||
///
|
||||
/// **A step a stale skip popped still counts as an owner** (the skip-purge decoupling, ruled
|
||||
/// 2026-07-31). Nothing here says so — that is `backedContent`'s answer, and keeping it there is
|
||||
/// what makes the pair one condition read three times rather than three conditions kept in step by
|
||||
/// hand.
|
||||
private func unownedTrashedComments(inCard id: ItemID, at folder: URL) -> [ItemID] {
|
||||
let backed = history?.backedContent ?? []
|
||||
return CommentThread.trashedCommentIDs(inCard: folder).filter { commentID in
|
||||
!backed.contains(.trashedComment(commentID, inCard: id))
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes the named entries from one card's `comments/.trash/`, in order — the removal half the
|
||||
/// sweep and the purge share, so "per entry, never the container" has one spelling.
|
||||
///
|
||||
/// A failure stops the batch and throws, and everything already removed stays removed:
|
||||
/// `purgeItem(at:)`'s posture, unchanged, and the reason the callers can each keep their own
|
||||
/// bracket around it.
|
||||
private static func purgeTrashedComments(_ ids: [ItemID], inCard folder: URL) throws(BoardWriteError) {
|
||||
for commentID in ids {
|
||||
try BoardWriter.purgeItem(at: CommentThread.trashedCommentFolder(commentID, inCard: folder))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3084,8 +3084,10 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
|
||||
/// note is the precedent one level up.
|
||||
case deleteComment(title: String?)
|
||||
|
||||
/// `comments/.trash/` emptied — at card-window close, and as the crash-residue sweep at the next
|
||||
/// open (§ Enhanced schema).
|
||||
/// `comments/.trash/` emptied whole — the container-wide purge
|
||||
/// (`BoardWriter.purgeCommentTrash(inCard:)`), which 13-native-undo.md retired from production on
|
||||
/// 2026-08-06: both purge consumers now remove per entry behind the ownership gate, through
|
||||
/// `purgeItem(at:)` and therefore under `.purge`. Kept with its primitive — see that method.
|
||||
///
|
||||
/// **No payload, unlike its four siblings**, and for `.agentGuide`'s reason: this is bookkeeping
|
||||
/// the app does on its own over a folder that is "never a UI surface", with one outcome nobody
|
||||
|
||||
@@ -264,10 +264,26 @@ extension BoardWriter {
|
||||
try moveComment(trashed, into: CommentThread.folder(inCard: cardFolder), operation: operation)
|
||||
}
|
||||
|
||||
/// **Empties one card's `comments/.trash/`** — at card-window close, and as the crash-residue
|
||||
/// sweep at the next open (01-storage-format.md § Enhanced schema: "purged when the card window
|
||||
/// closes (rides the close flush; crash residue sweeps at the next card-window open,
|
||||
/// armed-then-cleared like every heal memo)").
|
||||
/// **Empties one card's `comments/.trash/` whole** — and **no production caller does that any
|
||||
/// more** (13-native-undo.md ▸ Interaction with the trash, ruled 2026-08-06):
|
||||
///
|
||||
/// > "**Every purge of `comments/.trash/` is per-entry behind the ownership gate** (ruled
|
||||
/// > 2026-08-06 — the container-whole retirement purge retires) ... The container-whole purge
|
||||
/// > assumed one owning step per card's comment trash, and two sessions over the same card broke
|
||||
/// > it."
|
||||
///
|
||||
/// Both consumers — the retirement/no-step-close purge and the open-time residue sweep — now
|
||||
/// filter by `HistoryProviding.backedContent` and remove per entry through `purgeItem(at:)`
|
||||
/// (`BoardStore.purgeCommentTrash(inCard:)`, `BoardStore.sweepCommentTrashResidue(inCard:)`). A
|
||||
/// primitive that empties the container cannot express the gate, so nothing above it may call this.
|
||||
///
|
||||
/// It is **kept rather than deleted**, deliberately and narrowly: `WriteOperation.purgeCommentTrash`
|
||||
/// is the vocabulary's word for this work and owns a bespoke user-facing sentence
|
||||
/// (`BannerCenter` — "Couldn't tidy up deleted comments") plus rows in three exhaustive switches,
|
||||
/// and retiring the case would be a ripple across files for no behavioural gain. Read this as
|
||||
/// legacy: the shape the ruling retired, still exercised by `CommentWriteTests` so the case's
|
||||
/// plumbing stays honest, and the home of the operation vocabulary the gated purge's per-entry
|
||||
/// failures do not use.
|
||||
///
|
||||
/// `emptyTrash`'s rules, one level down and for its reasons:
|
||||
///
|
||||
@@ -278,9 +294,7 @@ extension BoardWriter {
|
||||
/// removed stays removed.
|
||||
/// - A card with no thread trash removes nothing and answers `[]`.
|
||||
///
|
||||
/// **It registers no undo step** — the permanent-delete posture (13-native-undo.md), which is
|
||||
/// also what makes the leftover comment steps on the board stack go stale and skip with a banner
|
||||
/// rather than resurrect a folder that is gone.
|
||||
/// **It registers no undo step** — the permanent-delete posture (13-native-undo.md).
|
||||
@discardableResult
|
||||
public static func purgeCommentTrash(inCard cardFolder: URL) throws(BoardWriteError) -> [ItemID] {
|
||||
let operation = WriteOperation.purgeCommentTrash
|
||||
|
||||
@@ -453,6 +453,203 @@ struct CommentResidueTests {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The purge's ownership gate
|
||||
|
||||
/// **Every purge of `comments/.trash/` is per-entry behind the ownership gate** (13-native-undo.md
|
||||
/// ▸ Interaction with the trash, ruled 2026-08-06):
|
||||
///
|
||||
/// > "a step's retirement and a no-step close remove only entries no live step still backs — the same
|
||||
/// > `backedContent` inventory the sweep consults, making it one condition, *three* consumers. The
|
||||
/// > container-whole purge assumed one owning step per card's comment trash, and two sessions over the
|
||||
/// > same card broke it: the second step's retirement — or a mere reopen-and-close that registered
|
||||
/// > nothing — emptied the first step's backing out from under it, silently killing an undo the stack
|
||||
/// > still promised."
|
||||
///
|
||||
/// The sweep's own half of that condition is `CommentResidueTests` above; the close-and-retirement
|
||||
/// arcs through a real card window are `CardSessionUndoTests`'. What is here is the gate itself, at
|
||||
/// the one method the ruling retired the container-whole behaviour from.
|
||||
@MainActor
|
||||
@Suite("Comments ▸ the purge's ownership gate")
|
||||
struct CommentPurgeGateTests {
|
||||
|
||||
private let cardID = ItemID(rawValue: Ident.card1)
|
||||
|
||||
/// One coarse close step's shape, as the fold registers it: a backing claim over one trashed
|
||||
/// comment, and a retirement that runs the deferred purge.
|
||||
private func coarseStep(
|
||||
holding commentID: String,
|
||||
purging store: BoardStore,
|
||||
inCard id: ItemID
|
||||
) -> HistoryStep {
|
||||
HistoryStep(
|
||||
name: "Changes to '\(cardTitle)'",
|
||||
backing: [.trashedComment(ItemID(rawValue: commentID), inCard: id)],
|
||||
retirement: HistoryStep.Retirement { store.purgeCommentTrash(inCard: id) },
|
||||
undo: { _ in .applied },
|
||||
redo: { _ in .applied }
|
||||
)
|
||||
}
|
||||
|
||||
@Test("A purge removes what no step names and spares what one does")
|
||||
func thePurgeIsPartitionedByOwnership() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let card = try makeCommentBoard(fixture)
|
||||
try fixture.item(commentPath(CommentIdent.one, inCard: card), commentText())
|
||||
// Seeded rather than deleted: a previous session's leftover, which nothing on this stack names.
|
||||
try fixture.item("\(card)/comments/.trash/\(CommentIdent.two)", commentText())
|
||||
// The stack is bound, not discarded: `BoardStore.history` is weak, so a provider nobody holds
|
||||
// is a board with no backing at all.
|
||||
let (store, history) = try makeStore(fixture)
|
||||
|
||||
#expect(store.deleteComment(ItemID(rawValue: CommentIdent.one), inCard: cardID))
|
||||
#expect(history.canUndo)
|
||||
store.purgeCommentTrash(inCard: cardID)
|
||||
|
||||
#expect(fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)"), "a live step backs this one")
|
||||
#expect(!fixture.exists("\(card)/comments/.trash/\(CommentIdent.two)"), "and nothing backs this one")
|
||||
}
|
||||
|
||||
@Test("A retirement's purge leaves another live step's backing standing")
|
||||
func aRetirementSparesTheOtherStepsBacking() throws {
|
||||
// The defect the ruling names, in the shape it takes on disk: two sessions over one card, each
|
||||
// leaving a coarse step holding its own entry. The first step retiring must not empty the
|
||||
// second's backing out from under it.
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let card = try makeCommentBoard(fixture)
|
||||
try fixture.item("\(card)/comments/.trash/\(CommentIdent.one)", commentText())
|
||||
try fixture.item("\(card)/comments/.trash/\(CommentIdent.two)", commentText())
|
||||
let (store, history) = try makeStore(fixture)
|
||||
|
||||
let first = coarseStep(holding: CommentIdent.one, purging: store, inCard: cardID)
|
||||
history.register(first)
|
||||
// Undone-and-superseded is the clean exit the ruling names, and the only one that retires
|
||||
// exactly one step: the undo puts `first` on the redo stack, and registering the second
|
||||
// session's step clears it — retiring `first` while `second` is already live.
|
||||
history.undo()
|
||||
history.register(coarseStep(holding: CommentIdent.two, purging: store, inCard: cardID))
|
||||
|
||||
#expect(!first.retirement!.isOwed, "the superseded step retired, and its purge ran")
|
||||
#expect(!fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)"), "its own backing went with it")
|
||||
#expect(fixture.exists("\(card)/comments/.trash/\(CommentIdent.two)"),
|
||||
"and the other session's backing is still there for its ⌘Z")
|
||||
}
|
||||
|
||||
@Test("An open card window owns its card's comment trash — the purge defers entirely")
|
||||
func anOpenWindowDefersThePurge() throws {
|
||||
// The carve-out (ruled 2026-08-06): "an open card window is itself an owner of its card's
|
||||
// comment trash ... because entries deleted in the live session are backed by the window's
|
||||
// fine steps, which the board-stack inventory cannot see". Total, not per entry — an unowned
|
||||
// leftover defers with the rest, because while a window is open the inventory is silent
|
||||
// rather than merely incomplete.
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let card = try makeCommentBoard(fixture)
|
||||
try fixture.item("\(card)/comments/.trash/\(CommentIdent.two)", commentText())
|
||||
let (store, _) = try makeStore(fixture)
|
||||
|
||||
store.cardWindowDidOpen(inCard: cardID)
|
||||
store.purgeCommentTrash(inCard: cardID)
|
||||
#expect(fixture.exists("\(card)/comments/.trash/\(CommentIdent.two)"), "deferred to the window's close")
|
||||
|
||||
store.cardWindowDidClose(inCard: cardID)
|
||||
store.purgeCommentTrash(inCard: cardID)
|
||||
#expect(try fixture.entryNames("\(card)/comments/.trash").isEmpty, "which settles it by the same gate")
|
||||
}
|
||||
|
||||
@Test("Only the open card's trash defers — another card's purge is untouched")
|
||||
func theDeferralIsPerCard() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
try fixture.item("", Item.board)
|
||||
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
|
||||
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: cardTitle))
|
||||
try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Ship it"))
|
||||
try fixture.item("\(Ident.lane1)/\(Ident.card1)/comments/.trash/\(CommentIdent.one)", commentText())
|
||||
try fixture.item("\(Ident.lane1)/\(Ident.card2)/comments/.trash/\(CommentIdent.two)", commentText())
|
||||
let (store, _) = try makeStore(fixture)
|
||||
|
||||
store.cardWindowDidOpen(inCard: cardID)
|
||||
store.purgeCommentTrash(inCard: cardID)
|
||||
store.purgeCommentTrash(inCard: ItemID(rawValue: Ident.card2))
|
||||
|
||||
#expect(fixture.exists("\(Ident.lane1)/\(Ident.card1)/comments/.trash/\(CommentIdent.one)"))
|
||||
#expect(!fixture.exists("\(Ident.lane1)/\(Ident.card2)/comments/.trash/\(CommentIdent.two)"))
|
||||
}
|
||||
|
||||
/// One card window over the fixture's card, wired exactly as `CardWindowHost` wires one — the two
|
||||
/// `static` configure calls, plus the marking its `openCommentThread` does beside the sweep.
|
||||
private func openWindow(_ fixture: WriterFixture, on store: BoardStore, card: String) throws -> CardWindowSession {
|
||||
let session = CardWindowSession()
|
||||
CardWindowHost.configureUndo(session, store: store, cardID: cardID)
|
||||
CardWindowHost.configureComments(session.comments, store: store, cardID: cardID, on: session.undo)
|
||||
session.comments.isEditable = true
|
||||
session.comments.cardFolder = fixture.url(card)
|
||||
session.body.save = { [weak store] text in store?.writeCardBody(inCard: cardID, body: text) ?? .vanished }
|
||||
session.body.adopt(diskBody: try FrontmatterDocument.parse(fixture.indexText(card)).body)
|
||||
store.cardWindowDidOpen(inCard: cardID)
|
||||
session.comments.open()
|
||||
return session
|
||||
}
|
||||
|
||||
@Test("The close gives up ownership before it purges — its own purge is never self-deferred")
|
||||
func theCloseUnmarksBeforeItPurges() async throws {
|
||||
// The ordering the carve-out lives or dies on (`CardWindowSession.endSession`): the no-step
|
||||
// close runs `purgeTrashNow()` itself, so a window that unmarked *after* that call would defer
|
||||
// its own purge into a no-op and hand the work to nobody.
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let card = try makeCommentBoard(fixture)
|
||||
let (store, history) = try makeStore(fixture)
|
||||
let session = try openWindow(fixture, on: store, card: card)
|
||||
|
||||
// A crashed sibling's leftover, landing after this window's own open-time sweep had run: the
|
||||
// window owns it while it is open, and owes it at the close.
|
||||
try fixture.item("\(card)/comments/.trash/\(CommentIdent.two)", commentText())
|
||||
store.purgeCommentTrash(inCard: cardID)
|
||||
#expect(fixture.exists("\(card)/comments/.trash/\(CommentIdent.two)"), "deferred while the window is open")
|
||||
|
||||
await session.endSession()
|
||||
|
||||
#expect(!history.canUndo, "nothing net happened — no coarse step took the purge on")
|
||||
#expect(store.openCardWindows.isEmpty, "the close gave the ownership back, first")
|
||||
#expect(try fixture.entryNames("\(card)/comments/.trash").isEmpty, "so the close's own purge ran")
|
||||
}
|
||||
|
||||
@Test("A live session's delete survives a purge the board stack cannot see it backing")
|
||||
func aLiveSessionsDeleteSurvivesAForeignPurge() async throws {
|
||||
// Why the carve-out has to be total: the delete's step is on the *window's* stack, so
|
||||
// `backedContent` — the board's — genuinely names nothing, and an ungated purge would remove
|
||||
// the folder the window's own ⌘Z restores from. The close then settles it by the same gate:
|
||||
// the coarse step becomes the owner, and the entry stays for as long as that step does.
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let card = try makeCommentBoard(fixture)
|
||||
try fixture.item(commentPath(CommentIdent.one, inCard: card), commentText())
|
||||
let (store, history) = try makeStore(fixture)
|
||||
let session = try openWindow(fixture, on: store, card: card)
|
||||
|
||||
session.comments.reload()
|
||||
session.comments.delete(ItemID(rawValue: CommentIdent.one))
|
||||
#expect(fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)"))
|
||||
#expect(!history.canUndo, "the fine step is the window's, not the board's")
|
||||
|
||||
// Another card's retirement, firing while this window is open.
|
||||
store.purgeCommentTrash(inCard: cardID)
|
||||
#expect(fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)"),
|
||||
"the open window owns it — the board's inventory could not have known")
|
||||
|
||||
await session.endSession()
|
||||
|
||||
#expect(history.canUndo, "the close folded the session into the coarse step")
|
||||
#expect(fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)"),
|
||||
"which is now the owner, by the same gate")
|
||||
history.undo()
|
||||
#expect(fixture.exists(commentPath(CommentIdent.one, inCard: card)), "so the coarse ⌘Z restores it")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Undo
|
||||
|
||||
@MainActor
|
||||
@@ -541,19 +738,47 @@ struct CommentUndoTests {
|
||||
#expect(fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)"))
|
||||
}
|
||||
|
||||
@Test("A purge leaves the delete step stale — it skips with a banner, resurrecting nothing")
|
||||
func purgeMakesDeleteStepsStale() throws {
|
||||
@Test("A purge spares what a live step backs — under the gate it cannot stale one")
|
||||
func purgeSparesALiveStepsBacking() throws {
|
||||
// The flip of what this test used to pin (13-native-undo.md ▸ Interaction with the trash,
|
||||
// ruled 2026-08-06): "**Every purge of `comments/.trash/` is per-entry behind the ownership
|
||||
// gate** ... Under the gate a purge cannot stale a live step by construction." The delete's
|
||||
// own step is the live one — its undo is the move back out of `comments/.trash/`, which is
|
||||
// exactly the backing claim `backedContent` inventories.
|
||||
let (fixture, card) = try board()
|
||||
defer { fixture.tearDown() }
|
||||
let path = commentPath(CommentIdent.one, inCard: card)
|
||||
try fixture.item(path, commentText())
|
||||
let (store, history) = try makeStore(fixture)
|
||||
|
||||
#expect(store.deleteComment(ItemID(rawValue: CommentIdent.one), inCard: ItemID(rawValue: Ident.card1)))
|
||||
store.purgeCommentTrash(inCard: ItemID(rawValue: Ident.card1))
|
||||
|
||||
#expect(fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)"),
|
||||
"the purge asked the stack first — this is a step's backing, not residue")
|
||||
#expect(history.canUndo)
|
||||
|
||||
history.undo()
|
||||
#expect(fixture.exists(path), "so ⌘Z still restores the comment")
|
||||
#expect(store.banners.signposts.isEmpty, "and nothing was stale, so nothing was said")
|
||||
}
|
||||
|
||||
@Test("A hand-removed trash entry does stale the delete step — lazily, with the banner")
|
||||
func aForeignRemovalStalesTheDeleteStep() throws {
|
||||
// Lazy invalidation is unchanged (13 ▸ Rules); what changed on 2026-08-06 is that the app's
|
||||
// own purge is no longer a source of it. So the staleness has to come from somewhere genuinely
|
||||
// foreign — a hand-editor emptying `comments/.trash/` in Finder, which is what this simulates.
|
||||
let (fixture, card) = try board()
|
||||
defer { fixture.tearDown() }
|
||||
try fixture.item(commentPath(CommentIdent.one, inCard: card), commentText())
|
||||
let (store, history) = try makeStore(fixture)
|
||||
|
||||
#expect(store.deleteComment(ItemID(rawValue: CommentIdent.one), inCard: ItemID(rawValue: Ident.card1)))
|
||||
store.purgeCommentTrash(inCard: ItemID(rawValue: Ident.card1))
|
||||
try FileManager.default.removeItem(at: fixture.url("\(card)/comments/.trash/\(CommentIdent.one)"))
|
||||
#expect(history.canUndo, "invalidation is lazy — the stack still looks full")
|
||||
|
||||
history.undo()
|
||||
#expect(!fixture.exists(commentPath(CommentIdent.one, inCard: card)))
|
||||
#expect(!fixture.exists(commentPath(CommentIdent.one, inCard: card)), "resurrecting nothing")
|
||||
#expect(!history.canUndo)
|
||||
#expect(store.banners.signposts.map(\.message) == ["Undo skipped — '\(cardTitle)' changed outside Lanework"])
|
||||
}
|
||||
@@ -572,6 +797,9 @@ struct CommentUndoTests {
|
||||
store.purgeCommentTrash(inCard: cardID)
|
||||
|
||||
#expect(!history.canUndo, "no byte capture in any tier — 13's rule")
|
||||
// The gate did not make the purge inert: nothing on the stack names this entry, so it goes —
|
||||
// per entry, and still without a step to show for it.
|
||||
#expect(!fixture.exists("\(card)/comments/.trash/\(CommentIdent.two)"))
|
||||
}
|
||||
|
||||
@Test("Comment expectations validate disk, which is why comments need no snapshot")
|
||||
|
||||
Reference in New Issue
Block a user