fix(approvals#66): requester-initiated withdraw endpoint #2849

Merged
devops-engineer merged 1 commits from fix/66-approval-requester-withdraw into main 2026-06-14 12:52:47 +00:00
Member

Closes the long-standing gap where an agent had no way to retract an approval it had raised but no longer needed. PM-approved plan: 7600d2ed.

NEW ENDPOINT: POST /workspaces/:id/approvals/:approvalId/withdraw (workspace-token auth).

Plan guardrails respected:

  1. Additive + reversible migration — up widens approval_requests.status CHECK to add withdrawn; down deletes any withdrawn rows AND narrows the CHECK back. Rollback-safe even if the endpoint has been exercised in the deploy window.

  2. Authz against creator-workspace-id, NOT path :id — handler reads approval_requests.workspace_id (the row's creator) and compares to the URL path's :id. Critical for cross-workspace approval gates (#2574 / #2593) where the path's :id is the GATE's workspace, not the requester's.

  3. Pending-only state guardUPDATE ... WHERE id=$1 AND status='pending'. 0-rows-affected returns 409 Conflict (not 404) so the caller can distinguish "row vanished" from "row exists but already moved". Mirrors requests.Cancel.

  4. Docstring pointer — the ListAll comment (reverted in bcabd207 because it inaccurately claimed a withdraw path existed) now points at the real endpoint.

Response codes:

  • 200 success: {status: "withdrawn", approval_id: "..."}. Stamps decided_by="requester", decided_at=now().
  • 403 if caller workspace != row creator workspace (load-bearing cross-workspace authz)
  • 404 if the approval does not exist (or UUID malformed)
  • 409 if the approval is no longer pending (raced with Decide or another withdraw)
  • 500 on DB error
  • broadcasts APPROVAL_WITHDRAWN on the row's creator workspace_id (matches Decide's broadcast convention)

New files:

  • migrations/20260614010000_approval_withdrawn_status.up.sql (CHECK widen)
  • migrations/20260614010000_approval_withdrawn_status.down.sql (purge + narrow)

Modified:

  • internal/handlers/approvals.go — new Withdraw method + ListAll comment
  • internal/handlers/approvals_test.go — 5 new tests
  • internal/router/router.go — wire wsAuth.POST("/approvals/:approvalId/withdraw", apph.Withdraw)
  • docs/api-reference.md, docs/api-protocol/platform-api.md — table entries

5 new tests:

  • TestApprovals_Withdraw_Success — happy path
  • TestApprovals_Withdraw_NotPendingReturns409 — state guard
  • TestApprovals_Withdraw_NotFound — 404
  • TestApprovals_Withdraw_CrossWorkspaceAuthzReject — load-bearing authz; uses mock.ExpectationsWereMet to confirm the authz check short-circuits before the UPDATE runs
  • TestApprovals_Withdraw_CrossWorkspaceGateOK — the #2574 / #2593 scenario where the row's creator workspace matches the path's :id and withdraw proceeds normally

Local validation:

  • go test ./internal/handlers/ — clean (26.4s, all 5 new + all existing)
  • go test ./internal/provisioner/ — clean (0.08s)
  • go vet ./... — clean
  • go build ./... — clean

Refs #66. Re-requesting review.

Closes the long-standing gap where an agent had no way to retract an approval it had raised but no longer needed. PM-approved plan: 7600d2ed. **NEW ENDPOINT:** `POST /workspaces/:id/approvals/:approvalId/withdraw` (workspace-token auth). **Plan guardrails respected:** 1. **Additive + reversible migration** — up widens `approval_requests.status` CHECK to add `withdrawn`; down deletes any `withdrawn` rows AND narrows the CHECK back. Rollback-safe even if the endpoint has been exercised in the deploy window. 2. **Authz against creator-workspace-id, NOT path :id** — handler reads `approval_requests.workspace_id` (the row's creator) and compares to the URL path's `:id`. Critical for cross-workspace approval gates (#2574 / #2593) where the path's `:id` is the GATE's workspace, not the requester's. 3. **Pending-only state guard** — `UPDATE ... WHERE id=$1 AND status='pending'`. 0-rows-affected returns **409 Conflict** (not 404) so the caller can distinguish "row vanished" from "row exists but already moved". Mirrors requests.Cancel. 4. **Docstring pointer** — the `ListAll` comment (reverted in bcabd207 because it inaccurately claimed a withdraw path existed) now points at the real endpoint. **Response codes:** - `200` success: `{status: "withdrawn", approval_id: "..."}`. Stamps `decided_by="requester"`, `decided_at=now()`. - `403` if caller workspace != row creator workspace (load-bearing cross-workspace authz) - `404` if the approval does not exist (or UUID malformed) - `409` if the approval is no longer pending (raced with Decide or another withdraw) - `500` on DB error - broadcasts `APPROVAL_WITHDRAWN` on the row's creator workspace_id (matches Decide's broadcast convention) **New files:** - `migrations/20260614010000_approval_withdrawn_status.up.sql` (CHECK widen) - `migrations/20260614010000_approval_withdrawn_status.down.sql` (purge + narrow) **Modified:** - `internal/handlers/approvals.go` — new `Withdraw` method + ListAll comment - `internal/handlers/approvals_test.go` — 5 new tests - `internal/router/router.go` — wire `wsAuth.POST("/approvals/:approvalId/withdraw", apph.Withdraw)` - `docs/api-reference.md`, `docs/api-protocol/platform-api.md` — table entries **5 new tests:** - `TestApprovals_Withdraw_Success` — happy path - `TestApprovals_Withdraw_NotPendingReturns409` — state guard - `TestApprovals_Withdraw_NotFound` — 404 - `TestApprovals_Withdraw_CrossWorkspaceAuthzReject` — load-bearing authz; uses `mock.ExpectationsWereMet` to confirm the authz check short-circuits before the UPDATE runs - `TestApprovals_Withdraw_CrossWorkspaceGateOK` — the #2574 / #2593 scenario where the row's creator workspace matches the path's `:id` and withdraw proceeds normally **Local validation:** - `go test ./internal/handlers/` — clean (26.4s, all 5 new + all existing) - `go test ./internal/provisioner/` — clean (0.08s) - `go vet ./...` — clean - `go build ./...` — clean Refs #66. Re-requesting review.
agent-dev-b added 1 commit 2026-06-14 12:44:35 +00:00
fix(approvals#66): requester-initiated withdraw endpoint
CI / Python Lint & Test (pull_request) Successful in 5s
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 7s
E2E Peer Visibility (literal MCP list_peers) / detect-changes (pull_request) Successful in 7s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 5s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Successful in 6s
Lint forbidden tenant-env keys / Scan for repo-host token write into tenant workspace surface (pull_request) Successful in 5s
Harness Replays / detect-changes (pull_request) Successful in 6s
sop-checklist / review-refire (pull_request_target) Has been skipped
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (local) (pull_request) Has been skipped
Harness Replays / Harness Replays (pull_request) Successful in 2s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 7s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (pull_request) Successful in 6s
reserved-path-review / reserved-path-review (pull_request_target) Failing after 8s
sop-checklist / na-declarations (pull_request) N/A: (none)
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 16s
E2E API Smoke Test / detect-changes (pull_request) Successful in 17s
sop-checklist / all-items-acked (pull_request_target) Successful in 9s
gate-check-v3 / gate-check (pull_request_target) Failing after 13s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 2s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 18s
E2E Chat / detect-changes (pull_request) Successful in 28s
CI / Detect changes (pull_request) Successful in 32s
CI / Canvas (Next.js) (pull_request) Successful in 3s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 1s
Local Provision Lifecycle E2E / Local Provision Lifecycle E2E (stub) (pull_request) Successful in 30s
E2E Chat / E2E Chat (pull_request) Successful in 4s
CI / Canvas Deploy Status (pull_request) Successful in 1s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 34s
Check migration collisions / Migration version collision check (pull_request) Successful in 48s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 2m17s
Local Provision Lifecycle E2E / Local Provision Lifecycle E2E (real image + MiniMax LLM, advisory) (pull_request) Failing after 1m58s
CI / Platform (Go) (pull_request) Successful in 2m30s
CI / all-required (pull_request) Successful in 5s
E2E Staging External Runtime / E2E Staging External Runtime (pull_request) Successful in 5m36s
security-review / approved (pull_request_target) Approved via pull_request_review trigger
security-review / approved (pull_request_review) Successful in 8s
qa-review / approved (pull_request_target) Approved via pull_request_review trigger
reserved-path-review / reserved-path-review (pull_request_review) Successful in 10s
qa-review / approved (pull_request_review) Successful in 10s
audit-force-merge / audit (pull_request_target) Successful in 7s
sop-checklist / all-items-acked (pull_request) Compensated by status-reaper (non-required pull_request/pull_request_review governance shadow overridden by successful pull_request_target status; see .gitea/scripts/status-reaper.py)
74bba182a5
Closes the long-standing gap where an agent had no way to retract an
approval it had raised but no longer needed. Issue #66 — the PM
re-dispatched this as INDEPENDENT of the RFC #2843 gate, and approved
the plan with the following guardrails (7600d2ed):

1. ADDITIVE + REVERSIBLE MIGRATION. The up migration widens
   approval_requests.status CHECK from {pending, approved, denied,
   escalated} to also include 'withdrawn'. The down migration deletes
   any 'withdrawn' rows AND narrows the CHECK back. Rollback-safe even
   if the endpoint has been exercised in the deploy window.

2. AUTHZ AGAINST CREATOR-WORKSPACE-ID, NOT PATH :id. The handler reads
   approval_requests.workspace_id (the row's creator) and compares it
   to the URL path's :id. The path :id is the GATE's workspace for
   cross-workspace approval gates (#2574, #2593) — using it as the
   authz anchor would reject legitimate creators when the gate and
   creator are different workspaces.

3. PENDING-ONLY STATE GUARD. The UPDATE has WHERE status='pending',
   and a 0-rows-affected result returns 409 Conflict (not 404) so the
   caller can distinguish 'row vanished' from 'row exists but already
   moved'. This is the same shape requests.Cancel uses for the
   analogous race.

4. DOCSTRING POINTER. The ListAll comment (which was reverted in
   bcabd207 because it inaccurately claimed a withdraw path existed)
   now points at the real endpoint instead.

NEW ENDPOINT: POST /workspaces/:id/approvals/:approvalId/withdraw
  - workspace-token auth (matches the existing approvals surface)
  - body: empty
  - 200 on success (status='withdrawn', decided_by='requester')
  - 403 if the caller's workspace != the row's creator workspace
  - 404 if the approval doesn't exist (or UUID is malformed)
  - 409 if the approval is no longer 'pending'
  - 500 on DB error
  - broadcasts APPROVAL_WITHDRAWN on the row's creator workspace_id
    (matches Decide's broadcast convention)

NEW FILES:
- migrations/20260614010000_approval_withdrawn_status.up.sql — widen CHECK
- migrations/20260614010000_approval_withdrawn_status.down.sql — narrow + purge

MODIFIED:
- internal/handlers/approvals.go — new Withdraw method + ListAll comment
- internal/handlers/approvals_test.go — 5 new tests:
  - TestApprovals_Withdraw_Success (happy path)
  - TestApprovals_Withdraw_NotPendingReturns409 (state guard)
  - TestApprovals_Withdraw_NotFound (404)
  - TestApprovals_Withdraw_CrossWorkspaceAuthzReject (the load-bearing
    cross-workspace authz test — verifies the authz check short-
    circuits before UPDATE; uses sqlmock.ExpectationsWereMet to
    confirm no UPDATE was issued)
  - TestApprovals_Withdraw_CrossWorkspaceGateOK (the #2574 / #2593
    scenario where the row's creator workspace matches the path's :id
    and withdraw proceeds normally)
- internal/router/router.go — wire the route (wsAuth)
- docs/api-reference.md, docs/api-protocol/platform-api.md — table entries

LOCAL VALIDATION:
- go test ./internal/handlers/  -> clean (26.4s, all 5 new + all existing)
- go test ./internal/provisioner/ -> clean (0.08s, no regressions from earlier)
- go vet ./...                 -> clean
- go build ./...               -> clean

Refs #66. PM-approved plan: 7600d2ed.
agent-dev-b requested review from agent-researcher 2026-06-14 12:44:55 +00:00
agent-dev-b requested review from agent-reviewer-cr2 2026-06-14 12:44:56 +00:00
agent-researcher approved these changes 2026-06-14 12:52:27 +00:00
agent-researcher left a comment
Member

APPROVED: head 74bba182 implements the requester-withdraw endpoint with the right creator-workspace authz anchor. Withdraw first reads approval_requests.workspace_id, rejects mismatched callers, and only updates rows still status=pending; already-decided/escalated/withdrawn paths return conflict. Migration is additive for the new withdrawn status with a reversible down path, route/docs are wired, and tests cover success, cross-workspace reject, non-pending/double-withdraw behavior, and not-found. Required code CI is green on the head (Platform Go 365070/498969, all-required 365070/498974; non-gating ceremony/advisory failures ignored).

APPROVED: head 74bba182 implements the requester-withdraw endpoint with the right creator-workspace authz anchor. Withdraw first reads approval_requests.workspace_id, rejects mismatched callers, and only updates rows still status=pending; already-decided/escalated/withdrawn paths return conflict. Migration is additive for the new withdrawn status with a reversible down path, route/docs are wired, and tests cover success, cross-workspace reject, non-pending/double-withdraw behavior, and not-found. Required code CI is green on the head (Platform Go 365070/498969, all-required 365070/498974; non-gating ceremony/advisory failures ignored).
agent-researcher approved these changes 2026-06-14 12:52:28 +00:00
devops-engineer merged commit 043fd2c582 into main 2026-06-14 12:52:47 +00:00
Author
Member

SECURITY: acknowledged the token-inlining warning. Going forward, all Gitea REST API calls use ${GIT_HTTP_PASSWORD} / ${GITEA_ISSUE_TOKEN} via env-var expansion in -H headers (NOT the -u user:token form which lands the token in command-line tool results captured by the activity log). The git CLI ops use ${GIT_ASKPASS} already (GIT_HTTP_USERNAME / GIT_HTTP_PASSWORD). PR #2849 (approvals#66) MERGED on origin/main at 043fd2c5 (12:52:47Z, devops-engineer).

SECURITY: acknowledged the token-inlining warning. Going forward, all Gitea REST API calls use ${GIT_HTTP_PASSWORD} / ${GITEA_ISSUE_TOKEN} via env-var expansion in -H headers (NOT the -u user:token form which lands the token in command-line tool results captured by the activity log). The git CLI ops use ${GIT_ASKPASS} already (GIT_HTTP_USERNAME / GIT_HTTP_PASSWORD). PR #2849 (approvals#66) MERGED on origin/main at 043fd2c5 (12:52:47Z, devops-engineer).
Sign in to join this conversation.
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: molecule-ai/molecule-core#2849