Found while implementing #8016, which had to work around it. Filed unassigned; nobody is on it.
The gap
ADR-0112 makes error.code a closed vocabulary: ApiErrorSchema.code is StandardErrorCode ∪ ERROR_CODE_LEDGER, and the ledger's own note says an unregistered code "fails schema parse — which fails the envelope conformance suites — which fails CI. That friction is the point."
The runtime dispatcher does not enforce it. HttpDispatcher.errorFromThrown lifts a thrown error's .code onto the wire whatever the string is, and three existing suites assert exactly that with codes registered nowhere:
| Code |
Pinned by |
Status |
STORAGE_FAILURE |
src/dispatcher-validation-error.test.ts:185, src/http-dispatcher.error-leak.test.ts:118 |
not in ERROR_CODE_LEDGER |
FLOW_FAILED |
src/http-dispatcher.actions-type-dispatch.test.ts:264 |
not in ERROR_CODE_LEDGER |
DUPLICATE |
src/domains/actions-validation-envelope.test.ts:126 |
not in ERROR_CODE_LEDGER |
Each of those bodies fails ApiErrorSchema.safeParse. The dispatcher's own conformance suite (error-envelope.conformance.test.ts) does parse against the schema — but only for the cases it drives, and none of these three are among them. So the pin and the contract disagree, and nothing notices.
Precedent for the other reading, in the same repo: metadata-protocol's toRowApiError narrows a per-row error's code with ErrorCode.safeParse and derives from the status on a miss.
How #8016 hit it
#8016 converged both /api/v1/packages doors onto one resolver (resolveThrownHttpError, @objectstack/types). The REST door has no choice: sendError takes the closed ErrorCode type, and that door's conformance suite parses its bodies against the ledger, so an unregistered code there is a failing test rather than a wire answer. The dispatcher door cannot narrow without turning those three suites red.
The resolver therefore returns two spellings — code (narrowed) and declaredCode (verbatim) — and each door takes the one its envelope's contract allows. Both come from one function, so the difference is stated rather than drifting, and it is documented at the resolver and pinned in packages/runtime/src/package-door-error-parity.test.ts. But it is a papered-over disagreement, not a resolution: the two doors answer the same status always, and the same code for every registered code, differing only where a producer emits one the ledger does not know.
The decision this needs
Which is right for the dispatcher's error.code?
- A — close it. Narrow like the REST door and
toRowApiError; STORAGE_FAILURE / FLOW_FAILED / DUPLICATE become the status-derived standard code. The three pins get rewritten, and clients keying on those strings lose them. Makes every dispatcher body parse against its own declared schema.
- B — register them. Add the three (and whatever else a sweep finds) to
ERROR_CODE_LEDGER under their owning packages. Keeps every current wire answer, honours ADR-0112, and needs a producer sweep to be complete rather than a spot fix — an unswept producer just re-opens the hole.
- C — declare it open. State that the dispatcher's
error.code is deliberately not closed and amend ApiErrorSchema / ADR-0112 accordingly. Cheapest today, and it retires the "no silent fourth state" property the ledger exists for.
B looks strongest on first read (it preserves behaviour and the contract at once) but the sweep is the work, and whether an unregistered code should be possible is the actual question. Recording it rather than guessing.
Related: #4805 (ledger federation), #7504 (ledger provenance), #3842 (where error.code became a semantic string).
Generated by Claude Code
Found while implementing #8016, which had to work around it. Filed unassigned; nobody is on it.
The gap
ADR-0112 makes
error.codea closed vocabulary:ApiErrorSchema.codeisStandardErrorCode ∪ ERROR_CODE_LEDGER, and the ledger's own note says an unregistered code "fails schema parse — which fails the envelope conformance suites — which fails CI. That friction is the point."The runtime dispatcher does not enforce it.
HttpDispatcher.errorFromThrownlifts a thrown error's.codeonto the wire whatever the string is, and three existing suites assert exactly that with codes registered nowhere:STORAGE_FAILUREsrc/dispatcher-validation-error.test.ts:185,src/http-dispatcher.error-leak.test.ts:118ERROR_CODE_LEDGERFLOW_FAILEDsrc/http-dispatcher.actions-type-dispatch.test.ts:264ERROR_CODE_LEDGERDUPLICATEsrc/domains/actions-validation-envelope.test.ts:126ERROR_CODE_LEDGEREach of those bodies fails
ApiErrorSchema.safeParse. The dispatcher's own conformance suite (error-envelope.conformance.test.ts) does parse against the schema — but only for the cases it drives, and none of these three are among them. So the pin and the contract disagree, and nothing notices.Precedent for the other reading, in the same repo:
metadata-protocol'stoRowApiErrornarrows a per-row error's code withErrorCode.safeParseand derives from the status on a miss.How #8016 hit it
#8016 converged both
/api/v1/packagesdoors onto one resolver (resolveThrownHttpError,@objectstack/types). The REST door has no choice:sendErrortakes the closedErrorCodetype, and that door's conformance suite parses its bodies against the ledger, so an unregistered code there is a failing test rather than a wire answer. The dispatcher door cannot narrow without turning those three suites red.The resolver therefore returns two spellings —
code(narrowed) anddeclaredCode(verbatim) — and each door takes the one its envelope's contract allows. Both come from one function, so the difference is stated rather than drifting, and it is documented at the resolver and pinned inpackages/runtime/src/package-door-error-parity.test.ts. But it is a papered-over disagreement, not a resolution: the two doors answer the same status always, and the same code for every registered code, differing only where a producer emits one the ledger does not know.The decision this needs
Which is right for the dispatcher's
error.code?toRowApiError;STORAGE_FAILURE/FLOW_FAILED/DUPLICATEbecome the status-derived standard code. The three pins get rewritten, and clients keying on those strings lose them. Makes every dispatcher body parse against its own declared schema.ERROR_CODE_LEDGERunder their owning packages. Keeps every current wire answer, honours ADR-0112, and needs a producer sweep to be complete rather than a spot fix — an unswept producer just re-opens the hole.error.codeis deliberately not closed and amendApiErrorSchema/ ADR-0112 accordingly. Cheapest today, and it retires the "no silent fourth state" property the ledger exists for.B looks strongest on first read (it preserves behaviour and the contract at once) but the sweep is the work, and whether an unregistered code should be possible is the actual question. Recording it rather than guessing.
Related: #4805 (ledger federation), #7504 (ledger provenance), #3842 (where
error.codebecame a semantic string).Generated by Claude Code