---
title: "Errors and limits"
description: "Every failure code ONE returns — HTTP statuses, the three error shapes, the four outcomes, CLI exit codes, SDK exceptions and MCP tool errors, with captured bodies."
type: reference
audience: both
verified: 2026-09-06
canonical: https://one.ie/docs/errors
---
Every status, outcome, exit code and exception the four surfaces return, with an
example of each. Bodies below were captured against production, produced by
driving the shipping module, or read from the source file named beside them, on
2026-09-06.

## HTTP statuses

`POST /api/ask/:receiver` on `one.ie`, `POST /ask/:receiver` on `api.one.ie`.

| Status | Body shape | Cause | Fix |
|---|---|---|---|
| 200 | outcome envelope | reached dispatch | read `outcome`, not the status |
| 400 | `problem+json` | empty receiver segment | check the path |
| 400 | `envelope_missing` | payload posted at the top level | wrap it in `data` |
| 400 | `validation` | payload failed the receiver's Zod schema | read `field`, `expected`, `example` |
| 401 | `invalid_token` | a `one-`/`osk_` token matched no row | mint a new key |
| 402 | `unauthorized` + `reason` | `grant-capability` only — workspace over limit or suspended | settle billing |
| 403 | `problem+json` | `isGatewayRequest` refused the caller | send a Bearer, or call `api.one.ie` |
| 403 | `unauthorized` + `reason` | `grant-capability` only — no `mint_capability` | ask an owner |
| 404 | **HTML** | any method other than POST — the route exports `POST` only | use POST |
| 404 | `{"error":"Not found"}` | on `api.one.ie`, a path outside its 14 substrate prefixes | drop the `/api` prefix, or call `one.ie` |
| 429 | `rate_limited` | window ceiling exceeded | wait `Retry-After` seconds |
| 500 | **text/plain** | a percent-malformed receiver segment | percent-encode the colon as `%3A`, never a bare `%` |
| 503 | `unauthorized` + `reason` | `grant-capability` only — the authority tree could not be read | retry |

Three shapes on that table catch callers out. A `GET` on either host answers 404
with the site's HTML 404 page, not JSON — measured `404 text/html` on both
`https://api.one.ie/ask/meta%3Acatalog` and
`https://one.ie/api/ask/meta%3Acatalog`. A receiver segment that
`decodeURIComponent` cannot decode is not caught. `POST https://api.one.ie/ask/%`
answers `500 text/plain` with the body `URI malformed`; the same path on
`https://one.ie/api/ask/%` answers `500 text/plain` with the body
`error code: 1101`. An empty segment is the one that answers
the 400 `problem+json`. And the route parses the body only when
`content-type` contains `application/json`; without that header the body is
discarded and the call is refused as if it were empty:

```bash
curl -s -X POST 'https://api.one.ie/ask/meta%3Aschema' -d 'receiver=world'
# {"error":"validation","receiver":"meta:schema","field":"receiver",
#  "hint":"Invalid input","expected":"string"}
```

Other doors:

| Route | Status | Body |
|---|---|---|
| `POST /api/signal/:receiver` | 401 | `{"error":"unauthorized"}` |
| `POST /api/mark/:edge`, `POST /api/warn/:edge` | 401 | `{"error":"unauthorized","reason":"no_session"}` |
| `POST /api/mark/:edge`, `POST /api/warn/:edge` | 400 | `problem+json`, `type: https://one.ie/errors/bad-edge` |
| `POST /api/mark/:edge`, `POST /api/warn/:edge` | 403 | `{"error":"forbidden","reason":"…"}` |

`/api/signal` requires a Bearer that byte-equals `SERVER_SECRET`. `/api/mark` and
`/api/warn` require a session, a world key, or a verified service credential.
`/api/ask` is the door a developer key opens. See [API](/docs/api).

## Three error shapes

The surface emits three, from three layers. There is no single shape.

| Shape | Content-Type | Emitted by |
|---|---|---|
| RFC 7807 problem | `application/problem+json` | route-edge transport refusals; the `mark`/`warn` bad-edge 400 |
| Plain JSON 4xx | `application/json` | envelope, validation, token and rate-limit guards; `mark`/`warn` auth |
| 200 + outcome | `application/json` | everything that reached dispatch |

