fix(approvals): terminal exit for revision_requested that preserves the board's decision note, and wake the requester on every decision (BLO-27036) - #1388
Conversation
1 similar comment
|
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 |
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: 36840c1
The core thesis is right and the withdraw path is implemented carefully: widening to APPROVAL_UNDECIDED_STATUSES (pending + revision_requested, verified in packages/shared/src/constants.ts:675) is exactly the intended set with no over-reach, the guarded UPDATE is preserved, and the reason is recorded where it cannot overwrite anything. One gap remains in the sibling function this PR rewrote.
Critical Issues (0)
Important Issues (1)
- [gstack/review + native-codex]
server/src/services/approvals.ts:595—resubmit's UPDATE is not status-guarded, so it can still silently destroy a board decision note — the exact loss class this PR exists to prevent.getExistingApproval(id, txDb)(:558) is a plainSELECTwith noFOR UPDATE, and the write at:595predicates oneq(approvals.id, id)alone.resolveApprovalacceptsrevision_requested(:177), so a boardapprove/rejectracing an agentresubmitinterleaves as: resubmit SELECTsrevision_requested→ board decision commits (status='approved', freshdecisionNote,decidedAt) → resubmit's unpredicated UPDATE applies anyway, reverting status topendingand nulling the board's new note. Under READ COMMITTED the UPDATE blocks on the row lock and then proceeds, because there is no status predicate left to re-evaluate.- The archive comment written at
:576makes the outcome actively misleading rather than merely lossy: it records the older revision note while the newer decision note is the one destroyed, so the card reads as though its reasoning was preserved. - Both siblings in this same file already carry the guard with comments explaining precisely this hazard —
requestRevisionat:541(eq(approvals.status, "pending")) andwithdrawat:649(inArray(approvals.status, resolvableStatuses)). Mirror it: addeq(approvals.status, "revision_requested")to the WHERE, and treat a zero-row result as a conflict the waywithdrawdoes at:653-659.
Suggestions (4)
- [pr-review-toolkit/code]
server/src/services/approval-resolution.ts:159— therevisebranch always callslistIssuesForApproval, butqueueRequesterWakeearly-returns at:70whenrequestedByAgentIdis null. The approve/reject branch needs that lookup for its activity log (:173); this one does not. Guard the call, or move the lookup insidequeueRequesterWakeafter the early return. - [pr-review-toolkit/types]
server/src/services/approval-resolution.ts:82,92—decisionNoteis written into bothpayloadandcontextSnapshot. The duplication is harmless but the column is unbounded text, andcontextSnapshotis persisted per run; a long board note is now stored twice on every decision wake. Consider a single home plus a length cap. - [pr-review-toolkit/types]
server/src/services/approvals.ts:637-644— when the note is preserved,decidedByUserIdanddecidedAtkeep their revision values on a now-withdrawncard, androutes/approvals.ts:592returns that record straight to clients. The attribution trade-off is well argued in the comment, but the withdrawal actor and timestamp exist only in the activity log and comment body, so any consumer rendering "decided by X at T" beside statuswithdrawnwill misreport. Worth a distinctwithdrawnBy/withdrawnAtif a consumer reads those fields. - [pr-review-toolkit/tests]
server/src/__tests__/approval-revision-exit.test.ts:229—seedRevisionRequestedsetsrequestedByAgentId: null, and the tests call the service directly. The headline scenario is an agent retiring its own moot card, butroutes/approvals.ts:576-579requiresreq.actor.agentId === existing.requestedByAgentId, so the agent-reachable path the PR title promises is never exercised end to end. SeedingrequestedByAgentId: requesterAgentIdwould cover it at no extra cost.
Strengths
- The rationale comments are unusually good — each one states the failure it prevents and cites the reproducing artifact (approval
f946c9b3, the 10-11 day unread measurement) rather than restating the code. readBoardDecisionNotetreating blank as absent is the right call: it keeps ordinarypendingwithdrawal behaviour byte-identical, and the test at:315pins that explicitly.- Wake coverage is genuinely thorough — all three decisions, the no-requester case, the already-applied case, and the scheduler-failure case that asserts the decision still returns. Table-driven without obscuring which assertion belongs to which state.
- Moving both the comment insert and the status write inside one transaction means a card can never reach
withdrawnwith no recorded reason. RUNNING_ISSUE_WAKE_REASONS_REQUIRING_FOLLOWUPandISSUE_RESPONSIBLE_USER_WAKE_REASONS(heartbeat.ts:1359,1368) are both updated — an easy half-fix to miss, since only the latter affectsisManualUserRunclassification.- The OpenAPI summary was updated to describe the widened contract instead of being left stale.
Recommended Action
- Add the status guard to
resubmit(Important) — one line plus the zero-row conflict, matchingwithdraw. - Consider the Suggestions opportunistically; the test-seeding one is the highest value.
Note: CI was still pending at review time (Build, server tests, e2e, typecheck all pending), so this review reflects static analysis only, not a green run.
|
@ally please re-review current head |
|
Follow-up pushed at head |
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: 7b12210
Prior Findings Dispositioned (1)
- prior:36840c1 important 1 — fixed —
server/src/services/approvals.ts:604— theresubmitUPDATE now requiresstatus = revision_requested; a concurrent board decision produces no update, throws, and rolls back the archive comment, preserving the newer decision and note. The regression test is atserver/src/__tests__/approval-revision-exit.test.ts:322.
Critical Issues (0)
Important Issues (0)
Suggestions (0)
Strengths
- The status-guarded
resubmittransition closes the previously identified lost-update race while keeping the note archive transactional. - Decision notes are preserved through requester withdrawal and archived before a successful resubmit clears the live decision fields.
- Requester wakes now cover approved, rejected, and revision-requested decisions, carry the decision note, and remain non-fatal when scheduling fails.
- The focused tests cover exact note persistence, authorization-relevant requester data, concurrent resubmit behavior, all wake states, and scheduler failure handling.
Recommended Action
- No Critical or Important issues found. The prior Important finding is fixed and verified against the current head.
- Merge when the remaining repository checks are green.
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: c6ce5ed
Prior Findings Dispositioned (1)
- prior:36840c1 important 1 — fixed —
server/src/services/approvals.ts:604— theresubmitUPDATE is guarded bystatus = revision_requested; a racing board decision therefore makes the update return no row and rolls back the archive comment instead of clearing the newer decision.
Critical Issues (0)
Important Issues (1)
- [gstack/review + native-codex]
.github/workflows/pr.yml:1— this PR contains a 499-file, 546-commit mega-diff spanning workflows, Helm, migrations, adapters, plugins, and broad test infrastructure in addition to the approval change. The current tip is a merge commit combining the approval branch with another long-lived branch. This scope makes the approval behavior and CI result difficult to isolate and materially increases merge-conflict and unintended-change risk.- Rebase or split the approval fix onto the intended base so the PR contains only the approval implementation, its focused tests, and directly required documentation. Re-run the complete required checks on that isolated tree.
Suggestions (0)
Strengths
- The
resubmitstatus guard and transactional note archive correctly close the previously reported lost-update race. - Requester wakes now cover approved, rejected, and revision-requested decisions and carry the decision note in both wake payloads.
- The focused tests exercise note preservation and the concurrent resubmit decision race.
Recommended Action
- Isolate the approval change from the unrelated mega-diff before merge.
- Merge only after the isolated tree's required checks are green.
c6ce5ed to
ab71f5d
Compare
…s the board's note, and wake the requester on every decision (BLO-27036) A `revision_requested` approval had exactly one agent-reachable exit: `resubmit` then `withdraw`. `resubmit` nulls `decisionNote`, `decidedByUserId` and `decidedAt`, and there is no revision history on those columns — so the only way to retire a moot card permanently destroyed the board's reasoning, which is often the sole record of why a request was refused. Reproduced live on approval f946c9b3: one `resubmit` call turned a four-paragraph board ruling into `null`. - `withdraw` now accepts the whole undecided set (`pending` + `revision_requested`) via the existing `APPROVAL_UNDECIDED_STATUSES` constant, so the card gets a one-hop terminal exit. - A `decisionNote` the board already wrote is preserved byte-identical, along with `decidedByUserId`/`decidedAt` so it stays correctly attributed. The withdrawal reason goes to an approval comment and the activity log instead. A card the board never wrote on keeps today's behaviour exactly: the reason lands in `decisionNote` and no comment is manufactured. - `resubmit` archives the note as an approval comment before clearing the decision fields, inside a transaction, so the reasoning survives the round trip even though the card is genuinely undecided again. - Requester wakes now fire on `rejected` and `revision_requested`, not just `approved`, and every wake carries `decisionNote` in both the payload and the context snapshot. A silent `rejected` is the worse half: it looks answered, so nothing re-examines it. - Both new reasons join `RUNNING_ISSUE_WAKE_REASONS_REQUIRING_FOLLOWUP` and `ISSUE_RESPONSIBLE_USER_WAKE_REASONS`, so a decision is not dropped just because a run happened to be in flight. Defect 3 in the filing issue (`requestedByAgentId` silently ignored on list) was already fixed on master by `approvalListConditions`; no change needed. Co-Authored-By: Claude <noreply@anthropic.com>
ab71f5d to
b5ce62c
Compare
|
@ally please review the isolated current head |
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: b5ce62c
Prior Findings Dispositioned (1)
- prior:c6ce5ed important 1 — no-longer-applicable —
server/src/services/approvals.ts:575— the current head contains the focused approval implementation, and the live PR file list contains only five directly related files; the previously reported mega-diff is absent.
Critical Issues (0)
Important Issues (0)
Suggestions (0)
Strengths
- The
resubmittransition archives a non-blank board note transactionally, guards the status-conditional update, and rolls back the archive on a lost race. - Requester wakeups now cover approved, rejected, and revision-requested decisions while preserving the decision note in the wake payload and context snapshot.
- Withdrawal preserves existing board attribution and records the withdrawal reason separately, while the focused tests cover note persistence and concurrent decision behavior.
- The current PR scope is isolated to the approval service, resolution flow, heartbeat wake reasons, OpenAPI text, and focused tests.
Recommended Action
- Merge after the remaining repository checks complete successfully.
Thinking Path
Linked Issues or Issue Description
Fixes BLO-27036. Unblocks BLO-27406 (20 cards stranded in
revision_requested), whose acceptance criterion could not previously be met without destroying the thing that defines those cards.Follow-up to #850 (BLO-19079), which added requester-scoped withdraw but left it gated on
pending.Live reproduction, 2026-08-15T16:52:54Z, approval
f946c9b3, oneresubmitcall:statusrevision_requestedpendingdecisionNotenulldecidedByUserIdoAfDyNGX…nulldecidedAt2026-08-04T20:00:46.452ZnullThe note was recoverable only because an earlier read in the same run happened to still be in hand.
Measured across 14 cards filed by one agent: 4 sat in
revision_requestedwith substantive board instructions for 10–11 days, unread. 10 were decidedrejectedin two bulk windows and 9 of those carrydecisionNote: nullwith zero approval comments.What Changed
The exit (
server/src/services/approvals.ts)withdrawaccepts the whole undecided set —pendingandrevision_requested— reusing the existingAPPROVAL_UNDECIDED_STATUSESconstant rather than introducing a second list. The concurrency guard becomesinArray(status, resolvableStatuses), so a racing board decision still wins.decisionNotethe board already wrote is preserved byte-identical, along withdecidedByUserId/decidedAt. Re-stamping the attribution would leave the note readable but misattributed to whoever withdrew the card.details.reasonon the activity log. A card can never end upwithdrawnwith no recorded reason.decisionNote, and no comment is manufactured. Blank/whitespace notes count as "the board wrote nothing".resubmitarchives the note as an approval comment before clearing the decision fields, now in a transaction. Clearing is still right — the card is undecided again, and a populateddecidedAton apendingcard would read as decided — but the reasoning survives.The wake (
server/src/services/approval-resolution.ts,heartbeat.ts)approval_approved,approval_rejected,approval_revision_requested. The reason strings are the onesactivity-log.tsalready anticipated.decisionNotein bothpayloadandcontextSnapshot, so the woken run can act on the reasoning without a second fetch.RUNNING_ISSUE_WAKE_REASONS_REQUIRING_FOLLOWUPandISSUE_RESPONSIBLE_USER_WAKE_REASONS. A board decision must not be dropped because a run happened to be in flight — as true of a refusal as of an approval.requestedByAgentIdis null, when another worker already applied the decision, or when the wake itself throws (still logged asapproval.requester_wakeup_failed, decision still returned).Not changed
requestedByAgentIdis applied inapprovalListConditions(approvals.ts:52). The issue was filed against older behaviour; I verified against master rather than inheriting the claim.skills/paperclip/SKILL.mdis deliberately untouched — feat(mcp): expose requester-scoped approval withdraw on paperclipApprovalDecision (BLO-27534) #1376 (BLO-27534) owns that doc surface and adds the withdraw section there. That PR and this one are complementary: it exposeswithdrawon the MCP tool, this one widens which statuses the underlying route accepts. Its description says "own still-pending card", which will want a one-line update once both land.Verification
New file
server/src/__tests__/approval-revision-exit.test.ts, 10 tests. The decision-note tests run against embedded Postgres, not a stub, so "byte-identical" is a real round trip through the column rather than an assertion about an UPDATE payload.The tests were confirmed meaningful, not vacuous. Stashing only the two service files and re-running gives
6 failed | 4 passed, and all six failures are the new-behaviour tests. The four that still pass are the guard tests (no-wake-without-requester, no-wake-on-already-applied, and the two pre-existing-behaviour cases), which is correct — they must hold either way.One test failure during development was a genuine finding rather than a flake:
approval_comments.author_agent_idcarries a real FK toagents, so the comment write inside the withdraw transaction requires the withdrawing agent to exist. It does in production (getActorInforeturns the authenticated agent), and the test now seeds one.Risks
Low-to-moderate; the moderate part is named honestly.
withdrawandresubmitnow write anapproval_commentsrow inside their transaction, so a failing insert rolls the whole operation back. That is deliberate — a withdrawal whose reason was silently dropped is exactly the class of data loss this PR exists to stop — but it is a new way for a previously simpler call to fail. The only realistic trigger is a badauthor_agent_id, which the route cannot produce.revision_requested. Authorization is unchanged and still server-side (req.actor.agentId !== existing.requestedByAgentId→ 403); this widens which statuses, never who. A decided card (approved/rejected/withdrawn) still 409s, pinned by a test.allowedStatusesin the details. Any caller string-matching that message would need updating; none in this repo does.Model Used
Claude Opus 5 (
claude-opus-5), 1M context, extended thinking, with tool use — running as the Paperclip CTO agent.Checklist
gh pr list --state open --limit 100grepped forapproval|revision|27036; found feat(mcp): expose requester-scoped approval withdraw on paperclipApprovalDecision (BLO-27534) #1376 (complementary, MCP tool surface), Add approval-enforcement reconciler: detect approved decisions that never reached the enforcing object (BLO-24631) #1309, fix(approvals): authorize issueIds on approval create (BLO-23763) #1271. None duplicate this change.Fixes: #/Closes #/Refs #OR (b) described the issue in-PR following the relevant issue templatewithdrawnstatus colour).POST /api/approvals/{id}/withdraw.skills/paperclip/SKILL.mddeliberately left to feat(mcp): expose requester-scoped approval withdraw on paperclipApprovalDecision (BLO-27534) #1376 to avoid a conflict.