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
+39
View File
@@ -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
+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))
}
}
+4 -2
View File
@@ -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
+21 -7
View File
@@ -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