Skip to content

fix(unassign): fence the remote label write in POST /api/issues/unassign (#1361) - #1368

Merged
joryirving merged 1 commit into
mainfrom
courier/misospace/dispatch/issue-1361
Oct 23, 2026
Merged

joryirving merged 1 commit into
mainfrom
courier/misospace/dispatch/issue-1361

Conversation

@itsmiso-ai

Copy link
Copy Markdown
Contributor

Fixes #1361.

Take the per-issue mutation guard in POST /api/issues/unassign and re-check that the holder still owns it immediately before the remote label write, so an unassign cannot strip the agent/* label a maintainer force-claim now owns.

Acceptance: the remote write happens under the anchor and only while this holder still owns it; on guard loss there is no remote write, a failure audit is written, and the request returns a retryable 409.

What changed

src/app/api/issues/unassign/route.ts wrote 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'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:

  • holds the per-issue mutation anchor (withIssueMutationGuard, operation unassign) 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.
  • writes remotely inside the anchor's fenced write (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 the stillHoldsGuard() tripwire the issue names: a read-then-act check cannot fence a remote write.
  • on guard loss: no remote write, a failure audit row (success: false), and a retryable 409. A failing audit write cannot mask the 409.
  • when another writer holds the anchor (a claim in flight): a retryable 409, no write, no audit row — the same shape the sibling assign route uses.
  • targets the anchor's issue: the canonical repoFullName/issueNumber come 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).
  • releases the anchor on every path: failures are caught inside the callback, so the anchor is released rather than demoted. A demoted unassign guard has no resume branch and would wedge every writer on the issue (claims included). src/lib/issue-mutation-guard.ts deletes an unassign guard 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.
  • keeps the route's documented 404 for an unknown issue: the anchor's guard row references the issue, so acquiring it for a missing issue fails the foreign key before the route's own lookup runs; that is mapped back to the 404 instead of leaking a 500.

Docs: docs/assignment-conflict-policy.md (§5) and the docs/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)

Command cwd Result
npm test /workspace exit 0 — 204 files passed / 5 skipped, 5264 tests passed, 26 skipped, 1 todo
npm run typecheck /workspace exit 0 — clean (tsc --noEmit)
npm run lint /workspace exit 0 — 0 errors, 1 pre-existing warning (src/app/login/page.tsx, untouched file)

Mutation-checked (the new tests fail against the pre-change sources, so they are real regression tests):

  • reverting src/app/api/issues/unassign/route.ts → the 6 new route tests fail (18 pass);
  • reverting 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:

  • the anchor's foreign-key failure turned an unknown-issue request into a 500 → now the documented 404, with a test;
  • the new happy-path test was not a regression test for the anchor itself → it now asserts the anchor request ({issueId, operation: "unassign"}) and that the issue read happens inside the anchor;
  • the guard-lib error-path change had no test → one added, mirroring the existing ownership case;
  • the §8.7 doc sentence overstated the guarantee ("nor strip the label that force-claim owns") → reworded to the precise claim.

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)

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

…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>

@its-saffron its-saffron Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 joryirving left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@joryirving
joryirving merged commit 60eb0d2 into main Oct 23, 2026
13 checks passed
@joryirving
joryirving deleted the courier/misospace/dispatch/issue-1361 branch October 23, 2026 16:35
@its-miso its-miso Bot mentioned this pull request Oct 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(ownership): guard the remote agent/* label write in POST /api/issues/unassign

2 participants