Skip to content

feat(approvals): require usable request payloads and add agent withdraw - #296

Open
claudegoogl-sudo wants to merge 7 commits into
masterfrom
feat/approval-request-integrity
Open

feat(approvals): require usable request payloads and add agent withdraw#296
claudegoogl-sudo wants to merge 7 commits into
masterfrom
feat/approval-request-integrity

Conversation

@claudegoogl-sudo

@claudegoogl-sudo claudegoogl-sudo commented Sep 7, 2026

Copy link
Copy Markdown
Owner

Thinking Path

  • Paperclip is the open source app people use to manage AI agents for work
  • Agents can put decisions in front of the human operator through approval cards, and the operator decides from a queue
  • The creation endpoint accepted any payload object, including an empty one, and no route let the requesting agent retract a bad card
  • A schema probe, a half-built payload, or a serialization bug therefore produced a permanently pending, completely blank card. It sat in the operator queue next to real, time-critical decisions with nothing to tell them apart. The agent that knew within seconds that the card was garbage had no way to remove it
  • Approval integrity is an authorization surface: blank cards cost operator attention, and un-retractable escalation attempts undermine trust in the queue
  • This pull request adds per-type payload validation at the creation boundary, and a withdraw route for the requesting agent
  • The benefit is an empty card can no longer reach the queue, and a mistaken request can be retracted in a logged, visible way

Linked Issues or Issue Description

No public GitHub issue exists for this defect.

What happened? POST /api/companies/{companyId}/approvals validated type and id fields but passed payload through unchecked. A request with {"type":"request_board_approval","payload":{}} returned 201 Created and wrote a pending approval with no title, no summary, no recommended action, and no risks. There was no DELETE/PATCH/withdraw route, so the card could only be cleared by a human.

Expected behavior: An approval card must be decidable on its own. A request_board_approval without a usable title and summary should fail with 4xx before any row is written. The agent that created a pending card should be able to withdraw it, and the withdrawal should be logged, not deleted.

Steps to reproduce:

  1. POST /api/companies/{companyId}/approvals with body {"type":"request_board_approval","payload":{}}.
  2. Pre-fix, the response is 201 Created and a permanently pending blank card appears in the operator approval queue.
  3. POST /api/approvals/{id}/withdraw returns API route not found, so the requesting agent cannot retract the card.

What Changed

  • packages/shared/src/validators/approval.ts: added requestBoardApprovalPayloadSchema (non-empty title, non-empty summary, passthrough for extra fields such as risks) and refineApprovalPayload, a discriminated per-type refinement. Types without an entry (hire_agent, approve_ceo_strategy, budget_override_required) keep their existing behavior, so no shared loose rule tightens them by accident.
  • server/src/routes/approvals.ts: the create route now validates through createApprovalRequestSchema (createApprovalSchema.superRefine(refineApprovalPayload)). An unusable request_board_approval payload returns 400 and writes no row. Added POST /api/approvals/{id}/withdraw: agent-actor only, must match requestedByAgentId, company access and run-context checks apply, and an approval.withdrawn activity-log entry records the agent and run.
  • server/src/services/approvals.ts: added withdraw with idempotent semantics. Only pending cards can be withdrawn; a repeat call by the same agent converges without a second log entry; a race lost to a board decision surfaces 422 via a conditional update on status = pending. The row is never deleted; withdrawn is a terminal status.
  • packages/shared/src/constants.ts: added withdrawn to APPROVAL_STATUSES. The approvals.status column is text, so no migration is needed. Every queue/inbox/badge query allows only pending and revision_requested, so a withdrawn card leaves operator attention automatically.
  • packages/mcp-server/src/tools.ts + tools.test.ts: paperclipApprovalDecision gains a withdraw action that routes to the new endpoint, giving agents a discoverable way to retract.
  • packages/shared: createApprovalRequestSchema (shape + discriminated refinement) is exported as the single shared definition. packages/mcp-server/src/tools.ts: paperclipCreateApproval parses its request body through that shared contract before the HTTP call, and its description states the title/summary requirement (the tool listing is the agent-facing discovery surface). The parse happens in the execute path, not as a schema wrapper, because the MCP server registers tools from the plain ZodObject schema.shape.
  • server/src/routes/approvals.ts consumes the shared createApprovalRequestSchema instead of assembling it locally, so the route and the MCP tool cannot drift.
  • docs/guides/board-operator/approvals.md: corrected the MCP tool name (paperclipCreateApproval, not paperclipApprovalRequest).
  • docs/api/approvals.md: documented the per-type payload requirements table, the withdraw route with its rules, and the updated lifecycle.
  • docs/guides/board-operator/approvals.md: documented the request_board_approval card type, the withdrawn transition, and that withdrawal is logged and terminal.
  • Tests: new server/src/__tests__/approval-routes-request-integrity.test.ts drives the real app with supertest; approvals-service.test.ts covers the service contract; two existing idempotency payloads gained the now-required summary.

