---
title: "Errors"
description: "fx.fail helpers, built-in HTTP statuses, OKE codes, and subsystem errors — values vs thrown invariants."
icon: "OctagonAlert"
source: "docs/spec/unified-theory.md"
---

OKE has two error families: **failures are values** a Flow returns
(`{ data: null, error: { code, data } }`), while **framework errors are thrown**
for invariant violations (permanent numeric code + cause + fix).

```text
OKE1110  domain table not found — migrations have not been applied.
         → run `oke db migrate` against this environment.
         https://oke.omqkhafi.dev/e/1110
```

Codes are stable after the domain-range renumber — match them safely across upgrades
(see the Unreleased Breaking Changes mapping if you still have a pre-renumber note).

<Callout title="The one rule">
  Switch on `error.code` (or a `match` named arm) for typed `data`. Use kind arms / `explain` for
  chrome when the code is unknown. Catch `OKE####` only for thrown framework invariants.
</Callout>

## Smallest Example

<Steps>

<Step>
### Return a typed failure

```typescript
on(
  http.get({
    in: z.object({ id: z.string() }),
  }),
  flow({
    do: async ({ id }, fx) => {
      const [row] = await fx.store(db).select().from(notes).where(eq(notes.id, id));
      if (!row) return fx.fail.notFound({ id });
      return row;
    },
  }),
);
```

</Step>

<Step>
### Handle it on the client

```typescript
import { match } from "okengine/client/explain";

const result = await api.notes.get({ id });
match(result, {
  ok: (note) => render(note),
  NotFound: () => showMissing(),
  _: (e) => toast(e.message),
});
```

</Step>

</Steps>

## `fx.fail`

Same function as `import { fail } from "okengine"`. Returns `{ data: null, error }` — it does **not**
throw. Built-in codes need no `errors:` bag. Domain codes (`OutOfStock`, `FlightFull`, `Duplicate`)
still go on the exposure:

```typescript
on(
  http.post({
    in: z.object({ sku: z.string() }),
    errors: { OutOfStock: z.object({ available: z.number() }) },
  }),
  flow({
    do: async ({ sku }, fx) => {
      const available = 0;
      if (available === 0) return fx.fail("OutOfStock", { available });
      return { sku };
    },
  }),
);
```

Optional `{ message }` overrides the catalog: `fx.fail("OutOfStock", data, { message })`.

### Helpers

| Call                                | Code                 | Status           | Payload                                                   |
| ----------------------------------- | -------------------- | ---------------- | --------------------------------------------------------- |
| `fx.fail.notFound(data?)`           | `NotFound`           | **404**          | `id?`, `key?`, …                                          |
| `fx.fail.unauthorized(data?)`       | `Unauthorized`       | **401**          | `reason?`                                                 |
| `fx.fail.forbidden(data?)`          | `Forbidden`          | **403**          | `gate?`, `reason?`                                        |
| `fx.fail.conflict(data?)`           | `Conflict`           | **409**          | `constraint?`, `table?`, `column?`, `id?`                 |
| `fx.fail.foreignKey(data?)`         | `ForeignKey`         | **409**          | `constraint?`, `table?`, `column?`                        |
| `fx.fail.rateLimited(data?)`        | `RateLimited`        | **429**          | `retryAfterMs?`                                           |
| `fx.fail.serviceUnavailable(data?)` | `ServiceUnavailable` | **503**          | `retryAfter?`                                             |
| `fx.fail.database(data)`            | `DatabaseError`      | by `data.reason` | `reason`, `sqlstate?`, `constraint?`, `table?`, `column?` |
| `fx.fail.internal(data?)`           | `InternalError`      | **500**          | `{}` — catalog message only                               |

`DatabaseError` status: `not_null` / `check` / `invalid` / `too_long` / `out_of_range` → **422**,
`retryable` → **503**, `unknown` / missing → **500**.

Declare a built-in on `errors:` **only to tighten** the payload. Shorter keeps
`NotFound: z.object({ code: z.string() })` because `{ code }` is not the built-in `{ id? }`.
Declared schema wins on the Manifest and the typed client.

The typed client always includes built-in codes. A declared key still wins.

### HTTP status

