Return a typed error contract from the shared Context operation

SI
5d ago
+746-12

Summary

Implements the one Done-when item of #2125 that no sibling ticket owns: "Permission failure, conflict, unsupported URL, unavailable media, partial result and budget exhaustion are explicit and tested." On main, contextOperationHandler.ts returns the same 409 body for every thrown error and registerContextTool.ts returns one fixed string. This PR adds ContextOperationError (nine stable codes: permission_denied, not_found, conflict, unsupported_input, not_ready, unavailable, budget_exhausted, storage_failed, internal), classifyContextOperationError (maps authored API and database messages, and Zod failures from stored RPC results, to codes; strips the Context storage operation failed: wrapper from callContextRpc first) and formatContextOperationError (HTTP 403/404/409/422/409/503/402/409/500 respectively). Both transports return the same code, authored message/error, retryable and guidance; the MCP result also sets isError: true. Raw database/provider diagnostics stay on the error cause, are logged server-side for internal, storage_failed and unavailable, and are never returned. The delegated per-action OAuth tools call the same handler, so they inherit the typed result unchanged. No new action is added; budget_exhausted is reserved for the spend path (#2123) and cancel/withdraw/correct/refresh stay with #2122/#2124.

Refs recoupable/app#2125 (child of epic recoupable/app#2116).

Classification decisions (grounded in the code)

  1. Database RAISE EXCEPTION text arrives as Context storage operation failed: <text>; the classifier strips the prefix before matching.
  2. An owner-scoped select ... into strict that matches no row (read_context_brief, read_context_execution, list_context_request_executions, list_context_release_request_target, ...) raises query returned no rows; that is not_found (404, not retryable), as are Subject outside ... rejections. Tested through the real handler for read_brief, read_execution, list_executions and verify_release on both transports.
  3. Both transports validate caller input before the operation (HTTP 400 {error, issues}; MCP SDK InvalidParams), so a ZodError inside it comes from parsing a stored RPC result. It is classified by shape: a root type mismatch (the lookup returned null) is not_found; a state/status mismatch is not_ready; any other field (e.g. identityConfirmed already true on verify_release, more than 100 slots on verify_release_tracks) is conflict (not retryable); other root failures (ID guards) are internal.
  4. storage_failed is an unrecognized database rule rejection: 409 and not retryable, because an identical retry is rejected again. Transient storage failures (fetch failed, statement/lock timeout, serialization failure, deadlock) are unavailable (503, retryable). Messages containing changed, disputed, Conflicting or conflict are conflict; too many is unsupported_input; the server-built Invalid compiled brief is internal.
  5. Retry guidance only tells callers to reuse an idempotency_key on actions that take one (read, read_brief, plan, list_* and verify_* do not).
  6. Unknown context operation / Unknown release case operation are programming guards and fall to internal, so an agent is not told its request_id is wrong.

A sweep of all 118 distinct RAISE EXCEPTION texts in the Context/release-case migrations on database main through the classifier gives: 22 conflict, 27 unsupported_input, 13 not_ready, 6 permission_denied, 4 not_found, 1 internal, 1 unavailable (Guest session unavailable, guest-only path) and 44 storage_failed (mostly worker-only invariants).

Merge sequencing

docs -> database -> api -> app. Docs contract first: recoupable/docs#361. No database change. This api PR second. No app change: chat/lib/context/proxyContextRequest.ts streams upstream status and body through, and no chat component branches on the old blanket 409. The HTTP error field is kept; code/retryable/guidance are additive.

