Add owner-scoped cancellation for saved Context requests

SI
5d ago
+196-0

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's PASS: 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.sql covers: queued → cancelled (no claim_token in the receipt, lease cleared, error still null, every other column unchanged, read_context_request shows cancelled, a later claim is refused); replay → already_cancelled with the row byte-for-byte unchanged; completed and partial → not_cancellable with the row unchanged; running with a live lease → cancelled, then claim_context_request returns false, commit_spotify_context raises "Worker no longer owns request", fail_context_request returns false and status stays cancelled with 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_privilege with no status change; the production service_role can execute; anon/authenticated cannot.
  • The test now also loads 20261009190000_context_research_without_roster.sql, so the stale-worker fence runs against the current production commit_spotify_context body 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 with Queued 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_at audit 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_guest raises "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 running request whose provider call already succeeded loses that uncommitted result; the request can be re-ingested with a new idempotency key.

🤖 Generated with Claude Code