Repository navigation
fix(unassign): fence the remote label write in POST /api/issues/unassign (#1361) - #1368
Conversation
…ign (#1361) The unassign route wrote the issue's whole label set to GitHub with updateIssueLabels (a FULL-label-set PUT) without holding the per-issue mutation guard, so it was not serialized against the claim path and not covered by the guard tripwire: an unassign running while a maintainer force-claim took an issue over could strip the new owner's `agent/*` label on GitHub while the local cache still said the new owner held it — the GitHub/cache divergence class #1037 fights. The route now takes the per-issue anchor (operation `unassign`) for the whole mutation and performs its remote write inside the anchor's FENCED write, so the guard check and the write are one atomic step: a guard that was stolen in the meantime means the forge function is never called and no label is stripped. - the label snapshot the PUT applies is taken INSIDE the anchor (issue read, "no label of that type" 400 and the set computation all happen under it), so the snapshot is the one the anchor saw. - the canonical repo/number come from the loaded DB record and a body mismatch is rejected with 400 before any write: the anchor is per issue, so the fenced write must target the anchor's issue, never an arbitrary body-supplied one (the claim route's #1129 invariant). - guard loss → no remote write, a failure audit row, and a retryable 409; another writer holding the anchor → a retryable 409 and no write. - failures are caught inside the callback, so the anchor is released instead of demoted: a demoted `unassign` guard has no resume branch and would wedge every writer on the issue, claims included. The guard lib's error path deletes an `unassign` guard for the same reason (it has no resume branch by design, and a demoted row is invisible to the ownership inspect surface). - the anchor's guard row references the issue, so an unknown issueId fails its foreign key before the route's own lookup runs; that is mapped back to the route's documented 404. Docs: the assignment-conflict policy and the ownership-reconciliation §8.7 implementation note state the new anchor, the fenced write and the 409 shapes. Follow-ups filed for the residuals found while reviewing this change: #1366 (base the unassign label set on live GitHub labels instead of the cached row) and #1367 (anchor the unclaim route's remote label removal). Local validation at this commit: npm test -> 5264 passed / 0 failed (26 skipped, 1 todo; 209 files, 5 skipped); npm run typecheck clean; npm run lint 0 errors (1 pre-existing warning in src/app/login/page.tsx). Mutation-checked: the new route tests (6) and the new guard-lib test (1) fail against the pre-change sources. --------- Co-authored-by: Courier <courier@localhost>
There was a problem hiding this comment.
AI Automated Review
Full PR review.
Analysis engine: MiniMax-M3.1-Flash (anthropic) — primary route · pr-reviewer-action v3.7.0
Recommendation: approve — the unassign route now satisfies the linked issue's fencing and guard-loss requirements without introducing a merge-blocking defect.
Linked Issue Fit
The implementation matches issue PR 1361: the issue snapshot is read under the per-issue guard, the GitHub full-label update runs through runFencedRemoteWrite, and guard loss suppresses the forge call, records a failure audit, and returns a retryable 409. Canonical repository and issue number come from the loaded database record, with body mismatches rejected as 400 before mutation. The route also preserves busy-anchor and missing-issue behavior, and its only UI caller supplies the canonical identity.
Claims held (3/3): the guarded remote-write claim, the stronger fenced-write replacement for stillHoldsGuard(), and the anchored-issue identity invariant are each supported by the route, shared guard implementation, tests, and documentation.
Tool Harness Findings
Repository inspection confirmed that IssueMutationGuard has a strict issue foreign key, runFencedRemoteWrite performs the owner-token CAS and forge call in one transaction, unassign has no resumable guard path, and the only located unassign caller sends the database issue identity. No conflicting implementation or caller was found.
joryirving
left a comment
There was a problem hiding this comment.
Secondary hard-blocker review on exact head 6d53beebb90be37d248d98072a23e49dd13852a8: APPROVE. The unassign route now takes the same per-issue mutation anchor as the other ownership writers, derives the canonical repo/issue identity inside that anchor, and executes the full-label-set GitHub write through runFencedRemoteWrite. That closes the actual force-claim race: override-first means the guard CAS misses and the forge function is never called; holder-first keeps the guard row locked across the bounded remote write. Guard loss returns a retryable 409 with a failure audit, busy ownership returns 409 without mutation, body/anchor identity mismatch is rejected before any write, and the non-resumable unassign guard is cleaned on error so it cannot wedge the issue. The cached-label freshness hole is pre-existing and separately tracked as #1366; unclaim is separately tracked as #1367. Exact-head primary review and all CI/security/image/smoke checks are green. No independent ownership/fencing blocker found.
Fixes #1361.
What changed
src/app/api/issues/unassign/route.tswrote the issue's whole label set to GitHub (updateIssueLabels— a full-label-set PUT) while holding no per-issue anchor, so it was not serialized against the claim path and not covered by the guard tripwire: an unassign running while a maintainer force-claim took the issue over could strip the new owner'sagent/*label on GitHub while the local cache still said the new owner held it — the GitHub/cache divergence class #1037 fights.The route now:
withIssueMutationGuard, operationunassign) for the whole mutation. The label snapshot the PUT applies is taken inside the anchor: the issue read, the "no label of that type" 400 and the set computation all happen under it.runFencedRemoteWrite). The guard check and the write are one atomic step — the guard row's exclusive lock is held across the forge call — so a guard that was stolen in the meantime means the forge function is never called and no label is stripped. This is the stronger successor of thestillHoldsGuard()tripwire the issue names: a read-then-act check cannot fence a remote write.success: false), and a retryable 409. A failing audit write cannot mask the 409.repoFullName/issueNumbercome from the loaded DB record and a body mismatch is rejected with 400 before any write (the claim route's Bind worker bearer tokens to agent names (derive actor from token, not self-reported header) #1129 invariant — the anchor is per issue, so the fenced write must never target an arbitrary body-supplied issue).unassignguard has no resume branch and would wedge every writer on the issue (claims included).src/lib/issue-mutation-guard.tsdeletes anunassignguard on the error path for the same reason — it has no resume branch by design, and a demoted row is invisible to the ownership inspect surface.Docs:
docs/assignment-conflict-policy.md(§5) and thedocs/ownership-reconciliation.md§8.7 implementation note state the anchor, the fenced write and the new 409/400 shapes.Local validation (run in this run's checkout, branch
courier/misospace/dispatch/issue-1361)npm test/workspacenpm run typecheck/workspacetsc --noEmit)npm run lint/workspacesrc/app/login/page.tsx, untouched file)Mutation-checked (the new tests fail against the pre-change sources, so they are real regression tests):
src/app/api/issues/unassign/route.ts→ the 6 new route tests fail (18 pass);src/lib/issue-mutation-guard.ts→ the new guard-lib test fails.Review handoff
An adversarial review of the final diff was run by a separate reviewer session against the settled scope (fence correctness, snapshot timing, guard-loss semantics, anchor lifecycle, cross-issue safety, test quality, regressions). It returned APPROVE WITH NITS; its findings were acted on in this PR:
{issueId, operation: "unassign"}) and that the issue read happens inside the anchor;ownershipcase;Limitation: every role on this lane (coordinator, implementer, reviewer) runs the same configured model, so the separate review provides no model-family diversity — it is an independent adversarial pass, not an independent model family.
Known residuals (tracked, not fixed here)
agent/*removal is not serialized against the claim path's label add — filed as fix(unclaim): anchor the unclaim route's remote agent-label removal on the per-issue mutation guard #1367.External verification
CI/checks, the AI review and human review are pending — not run and not claimed green here. The operator verifies CI after this run exits. The PR is left open for human review; it is not merged.
Co-authored-by: Courier courier@localhost