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:
2026-08-06 17:10:09 -04:00
parent a60d97689e
commit ea15d1ac74
7 changed files with 449 additions and 55 deletions
+42
View File
@@ -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
+110 -41
View File
@@ -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))
}
}