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
+232 -4
View File
@@ -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")