fix(issues): stop a stale run lock from authorizing irreversible deletes (BLO-29150) - #1451
Conversation
…tes (BLO-29150) `assertAgentIssueMutationAllowed` short-circuits on `isCurrentIssueExecutionRun`, which compares only `checkoutRunId` / `executionRunId` against the actor's run and is assignee-agnostic. That `return true` lands above both the `issue:mutate` boundary and the assignee-mismatch branch, so an agent holding a checkout lock left stale by a reassignment could hard-delete an issue that had since been assigned to another agent — the row and its attachment objects, irreversibly. Measured before the fix: `holds lock / not assignee` returned 200 and the row was gone, while the `no lock / not assignee` control was refused 403. The lock was the entire grant. Fixed route-locally rather than by touching the shared short-circuit, which is load-bearing for the ~24 other routes that let a lock holder conclude its own in-flight work. A new opt-in, `requireAssignmentForRunLockAuthority`, additionally requires the lock holder to still be the assignee; it falls through to the ordinary checks instead of denying, so a checkout-management override still authorizes. The sweep the issue asked for found two more routes in the same irreversible class, both opted in here: `DELETE /attachments/:id` (deletes the storage object before the row) and `DELETE /work-products/:id` (hard `db.delete`). Their neighbouring `assertDeliverableMutationAllowedByRunContext` call is not an ownership gate — it filters cheap status-only/planning-only recovery runs by context snapshot and returns true for an ordinary run. The reversible routes on the helper (watchdog disable, approval unlink, document upsert) are deliberately left alone. Tests assert the full lock x assignee matrix through the express harness, not the single fixed cell: a service-level test would pass while the route-layer hole stayed open, and pinning only the fixed cell would let a later change to the shared helper flip a different one. Co-Authored-By: Claude <noreply@anthropic.com>
1 similar comment
|
@ally please review at head 8722e21 — this is an authorization change on the shared issue-mutation helper, so please weight these four:
|
There was a problem hiding this comment.
Ally — Consolidated PR Review
Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 8722e21
Looks good. The fix is correct, minimal, and lands the guard at the right altitude — route-local opt-in rather than a change to the shared short-circuit that ~25 callers depend on. I verified the deny actually materialises by tracing the fall-through, and I independently re-ran the destructive-route audit rather than taking the PR table on trust.
Critical Issues (0)
Important Issues (0)
Suggestions (3)
-
[comments]
server/src/routes/issues.ts:5695— The new option's JSDoc opensBLO-29150: DELETE /issues/:id only., but three routes pass it (issues.ts:9435,12055,14413), and the same docstring's closing sentence ("Opt in from routes whose effect outlives the run holding the lock") contradicts it. This matters more than usual here because the sibling options in this exact block useX onlyas a load-bearing scope contract —allowExecutionStageParticipantDecisionisPATCH /issues/:id only, andallowCoordinationMetadata's comment leans on being single-caller. A later auditor readingonlyas an invariant finds it already violated at merge.- Suggest opening with the general rule and naming the current opt-ins, e.g.
Opt-in, currently DELETE /issues/:id, DELETE /attachments/:attachmentId and DELETE /work-products/:id.
- Suggest opening with the general rule and naming the current opt-ins, e.g.
-
[code]
server/src/routes/issues.ts:5751— Theissue.assigneeAgentId === nullcarve-out is described as "an unassigned row has no assignee to diverge from", butassigneeAgentId === nullis also how a row assigned to a human is represented (assigneeUserIdset). So for an issue reassigned from agent A to a person, A's stale lock still authorizes the irreversible delete — which is the case the PR's stated invariant ("authorization computed at checkout time can no longer outlive the assignment it was computed against") would most want covered.- Pre-existing and not worsened by this PR: tightening this condition alone would change nothing, because the fall-through hits the older unassigned early-return at
issues.ts:5875and returns true regardless. Closing it needs both sites plus a boundary check on user-assigned rows. Worth either a follow-up or one sentence in the carve-out comment saying user-assigned rows are knowingly out of scope, so the next reader doesn't conclude delete authority is now assignment-gated in general.
- Pre-existing and not worsened by this PR: tightening this condition alone would change nothing, because the fall-through hits the older unassigned early-return at
-
[tests]
server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts:4361— The matrix is the right call and four of the nine cells genuinely fail onmaster, but the two irreversible-route cells assert only the status code (409) where theDELETE /issues/:idcells also pinres.body.detailsand the error string. Since409is reachable from bothdeny_active_checkouthere andassertCheckoutOwnerelsewhere in this suite, addingexpect(res.body.error).toBe("Issue is checked out by another agent")to the attachment and work-product cells would pin why they were refused, matching the precision of the cells above them.
Strengths
- Falling through instead of
return falseis the subtle, correct choice, and the comment explains why.return falsewould have denied atasks:manage_active_checkoutsmanager that clears the boundary on its own authority — I confirmed the fall-through reacheshasActiveCheckoutManagementOverride(issues.ts:5885) and that path still works. - The destructive-route audit holds up. I re-derived it from the router table rather than the PR body: of the ten
router.deletehandlers, the three opted in are exactly the ones that are irreversible and agent-reachable.DELETE /issues/:id/documents/:key(issues.ts:9092) does hard-delete viadocumentsSvc.deleteIssueDocument, but it is board-only (403atissues.ts:9096), so it is correctly excluded — and the twostorage.deleteObjectcall sites in the file (12070,14421) are both behind the new guard. - The observation that
assertDeliverableMutationAllowedByRunContextis not an ownership gate is accurate and worth having written down at the call site — it sits immediately below the helper on both deliverable routes and reads like a second lock. - Test-mock reasoning is sound: the permissive default
mockAccessService.decideallowsissue:mutatebut nottasks:manage_active_checkouts, so the "boundary-clearing lock holder" cell really does isolate the lock as the only variable rather than passing for the wrong reason.
Recommended Action
- No Critical or Important issues — nothing blocking merge from this review.
- Consider the Suggestions opportunistically; the
issues.ts:5695docstring is a one-line fix and the cheapest of the three. - Note that CI was still in flight when this review was written —
General tests (server 1–4/4),Typecheck + Release RegistryandBuildwere allpending. This review is a read of the code, not a substitute for those going green; the new tests are mock-based, so theassertCheckoutOwner/decideIssueAccessinteractions they stand in for are only proven by the suite actually running.
Thinking Path
Linked Issues or Issue Description
Refs BLO-29150 (Paperclip board —
https://paperclip.blockcast.net/BLO/issues/BLO-29150)Found while asserting BLO-27356's AC-2 ("
DELETE /issues/:idand the other routes sharingassertAgentIssueMutationAllowedshow no behaviour change — asserted, not assumed"). BLO-27356 was filed on the premise that fixing release would "grant lock-holders delete authority as a side effect — a strictly worse bug than the one being fixed." That side effect already existed onmasterand required no widening to reach.Measured matrix before this change (
DELETE /api/issues/:id, agent A, embedded-Postgres route harness):200— deleted403(grant boundary)200— deleted (expected)409run ownership conflictThe only difference between row 1 and the row-2 control is the checkout lock. The lock was the grant. Note the inversion: the assignee without the lock is refused, while a non-assignee with a stale lock succeeded.
This is a TOCTOU staleness, not escalation from nothing — since #1353 acquiring the lock requires clearing
issue:mutateat checkout time, so the holder was authorized when it checked out. The defect is that this authorization was still honoured for an irreversible delete after the row had been reassigned away.Prior art: #1353 (merged) made acquiring the lock require clearing
issue:mutateat checkout time. No open or merged PR addresses the post-reassignment case; nearest neighbours reviewed were #1054, #911 and #1124 (stale-lock lifecycle, not delete authority).It also contradicted the helper's own documented intent, which names
DELETE /issues/:idtwice as the route that must fail closed (issues.tscomments at theallowCreatorOrManagerChainOwnershipoption and the creator/manager-chain deny). The short-circuit sat above both guards and defeated them.What Changed
requireAssignmentForRunLockAuthoritytoassertAgentIssueMutationAllowed. When set, theisCurrentIssueExecutionRunshort-circuit additionally requires that the lock holder still be the issue's assignee (or the row be unassigned).tasks:manage_active_checkoutsoverride, an unassigned row — is still allowed by the paths below. Returningfalsethere would have denied a manager that legitimately clears the boundary.DELETE /issues/:id—svc.removeis a hard delete, and the route then deletes every attachment object from storage.DELETE /attachments/:attachmentId— callsstorage.deleteObjectbefore removing the row.DELETE /work-products/:id—workProductsSvc.removeis a harddb.delete(issueWorkProducts).BLO-29150describe block asserting the full lock×assignee matrix plus the sweep cases.Audit of the other destructive routes on this helper
The issue asked for this explicitly rather than leaving it implicit. All 24 call sites reviewed; the destructive ones:
DELETE /issues/:idstorage.deleteObjectper attachmentDELETE /attachments/:attachmentIdstorage.deleteObjectbefore the row, thenremoveAttachmenthard-deletes bothissueAttachmentsand the sharedassetsrow (services/issues.ts:11825, deletes at:11851-11852). Storage failures are onlylogger.warn-ed, so the blob can be destroyed even when the DB step 404sDELETE /work-products/:iddb.delete(issueWorkProducts)(services/work-products.ts:282) — no tombstone, no revision historyDELETE /issues/:id/comments/:commentId?mode=cancel/ queued branch is a hardtx.delete(issueComments)(services/issues.ts:11511); the default branch tombstones. Both branches are gated route-locally byactorOwnsComment, so a stale lock holder can only destroy a comment it authoredDELETE /issues/:id/approvals/:approvalIddb.delete(issueApprovals)(services/issue-approvals.ts:128) of the join row — the approval itself survives and the link is re-creatable; also gated byassertCanManageIssueApprovalLinksDELETE /issues/:id/watchdogPUT /issues/:id/documents/:keyOne finding worth flagging for review:
assertDeliverableMutationAllowedByRunContext, which sits next to the helper call on both the attachment and work-product routes, reads like a second gate but is not an ownership check — it filters cheap status-only / planning-only recovery runs by their context snapshot and returnstruefor an ordinary run. It does not close this hole on its own.Verification
The test was confirmed to actually catch the bug. With the fix reverted on the route only (helper option left in place), the two non-assignee cells fail exactly as the production matrix measured —
expected 200 to be 403andexpected 200 to be 409, i.e. the row was deleted:Regression scope:
Two notes on how the tests are built, both deliberate:
holds lock + is assignee → 200andunassigned row → 200cells are pinned for that reason, as iswatchdog delete → 200so that a future blanket application of the new option trips a test.There is also a deliberately sharper case than the reported one: an actor that does clear the
issue:mutateboundary (company-wide grant) still must not delete a row assigned elsewhere on the strength of the lock. Without that cell, "fix it by leaning on the boundary" would have looked sufficient.Risks
false; only the three named routes pass it. Every other caller is byte-identical.403/409the caller already handles — but if some automation relied on deleting a reassigned row via a lock, it will now be refused. No such caller was found in-tree.assigneeAgentId === nullstill short-circuits, so behaviour there is exactly as before. This is theagents.removestrand case; it is not a live hole because the surviving lock names a run belonging to a deleted agent, which cannot authenticate. Tightening it would be a separate, wider change.runningholder alone (by design),escalateStaleRunRefirewriting assignment through a directdb.updatethat bypassessvc.update's lock-clearing, andagents.removeleaving both run columns pointed at a deleted agent's runs. This PR makes the stale pair harmless for irreversible deletes rather than preventing it.Model Used
Claude Opus 5 (
claude-opus-5, 1M-context variant), extended thinking, agentic tool use (Claude Code via the Paperclipclaude_k8sadapter). Fix and tests authored in-session; the pre-fix matrix was reproduced against the route harness and the post-fix behaviour verified by reverting the route opt-in and observing the two cells fail.Checklist
Fixes: #/Closes #/Refs #OR (b) described the issue in-PR following the relevant issue template