### RFC 7807

`{ type, title, status, detail? }`. `type` defaults to `"about:blank"`.
Source: `one.ie/web/src/lib/api/problem.ts`.

```json
{"type":"about:blank","title":"forbidden","status":403,
 "detail":"direct substrate access not permitted"}
```

Captured: `POST https://one.ie/api/ask/meta%3Acatalog` with no `Authorization`.

### Plain JSON 4xx

```json
{"error":"invalid_token",
 "error_description":"Token expired or revoked. Re-exchange your identity_assertion at /api/oauth2/token."}
```

Captured: `POST https://api.one.ie/ask/meta%3Acatalog` with
`Authorization: Bearer one-000000000000000000000000000000`. The response also
carries `WWW-Authenticate: Bearer resource_metadata="https://one.ie/.well-known/oauth-protected-resource"`.

### 200 + outcome envelope

```json
{"outcome":"failure",
 "result":{"error":"forbidden","receiver":"tasks:list","required":"member",
           "callerRole":"anonymous",
           "reason":"forbidden: this receiver requires an authenticated caller — a session, a world key, or a service credential",
           "hint":"Requires an authenticated caller with membership in the target workspace.",
           "forbidden":true},
 "signalId":"01a0769bbfcc1f64b4c3013f","receiver":"tasks:list"}
```

Captured: `POST https://api.one.ie/ask/tasks%3Alist` with `{"data":{"tag":"x"}}`
and no credential. The status is 200. Clients on this wire read `result` out of a
200 and throw on non-2xx, so an authorization refusal is deliberately not an HTTP
error.

`reason` appears only when the refusing gate supplied one; `hint` only when the
declared auth label has one (`forbiddenResponse` in
`one.ie/web/src/lib/receiver-envelope.ts`).

## 401 vs 403

They need opposite fixes.

| | 401 | 403 |
|---|---|---|
| Meaning | the token matched no record | the caller is real but may not do this |
| Emitted by | `verifyWorldKey` found no row | `isGatewayRequest` refused the request shape |
| Shape | `{"error":"invalid_token",…}` | `problem+json`, title `forbidden` |
| Fix | mint a new key | send any well-formed Bearer, or call `api.one.ie` |

An authorization denial on a receiver the caller reached is neither: it is HTTP
200 with `outcome: "failure"` and `result.forbidden: true`. `result.required`
names the declared auth label and `result.callerRole` names what the door
resolved. See [Authority and access](/docs/authority).

## 429 and rate limits

One fixed 60-second window, per worker isolate.
Source: `one.ie/web/src/lib/api/rate-limit.ts`.

| Constant | Value | Bucket |
|---|---|---|
| `RATE_LIMIT_WINDOW_MS` | `60_000` | the window |
| `RATE_LIMIT_PER_KEY` | `120` | a verified bearer or session actor |
| `RATE_LIMIT_PER_IP` | `300` | source IP |
| `RATE_LIMIT_MEDIA_PER_IP` | `20` | `/api/tts`, `/api/stt` — tightens whichever bucket applies |

Bucket order: a bearer the route has **verified** → the session actor → the source
IP. `/api/ask` does not pass `bearerVerified`, so a world key on that door falls
through to the IP bucket of 300. Verified service principals are exempt and the
limiter returns `null` for them.

Driving `RateLimitStore` past `RATE_LIMIT_PER_IP` and calling
`rateLimitedResponse` produces:

```json
{"error":"rate_limited","retryAfter":60}
```

with headers:

```
ratelimit-limit: 300
ratelimit-remaining: 0
ratelimit-reset: 1788695402
retry-after: 60
```

`RateLimit-Reset` is unix seconds. `RateLimit-Remaining` is floored at 0.
`Retry-After` is seconds, minimum 1, and appears on 429 only.

The three `RateLimit-*` headers are attached to the **dispatched-result 200** and
to the 429. Measured on `api.one.ie`: a `meta:types` 200 carried
`ratelimit-limit: 300`; a `dissolved` 200 and a 400 `validation` carried no
`RateLimit-*` header at all.

## 503 is not a denial

`decide()` returns three answers, not two: granted, denied, and could-not-read.