A bare `404` with body `Not Found` means **no route matched**. `fx.fail.notFound` is a JSON envelope
at **404**. Clients distinguish by envelope, not status. Domain codes stay **400**.

| Code                                             | Status      | Helper                                                                       |
| ------------------------------------------------ | ----------- | ---------------------------------------------------------------------------- |
| `ValidationError`                                | `422`       | none — failed `in`, `do` never runs                                          |
| `IdempotencyKeyMissing`                          | `400`       | none — `idempotency: "required"` and no header                               |
| `IdempotencyKeyInvalid`                          | `400`       | none — header is not 16–255 printable ASCII                                  |
| `IdempotencyKeyReused`                           | `422`       | none — same key, different payload                                           |
| `IdempotencyInProgress`                          | `409`       | none — `Retry-After` until the in-flight call finishes                       |
| `Unauthorized`                                   | `401`       | `unauthorized` · also a gate denial                                          |
| `Forbidden`                                      | `403`       | `forbidden` · also a gate denial                                             |
| `NotFound`                                       | `404`       | `notFound`                                                                   |
| `Conflict` / `ForeignKey`                        | `409`       | `conflict` / `foreignKey`                                                    |
| `UnsupportedMediaType`                           | `415`       | none — QUERY, or a JSON-shaped body that is not `application/json` / `+json` |
| `PayloadTooLarge`                                | `413`       | none — body exceeds `maxRequestBodySize` or the route `maxBodySize`          |
| `RateLimited` / `AuthRateLimited`                | `429`       | `rateLimited` · `AuthRateLimited` has none                                   |
| `InvalidQuery`                                   | `400`       | none — QUERY or JSON body that does not parse (`malformed_body`)             |
| `AuthFailed`                                     | `400`       | none — credentials / policy, not “no session”                                |
| `DatabaseError`                                  | by `reason` | `database`                                                                   |
| `ServiceUnavailable`                             | `503`       | `serviceUnavailable`                                                         |
| `InternalError`                                  | `500`       | `internal` — never copies a thrown `message`                                 |
| Domain (`OutOfStock`, `FlightFull`, `Duplicate`) | `400`       | `fx.fail("OutOfStock", data)`                                                |

`UnsupportedMediaType` (415) is this rule, for a non-empty body. Let `mt` be the media type before `;`:

1. `application/json` or `application/*+json` is parsed. A parse failure is 400 `InvalidQuery` with reason `malformed_body`.
2. A route with `jsonContentType: "any"` tries JSON and otherwise keeps the raw string.
3. `text/plain`, form-urlencoded, multipart, or a missing type, when the trimmed body starts with `{` or `[` and parses as JSON, is 415.
4. Anything else is the raw string. `curl -d` needs `-H 'content-type: application/json'`.

`OKE1074` means the durable run lost its lease. The step result is not written and compensation does not run. The caller sees a busy lease, not a new failure of the work.

`OKE1076` means `codeVersion` was set and the stored stamp is a different `app:` value. Leave the old value pinned until sleeping runs drain, then change it. Unset `codeVersion` checks nothing. A stored stamp from 0.23.1 (no `app:` prefix) is restamped, not failed.

### Client chrome

`import { match, matchError, explain } from "okengine/client/explain"`. Do not switch on every
code — a `create` can return store auto-map, gates, transport, and domain codes.

| `kind`        | Codes                                                                                  |
| ------------- | -------------------------------------------------------------------------------------- |
| `auth`        | `Unauthorized`, `AuthFailed`                                                           |
| `permission`  | `Forbidden`                                                                            |
| `missing`     | `NotFound`; `TransportError` with HTTP 404                                             |
| `conflict`    | `Conflict`, `ForeignKey`                                                               |
| `invalid`     | `ValidationError`, `InvalidQuery`, `UnsupportedMediaType`; `DatabaseError` 422 reasons |
| `limited`     | `RateLimited`, `AuthRateLimited`                                                       |
| `unavailable` | `ServiceUnavailable`; `DatabaseError` `retryable`; other `TransportError`              |
| `failed`      | `InternalError`, unknown `DatabaseError`, domain codes                                 |

