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:
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user