| Decision | Status | Reason string |
|---|---|---|
| granted | — | `via: controls \| rung \| delegation` |
| determined no | 403 | `action_not_permitted` |
| could not read | 503 | `authority_undetermined` |

Source: `one.ie/web/src/lib/decide.ts`. `api-auth.ts` raises one further 503 that
is not a `decide()` answer — `audit_write_failed`, in the table below.
An undetermined lookup never short-circuits the walk — the remaining paths still
run, and 503 is returned only when nothing granted. A 503 is retryable; a 403 is
not.

### `requireAuth` reasons

A route that calls `requireAuth` answers
`{"error":"unauthorized","reason":"<reason>"}` at the `AuthError`'s own status.
`AuthErrorReason` is a closed union of nine members
(`one.ie/web/src/lib/principal.ts:38-48`); eight are thrown today.

| Reason | Status | Thrown when |
|---|---|---|
| `no_session` | 401 | no session, or no `world_keys` row for the key |
| `action_not_permitted` | 403 | `tierOf(role) < minTier(action)`, or a world key reaching across groups |
| `ip_not_allowed` | 403 | the group policy's `ip_allowlist` excludes the caller |
| `method_not_allowed` | 403 | the caller's auth provider is not in `allowed_methods` |
| `billing_over_limit` | 402 | workspace is `over_limit`/`floored`, role below admin, write action |
| `billing_suspended` | 402 | workspace is `suspended`, role below admin, tier ≥ 1 |
| `audit_write_failed` | 503 | the owner-audit write failed in `enforce` mode |
| `authority_undetermined` | 503 | `decide()` could not read the tree |
| `enforce_blocked` | — | declared in the union; thrown nowhere in `one.ie/web/src` |

80 route files under `one.ie/web/src/pages/api/` return `authErrorResponse`. On
the `/api/ask` door itself, only the `grant-capability` receiver calls
`requireAuth`; every other receiver's refusal is the 200 + `outcome: "failure"`
envelope above.

The `/api/ask` door has its own version of the same distinction. When a key
lookup throws rather than answers, it returns HTTP 200 with:

```json
{"outcome":"failure",
 "result":{"error":"identity_undetermined",
           "reason":"could not read the caller identity — this is an upstream fault, not a denial",
           "retryable":true,"durability":"did-not-land"}}
```

Retry it. Do not re-authenticate.

## The four outcomes

| Outcome | Means | Produced by |
|---|---|---|
| `result` | the handler ran and returned, **or threw** | `dispatchReceiver` resolved; a throw becomes `result: {error, forbidden:false}` |
| `failure` | refused | auth-label refusal, `requiresAttestedCaller` refusal, `identity_undetermined`, a handler throw whose message starts with `forbidden`, `grant_failed` |
| `dissolved` | nothing ran, and nothing will | toxic entry tag, `no_handler`, `unknown_receiver` |
| `timeout` | no outcome arrived in the window | `awaitOutcomeHttp` only |

The `result`/`failure` split is not "worked / did not work". The route returns
`outcome: "failure"` only when `result.forbidden` is true; every other thrown
handler error is reported as `outcome: "result"` with an `error` key inside
`result`. Check `result.error` as well as `outcome`.

The `no_handler` and `unknown_receiver` dissolves carry `reason` and `hint`:

```json
{"outcome":"dissolved","signalId":"01a07689bf5b5e30ffc258f2",
 "receiver":"tutor:explain","reason":"unknown_receiver",
 "hint":"unknown receiver; no external signal processor configured"}
```

| `reason` | Means |
|---|---|
| `no_handler` | the receiver is declared but has no in-process handler |
| `unknown_receiver` | the name is in no registry |

The third dissolve — a toxic entry tag — carries neither. It returns
`{"outcome":"dissolved","signalId":…,"receiver":…}` and nothing more, so a
client that reads `reason` off every dissolved envelope gets `undefined`.

`timeout` is reachable only on the legacy external-processor path, which runs
only when `env.NANOCLAW_URL` is set. In this repo that variable appears only as a
commented hint at `one.ie/web/wrangler.toml:153`. Production is unset on the wire
too: an unknown receiver reaches the dissolved branch only when `NANOCLAW_URL` is
absent, and the `tutor:explain` capture above came back from production. Three of
the four outcomes are reachable on the shipped configuration.