```typescript
match(result, {
  ok: (data) => show(data),
  FlightFull: (data) => offerWaitlist(data.seatsLeft),
  auth: () => goSignIn(),
  invalid: (e) => setForm(e.fields),
  _: (e) => toast(e.message),
});
```

Dispatch: `ok` → named code → kind → required `_`. `explain(error).fields` maps
`ValidationError` issues to `{ email: "Required" }`. Full loop: [Calling](/docs/client/calling).

`TransportError` always sets both `error.message` and `error.data.message` to the same string.
Prefer `error.message` in UI code.

### Store auto-map

`fx.store` constraint and connection failures become typed `fail` values (same family as
`ValidationError`). Public `data` is names only — never `message` / `detail` / `hint`.
`get` returning `null` is **not** auto-`NotFound` — return `fx.fail.notFound` yourself.

#### SQL auto-map

| Signal                                                                | Result                                                                            |
| --------------------------------------------------------------------- | --------------------------------------------------------------------------------- |
| Postgres `23505` / `23P01` · SQLite UNIQUE / PRIMARY KEY              | `Conflict` **409**                                                                |
| Postgres `23503` / `23001` · SQLite FOREIGN KEY                       | `ForeignKey` **409**                                                              |
| Postgres `23502` / `23514` · SQLite NOT NULL / CHECK                  | `DatabaseError` `not_null` / `check` **422**                                      |
| Postgres `22P02` / `22007` / `22023` / `42804`                        | `DatabaseError` `invalid` **422**                                                 |
| Postgres `22001`                                                      | `DatabaseError` `too_long` **422**                                                |
| Postgres `22003` / `22008`                                            | `DatabaseError` `out_of_range` **422**                                            |
| Postgres `40001` / `40P01` / `55P03` · SQLite BUSY                    | left thrown so `flow.retry` can run; after exhaust → `ServiceUnavailable` **503** |
| `08xxx` / `53xxx` / `57P0x` / `57014` / `25P03` / connection / paused | `ServiceUnavailable` **503**                                                      |
| `42P01` / `42703` with `domainDdl: "off"`                             | **OKE1110** (unchanged)                                                           |
| other SQLSTATE                                                        | `DatabaseError` `unknown` **500**                                                 |

#### KV auto-map

Redis-wire `store.kv` only (not Gate / Signal clients). Memory KV does not throw these.

| Signal                                                      | Result                                                                            |
| ----------------------------------------------------------- | --------------------------------------------------------------------------------- |
| Connection / `CLUSTERDOWN` / `READONLY` / `OOM` / `MISCONF` | `ServiceUnavailable` **503**                                                      |
| `BUSY` / `TRYAGAIN` / `LOADING`                             | left thrown so `flow.retry` can run; after exhaust → `ServiceUnavailable` **503** |
| `WRONGTYPE` / `NOSCRIPT`                                    | unmapped — `InternalError` **500**                                                |

#### Files auto-map

`fs` and `s3` (`fx.store` files handle). Missing objects stay `null`.

| Signal                                             | Result                                                                            |
| -------------------------------------------------- | --------------------------------------------------------------------------------- |
| `AccessDenied` / `EACCES` / `EPERM` / HTTP **403** | `Forbidden` **403**                                                               |
| `fs` key starts with `/` or contains `..`          | `Forbidden` **403** (`reason: invalid_key`)                                       |
| `NoSuchBucket` / `ENOSPC` / connection             | `ServiceUnavailable` **503**                                                      |
| `SlowDown` / `EBUSY` / HTTP **429**                | left thrown so `flow.retry` can run; after exhaust → `ServiceUnavailable` **503** |
| `NoSuchKey` (thrown)                               | unmapped — `InternalError` **500**                                                |

## Localized messages

Typed failures and OKE codes ship English and Arabic ICU catalogs. Locale comes
from `Accept-Language` (matched to `i18n.locales`); fallback is `i18n.default`.

| Surface            | Keys                                       | How it appears                           |
| ------------------ | ------------------------------------------ | ---------------------------------------- |
| `fx.fail` / `fail` | `errors.{code}` · `errors.{code}.{reason}` | Optional `error.message` on the envelope |
| Thrown `OkeError`  | `oke.{code}.cause` · `oke.{code}.fix`      | Cause + fix lines in the thrown message  |

