Canonical Error Envelope¶
Verified against the code · 4996d1ceb770fb4545edebc5fd140792bfb23830 · 2026-10-20
956 D1b-4 (2026-10-20): 1 finding(s) fixed — enforced_by now names the single @app.exception_handler(BackendDomainError) at server.py:1212, which reads http_status/error_code off the raised error (the nine per-type handlers were collapsed into it). Re-verified against main 4996d1ce. #935 S1 (2026-10-21): OverlayRootConflictError (500, overlay_root_conflict) added to the table and to the frozen mapping in tests/test_error_canon_guard.py. Checked against archon/issue-935.¶
The error-canon invariant guarantees that a failure looks the same to a client
no matter where in the backend it originated, and that each failure is mapped to
HTTP — and logged — exactly once. It is the settled shape of Contract 05 (Error
Handling) as it applies across EIDOS Explorer's engines and routes.
The Envelope¶
Every typed domain error (a BackendDomainError subclass) surfaces as the
same JSON body:
{ "error": "before_history_floor", "detail": "as_of '2020-01-01' precedes history floor '2026-05-31'", "trace_id": "5c52e41d-…" }
Scope: the envelope is guaranteed for the typed errors below, AND a raw
raise HTTPException(detail={"error": …}) is normalised to it too. A single
StarletteHTTPException handler in server.py lifts the detail dict's keys to
top level and adds trace_id (trin 3.1.1), so a router that raises the dict shape
emits the canonical envelope without every one of the ~170 raw sites being
rewritten. A plain-string HTTPException is returned as the framework default
({"detail": …}) — unchanged. Those raw sites are a residual that is now
bounded, not just declared: their count in backend/api/ and backend/routes/
is a per-file ratchet that may only fall, and HTTPException below the API
boundary (infra/, domain/, persistence/) is a hard zero (the
violated_by clause of error-canon.yaml, now defended).
erroris the stable, machine-readable code. Clients and cross-domain consumers branch on this field — never on the free-text detail. For example the cross-domain reconstruction fold readserror == "before_history_floor"orerror == "rulebook_before_history_floor"to decide a secondary domain did not exist at time T and omit it from the model. The two codes carry identical OMIT semantics (issue #604 D1): a domain whose audit history or whose rulebook history does not reach back to T is absent at T — it is not operationally unreachable.detailis user-safe human text. It never contains a stack trace, an upstream response body, or an internal diagnostic (Contract 05 R13/R16).trace_idties the response to the server-side structured log line and is echoed in theX-Trace-Idheader when present.
Typed Domain Errors¶
Failures are represented as a typed hierarchy rooted at BackendDomainError
(backend/domain/domain_errors.py). Each layer raises the typed error that
describes what it observed — it does not reach for the web framework:
| Error | HTTP | error code |
|---|---|---|
UnknownDomainError |
404 | unknown_domain |
SelfProxyError |
421 | self_proxy |
IdDomainConflictError |
409 | id_domain_conflict |
OverlayRootConflictError |
500 | overlay_root_conflict |
LegacyEidosFileNotConvertedError |
500 | legacy_eidos_file_not_converted |
LegacyOverlayFileRefusedError |
500 | legacy_overlay_file_refused |
HistoryFloorError |
404 | before_history_floor |
RulebookHistoryFloorError |
404 | rulebook_before_history_floor |
PermissionDenied |
403 | permission_denied |
NodeNotFoundError |
404 | node_not_found |
ValidationError |
422 | validation_error |
IAMValidationError |
422 | iam_validation_error |
IAMHistoryFloorError |
404 | iam_before_history_floor |
DomainExportError |
422 | domain_export_error |
ExternalServiceError |
its status |
its code |
This table is not hand-maintained: each error carries its HTTP mapping as class
attributes — http_status and error_code (instance attributes for the dynamic
ExternalServiceError) — and tests/test_error_canon_guard.py verifies the table
against the classes. A granular machine code that is distinct from error — e.g.
PermissionDenied's P-WRITE_ACCESS_REQUIRED / P-ADMIN_REQUIRED — is surfaced
on its own code field in the envelope, never buried in detail; error
stays the client-stable value (permission_denied).
Because these are ordinary exceptions, the module that detects a problem raises
one and returns — no HTTP concern leaks into infra, domain, or persistence code.
The canonical cross-domain client (infra/domain_client.py) is illustrative: an
unknown domain raises UnknownDomainError, a routing loop raises
SelfProxyError, and an upstream/library failure is wrapped in
ExternalServiceError. No fastapi.HTTPException ever crosses the infra layer
line.
One Mapping, One Log¶
The HTTP status and body are produced in exactly one handler: a single
@app.exception_handler(BackendDomainError) in backend/server.py (trin 3.1.1 —
the nine former per-type handlers collapsed into it). It reads the raised error's
http_status / error_code and folds the attributes named in the error's
log_fields (an upstream status, a history floor date, a domain key) into the one
domain_error structured log via log_extra — there is no second, lower-level log
for the same error. The former double-log (a dedicated external_service_error
line plus the domain_error line) was collapsed into this single record.
A router therefore never needs to translate a typed domain error itself. It lets
the error propagate. Where a route wraps reconstruction in a try that also
handles ValueError/RuntimeError, a leading except BackendDomainError: raise
ensures a typed error skips the catch-all and reaches its handler unchanged.
Why the Re-mappings Were Removed¶
Before this canon, the same failure was caught and re-mapped through the layers at
several sites, producing three different 403 bodies (a bare string, a nested
{error, message}, and the canonical envelope) for what was semantically one
"you may not write here". Import routes hand-built a 403; the historical as_of
routes hand-built a 404 whose code was buried in a detail:"before_history_floor:…"
prefix string. A client could not rely on a single shape, and the same error was
logged twice.
Deleting those re-mappings — and letting ctx.require_write()'s PermissionDenied
and reconstruction's HistoryFloorError propagate to their single handlers — is
what makes the envelope above a guarantee rather than a convention.
Where It Is Enforced¶
tests/test_error_canon_guard.py holds the teeth (all AST, resolving the
HTTPException import binding per file so an aliased import cannot evade):
- No re-map (frozen at zero): no file under
backend/api/orbackend/routes/may catchPermissionDenied,HistoryFloorError,UnknownDomainError, orSelfProxyErrorand re-raise it as anHTTPException— delete the re-map and let the error propagate. - Raw-HTTPException ratchet (trin 3.1.1): the count of raw
raise HTTPException(...)per file inapi/+routes/is frozen at its true population and may only fall — a new such file is rejected, and a file whose count drops must lower its baseline. The normalizer means these still emit the envelope; the ratchet drives them toward typed errors over time. - Below-boundary zero (trin 3.1.1):
HTTPExceptionnever appears ininfra/,domain/, orpersistence/— the standingviolated_byclause, now defended. - The status table is verified against the error classes'
http_status/error_code, so the doc above cannot drift from the code.
A small number of pre-existing boundary mappings with deliberate status divergence
or an audit side-effect — IAMValidationError surfaced as a deliberate 400
invalid_request, the Documentation 360 report path's _audit_reject before
re-raise, and DomainExportError in the export routes — are intentionally out of
scope and tracked separately (they are not part of the fejl-kanon).