Every error Adonia raises extends AdoniaException and carries a stable
E_ADONIA_* code. Codes are public contract: match on error.code, never on
the message. They do not change within a major version.
The codes
| Code | Status | Class | Raised when |
|---|---|---|---|
E_ADONIA_INVALID_CONFIG |
500 | InvalidConfigException |
config/adonia.ts or a panel option has an invalid shape (message is path-qualified) |
E_ADONIA_INVALID_FIELD_CAPABILITY |
403 | InvalidFieldCapabilityException |
a field callback URL is missing its app-key signature, or its signed route, form mode, or edit record id was changed |
E_ADONIA_PANEL_CONFLICT |
500 | PanelConflictException |
two panels claim the same (domain, path) mount, or a panel id is registered twice |
E_ADONIA_UNKNOWN_RESOURCE |
404 | UnknownResourceException |
the :resource segment names no registered resource |
E_ADONIA_UNKNOWN_FIELD |
404 | UnknownFieldException |
field.options/field.upload names a field the request cannot see |
E_ADONIA_UNKNOWN_PAGE |
404 | UnknownPageException |
pages.show names a slug no registered page claims |
E_ADONIA_UNKNOWN_ACTION |
404 | UnknownActionException |
actions.* names an action the resource does not expose to this request |
E_ADONIA_UNAUTHORIZED |
403 | UnauthorizedException |
a panel access() verdict of false, or a denied ability |
E_ADONIA_INVALID_CURSOR |
422 | InvalidCursorException |
a ?cursor= token fails to decode or validate, names an unsortable key, or continues an ordering other than the request’s (resources may opt out with static onInvalidCursor = 'reset') |
E_ADONIA_UNSAFE_JSON |
422 | UnsafeJsonException |
a json/key-value document is malformed, cyclic, nested past 64 levels, or carries a prototype-polluting key (__proto__, constructor, prototype) |
E_ADONIA_UPLOAD_REJECTED |
422 | UploadRejectedException |
field.upload has no usable multipart part — none sent, more than the field accepts, or one the body parser already flagged |
E_ADONIA_UPLOAD_TYPE |
422 | UnsupportedUploadTypeException |
the stored bytes’ magic-byte signature matches no accepted media type |
E_ADONIA_UPLOAD_SIZE |
422 | UploadTooLargeException |
the bytes on disk exceed uploads.maxSizeMb or the field’s maxSize() |
E_ADONIA_UPLOAD_DIMENSIONS |
422 | InvalidImageDimensionsException |
an image violates the field’s dimensions({...}) rules, or carries no measurable header while such rules are declared |
E_ADONIA_UNSCOPED_RESOURCE |
500 | UnscopedResourceException |
a resource on a tenant-scoped panel declares no tenantScope (opting out must be explicit) |
E_ADONIA_TENANT_NOT_FOUND |
404 | TenantNotFoundException |
the panel’s tenant() resolver returns null or rejects |
E_ADONIA_STALE_STATE |
409 | StaleStateException |
optimistic-lock conflict on update; the client reloads and re-applies |
E_ADONIA_CODEGEN |
500 | CodegenException |
indexAdoniaResources() cannot produce its artifacts |
E_ADONIA_PLUGIN |
500 | PluginException |
a plugin fails outside the §16.4 isolation boundary |
The upload family shares one base — UnsupportedUploadTypeException,
UploadTooLargeException and InvalidImageDimensionsException all extend
UploadRejectedException — so a client that only wants “the upload was
refused” catches the base and one that wants a specific message switches on
the code. All four are 422 rather than 400: the request is well-formed HTTP
addressing a real field, and it is the CONTENT that fails the field’s rules.
AdoniaException itself carries E_ADONIA at 500. It is the base, not a
code any thrower uses, and it is what an error reaching the handler without a
subclass code reports.
Two renderings, chosen by Accept
AdoniaException defines handle(error, ctx), which v7’s ExceptionHandler
prefers over its own rendering. The branch therefore lives on the error, and a
host app that replaces its exception handler keeps it.
- JSON endpoints (
request.accepts(['html','json']) === 'json', or no Inertia on the context) →{ code, message }at the exception’s status.messageiserror.detail: the message without the[E_ADONIA_*]prefix thaterror.messagecarries, since the code already rides beside it. - Browsers and Inertia XHR → the
adonia/errorpage (below).
Both responses are Cache-Control: no-store. They depend on who asked
(abilities, tenant) and on state expected to change, so a shared cache holding
one would serve a stale 403 or a resolved 404 to somebody else.
The adonia/error page
One page serves 403, 404, 500 and every other status. Props are
AdoniaErrorPageProps, built by errorPageProps in
packages/core/src/errors.ts:
import type { AdoniaErrorPageProps } from '@adonia/core'
const props: AdoniaErrorPageProps = {
status: 404, // number
code: 'E_ADONIA_UNKNOWN_RESOURCE', // string | null — null for a foreign error
message: null, // string | null — error.detail in development, ALWAYS null in production
title: 'Not found', // string — keyed by status, not by code
description: 'That page does not exist.', // string — generic, always safe to show
backUrl: '/admin', // string | null — the panel mount path
}title/description are keyed by status, not by code: the reader is a
panel user, for whom E_ADONIA_UNKNOWN_FIELD and E_ADONIA_UNKNOWN_RESOURCE
are both “that does not exist”. A status with no copy of its own falls back by
class (4xx vs 5xx), so a new code renders sensibly without a table edit.
message is the one leak-prone member, so the server — not the page — decides
it. In production the page receives null and has no other source for the
text; a leak is impossible by construction rather than by discipline. code
is shown in production: it is documented contract, it is what a support
ticket should quote, and it reveals nothing the docs do not.
Overriding it
The React half is ErrorPage from @adonia/ui/pages, scaffolded into the host
app as inertia/pages/adonia/error.tsx. Two levels of override:
// inertia/adonia.ts — replace the body, keep the panel chrome.
import { adonia } from '@adonia/ui'
export default adonia({
registry: (r) => r.page('error', MyErrorBody), // props: ErrorPageProps
})# Replace the whole page, chrome included.
node ace adonia:eject errorThe registry override is the usual one: a branded 404 belongs inside the sidebar the user was already looking at, not on a bare white page.
Foreign errors inside a panel route
A TypeError in a resource query(), a Lucid E_ROW_NOT_FOUND, anything the
app throws from a transformer — none of them are AdoniaExceptions, and
without help they escape to the app’s global handler, dropping the visitor out
of the panel on a route the panel owns.
PanelErrorMiddleware is a boundary applied to each panel route group (after
the panel slot and the §9.1 share hook, before the access gate and the app’s
own panel.middleware([...])). It renders the same chromed page. It is scoped
to panel groups on purpose: Adonia has no business deciding how the host app
renders errors on its own routes.
It re-throws — leaving the error to the app’s handler, exactly as before — when:
- the error is an
AdoniaException(it renders itself); - the error defines its own
handle(v7’s self-handling protocol: hijacking it would turn a validation redirect-back or an auth challenge into a 500 page); - the client wants JSON (there is no Adonia code to put in the body for a foreign error, so the host handler’s shape is the honest answer);
- there is no Inertia or no panel on the context (no chrome to render into).
An error carrying its own 4xx/5xx status keeps it; anything else becomes 500.
A 5xx is written to ctx.logger before the page is sent, because the visitor
only ever sees generic copy and operators still need the stack.
Tests
packages/core/tests/errors.spec.ts— every code’s class/status/detail, a rendered-page test and a JSON-endpoint test per code, the production no-leak assertion per code, the copy fallbacks, and the boundary’s re-throw/render matrix.packages/ui/tests/error_page.test.tsx— chrome, copy, the dev-only detail, and ther.page('error', …)override.