Override via `defineLocale`. Pass `fail(code, data, { message })` for a custom
string. Custom app codes stay message-less until registered. Full catalogs:
[i18n](/docs/reference/i18n).

## OKE numeric codes

| Code   | Name                   | Cause                                                  | Fix                                                             |
| ------ | ---------------------- | ------------------------------------------------------ | --------------------------------------------------------------- |
| `1001` | undeclared read        | Flow reads a resource not in `effects.reads`           | Add it to the flow's `effects.reads`                            |
| `1002` | undeclared write       | Flow writes a resource not in `effects.writes`         | Add it to the flow's `effects.writes`                           |
| `1003` | undeclared emit        | Flow emits a signal not in `effects.emits`             | Add it to the flow's `effects.emits`                            |
| `1004` | undeclared send        | Flow sends a template not in `effects.sends`           | Add it to the flow's `effects.sends`                            |
| `1005` | undeclared ask         | Flow asks a prompt not in `effects.asks`               | Add it to the flow's `effects.asks`                             |
| `1006` | undeclared secret      | Flow reads a secret not in `effects.secrets`           | Add it to the flow's `effects.secrets`                          |
| `1007` | undeclared call        | Flow calls a flow not in `effects.calls`               | Add it to the flow's `effects.calls`                            |
| `1008` | undeclared fetch       | Flow fetches a host not in `effects.fetches`           | Add the hostname to the flow's `effects.fetches`                |
| `1009` | undeclared embed       | Flow embeds with a model not in `effects.embeds`       | Add it to the flow's `effects.embeds`                           |
| `1020` | no effects declared    | Flow has no `effects` and no Manifest to infer from    | Run `oke build` / `oke dev`, or declare effects                 |
| `1030` | adopt barrel stale     | A `src/flows/<unit>` folder was not adopted            | Run `oke dev` or `oke build` to regenerate `src/flows/index.ts` |
| `1040` | HTTP path unresolved   | Pathless `http.get()` never received a file-tree stamp | Import `@/flows`, or pass `http.get("/…")`                      |
| `1041` | HTTP route clash       | Two HTTP flows share the same method + path            | Give each flow a unique method + path                           |
| `1045` | HTTP flow unnamed      | Adopted HTTP flow still has no `unit.export`           | Export from `flows/<unit>/` or pass a named `flow`              |
| `1050` | live exposure dup      | Same signal, gates, and match on two GET routes        | Change the gate or path-param filter                            |
| `1060` | MCP tool duplicate     | Two MCP tool bindings share the same tool name         | Give each MCP tool exposure a unique name                       |
| `1070` | flow name duplicate    | Two Flows share the same Manifest / `fx.call` name     | Give at least one an explicit `flow("…")` or tree export        |
| `1071` | once-signal multi-flow | Two different Flows bound to the same `signal.once`    | Use `signal.broadcast`, or bind only one Flow                   |
| `1072` | flow unnamed           | Signal / Clock consumer still has no `unit.export`     | Export from `flows/<unit>/` or pass a named `flow`              |
| `1110` | schema missing         | Domain table absent in `prod` — no auto-DDL            | Run `oke db migrate` against this environment                   |
| `1510` | vault secret missing   | Contract has no value in any resolution layer          | `oke vault set <name>`, `.env.local`, or a `dev:` fallback      |
| `1210` | live resume gap        | `Last-Event-ID` is not on the retained tape            | Reconnect without the cursor; remaining tape replays            |
| `1240` | orphan emit            | Emit with zero subscribers and `optional` false        | Add `on(signal, …)` or declare `optional: true`                 |
| `1250` | signal schema          | Emit payload failed the signal's `schema`              | Pass a payload that matches `schema`, or remove it              |
| `1605` | channel schema         | Send payload failed the template's `schema`            | Fix template `data` payload or the template `schema`            |
| `1810` | tenant required        | Tenant-scoped op with no `fx.tenant.id`                | `switchTenant`, signed `tid`, or tenant header                  |
| `1820` | tenant not member      | Client-supplied tenant id is not a membership          | Pick from `listTenants` or add the user as a member             |
| `1830` | tenant unknown scope   | Tenant role used an invented or `console:*` scope      | Use a declared application scope                                |