Verification

  • cd server && npx vitest run src/__tests__/approval-routes-request-integrity.test.ts src/__tests__/approvals-service.test.ts26 passed (15 route + 11 service; adds the cross-tenant withdraw case: a pending approval from another company returns the same 404 as a missing one, with no withdraw and no activity entry). Route-level coverage through createApp: empty payload → 400 with no row written; whitespace-only title, missing summary, and non-string summary → 400; usable payload → 201 with trimmed fields and passthrough extras; hire_agent / budget_override_required / approve_ceo_strategy payloads unchanged; withdraw by the requesting agent → 200 with an activity entry naming agent + run; repeat withdraw → converging no-op; withdraw by a different agent or a board actor → 403; withdraw of a decided card → 422; unknown id → 404. Service-level coverage: idempotency, refusal for non-requester, refusal for decided cards, and the lost-race path.

  • cd packages/shared && npx vitest run src/validators/approval.test.ts3 passed.

  • cd packages/mcp-server && npx vitest run src/tools.test.ts15 passed. New: executing paperclipCreateApproval with an empty request_board_approval payload returns the field-level zod issues (payload.title / payload.summary Required) and never calls the API. Drive-by (disclosed): the pre-existing allows create issue requests to omit status... exact-body expectation was updated for the allowDuplicate: false schema default introduced by upstream fix(issues): deduplicate repeated creates paperclipai/paperclip#9650 — the file is not in any CI shard, so the stale assertion only failed on a local run; caught and fixed while adding the tool-boundary test.

  • cd server && npx vitest run src/__tests__/approval-routes-idempotency.test.ts → the approve/reject duplicate-side-effect tests hit a 5s cold-start timeout flake. A/B at the base commit: the same file fails the same way without this change (2 of 3 base runs). The two payloads in that file were only updated to carry the now-required summary.

  • Typecheck: pnpm typecheck green in packages/shared, packages/mcp-server, and server (the server run needs NODE_OPTIONS=--max-old-space-size=6144 in this sandbox; the default heap OOMs inside ensure-plugin-build-deps).

  • Latest CI: run 34097177778 (head 330dab994, includes the merge of master with the test(server): accept probe-timeout transport in readiness failure assertion #276 readiness deflake) — 24/24 checks green.

Risks

  • Behavior change by design: agents that create request_board_approval cards must now send a non-empty title and summary. Requests that worked before but carried no summary now get 400. This is the point of the change, and the MCP request tool and docs now state the requirement.
  • The withdraw route narrows on purpose: board users get 403 (they can reject instead), decided cards get 422, and non-requester agents get 403. Withdrawal keeps the row with a terminal withdrawn status and writes an activity entry naming the agent and run, so an attempted escalation cannot be erased.
  • The withdrawn status is new to consumers. UI lists and badges use explicit pending/revision_requested allow-lists, so unknown statuses already render inertly; the status pill shows the literal status text. A dedicated icon/color for the pill is a possible follow-up.
  • No database migration: approvals.status is a text column, and the new value is written through the same update path as existing statuses.
  • Tool-boundary validation is additive UX, not a second enforcement point: the server route remains the authoritative boundary (an MCP parse skip or a direct API call is still rejected with 400 there). The one disclosed drive-by test fix (above) touches an expectation only, no production code path.

Model Used

Exact model id not exposed to this session; operating as agent "Coder" under the Paperclip runtime (extended thinking + tool use: code search, edits, test execution, and GitHub CLI).

Checklist

  • I have included a thinking path that traces from project context to this change
  • I have specified the model used (with version and capability details)
  • I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work — reviews/approvals are an established first-class area; this hardens the existing approval gate, it does not overlap planned work
  • I have searched GitHub for duplicate or related PRs and linked them above — N/A, no open or merged PR covers approval payload validation or withdrawal
  • I have either (a) linked existing issues with Fixes: # / Closes # / Refs # OR (b) described the issue in-PR following the relevant issue template — described in-PR using the bug-report template fields (What happened / Expected behavior / Steps to reproduce)
  • I have not referenced internal/instance-local Paperclip issues or links (only public GitHub #NNN / github.com/paperclipai/paperclip URLs)
  • My branch name describes the change (feat/approval-request-integrity) and contains no internal Paperclip ticket id or instance-derived details
  • I have run tests locally and they pass
  • I have added or updated tests where applicable
  • I have updated relevant documentation to reflect my changes
  • I have considered and documented any risks above
  • All Paperclip CI gates are green — run 34097177778 at head 330dab994: all 24 checks green, 0 failures (Storybook visual regression and review skipped as on every PR). The one earlier red (General tests (server (2/5))) was the readiness-probe transport flake; resolved by landing test(server): accept probe-timeout transport in readiness failure assertion #276 and merging master into this branch, not by re-running.
  • Greptile is 5/5 with no open P2s, recommendations, or follow-ups — Greptile has never posted on this repository: no Greptile comment or review exists on this PR nor on merged PRs fix(ui): stop external adapter UI parsers from crashing on readonly caches getter #290, fix(redaction): preserve leading/trailing whitespace in structural JSON redaction #291, fix(recovery): stop stale-run watchdog filing on dead runs; auto-resolve terminated-run alerts #297. Left unchecked deliberately rather than ticked vacuously; enabling the integration on the fork is a repo-settings action outside this change.
  • I will address all Greptile and reviewer comments before requesting merge

Compliance statement: Follows CONTRIBUTING.md PR template — sections present: Thinking Path, Linked Issues or Issue Description, What Changed, Verification, Risks, Model Used, Checklist. Path-2 #dev pre-agreement gate: this is a small, focused (Path-1) change — one approval subsystem, targeted validation plus one new route — so the Path-2 gate does not apply.

claudegoogl-sudo and others added 2 commits September 7, 2026 06:55
Two defects compounded into an operator-inbox hazard: a
request_board_approval with an empty payload was creatable (201 Created),
and the requesting agent had no route to retract it, so a schema probe
left a permanently pending blank card sitting next to real decisions.

Creation side:
- discriminated per-type payload validation shared by the server route and
  the MCP tool: request_board_approval now requires non-empty title and
  summary; hire_agent / approve_ceo_strategy / budget_override_required
  keep their existing (no extra) requirements
- POST /api/companies/{id}/approvals with payload {} returns 400 and
  writes no row (previously 201)

Withdrawal side:
- POST /api/approvals/{id}/withdraw: only the requesting agent, only while
  pending; sets a terminal `withdrawn` status (row kept for audit) and
  logs an approval.withdrawn activity entry naming the agent and run
- retries by the same agent converge; losing a race with a board decision
  surfaces 422 instead of double-writing
- paperclipApprovalDecision MCP tool gains a `withdraw` action
- docs: API reference (payload table, withdraw route, lifecycle) and the
  board-operator approvals guide

Tests driven through createApp + supertest: empty/whitespace/missing/
non-string payloads rejected with no row written; usable payload accepted
and trimmed; other approval types unaffected; withdraw by the requester
logged, by a different agent or a board actor refused (403), on an
already-decided card refused (422), unknown id 404; service-level
idempotency and race coverage.

Verification: vitest suites for server approval routes/service, shared
approval validators, and mcp-server tools (incl. the new withdraw-routing
test) pass; typecheck green for shared, mcp-server, and server. Two
pre-existing base failures/flakes were A/B-verified at the base commit
(approve-idempotency cold-start timeout; mcp create-issue allowDuplicate
expectation) and are left untouched.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
… OpenAPI

CI caught two gaps in the withdraw route:

- The route looked the approval up and then asserted company access, so a
  missing id returned 404 while a cross-tenant id returned 403 — an
  approval-id existence oracle across companies. The route now goes through
  the shared requireApprovalAccess helper, which folds the hasCompanyAccess
  gate into the existence check (both cases 404) before the write-path
  assertion, matching the pattern documented on hasCompanyAccess.
- POST /api/approvals/{id}/withdraw was missing from the OpenAPI registry;
  the mounted-route coverage test failed. Registered with its real
  outcome set (403 forbidden, 404 not found, 422 unprocessable).

Both regression points are covered by the existing cross-tenant existence
oracle guard and openapi mounted-route coverage suites.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
@claudegoogl-sudo

Copy link
Copy Markdown
Owner Author

CI on the first run caught two real gaps in the withdraw route. Both fixed in 4211ca1:

  1. Cross-tenant existence oracle (authz-existence-oracle-guard): the route did svc.getById(id) then assertCompanyAccess(req, approval.companyId), so a missing id returned 404 while a cross-tenant id returned 403 — an id-enumeration oracle. The route now uses the shared requireApprovalAccess helper, which folds hasCompanyAccess into the existence check (both cases 404) before the write-path assertion, matching the pattern documented on hasCompanyAccess in routes/authz.ts.

  2. OpenAPI coverage (openapi-routes): POST /api/approvals/{id}/withdraw was not registered in the OpenAPI registry. Registered with its real outcome set (403/404/422).

Local re-verification: authz-existence-oracle-guard.test.ts 2/2, openapi-routes.test.ts 5/5, approval-routes-request-integrity.test.ts 14/14, approvals-service.test.ts + integrity 25/25, server typecheck green.

Behavior note for reviewers: cross-tenant approval ids now consistently return 404 on withdraw (existence folded into the access gate); the 403 path remains only for same-company actors that are not the requesting agent (different agent or board actor).

claudegoogl-sudo and others added 5 commits September 7, 2026 07:36
… contract

Export createApprovalRequestSchema (shape + discriminated payload
refinement) from shared and consume it in both boundaries: the server
route keeps its authoritative 4xx, and the paperclipCreateApproval tool
now parses the request body before the HTTP call, so an undecidable
request_board_approval card fails at the tool boundary with actionable
field-level feedback instead of an API 400 after the fact. The contract
is stated in the tool description, which is the agent-facing discovery
surface. The tool schema itself stays a plain ZodObject because the MCP
server registers tools from schema.shape.

Also correct the operator guide: the tool is paperclipCreateApproval,
not paperclipApprovalRequest.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
…tool boundary

Drives paperclipCreateApproval through a real execute: an empty payload
returns the field-level zod issues (payload.title / payload.summary
Required) and never reaches the API.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
… schema default

createIssueInputSchema defaults allowDuplicate to false since upstream
paperclipai#9650, so the parsed create body always carries it. The file is not in
any CI shard, so the stale exact-equality assertion only surfaces on a
local run; caught while adding the tool-boundary test above.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
Dynamic coverage for the existence-oracle fold on the withdraw route: a
pending approval from another company is indistinguishable from a
missing one (404, no withdraw, no activity entry).

Co-Authored-By: Paperclip <noreply@paperclip.ing>
@claudegoogl-sudo

Copy link
Copy Markdown
Owner Author

Review changes pushed — head is now 330dab994 (includes a merge of current master).

CI: all 24 checks green on run 34097177778, including the previously-red readiness shard. That failure was the known readiness-probe transport flake; instead of re-run roulette I merged master — which now contains #276 (the readiness-probe deflake, freshly landed with green CI and verified on master) — into this branch, so CI runs deterministically.

Greptile: still no Greptile engagement anywhere on this repo (no comment or review on this PR, nor on merged #290/#291/#297). The 5/5 checklist item is left open with that note — enabling the integration is a repo-settings action, and I did not want to tick it vacuously.

New in this push (review response):

  • paperclipCreateApproval now parses its request through the same shared discriminated payload schema as the server route — an undecidable request_board_approval payload fails at the tool boundary with field-level feedback (payload.title / payload.summary Required), and the tool description states the contract. Parsed in the execute path rather than as a schema wrapper because the MCP server registers tools from the plain ZodObject schema.shape.
  • Operator guide: corrected the tool name reference.
  • Added a route test: cross-tenant withdraw folds into the same 404 as a missing approval.
  • Disclosed drive-by (test-only): tools.test.ts createIssue exact-body expectation aligned with the allowDuplicate schema default from fix(issues): deduplicate repeated creates paperclipai/paperclip#9650 — the file is in no CI shard, so the stale assertion only failed locally.

Local verification: server request-integrity + approvals-service 26/26, MCP tools 15/15, shared validators 3/3; typecheck green in shared, mcp-server, and server.

@claudegoogl-sudo

Copy link
Copy Markdown
Owner Author

Coordination note from a sibling change: PR #310 (fix/approval-create-reject-empty-payload) adds a small, type-agnostic guard on the same create route — an empty payload ({}) now returns 422 with code: "approval_payload_empty" before any row is written.

Relationship to this PR:

Flagging here so reviewers see both shapes in one place and merge order can be chosen deliberately.

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.

1 participant