Skip to content

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).

  • error is 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 reads error == "before_history_floor" or error == "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.
  • detail is user-safe human text. It never contains a stack trace, an upstream response body, or an internal diagnostic (Contract 05 R13/R16).
  • trace_id ties the response to the server-side structured log line and is echoed in the X-Trace-Id header 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/ or backend/routes/ may catch PermissionDenied, HistoryFloorError, UnknownDomainError, or SelfProxyError and re-raise it as an HTTPException — delete the re-map and let the error propagate.
  • Raw-HTTPException ratchet (trin 3.1.1): the count of raw raise HTTPException(...) per file in api/ + 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): HTTPException never appears in infra/, domain/, or persistence/ — the standing violated_by clause, 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).