Add owner-scoped cancellation for saved Context requests
Summary
Adds public.cancel_context_request(p_owner, p_actor, p_request) so a saved Context request has a deterministic, owner-scoped stop state. cancelled was already an allowed context_requests.status that claim_context_request, commit_spotify_context, fail_context_request, create_context_execution, claim_context_execution_node and save_context_brief honor, but no function ever set it. The function rechecks workspace membership inside the transaction (reusing authorize_context_case_actor), locks the request row, and returns {outcome, request}: cancelled for a queued, failed or running request (status becomes cancelled and the worker claim and lease are cleared, so a stale worker can no longer commit or record failure over it and the request is never re-claimed), already_cancelled for an idempotent replay with no write, or not_cancellable for a completed or partial request, which keeps its saved evidence and status instead of raising. Cancellation changes only status, claim_token, lease_expires_at and updated_at: the saved error and output are kept, so a failed request's classified failure reason stays readable and request state never overwrites provider state. It makes no credit, enrichment-attempt or provider-settlement change and never withdraws accepted evidence. This advances ticket item "Cancellation, permission changes and retry limits have deterministic persisted outcomes".
Refs recoupable/app#2122 (child of epic recoupable/app#2116).
Merge sequencing
docs recoupable/docs#359 → database (this PR) → api recoupable/api#1017 (the cancel HTTP/MCP action; apply this migration before deploying it). No app/chat PR. Depends on no open PR; requires the already-merged 20261008160000_context_release_cases.sql for authorize_context_case_actor. The migration uses the collision-free name 20261010212200_context_request_cancellation.sql and a new CI workflow file (context-request-cancellation.yml) instead of editing release-cases.yml, to stay clear of database#88-#95. Open api#1001 writes classified failure reasons into context_requests.error; this migration leaves that column untouched, so those reasons survive a cancel.
Verification
- Ran (PostgreSQL 17.7, disposable cluster,
LC_ALL=C):python3 -m unittest discover -s tests -p 'test_request_cancellation.py'→Ran 2 tests ... OK(the new fixture plus the inherited release-case lifecycle test; stderr contains the fixture'sPASS:notice). - Ran:
test_release_cases.py→Ran 1 test ... OK;test_company_assessment.py→Ran 3 tests ... OK(unchanged suites). - Fixture
supabase/tests/context_request_cancellation.sqlcovers: queued → cancelled (noclaim_tokenin the receipt, lease cleared,errorstill null, every other column unchanged,read_context_requestshowscancelled, a later claim is refused); replay →already_cancelledwith the row byte-for-byte unchanged; completed and partial →not_cancellablewith the row unchanged; running with a live lease → cancelled, thenclaim_context_requestreturns false,commit_spotify_contextraises "Worker no longer owns request",fail_context_requestreturns false and status stayscancelledwith no error written; failed → cancelled, keeps its saved failure reason, every column other than status/claim/lease/updated_at unchanged, and not re-claimable; other owner →no_data_found; non-member and revoked member →insufficient_privilegewith no status change; the productionservice_rolecan execute;anon/authenticatedcannot. - The test now also loads
20261009190000_context_research_without_roster.sql, so the stale-worker fence runs against the current productioncommit_spotify_contextbody rather than the original pipeline definition. - Red → green (initial commit): with the fixture and python test in place but no migration, the run failed with
function public.cancel_context_request(uuid, uuid, uuid) does not exist. - Red → green (review fix): with the tightened fixture against the previous migration (which wrote
error='Cancelled by a workspace member before completion'), the run failed withQueued cancellation receipt is wrong(Ran 2 tests,FAILED (failures=1)); it passes with the fixed migration. - Not verified: hosted deployment, live providers, production database (the migration has not been applied to any hosted database). PostgreSQL 15/16 runs only in this PR's CI workflow; local validation used PostgreSQL 17.7.
Out of scope / known gaps
- No
cancelled_by/cancelled_ataudit columns (the actor is authorized in storage but not recorded). - Module executions only exist on completed or partial requests, which this function reports
not_cancellable, so it cancels the metadata stage only; cancelling paid per-module execution and its credit reservation/settlement stays with the paid-execution lane (CE07 #2123/#2105). - Guest adoption:
complete_context_guestraises "Destination request busy" when its destination request was cancelled, so that guest worker fails and retries (bounded, free provider) instead of ending cleanly; left for a follow-up guest migration. - No scheduled dispatch-gap sweep. A
runningrequest whose provider call already succeeded loses that uncommitted result; the request can be re-ingested with a new idempotency key.
🤖 Generated with Claude Code