<Callout title="Effects are usually inferred">
  The 1001–1007 · 1008 · 1009 family exists for flows that declare effects explicitly. Most apps
  never write an `effects` block — inference covers them — so seeing one of these means an explicit
  declaration drifted from the code.
</Callout>

## Gate denials (typed failures)

Returned before `do` — not thrown. Same codes as `fx.fail.unauthorized` / `.forbidden` /
`.rateLimited`:

| Code           | When                                         | Payload                                                                                    |
| -------------- | -------------------------------------------- | ------------------------------------------------------------------------------------------ |
| `Unauthorized` | Policy denied, request not authenticated     | —                                                                                          |
| `Forbidden`    | Policy denied, authenticated but not allowed | `gate`, `reason` (`tenant_required` · `not_member` · `unknown_scope` · `session_only` · …) |
| `RateLimited`  | Rate gate budget exhausted                   | `retryAfterMs`                                                                             |

## Subsystem errors

Thrown by specific subsystems — each names its own cause:

| Error                         | Thrown when                                                        | What to do                                                            |
| ----------------------------- | ------------------------------------------------------------------ | --------------------------------------------------------------------- |
| `VaultBootError`              | A vault contract has no value in any resolution layer              | TTY **OKE1510** — set each listed name                                |
| `VaultError` (`UNSUPPORTED`)  | Managed provider unknown                                           | Use an official id, built-in `vault`, or `env`                        |
| `VaultSealed`                 | Console rotate-master while this process holds no master key       | Export `OKE_VAULT_MASTER_KEY` or run `oke vault unseal`               |
| `VaultRotateBusy`             | Master-rotation lease held or a batch is already in flight         | Wait and continue, or resume with `oke vault rotate-master --new-key` |
| `VaultUnsupported`            | Console vault action on a non-builtin backend or missing SQL       | Use `drivers.vault = "vault"` and set `DATABASE_URL`                  |
| `AiPiiBuildError`             | Build: a flow sends PII fields to a third-party model              | Drop the fields or add `allowPii: true` — fields named                |
| `AiSchemaValidationError`     | A model response failed the prompt's `out` schema                  | Fix the prompt or the schema — response didn't conform                |
| `ScheduleNotOverridableError` | Console tried to edit a clock declared without `overridable: true` | Declare it overridable and redeploy                                   |
| `ClockResourceNotFoundError`  | Console action targeted an unknown clock name                      | Check the name against your `clock()` declarations                    |
| `DryRunWriteIsolationError`   | A write attempted inside a dry run                                 | Dry runs never write — use a real run                                 |
| `ManifestValidationError`     | The compiled Manifest failed schema validation                     | Re-run the build; the error names the offending entry                 |
| `CrossPlaneError`             | A user-plane token was used on the operator plane (or vice versa)  | Use the correct principal for the plane                               |
| `AttenuationError`            | A token was used beyond its attenuated scope                       | Re-issue with the needed scope                                        |
| `SessionError`                | Session token invalid, expired, or malformed                       | Re-authenticate                                                       |
| `OperatorError`               | Operator-plane operation failed its checks                         | Error message names the failed check                                  |
| `AccessGrantError`            | Console access grant rejected                                      | Re-request access with a valid grant                                  |
| `UnsupportedPathError`        | A route path shape the router can't compile                        | Simplify the path pattern                                             |

## Troubleshooting

<Accordions>

<Accordion title="I hit OKE1001–1009">
  The capability token does not include what the Flow touched. Add the missing ledger entry. An
  `effects` block must include every effect inference can see.
</Accordion>

<Accordion title="OKE1900 fx hidden from inference">
  Cause: the Flow renames `fx`, lets a chain alias escape, or passes `fx` to a callee inference
  cannot resolve. Fix: name the parameter `fx`, keep `const q = fx.store(db)` in the same function,
  and include every effect the walk can see. Extra keys in an `effects` block are allowed.
</Accordion>

<Accordion title="OKE1040 pathless HTTP never stamped">
  Import `@/flows`, or pass an explicit path: `http.get("/users/:id")`.
</Accordion>

<Accordion title="OKE1041 method + path bound twice">
  A resource mount and a handwritten route share the same method + path. Drop one binding.
