Return a typed error contract from the shared Context operation
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)
- Database
RAISE EXCEPTIONtext arrives asContext storage operation failed: <text>; the classifier strips the prefix before matching. - An owner-scoped
select ... into strictthat matches no row (read_context_brief,read_context_execution,list_context_request_executions,list_context_release_request_target, ...) raisesquery returned no rows; that isnot_found(404, not retryable), as areSubject outside ...rejections. Tested through the real handler forread_brief,read_execution,list_executionsandverify_releaseon both transports. - Both transports validate caller input before the operation (HTTP 400
{error, issues}; MCP SDKInvalidParams), so aZodErrorinside it comes from parsing a stored RPC result. It is classified by shape: a root type mismatch (the lookup returnednull) isnot_found; astate/statusmismatch isnot_ready; any other field (e.g.identityConfirmedalreadytrueonverify_release, more than 100 slots onverify_release_tracks) isconflict(not retryable); other root failures (ID guards) areinternal. storage_failedis 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) areunavailable(503, retryable). Messages containingchanged,disputed,Conflictingorconflictareconflict;too manyisunsupported_input; the server-builtInvalid compiled briefisinternal.- Retry guidance only tells callers to reuse an
idempotency_keyon actions that take one (read,read_brief,plan,list_*andverify_*do not). Unknown context operation/Unknown release case operationare programming guards and fall tointernal, so an agent is not told itsrequest_idis 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 inlib/mcp/tools/context/registerContextTool.ts, because #983 moves the tool callback intohandleContextToolOperation.ts. Resolution: keep #983's structure and replace its catch withreturn getContextToolResultError(classifyContextOperationError(error));(already setsisError: true). ItsevidenceAttachmentTransports.test.tsasserts HTTP 409 for an unclassified non-storage error throughcontextOperationHandler; under this contract that is 500internal, so whichever PR merges second updates that assertion. Not run (conflicted tree). - README: this PR appends an
Error contractsubsection at end oflib/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.tsfails to collect on the merged tree because of #981's pre-existing conflict withmain, not this PR. - Sibling PRs that add new domain messages (for example
Organization access deniedin api#1022) are classified by the generic rules (that one isstorage_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/*.localsuites. - 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 inlib/context,lib/mcp/tools/contextor any file changed here (same count as before the review fixes). - Ran:
pnpm exec eslinton the 8 changed TypeScript files -> exit 0.pnpm exec prettier --checkon those files pluslib/context/README.md-> clean. processContextOperation.ts,callContextRpc.tsandcontextToolOperations.tsare unchanged againstorigin/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_exhaustedyet (#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}/ MCPInvalidParamswithout a typedcode; only inputs that pass the schema and are refused by the operation (e.g. a YouTube video URL oningest) return 422unsupported_input. - Classification is by authored message text and stored-result shape; an unrecognized database message is reported as
storage_failedrather 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
errorfield 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
ContextOperationErrorwith 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 errorcauseand are never returned. - Both transports now return the same
code,message,retryable, andguidance; the MCP result setsisError: trueand the HTTPerrorfield is retained for existing clients. - Missing or other-workspace records map to
not_found(404); deterministic database rejections map to non-retryablestorage_failed(409) while transient fetch, timeout, and serialization failures map to retryableunavailable(503). - Zod failures from stored RPC results map by shape: a missing lookup is
not_found, a state mismatch isnot_ready, a conflicting saved field isconflict. budget_exhausted(402) is reserved for the spend path in #2123 and is not thrown yet.