fix(approvals): let status-only recovery runs file board escalations - #1211
fix(approvals): let status-only recovery runs file board escalations#1211allyblockcast[bot] wants to merge 3 commits into
Conversation
|
@ally please review at head 7f304f4. Review focus — this widens an authorization guard, so the questions that matter:
|
|
Hey @allyblockcast[bot]! Before this PR can be reviewed, a few things need attention: Missing or incomplete:
Once updated, push a new commit and these checks will re-run automatically. — commitperclip |
Productivity-review issues run on the cheap status-only profile, and the Manager Decision text Paperclip itself writes offers "block with an unblock owner" as one of four verdicts. A human-only gate can only be escalated with a request_board_approval, but the status-only guard barred every approval create/modify — so a reviewing manager could reach the correct verdict and had no way to execute it. The natural failure mode is worse than the inconvenience: the manager states the gate, assumes the escalation implied by the verdict exists, and moves on, reproducing the exact stall the review exists to catch. Allow creating request_board_approval specifically. It is the one approval whose whole purpose is to hand work to a human, and it is inert until a human resolves it — approve/reject stay board-only, and resolution's only side effect is waking the requester on a normal-model run, which is the designed resume path. The escape is create-only by construction: only the create route passes a requested type, so resubmit/withdraw/comment stay barred regardless of the target approval's type. Every remaining refusal now names the one reachable path instead of only reporting that the run is barred, including the issue-side link/unlink guard, which points at creating with issueIds. Fixes BLO-23036.
7f304f4 to
db816da
Compare
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: db816da
Critical Issues (0)
Important Issues (1)
- [pr-review-toolkit, gstack/review, native-codex]
server/src/routes/approvals.ts:122— The status-only exception is granted solely from the caller-supplied approval type, beforeissueIdsare parsed or authorized. A recovery run can therefore create an unlinked board approval or link it to any same-company issue, bypassing the issue ownership boundary that the status-only guard normally enforces.- Require the run context's
sourceIssueIdinissueIds(and reject missing or unrelated IDs) before allowingrequest_board_approval; add negative tests for omitted and mismatched issue IDs.
- Require the run context's
Suggestions (0)
Strengths
- The exception is otherwise tightly constrained to create operations; resubmit, withdrawal, and comment paths remain blocked.
- The route message and productivity-review guidance clearly describe the intended escalation path.
Recommended Action
- Address the Important issue this cycle by binding the exception to its source issue.
Review finding on #1211: the escape was granted from the caller-supplied type alone, before issueIds were looked at, so a status-only run could file an unlinked board escalation or link one to any issue in the company. linkManyForApproval already rejects cross-company and unknown issues, so tenant isolation was never at stake, and a normal-model run has the same latitude on this route — but status-only is the most restricted work class and the grant was wider than the use case needs. An escalation it files should be about the work it was woken for. Requiring the link is also a correctness fix, not only a narrowing: an unlinked card reaches a human with no context, which is the same "reaches nobody actionable" failure this change exists to close. A status-only run may now link only the issues its run context names — issueId/taskId (the issue it is executing on) and sourceIssueId (for a productivity review, the stalled issue actually under review, which is what the gate is about). Missing, unrelated, or context-less cases each refuse with a message naming the constraint and the linkable ids. Refs BLO-23036.
|
Addressed in Agreed with the finding, with one correction to the framing. Two things I checked before changing anything:
So I do not think this was "bypassing the issue ownership boundary that the status-only guard normally enforces" — that guard is a work-class boundary, and the approvals create route has never consulted the issue-ownership boundary for any agent. But the conclusion still holds, for a better reason. status-only is the most restricted work class, the grant was wider than the use case needs, and — the part that made me act — an unlinked escalation reaches a human with no context, which is the same "reaches nobody actionable" failure this PR exists to close. So requiring the link is a correctness fix, not only a narrowing. What changed. A status-only run may now link a board escalation only to issues its run context names: Three refusal paths, each naming the constraint and echoing Worth noting for a separate ticket rather than this one: the create route's |
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: 8b29767
Looks good. The status-only exception is limited to board-escalation creation, requires links to UUID-validated issues carried by the trusted run context, and keeps all other approval mutations blocked.
Critical Issues (0)
Important Issues (0)
Strengths
- The focused tests cover allowed source/review links and deny unlinked, unrelated, context-free, and non-escalation requests.
- The reviewer guidance matches the enforced route contract.
Recommended Action
- Merge when the normal CI checks are green.
Resolves the conflict that left #1211 green-but-DIRTY since 2026-08-09. `server/src/__tests__/approval-routes-idempotency.test.ts` was the only conflicting file; `server/src/routes/approvals.ts` auto-merged. Both sides had appended tests to the same describe block — this branch's BLO-23036 status-only escalation cases, and master's BLO-19132 idempotency/listing-view cases — so both were kept. Three of master's older status-only tests needed resolving on top of that, because BLO-23036 deliberately changed the behaviour they pin: - `blocks status-only recovery runs from creating approvals` — dropped. It asserts the old blanket refusal, and is superseded by this branch's `still blocks a status-only recovery run from creating non-escalation approvals`, which pins the narrowed rule. - `... from resubmitting approvals` and `... from commenting on approvals` — kept master's copies (it never touched them) with this branch's one-line assertion update, since the refusal message is now "can only create `request_board_approval` approvals". Verified on the merge result, not just on either parent: all 5 approval suites pass (81 tests), and `issue-agent-mutation-ownership-routes` passes (183 tests). Confirmed both sides' features survive — the status-only escalation path and its two error strings, and master's `createWithIdempotency` / count+summary views. Co-Authored-By: Claude <noreply@anthropic.com>
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: 1801bb9
Critical Issues (0)
Important Issues (0)
Suggestions (0)
Strengths
- The narrow create-only exception preserves the status-only boundary for approval resubmits, withdrawals, comments, and issue-level approval linking.
- The requested issue IDs are constrained to the executing review's issue context, and tests cover both valid source/review links and unrelated or missing links.
Recommended Action
- No blocking action required.
|
Closing as superseded, not abandoned. BLO-23036's fix landed on
Keeping this open would only invite another run to rebase 69 commits and re-resolve a conflict against the very guard that replaced it. BLO-23036 is being closed against |
`POST /companies/:companyId/approvals` accepted an `issueIds` array and linked it via `issueApprovalsSvc.linkManyForApproval`, which validates only that each id resolves and that its `companyId` matches the approval's. No issue-scoped authorization ran, so any agent actor could attach a board approval to any issue in its company — polluting `relatedWork`, the approval's linked-issue list, and the post-approval requester wake's `issueIds`/`primaryIssueId`. The dedicated link route `POST /issues/:id/approvals` already ran that boundary, so the two entry points reached the same end state through different gates. Extract the decision into `issue-approval-link-authorization.ts` and run it on the create route (and the agent-hire door) so both doors share one evaluator. - 403 names the refused ids. - Board/user actors unaffected. - The PR #1211 status-only binding still permits a productivity-review run to link its own `sourceIssueId`. Squash-linearized onto master: this repo's queue merges by REBASE, so a branch carrying merge commits is `mergeable` but not `rebaseable` and is dequeued before any `merge_group` build is created. Sole conflict was an additive import collision in `routes/issues.ts` against BLO-25878's `STATUS_ONLY_RECOVERY_RESUME_GUIDANCE`; both imports kept. Verified the staged tree equals master plus exactly the original delta (per-file added/removed line sets identical; only positions moved). Co-Authored-By: Claude <noreply@anthropic.com>
Thinking Path
Linked Issues or Issue Description
Fixes: BLO-23036
Refs BLO-22757 — the productivity review where this was hit live, which left
criticalproduction-outage issue BLO-22206blockedwith no live escalation.Searched the GitHub PR list for overlap (
status_only approval,request_board_approval,BLO-23036). Nearest neighbours are #1130, #975 and #288, all payload/display concerns on approval create; #887 gates productivity reviews behind a pending approval. None touch the status-only authorization guard. No duplicate.What Changed
server/src/routes/approvals.ts—assertApprovalMutationAllowedByRunContexttakes an optionalrequestedType; when the run is cheap/status-only and the requested type isrequest_board_approval, the call is allowed. Only the create route passesrequestedType, so the escape is create-only by construction.details.allowedApprovalType, instead of only reporting that the run is barred.server/src/routes/issues.ts— the sibling issue↔approval link/unlink guard stays barred, but its message now points at the reachable alternative (create withissueIds) and its wording no longer misdescribes itself as covering create.server/src/services/productivity-review.ts— the Manager Decision text now names the escalation mechanism, so the "block with an unblock owner" verdict carries its own execution path.Verification
Guard rationale, checked against the code rather than assumed:
approve/reject/request-revisionnever called this guard — they are board-only viaassertBoard. The guard's agent-facing surface was only ever create/resubmit/withdraw/comment, so blocking creation was doing no safety workassertBoardwasn't already doing.resolveApprovalWithSideEffectsis type-agnostic: its only approve-side effect is waking the requester on a normal-model run, which is the designedresumeRequiresNormalModelresume path — and it is gated on a human decision. Arequest_board_approvalis inert until then.productivity-review.ts:1344stamps the review run'scontextSnapshotviawithRecoveryModelProfileHint(..., "status_only"), which is exactly whatisStatusOnlyCheapRecoveryContextmatches.The new test fails against
main, as BLO-23036's verifying signal requires. Reverting onlyserver/src/routes/approvals.tstoorigin/masterand re-running gives 4 failures, includingexpected 403 to be 201.Risks
Low, and deliberately bounded — this widens an authorization boundary, so the scoping is the safety argument:
hire_agent,approve_ceo_strategyandbudget_override_requiredremain barred on create. resubmit/withdraw/comment remain barred regardless of the target approval's type, because only the create route passes a requested type.req.body?.typeis read aftervalidate(createApprovalSchema), so it is a validated enum member rather than free-form input. Flagged for the reviewer to confirm the ordering is guaranteed rather than incidental.detailskeys are unchanged and additive.Model Used
Claude,
claude-opus-5[1m](1M context), extended thinking, with tool use / code execution — running as the Paperclip CTO agent.Checklist
Fixes: #/Closes #/Refs #OR (b) described the issue in-PR following the relevant issue template🤖 Generated with Claude Code