errors
See also: Error system cookbook · api · Dédalo API v1 · data_manager · RQO · Tool server contract
The error subsystem is Dédalo's closed vocabulary of failure. Every refusal the engine reports — over the JSON API, a tool response, a stream frame or the assistant bridge — carries exactly one registered code, and exactly one function turns a thrown value into a wire body. This page is the developer reference for that system: what to throw, what reaches the caller, what the browser does with it, and how to add a code.
The normative specification is engineering/ERRORS_SPEC.md; this page teaches the model
and the day-to-day workflow.
What a failure IS
A failure is a throw of a DedaloError carrying a registered code. It is never a
returned body:
// success — the payload goes in `data`; owned top-level fields ride as `extend`
return { status: 200, body: ok({ context, data: rows }, { requestId: context.requestId }) };
// failure — throw. The dispatcher converts it; the status comes from the registry
throw new DedaloError('perm.denied', { coordinates: { section_tipo, section_id } });
Three rules follow, and all three are mechanically enforced:
- Handlers throw to fail and return
ok(...)to succeed. No handler, helper or route may build a body carryingok,erroror the retiredresult/msg/errorsfailure keys. A helper may exist only if it throws (returningnever). - One code, never prose. A caller cannot act on a sentence, an operator cannot count
one, and a translator cannot translate one. Branch on
error.code; never onerror.message. - A body without
okis a bug, not a compatibility case. The dispatcher's own catch refuses it asinternal.unexpected, so a half-shaped body can never reach the wire and appear to work.
The code lives in the registry — src/core/errors/registry.ts — which is the ONLY place a
code may be born. The typed throw is src/core/errors/dedalo_error.ts; the converter is
src/core/errors/convert.ts; the envelope schema is src/core/errors/schema.ts.
The registry
ERROR_REGISTRY is a data table: code → specification. Every field is authored
explicitly, none is derived at runtime.
| field | meaning |
|---|---|
category |
one of eight, and it is the HTTP status: caller 400 · auth 401 · permission 403 · not_found 404 · conflict 409 · limit 429 · unavailable 503 · internal 500. A code needing another status is another code. |
status |
the HTTP status; must equal the category's, unless the code is a named exemption with its reason beside it. |
label_key |
the UI label key the browser renders — by convention error_<code with the dot as an underscore>, or a pre-existing key that already says the same thing (perm.denied reuses no_access_page). |
message |
registry-owned English, for logs and command-line use. It never interpolates caller data. |
severity |
info / warn log through console.info / console.warn; error / fatal through console.error. An expected refusal (an expired session) is info — traffic, not a fault. |
disclosure |
public means a vetted publicMessage from the throw site may replace message on the wire; operator means it never can. |
retryable |
whether a later attempt could plausibly succeed. The client's retry policy and the Retry-After header for limit / unavailable read it. |
details_keys |
the ONLY details keys the converter lets onto the wire, scalars only. The label's {param} placeholders equal this list, both ways. |
hint |
the model-facing next move, used by the assistant bridge's structured errors. |
reason |
a named exemption: why the code exists although no engine path throws it (a client-minted or reserved code). |
Codes follow the grammar <domain>.<condition> (^[a-z][a-z0-9_]*\.[a-z][a-z0-9_]*$). The
domain is the subsystem that owns the refusal — auth, perm, request, record,
section_id, tool, mcp, external, diffusion, internal, and so on — and it is
queryable: isErrorInDomain(error, 'section_id') is how a catch site recognises a family
without reaching for a built-in error class.
Category is a wire fact, not blame
internal means "the engine could not honour a well-formed request". It is the only
category whose message never varies per throw, and it never echoes the wrapped
exception.
Envelope v2
Every JSON body is one of exactly two shapes, discriminated by ok. A success:
{
"ok": true,
"request_id": "5f2a…",
"data": { "context": [], "data": [] },
"notices": [
{ "code": "record.delete_children_refused",
"label_key": "error_record_delete_children_refused",
"retryable": false,
"details": { "not_deleted": "12,17" } }
],
"csrf_token": "…"
}
A failure:
{
"ok": false,
"request_id": "5f2a…",
"error": {
"code": "perm.denied",
"category": "permission",
"message": "Insufficient permissions",
"label_key": "no_access_page",
"retryable": false
},
"csrf_token": "…"
}
request_idis top level on both shapes — the one join key to the access log line, which also carrieserror_codeanderror_categoryon a failure.- The HTTP status is an error channel:
ok:falsealways means a non-2xx status, and the status is the category's.auth.not_loggedis a 401 withlabel_keyerror_auth_not_logged;perm.deniedis a 403.limitandunavailablefailures may carryRetry-After. datais the whole payload. A handler never puts payload anywhere else.- The schema (
apiEnvelopeSchema) is a discriminated union onokwitherror.codetyped as the enum of registered codes, so registry totality reaches every consumer that parses a body. RQO re-exports it as the live response schema.
The full wire-level description of the two shapes, including the status table, is in the API reference.
Notices — the non-fatal channel
A notice is a coded fact about a successful answer: {code, label_key, retryable,
details?}, carried in notices[] beside data. Degradation is a notice, never an error —
an external source that timed out, a partial delete, a truncated value. The request was not
wrong, so the response is ok:true and the payload stays.
The record-delete path is the canonical producer: children that refused to go are reported
as record.delete_children_refused with the refused ids in its one declared detail, while
the records that were deleted remain the payload.
Choosing between a notice and a failure
Ask what the caller keeps. If there is a usable answer and something about it is worth saying, it is a notice. If there is no answer at all, throw.
Extension keys
A handler may put fields it owns at top level beside the envelope keys — total,
job_id, pid, environment, saml_redirect, action, and the maintenance widgets'
msg / errors reports. They are passed as extend and spread first, so a reserved
envelope key (ok, request_id, data, notices, error) can never be overridden by
one. Payload data belongs in data; the extension set is closed and frozen by a gate.
One name is forbidden on both shapes: result. It was the retired mirror of data, and
a body carrying it fails the schema at parse time — which is what makes a converter
regression impossible to land quietly. An extension key on a failure is exceptional and
named: the start refusal carries environment (the labels the refused client needs for
its no-access page), and the CSRF gate carries action with a fresh token.
csrf_token is appended by the dispatcher to every response of a session, success and
failure alike. A handler never sets it.
The disclosure ladder
What reaches the wire is decided in one place, toErrorBody, and only these four rungs:
messageis the registry English — replaced by the throw'spublicMessageonly when the code's disclosure ispublic. A public code is one whose sentence is written for the caller:request.invalid_optionsnaming the offending option, the assistant tool refusals echoing the model's own input back at it.detailsis the throw'sdetailsfiltered to the code'sdetails_keys, scalars only. A non-scalar under a declared key is dropped, never stringified.coordinates(tipo, section_id, job id) andcauseare log-only and never serialized. Aninternal.*failure never echoes the exception it wrapped.debug—{exception, stack, coordinates, cause_chain}— appears only when the installation runs withDEDALO_DEBUG_API_ERRORS=true. The literaldebugexists in exactly one source file, the converter, and one schema.
DEDALO_DEBUG_API_ERRORS is a development switch
It puts stack traces and record coordinates on the wire. Leave it off in production;
the request_id plus the access log is the supported way to trace a failure.
Classification happens in toDedaloError: a DedaloError passes through untouched; a
temporal-dead-zone ReferenceError — raw, or anywhere in the cause chain — flips the
process poison latch and becomes internal.module_poisoned; anything else becomes
internal.unexpected carrying the original as a log-only cause. Because the latch lives
in the converter and not in the dispatcher's catch, tool, stream and assistant paths latch
too.
The client side
The browser half is a small family of pure modules under client/dedalo/core/common/js/,
described in full on the data_manager page.
ApiError(api_error.js) is the ONE client-side error model. Every failure — anok:falseenvelope, a transport rejection, an unparseable stream frame — becomes one, and the client dispatches onapi_error.codeonly. Failures the client itself produces (no answer reached it) use theclient.*domain:client.network,client.timeout,client.aborted,client.bad_response,client.http_status,client.worker,client.offline— andclient.render_failed, minted byui.load_item_with_spinnerwhen a render callback throws: the error panel takes the place the node would have had, so a failed build is never an empty container.request_failed(api_response)is THE test for a failure, andresponse_data(api_response)is the one payload accessor. A handler-owned top-level field is read throughresponse_extension(api_response, key), never as the error channel.error_text(api_error)(render_api_error.js) resolves the sentence to show: thelabel_keyin the user's language first, then the registrymessage, then the code. Because the label carries the translation, a failure is localized like everything else. The renderer never uses an HTML-parsing sink — a message can contain user-typed text.handle_api_error(api_error, ctx)(error_dispatch.js) executes the policy and resolves{recovered}; a caller that can retry just awaits it. The policy table (error_policy.js) maps a code to one action, resolved exact code →<domain>.*→*:relogin,no_access_page,page_panel,csrf_retry,toast,modal,inline,silent.auth.not_loggedrelogs in and retries;perm.*shows the no-access page;record.in_useis a modal;validation.*renders inline next to the field.- Notices go through the same table via
handle_api_notice, at severitywarningand with page-level actions suppressed — the request succeeded, so nothing may take the page away from the user. A caller that wants to render a notice itself claims ownership when it makes the request.
A tool or area registers its own domains additively with register_error_policy; a core
entry can never be overridden, and an attempt throws in development so it is found.
Streams
A stream frame is a frame, not an envelope: toStreamFrame(error) produces
{is_running:false, error:{…}}, where error is the same error body the envelope carries.
The assistant's SSE run ends with an event: error whose data is that body plus the
registry hint — the event name already says "terminal", so the frame needs no ok. A
provider or transport failure mid-run is ai.provider_failed (503, retryable) with the raw
error kept as a log-only cause. Diffusion persists the failed job's result as the
follow-stream's terminal frame, so the report the user reads and the frame the stream
emitted are the same object; a plan that failed to compile is public-disclosure, so the
compiler's cause list survives to the report, while anything the run did not type is an
operator-disclosure diffusion.run_failed.
The assistant bridge
The assistant's structured failure is toStructuredErr(error):
{ok:false, error:{code, message, hint?, details?}, …extend} — the same registry code, plus
the code's hint as the model-facing next move. Assistant-facing codes are deliberately
public disclosure: the tool authors the sentence for the model as publicMessage and it
is the wire message, because a caller's own input echoed back is the whole value of a 400 or
a 404. A payload the model needs that is not a scalar — the candidate list behind an
ambiguous label — rides in extend at top level beside error. The JSON-RPC layer keeps
its own numeric codes and carries the error body in error.data. Tool responses over the
HTTP API are the envelope: see the tool server contract.
Adding a code
- Register the row in
ERROR_REGISTRY(src/core/errors/registry.ts): category (which fixes the status),label_key, registry Englishmessage,severity,disclosure,retryable,details_keysif and only if the label has{params}, ahintfor a model-facing code, and areasononly when no engine path throws it. - Add the label to
src/core/labels/master.json, sorted, in the same commit — the code and its label ship together. - Throw it at the site:
throw new DedaloError('<code>', {…}), withcoordinatesfor log-only identifiers,publicMessageonly for a public-disclosure code, andextendonly for a named extension key. - Add or extend a test that proves the site refuses the way you claim it does. If the
browser needs a non-default action, add the code (or its
<domain>.*) to the client policy table as well. - Write the wire-contract entry if the wire changed — a new code on an existing response, a status change, a notice where a failure used to be. The contract ledger is the wire law, and the entry lands the same day.
Nothing in the dispatcher or the HTTP layer changes when you add a code. That is the point of the registry.
The gates
Every rule on this page is mechanically enforced; a rule without a gate is treated as already rotting. The tests that hold this subsystem:
| gate | holds |
|---|---|
error_registry_native |
the registry is coherent — grammar, a label for every code, label placeholders equal to details_keys, status matching category, family totality. |
error_envelope_native, error_converter_native |
the two envelope shapes and the disclosure ladder. |
dispatch_error_native |
the chokepoint — registry status, ok:false implying non-2xx, Retry-After, csrf_token on both outcomes, a non-envelope handler body refused. |
error_taxonomy_tripwire |
the whole tree only speaks through the registry: no hand-built failure bodies, no orphan codes, every client-spoken code resolving. |
error_throw_ratchet |
untyped throws may only shrink, and the zero-tier directories stay at zero. |
client_error_contract_tripwire |
the client half — one transport, no second fetch wrapper, no reader of the retired keys. |
labels_tripwire |
every label_key ships in the label catalog. |
authorization_denial_native, session_not_logged_contract, csrf_handshake |
the named refusals and their exact shapes. |
Run the first, the taxonomy tripwire and the labels tripwire together after any registry edit; they are fast and they catch an orphan code, a missing label and a mismatched placeholder in one pass.