## Validation errors

A payload that fails the receiver's Zod schema is refused before dispatch, with
HTTP 400 and a repair affordance. Source:
`one.ie/web/src/lib/receiver-envelope.ts` (`affordance`).

| Field | Present when |
|---|---|
| `error` | always — `"validation"` |
| `receiver` | always |
| `field` | the first issue has a path |
| `hint` | the first issue's message, or the missing-envelope hint |
| `expected` | the first issue carries an `expected` |
| `got` | the first issue carries a `received` |
| `example` | the receiver declares an example |

```bash
curl -s -X POST 'https://api.one.ie/ask/meta%3Aschema' \
  -H 'content-type: application/json' -d '{"data":{}}'
```

```json
{"error":"validation","receiver":"meta:schema","field":"receiver",
 "hint":"Invalid input","expected":"string"}
```

Validation runs **before** the auth-label gate. A `member`-labelled receiver
called with a malformed payload and no credential answers 400 `validation`, not
`forbidden`.

The wrong-level payload is a separate, earlier check:

```bash
curl -s -X POST 'https://api.one.ie/ask/meta%3Aschema' \
  -H 'content-type: application/json' -d '{"receiver":"world"}'
```

```json
{"error":"envelope_missing","receiver":"meta:schema",
 "hint":"Wrap your payload in a \"data\" key: POST { \"data\": { ... } }",
 "got":["receiver"]}
```

## CLI exit codes

`@oneie/cli` 4.0.7. Measured by running each case.

| Code | Case | Stream |
|---|---|---|
| 0 | success | JSON or `ok  k=v` on stdout |
| 0 | a 200 carrying `outcome: failure` or `dissolved` | body on stdout |
| 1 | unknown command | `error: unknown command '…'` on stderr |
| 1 | missing required argument | `error: missing required argument '…'` on stderr |
| 1 | any non-2xx from the API | `METHOD url → status` then the body, on stderr |
| 1 | `--data` is not valid JSON | `error: --data must be valid JSON` on stderr |
| 1 | `doctor` / `whoami` / `eval` failing a check | human text |
| 1 | an unhandled throw | stderr, or `{"ok":false,"error":…}` on stdout under `--json` |
| child | `one dev`, `one deploy`, `one ship` | the subprocess's own code |

Measured examples:

```bash
one ask meta:schema --data '{}'           # exit 1, 400 body on stderr
one ask meta:schema --data '{"receiver":"world"}'   # exit 0
one ask tutor:explain --data '{}'         # exit 0, dissolved body on stdout
```

The third row is the one to branch on: **a `failure` or `dissolved` outcome exits
0**, because the HTTP status was 200. Parse `outcome` from the body. See
[CLI](/docs/cli).

## SDK — thrown vs returned

`@oneie/sdk` 0.14.13. `ask()` throws on transport failure and returns on every
dispatched outcome.

| Status | Thrown | Class |
|---|---|---|
| 401, 403 | yes | `AuthError` |
| 400, 422 | yes | `ValidationError` |
| 429 | yes | `RateLimitError` |
| 408, 504 | yes | `TimeoutError` |
| client abort | yes | `TimeoutError`, status 408 |
| any other non-2xx | yes | `SubstrateError` |
| 200 | no | an `Outcome` |

All extend `SubstrateError`. Import from `@oneie/sdk/errors`.

A status-derived throw carries the message `` `${method} ${path} → ${status}` ``;
a client abort carries `` `${method} ${path} → client timeout after ${timeoutMs}ms` ``
instead, with no status in the string. `throwForStatus`
constructs every status-derived error with the message and status only, so
`ValidationError.body` and `RateLimitError.retryAfterMs` are always `undefined`
on an error raised this way. The response body is not attached — read it from the
HTTP layer if you need `field` and `expected`.

`errors.ts` also declares `DissolvedError`, `InsufficientCreditsError` and
`PaymentExceedsBudgetError`. Nothing in `packages/sdk/src` throws them.

Client-side behaviours that are not the server's:

| Setting | Default | Effect |
|---|---|---|
| request deadline | 8000 ms | aborts and throws `TimeoutError` |
| `retry.maxAttempts` | 1 | no retry unless configured |
| retryable statuses | 429, 502, 503, 504, no-status | everything else throws on the first attempt |

`ask(receiver, data, timeoutMs)` sends `timeoutMs` to the **server** (default
10000, clamped to 30000). It does not extend the client's 8000 ms deadline —
that is `new SubstrateClient({ timeoutMs })`. Asking for 20000 on a client with
default settings aborts at 8000.

### The `kind` field is derived, and it inverts

`client.ts:229-233` derives `kind` from which keys are present in the response,
not from the `outcome` field the server sent. The two disagree in both
directions. Measured against production:

```ts
// an unattested caller against a member-labelled receiver
await one.ask("tasks:list", { tag: "x" });
// wire outcome "failure"   → kind "result"

// a receiver in no registry
await one.ask("tutor:explain", {});
// wire outcome "dissolved" → kind "failure"
```

A `failure` envelope carries a `result` key, so `kind` becomes `result`. A
`dissolved` envelope carries neither `result` nor `dissolved`, so `kind` falls
through to `failure`. Branch on `outcome.outcome`, which is the server's own
field and is present on the returned object. Do not branch on `outcome.kind`
until this is fixed. See [SDK](/docs/sdk).

## MCP — tool errors

`@oneie/mcp` 0.6.1. A non-2xx from the API becomes a thrown `Error` whose message
embeds the response body, and the tool result carries `isError: true`.

Message format (`serve.ts:23`):

```
<METHOD> <path> → <status> — <body, truncated at 1000 chars>
```

The ` — <body>` half is present only when the response had a body.

So a `meta:schema` call with a missing `receiver` produces this message:

```
POST /api/ask/meta%3Aschema → 400 — {"error":"validation","receiver":"meta:schema","field":"receiver","hint":"Invalid input","expected":"string"}
```

The tool result is one text block holding `{"error": String(err)}`, with
`isError: true` (`serve.ts:101-106`):

```json
{"content":[{"type":"text","text":"{\"error\":\"Error: POST …\"}"}],
 "isError":true}
```

The embedded 400 keeps `error`, `field`, `expected`, `got`, `hint` and `example`,
so a model can repair the call from the error alone. The truncation is a hard
1000-character slice of the body string; a longer body is cut without an
ellipsis or a marker.

A 200 carrying `outcome: "failure"` or `outcome: "dissolved"` is **not** an
error: `apiCall` returns the parsed body and `isError` is absent. Read `outcome`
from the tool's text content. See [MCP server](/docs/mcp).

## Known limits

- `timeout` is unreachable on the shipped configuration. Proved on the wire: an
  unknown receiver reaches the dissolved branch only when `!env.NANOCLAW_URL`,
  and `tutor:explain` answered `unknown_receiver` from production, so
  `awaitOutcomeHttp` never runs there.
- The SDK's `Outcome.kind` disagrees with the wire's `outcome` in both
  directions, measured above.
- `RateLimit-*` headers appear on the dispatched-result 200 and the 429 only, not
  on `dissolved` 200s or on 4xx.
- The rate-limit store is a `Map` in worker isolate memory. The ceilings are per
  isolate, not a global guarantee across the edge.
- `/api/ask` does not pass `bearerVerified`, so a world key never reaches the
  120-per-key bucket on that door.
- Nothing about 429 or the `RateLimit-*` headers appears in
  [/openapi.yaml](/openapi.yaml). Zero matches for `429` or `RateLimit-Limit` in
  that file.
- `mark` and `warn` mix shapes: their bad-edge 400 is `problem+json` with a
  non-default `type`, their auth and self-deal refusals are plain JSON.
- The 401 `invalid_token` body points at `/api/oauth2/token` for re-exchange.
  That path is not among the 30 documented in the OpenAPI spec.
- A wrong HTTP method on the ask door answers with an HTML 404 page, and a
  percent-malformed receiver segment answers `500 text/plain` — `URI malformed`
  through `api.one.ie`, `error code: 1101` through `one.ie`. An agent that
  assumes JSON on every response from this surface will fail to parse either.
- `enforce_blocked` is a declared `AuthErrorReason` that nothing throws.