Audit — R93 B3 Document Dual Error-Envelope Pattern in s17
Round: R93 debug-sweep (fix #3 of 6, doc-only)
Branch: feature/r93-b3-s17-dual-envelope
Base SHA: fbc8808a
β task: 1a267787-b864-4f5f-a7dd-669a6268dbe7
§1. Goal
Document the two error-reporting contracts that coexist on the Phase 0 + R89A + R89B + R92 MCP surface. The current docs/spec/s17-mcp-surface.md §6 Response shape describes only one error envelope ({ok:false, error:{code,message,details?}}), but the live surface uses two distinct patterns. A client written against the spec as it stands will mis-parse half the surface.
§2. The two patterns observed
Pattern A — throw → α middleware envelope. Handler throws; the α middleware Stage 4 catch (src/server.ts:362-375) wraps the error as:
{ "ok": false, "error": { "code": "HANDLER_ERROR", "message": "<err.message>" } }
Or for Zod input failure (Stage 2, src/server.ts:309-336):
{ "ok": false, "error": { "code": "INVALID_PARAMS", "message": "...", "details": { "issues": [...] } } }
The structuredContent field at the JSON-RPC layer is the SAME envelope (isError: true flag set).
Pattern B — return error envelope as data. Handler returns a structured error value (no throw); middleware Stage 4 wraps it as success-with-data:
{ "ok": true, "data": { "ok": false, "error": { "code": "<DOMAIN_CODE>", "message": "..." } } }
structuredContent.ok === true; the actual error is at structuredContent.data.ok.
This pattern is explicitly documented in code at src/tools/merkle.ts:392-405:
“Envelope returned by handlers on domain error. Pattern matches the task-tool handlers (
src/domains/tasks/repository.ts:700-765) — success returns the data payload directly (α middleware wraps it), failure returns a structured{ok:false, error:{...}}as the data payload so the client can readenvelope.data.ok.”
§3. Per-tool inventory (27 tools)
Verified at fbc8808a via grep + live MCP probes during the R93 debug audit:
Pattern A (throw → middleware envelope) — 17 tools
| Tool | Source | Error codes |
|---|---|---|
server_ping |
src/server.ts:541-554 |
INVALID_PARAMS only |
server_health |
src/tools/health.ts |
INVALID_PARAMS only |
thought_record |
src/domains/trail/repository.ts:404-420 |
INVALID_PARAMS, HANDLER_ERROR (e.g. SQLite UNIQUE violation) |
thought_record_list |
src/domains/trail/repository.ts:422-441 |
INVALID_PARAMS, HANDLER_ERROR |
audit_verify_chain |
src/domains/trail/verifier.ts:174-202 |
INVALID_PARAMS, HANDLER_ERROR |
skill_list |
src/domains/skills/repository.ts |
INVALID_PARAMS, HANDLER_ERROR |
reputation_get |
src/domains/reputation/tools.ts |
INVALID_PARAMS, HANDLER_ERROR |
reputation_history |
src/domains/reputation/tools.ts |
INVALID_PARAMS, HANDLER_ERROR |
reputation_leaderboard |
src/domains/reputation/tools.ts |
INVALID_PARAMS, HANDLER_ERROR |
reputation_check_gates |
src/domains/reputation/tools.ts |
INVALID_PARAMS, HANDLER_ERROR |
consensus_propose |
src/domains/consensus/tools.ts:552-563 |
INVALID_PARAMS, HANDLER_ERROR (ROUND_NOT_FOUND, ALREADY_VOTED) |
consensus_vote |
src/domains/consensus/tools.ts:565-576 |
INVALID_PARAMS, HANDLER_ERROR (ROUND_NOT_FOUND, ALREADY_VOTED) |
consensus_finality |
src/domains/consensus/tools.ts:578-589 |
INVALID_PARAMS, HANDLER_ERROR (ROUND_NOT_FOUND) |
consensus_gossip |
src/domains/consensus/tools.ts:591-602 |
INVALID_PARAMS only (Phase 0 always returns empty arrays) |
vrf_eval |
src/domains/consensus/tools.ts:604-615 |
INVALID_PARAMS, HANDLER_ERROR (INVALID_KEY) |
router_score |
src/domains/router/tools.ts:434-445 |
INVALID_PARAMS, HANDLER_ERROR |
router_call |
src/domains/router/tools.ts:447-458 |
INVALID_PARAMS, HANDLER_ERROR (FallbackChainExhaustedError) |
router_fallback |
src/domains/router/tools.ts:460-471 |
INVALID_PARAMS, HANDLER_ERROR |
router_stats |
src/domains/router/tools.ts:473-484 |
INVALID_PARAMS only |
Wait, that’s 19 not 17. Let me recount the table — yes 19 entries above.
Pattern B (handler-returned error envelope as data) — 8 tools
| Tool | Source | Domain error codes returned in data.error.code |
|---|---|---|
task_create |
src/domains/tasks/repository.ts:~700-720 |
ERR_VALIDATION (per Phase 0 task contract) |
task_get |
src/domains/tasks/repository.ts:~723-748 |
ERR_NOT_FOUND |
task_update |
src/domains/tasks/repository.ts:475 + 720+ |
ERR_NOT_FOUND, ERR_WRITEBACK_REQUIRED, ERR_INVALID_TRANSITION |
task_list |
src/domains/tasks/repository.ts |
(no observed domain errors — returns data) |
task_next_actions |
src/domains/tasks/repository.ts |
(no observed domain errors — returns data) |
audit_session_start |
src/tools/merkle.ts:417-444 |
ERR_SESSION_EXISTS |
merkle_finalize |
src/tools/merkle.ts:446-493 |
ERR_SESSION_NOT_FOUND, ERR_ALREADY_FINALIZED, ERR_NO_RECORDS |
merkle_root |
src/tools/merkle.ts:495-519 |
ERR_NOT_FINALIZED |
That totals to 19 + 8 = 27 ✓.
task_list and task_next_actions are technically in the same handler-block as the other task tools and would return Pattern B envelopes if they had failure paths — they currently don’t observably emit errors from the handler.
§4. Live reproduction (the inconsistency)
task_get {id:"00000000-…"} → {ok:true, data:{ok:false, error:{code:"ERR_NOT_FOUND",…}}}
merkle_root {session_id:"missing"} → {ok:true, data:{ok:false, error:{code:"ERR_NOT_FINALIZED",…}}}
consensus_finality {round_id:"bad"} → {ok:false, error:{code:"INVALID_PARAMS",…}} (regex reject — Pattern A)
vrf_eval {priv_key_hex:""} → {ok:false, error:{code:"HANDLER_ERROR", message:"INVALID_KEY: ..."}}
A naïve client that checks response.structuredContent.ok will:
- Correctly detect failure on
vrf_eval(Pattern A). - See
ok:trueonmerkle_rooteven when the session does not exist — and miss the error unless it also reaches intodata.ok.
§5. Why both patterns exist
- Pattern A (throw) is the JSON-RPC norm and lines up with how the α middleware was originally designed (
src/server.ts:362-375). - Pattern B (return error envelope as data) was chosen for
task_*andmerkle_*because domain-specific error codes (ERR_NOT_FOUND,ERR_NOT_FINALIZED,ERR_WRITEBACK_REQUIRED, etc.) deserved a structured response without conflating with transport-levelHANDLER_ERROR. The explicit choice is documented atsrc/tools/merkle.ts:392-405.
The dichotomy is INTENTIONAL but currently undocumented. Clients reading s17-mcp-surface.md §6 alone cannot predict which path a tool takes.
§6. Constraints on the fix
- MUST NOT modify any handler behaviour. Pattern preservation is the entire premise.
- MUST update
docs/spec/s17-mcp-surface.md §6to describe both patterns. - MUST cite the source comment at
src/tools/merkle.ts:392-405as the canonical Pattern B definition. - MAY link to the per-tool catalogue (
docs/reference/mcp-tools-phase-0.md) for the exhaustive per-tool table — but the catalogue currently only covers the 14 Phase 0 tools; out of scope to extend it to 27 in this slice. - SHOULD include at least one example of each pattern at the wire-level so client developers can dispatch on the right shape.
§7. Path forward
Edit docs/spec/s17-mcp-surface.md §6 (lines 77-91). Replace the single error-envelope block with two subsections: §6.1 success envelope + §6.2 dual failure envelope. Append a §6.3 “Which pattern does which tool family use” mini-table that maps tool families to patterns and references the source comment at src/tools/merkle.ts:392-405.
Proceeding to contract.