The error contract — typed, code-discriminated failures
Every error response OnERP’s API sends back has the same shape and the same rule for
telling failures apart: a stable code string — not the HTTP status, not the message
text, not the exception’s class. It is what the filter writes, what the client parses,
and what i18n keys off of.
The wire contract
Section titled “The wire contract”Every error response body is a lean subset of RFC 9457 Problem
Details — no type URI, no title, no
application/problem+json ceremony, just the fields that carry real information:
// @onerp/meta/records — the wire envelopeinterface ErrorBody { code: AppErrorCode | (string & {}); // discriminator, e.g. "core.cross_bo_reference" status: number; // advisory duplicate of the HTTP status detail: string; // English fallback for logs / non-i18n clients // + the variant's own params, flattened onto the body}The HTTP status is still the coarse signal (4xx vs 5xx, retriable 409); code is the
fine signal a caller branches on. A core.cross_bo_reference body, for example, also
carries blockers: ReferenceBlocker[] flattened alongside code/status/detail.
Framework codes are closed, domain codes are open
Section titled “Framework codes are closed, domain codes are open”The core.* namespace is the framework’s catalog — a closed discriminated union in
@onerp/meta/records, AppError, that the client narrows on with full type safety.
packages/meta/src/records/errors.ts is the source of truth; this is what it holds
today:
export type AppError = | { code: "core.validation_failed"; issues: FieldIssue[] } | { code: "core.concurrent_modification"; businessObjectName: string; businessObjectId: string } | { code: "core.stale_revision"; businessObjectName: string; businessObjectId: string } | { code: "core.cross_bo_reference"; blockers: ReferenceBlocker[] } | { code: "core.record_locked"; businessObjectName: string; recordStatus: string } | { code: "core.not_deletable"; businessObjectName: string; recordStatus: string } | { code: "core.not_archivable"; businessObjectName: string; recordStatus: string } | { code: "core.not_restorable"; businessObjectName: string; recordStatus: string } | { code: "core.tree_children"; businessObjectName: string; field: string; count: number } | { code: "core.invalid_cursor" } | { code: "core.action_not_found"; action: string } | { code: "core.function_not_found"; function: string } | { code: "core.action_not_available"; action: string } | { code: "core.not_found"; businessObjectName: string; id: string } | { code: "core.forbidden"; permission?: string } | { code: "core.unauthorized" } | { code: "core.no_active_workspace" } | { code: "core.workspace_not_found" } | { code: "core.workspace_membership_ended" } | { code: "core.pdf_render_failed" } | { code: "core.request_in_flight"; idempotencyKey: string } | { code: "core.request_rejected" } | { code: "core.internal" };A variant that declares params may only be raised where those params are known — the
client’s i18n copy interpolates them, so a body missing one renders the placeholder
verbatim. That is why the coarse status fallback below emits core.request_rejected,
which declares none, rather than core.not_found.
<domain>.* codes are open — a domain adds one the first time it needs it, with no
change to @onerp/meta/records. The wire code field is typed AppErrorCode | (string & {}),
so a domain code type-checks without widening to string and losing the closed union’s
narrowing for core.*. The client renders any code it recognizes through its
errors.<code> i18n key, and falls back to the server’s detail for one it does not.
The three workspace-scoped failures
Section titled “The three workspace-scoped failures”The session, the workspace and the membership between them fail separately, and each failure has its own recovery — which is the whole reason they are three codes rather than one 403:
| Code | Status | What happened | Recovery |
|---|---|---|---|
core.no_active_workspace |
403 | The account holds no membership anywhere | The no-workspace screen: wait for an invitation, or use another account. Never signs out |
core.workspace_not_found |
401 | The session names a workspace that no longer resolves | Sign out and back in — the session outlived the workspace |
core.workspace_membership_ended |
403 | The workspace is alive and the session is valid; this account’s membership in it ended | Re-point the session at a workspace the account still belongs to and reload; none left lands on the no-workspace screen |
core.workspace_membership_ended is a 403 and not a 401 because nothing is wrong with
the credential — only with the workspace it is scoped to. Signing the account out would
throw away a session that still works for every other workspace it belongs to.
Raising an error
Section titled “Raising an error”Every throwable error is a thin subclass of AppException (@onerp/core), which wraps
Nest’s HttpException and carries the code, the HTTP status, a structured payload, and
an English detail. A subclass is about four lines:
export class RecordLockedError extends AppException { constructor(businessObjectName: string, recordStatus: string) { super( "core.record_locked", 422, { businessObjectName, recordStatus }, `${businessObjectName} is locked in status '${recordStatus}'`, ); }}The constructor guards two things a subclass author leans on: a payload key named code,
status or detail throws, because the body flattens the payload between those envelope
fields and would silently clobber one; and message is overwritten with detail, so a
log line or a BullMQ failedReason reads the real cause rather than the spelled-out
class name.
A domain error is identical but for its <domain>.* code —
domains/inventory/src/errors.ts and domains/eu-vat/src/errors.ts hold the ones that
ship today. Throwing one is throwing any other exception.
Framework and domain are a namespace split, not two hierarchies: AppException is the
only class @onerp/core exports, so nothing outside the package branches on a subclass —
and nothing needs to. The class is gone the moment the response is
serialized; code is the discriminator on both sides of the wire, and setting it
correctly is the subclass’s whole job.
The single serialization seam
Section titled “The single serialization seam”Exactly one place turns a thrown error into a response body: the global @Catch()
ErrorFilter in @onerp/core, which delegates to toErrorBody. That is the only
function that knows how to shape a body, and it handles every exception shape the
pipeline can produce:
- An
AppException(framework or domain) — its owngetResponse()already is theErrorBody. - The meta
ValidationErroror a strayZodError— folded intocore.validation_failedwith anissues[]array, whichever validator produced them. - Any other Nest
HttpException— 401 and 403 map tocore.unauthorizedandcore.forbidden, the only two coarse codes with no required params. Every other 4xx reportscore.request_rejectedand 5xx reportscore.internal, both keeping their own status on the wire — socore.internalis not always a 500. - Anything else (a plain
Error, a driver error) —core.internal, 500, the real cause logged server-side and never leaked into the body.
Throw sites never shape a body by hand; they throw a typed error (or let Nest’s own exceptions surface) and trust the filter.
i18n: the client renders, the server never does
Section titled “i18n: the client renders, the server never does”The server sends code + structured params — never a rendered message. Rendering happens
client-side, from an errors.<code> i18n namespace. The copy lives in the framework
bundle for every code, core.* and <domain>.* alike: a domain ships no message bundle —
its translations sit on its defs and cover labels, descriptions and option labels only.
errors: { core: { record_locked: "This %{businessObjectName} is %{recordStatus} and can't be changed.", }, inventory: { negative_stock: "This movement would take stock negative for this product and location.", },},describeError resolves any caught value to display copy, translating errors.<code>
with the error’s own payload spread in as interpolation params, and falling back to the
server’s detail for a code with no key yet — which is where eu-vat’s codes land today.
detail is not the primary render path; it’s what a curl, a log line, or an untranslated
code reads.
Consuming errors on the client
Section titled “Consuming errors on the client”@onerp/client’s errorFrom is the one place a non-ok HTTP response is parsed: any body
matching the ErrorBody shape becomes a typed AppErrorException, and anything else (a
proxy error, a malformed response) becomes a generic Error carrying only the status.
Callers react to a specific failure with error.is("core.…"), which narrows body to
that variant so its params read without a cast:
if (error instanceof AppErrorException && error.is("core.workspace_membership_ended")) { repointToAnotherWorkspace();}is covers the closed core.* catalog only — a <domain>.* code has no typed variant,
so branch on body.code for those. Anything not handled falls through to describeError.
Rendering a payload, not just a message
Section titled “Rendering a payload, not just a message”A code whose payload is worth showing registers a component in
packages/react/src/sdk/feedback/error-details/registry.ts, keyed by the code and typed
against the variant it carries. <ErrorDialogProvider> renders the registered detail
under the message, so it reaches every surface the policy routes through — an action, a
list row, a delete button — not one button that knew to look:
export const errorDetailRegistry = Object.freeze({ "core.cross_bo_reference": ReferenceBlockersDetail, "core.validation_failed": ValidationIssuesDetail,}) satisfies Partial<{ [K in AppErrorCode]: ComponentType<ErrorDetailProps<K>> }>;The registry is partial by design: a code with no entry — every <domain>.* code
included — shows its message alone.
core.validation_failed renders its issues this way. A FieldIssue names the business
object its path addresses, stamped where the issue is raised, so the dialog reads the
field’s label out of that record’s definition in the active locale — and an issue raised
on a record the write only touched downstream is labelled as that record’s, not as the
one the user was looking at. The request-shape sources carry none: the global
ValidationPipe and the controller’s Zod parsing of filter and sort refuse a request,
not a record, and those issues show their raw path.
A form’s save and release are where validators run, so a refusal there also lands inline: the form keeps the issues addressed to its own record and shows them on those fields until the user edits them.