</Accordion>

<Accordion title="OKE1072 Signal or Clock flow unnamed">
  Cause: `A {kind} flow on "{trigger}" has no name.`
  Fix: pass an explicit name — `on(handle, flow("unit.export", { do }))`. See
  [Signal](/docs/elements/signal) · [Clock](/docs/elements/clock).
</Accordion>

<Accordion title="OKE1071 once signal bound to more than one Flow">
  Cause: `Once signal "{signal}" is bound to more than one Flow ({flows}).` Use `signal.broadcast`
  if each Flow should get a copy, or bind only one Flow. See [Once · Competing
  consumers](/docs/elements/signal/once#competing-consumers-once-vs-broadcast).
</Accordion>

<Accordion title="OKE1110 in production">
  Domain tables missing — prod has no auto-DDL. Run `oke db migrate` against that environment
  ([CLI](/docs/reference/cli)).
</Accordion>

<Accordion title="Unauthorized / Forbidden / RateLimited">
  These are gate denials returned as values before `do` runs — not thrown OKE codes. Fix the policy,
  membership, or rate budget. See [Gate](/docs/elements/gate).
</Accordion>

<Accordion title="Do I need errors: { NotFound }?">
  No. Built-in codes are always on — `return fx.fail.notFound({ id })`. Put `errors:` only for
  domain codes, or to tighten a built-in payload (`{ code }` instead of `{ id? }`).
</Accordion>

<Accordion title="Unique insert is 500 InternalError">
  Unique / exclusion → `Conflict` **409**; missing FK → `ForeignKey` **409**; invalid →
  `DatabaseError` **422**. Serialization (`40001` / `40P01`) stays thrown until retries exhaust,
  then **503**. See [SQL auto-map](#sql-auto-map).
</Accordion>

<Accordion title="Redis down returns InternalError">
  Connection / `CLUSTERDOWN` / `READONLY` map to `ServiceUnavailable` **503**. `BUSY` / `TRYAGAIN`
  stay thrown until `flow.retry` exhausts, then **503**. `WRONGTYPE` stays `InternalError` — see [KV
  auto-map](#kv-auto-map).
</Accordion>

<Accordion title="S3 AccessDenied / disk full is InternalError">
  `AccessDenied` and `fs` path-escape keys → `Forbidden` **403**. `SlowDown` / `EBUSY` stay thrown
  until retries exhaust, then **503**. `ENOSPC` / `NoSuchBucket` → **503**. Missing `get` is still
  `null`, not `NotFound` — see [Files auto-map](#files-auto-map).
</Accordion>

<Accordion title="404 Not Found — route missing or domain miss?">
  Bare body `Not Found` means no HTTP route. JSON `{ error: { code: "NotFound" } }` is
  `fx.fail.notFound`. Clients distinguish by envelope.
</Accordion>

<Accordion title="InternalError leaked a stack / thrown message">
  Unhandled throws use the catalog string only. The thrown `message` is not copied onto the
  envelope. Expected misses belong in `fx.fail.notFound` or a domain `fx.fail("OutOfStock", data)`.
</Accordion>

<Accordion title="VaultBootError at startup">
  TTY title **OKE1510**. A vault contract has no value in any resolution layer. The printer lists
  every gap — set the missing names ([Vault](/docs/elements/vault)).
</Accordion>

</Accordions>

## Learn more

- [fx](/docs/reference/fx) — `fx.fail` helpers on the door
- [i18n](/docs/reference/i18n) — catalogs, `fx.t`, locale matching
- [Flow](/docs/elements/flow) — envelope and domain `errors:`
- [HTTP](/docs/elements/flow/http) — response envelopes
- [Calling](/docs/client/calling) — `match` / `explain` on the client
- [Gate](/docs/elements/gate) — where the three denials come from
- [CLI](/docs/reference/cli) — `oke db migrate` and friends

## Next

<Cards>
  <Card title="fx" description="The door that records effects." href="/docs/reference/fx" />
  <Card
    title="Gate"
    description="Where Unauthorized and Forbidden come from."
    href="/docs/elements/gate"
  />
  <Card title="Client" description="match unknown errors in the browser." href="/docs/client" />
</Cards>