Overlap with OPEN sibling api PRs, rehearsed with git merge-tree against each branch head on 2026-10-10 and, where the merge is textually clean, by running that branch's changed lib/context/lib/mcp tests plus transportErrorParity.test.ts on the merged tree:

  • api#983 (codex/context-evidence-attachments): new textual conflict in lib/mcp/tools/context/registerContextTool.ts, because #983 moves the tool callback into handleContextToolOperation.ts. Resolution: keep #983's structure and replace its catch with return getContextToolResultError(classifyContextOperationError(error)); (already sets isError: true). Its evidenceAttachmentTransports.test.ts asserts HTTP 409 for an unclassified non-storage error through contextOperationHandler; under this contract that is 500 internal, so whichever PR merges second updates that assertion. Not run (conflicted tree).
  • README: this PR appends an Error contract subsection at end of lib/context/README.md, which textually conflicts with the end-of-file appends in api#1004, api#1008 and api#1024; resolve by keeping both sections.
  • api#1011 (#2120, a blocking dependency): clean merge; 4 files, 196 passed, 0 failed (its denial assertions are now contract-agnostic).
  • api#1008: 4 files, 33 passed. api#1017: 5 files, 51 passed. api#1022: 3 files, 53 passed. api#1024: 4 files, 60 passed. api#1019: 4 files, 47 passed. api#1015: 2 files, 32 passed. All 0 failed.
  • api#981: no new conflict from this PR; its context tests pass (28 passed). fullOAuthCatalog.test.ts fails to collect on the merged tree because of #981's pre-existing conflict with main, not this PR.
  • Sibling PRs that add new domain messages (for example Organization access denied in api#1022) are classified by the generic rules (that one is storage_failed, 409) until a one-line rule is added when they merge.

Verification

  • Red -> green (initial commit b11ce2b): the two new test files failed at module resolution before implementation (Cannot find module '../ContextOperationError'), then passed.
  • Red -> green (review fixes, b6502fb): with only the four contract source files reverted to b11ce2b, pnpm exec vitest run lib/context/__tests__/contextOperationErrors.test.ts lib/context/__tests__/transportErrorParity.test.ts -> Test Files 2 failed; Tests 25 failed | 67 passed (92). With the fixes -> Test Files 2 passed; Tests 94 passed (70 + 24), 0 failed.
  • Ran: pnpm exec vitest run lib/context -> Test Files 69 passed | 8 skipped (77); Tests 420 passed | 8 skipped (428); 0 failed. Skipped files are the existing gated *.live / *.local suites.
  • Ran: pnpm exec vitest run lib/mcp -> Test Files 28 passed (28); Tests 152 passed (152).
  • Ran: pnpm exec tsc --noEmit -> exit 2, 198 errors across 65 files, all in unrelated pre-existing test fixtures; 0 in lib/context, lib/mcp/tools/context or any file changed here (same count as before the review fixes).
  • Ran: pnpm exec eslint on the 8 changed TypeScript files -> exit 0. pnpm exec prettier --check on those files plus lib/context/README.md -> clean.
  • processContextOperation.ts, callContextRpc.ts and contextToolOperations.ts are unchanged against origin/main.
  • Not verified: hosted deployment, live providers (every RPC and dispatch is a vi.fn() mock), any hosted or production database (database messages were read from migration files only), Mintlify render of the docs PR, and behavior of external HTTP clients that branch on the old blanket 409.

Out of scope / known gaps

  • No cancel, source withdrawal, scoped correction or selected-topic refresh action (owned by #2122 / #2124); their new messages may need one-line classifier rules.
  • No spend path throws budget_exhausted yet (#2123); the code and 402 status are reserved and tested via the formatter only.
  • The guest endpoints (/api/context/guest*) keep their own handlers and are not classified here.
  • Schema-level rejections (album, playlist or other non-track URLs sent to ingest) stay HTTP 400 {error, issues} / MCP InvalidParams without a typed code; only inputs that pass the schema and are refused by the operation (e.g. a YouTube video URL on ingest) return 422 unsupported_input.
  • Classification is by authored message text and stored-result shape; an unrecognized database message is reported as storage_failed rather than guessed.
  • HTTP status for domain failures changes from a blanket 409 to per-code statuses (403/404/422/500/503 for the corresponding causes); the error field is kept.

šŸ¤– Generated with Claude Code


Summary by cubic

Gives the shared Context operation a typed error contract so HTTP and MCP callers see the same stable failure codes and guidance instead of one blanket 409 body with a fixed message.

  • Adds ContextOperationError with nine codes mapped to per-code HTTP statuses, and a classifier that maps authored API and database messages to those codes; raw database and provider diagnostics stay on the error cause and are never returned.
  • Both transports now return the same code, message, retryable, and guidance; the MCP result sets isError: true and the HTTP error field is retained for existing clients.
  • Missing or other-workspace records map to not_found (404); deterministic database rejections map to non-retryable storage_failed (409) while transient fetch, timeout, and serialization failures map to retryable unavailable (503).
  • Zod failures from stored RPC results map by shape: a missing lookup is not_found, a state mismatch is not_ready, a conflicting saved field is conflict.
  • budget_exhausted (402) is reserved for the spend path in #2123 and is not thrown yet.