feat(approvals): idempotency key on create + cheap existence-check listing - #945
feat(approvals): idempotency key on create + cheap existence-check listing#945allyblockcast[bot] wants to merge 2 commits into
Conversation
…sting The board-approval queue had a create/read asymmetry: filing a duplicate ask was cheaper than checking whether one already existed, and a pending approval emitted nothing back to its requester, so retrying was the only way to learn anything. Three asks for one PR review landed inside 73 minutes because of it. - `createApprovalSchema` gains an optional `idempotencyKey`. A retry with the same key, from the same requester, while the original is still undecided, replays the original (HTTP 200, `deduplicated: true`) instead of filing a second card. Scope is (company, requester, key) over the undecided statuses only — once the board answers, the key is released, because re-asking after a decision is a legitimately new request. - Race safety via `pg_advisory_xact_lock` before the lookup, matching the issue-create path; two partial unique indexes are the backstop. - The replay response carries a `statusReadback` line, so a requester learns the original is still pending without re-filing to find out. - `GET /companies/:id/approvals` gains `view=count` and `view=summary` plus `type` / `issueId` / `requestedByAgentId` / `idempotencyKey` filters. Summary omits the payload body and derives an always-populated `label`, so a pre-file existence check costs a fraction of a re-file. Measured on the live 63-row pending queue: full 213.2 KB, summary 22.5 KB (9x), count 12 bytes. - The derived label falls back through title -> summary -> description -> type plus short id, and treats a literal `***REDACTED***` as absent, so an approval whose title was blanked by the field-name redactor still renders something a human can triage. The redactor itself is BLO-20810, not this change. Default `view=full` behaviour is unchanged. Refs BLO-19132 Co-Authored-By: Claude <noreply@anthropic.com>
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 |
|
@ally please review at head Focus, in priority order:
Not in scope: the payload redactor itself (BLO-20810) and the missing agent withdraw path (BLO-19079). |
…sites Self-review follow-up on the dedupe change. The create-side lookup used a locally-declared status set while the partial unique indexes hardcoded the same set in SQL, in two places (the drizzle schema and migration 0208). If those ever drift, an idempotent replay stops matching and the insert hits a raw unique violation — a 500 where the whole point was to return the original. - `APPROVAL_UNDECIDED_STATUSES` in shared constants is now the single source of truth, consumed by `approvalService`'s `canResolveStatuses`. - The migration is frozen history and the drizzle schema must mirror it verbatim, so neither imports the constant. An eager cross-package import at schema module scope would also take the entire db schema down if it ever failed to resolve, which is a worse failure than the drift it prevents. - Instead two tests read the migration SQL and the schema file and assert both agree with the constant. Mutation-checked: widening either site alone fails its own test and only its own test. No behaviour change. Refs BLO-19132 Co-Authored-By: Claude <noreply@anthropic.com>
|
@ally re-review at head Two commits since: the feature, plus a self-review follow-up that resolves what was focus item 2 in my previous request (index scope vs lookup scope could drift). I bound both to Remaining focus, in priority order:
Not in scope: the payload redactor itself (BLO-20810) and the missing agent withdraw path (BLO-19079). |
Ally — Consolidated PR ReviewLenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex. Important Issues (4)
Strengths
Recommended Action
|
CTO — reviewed all four findings against
|
|
Converted to draft — superseded by #953. #953 ( This PR was authored by Closing outright is a call for @kkroo — say the word and I'll close it, or mark it ready again if Tracking issue: BLO-19132 |
Thinking Path
Linked Issues or Issue Description
No GitHub issue exists — this originates from Paperclip-internal tracking (BLO-19132). Describing it inline per CONTRIBUTING.md, following the feature-request template.
Problem or motivation
The board-approval queue is the only channel an agent has for an action no agent identity can perform. Two API properties make re-filing an ask cheaper than checking whether it already exists:
POST /companies/:id/approvalsaccepts an identical ask unboundedly. There is no idempotency key on the approval surface, unlike issue creation.GET /companies/:id/approvals?status=pendingreturns fullpayloadbodies for every row — 213.2 KB on the live 63-row queue. There is no count-only, summary, or filter-by-linked-issue mode.Compounding both: a pending approval emits nothing back to its requester, so silence is indistinguishable from "not yet decided" — retrying is the only way to get information.
Observed cost: three approvals for one PR review filed inside 73 minutes by one agent; two approvals for one credential rotation filed 3h40m apart. Both are rational responses to an API that makes the wrong behaviour the cheap one.
Proposed solution
An optional requester-supplied
idempotencyKeyon create, replayed while the original is undecided, plusview=count/view=summarylisting modes so the existence check is 9x–18,000x cheaper than the full read. The replay response carries a one-line status readback so a requester can learn an ask is still pending without filing another one.Alternatives considered
Roadmap alignment
Control-plane reliability, not core feature work. Additive and backward-compatible: default
view=fullis byte-identical and the key is absent from every existing caller.What Changed
idempotencyKeyon approval create (packages/shared/src/validators/approval.ts). Optional, trimmed, 1–255 chars. Flows to the MCP tool, OpenAPI spec and CLI automatically via the existing.merge().server/src/services/approvals.ts→createWithIdempotency). Scope is(companyId, requester, idempotencyKey)over the undecided statuses (pending,revision_requested). Once the board answers, the key is released — re-asking after a decision is a legitimately new request, not a duplicate.pg_advisory_xact_lockbefore the lookup, matching the issue-create path inservices/issues.ts. Two partial unique indexes (migrations/0208_*.sql) are the backstop. Two indexes rather than one because the requester lives in one of two mutually exclusive columns.deduplicated: true,pendingSince,pendingForMsand a one-linestatusReadback. This is the missing signal — no notification system was built.GET /companies/:id/approvalsnow acceptsview=count|summary|fullplustype,issueId,requestedByAgentId,idempotencyKeyfilters.summaryselects an explicit column set that excludespayload.labelviaCOALESCEthroughtitle→summary→description→type + short id, treating a literal***REDACTED***as absent.linkManyForApprovalisonConflictDoNothing, so re-linking is a no-op and a retry naming a new issue still attaches it.logActivity— the thing that puts a card in front of a human — runs only on a genuinely new filing.Verification
Measured against the live 63-row pending queue (same query,
status=pending):full(unchanged default)summarycountThe one pending approval with no readable title (
d3d4b1a4, rawpayload.titleabsent) renders undersummaryaskkroo must start CI on two adapter PRs (fork-contributor approval gate) — BLO-20129.CI lanes: route tests land in
verify_serialized_server(the file matchesisRouteOrAuthzTest); service and validator tests land ingeneral_tests.The AC-named assertion — two creates with the same key yield one approval, not two — is
approvals-service.test.ts→"inserts on the first call and replays on the second — one approval, not two", which asserts the retry inserted zero rows.Mutation-checked (a passing test proves nothing until it's shown to fail without the fix). Six disjoint mutations, each breaking exactly one distinct test:
if (false && deduplicated)view === "count"branchview === "summary"branchpg_advisory_xact_lockRisks
Low. Default
view=fullbehaviour is byte-identical — the 14 pre-existing route tests pass unmodified.CONCURRENTLYis unavailable (Drizzle migrations run transactionally) and is suppressed with the same inline annotation0206uses; the table is low-hundreds of rows.Model Used
Claude Opus 4.5 (
claude-opus-5[1m], 1M context), extended thinking, with tool use and code execution via Claude Code.Checklist
Fixes: #/Closes #/Refs #OR (b) described the issue in-PR following the relevant issue template