Errors and limits
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.
Reading this as an agent? The same page in raw markdown: /docs/errors.md
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:
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.
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.
{"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
{"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
{"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.
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:
{"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:
{"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:
{"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 |
curl -s -X POST 'https://api.one.ie/ask/meta%3Aschema' \
-H 'content-type: application/json' -d '{"data":{}}'
{"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:
curl -s -X POST 'https://api.one.ie/ask/meta%3Aschema' \
-H 'content-type: application/json' -d '{"receiver":"world"}'
{"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:
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.
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:
// 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.
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):
{"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.
Known limits
timeoutis unreachable on the shipped configuration. Proved on the wire: an unknown receiver reaches the dissolved branch only when!env.NANOCLAW_URL, andtutor:explainansweredunknown_receiverfrom production, soawaitOutcomeHttpnever runs there.- The SDK’s
Outcome.kinddisagrees with the wire’soutcomein both directions, measured above. RateLimit-*headers appear on the dispatched-result 200 and the 429 only, not ondissolved200s or on 4xx.- The rate-limit store is a
Mapin worker isolate memory. The ceilings are per isolate, not a global guarantee across the edge. /api/askdoes not passbearerVerified, 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. Zero matches for429orRateLimit-Limitin that file. markandwarnmix shapes: their bad-edge 400 isproblem+jsonwith a non-defaulttype, their auth and self-deal refusals are plain JSON.- The 401
invalid_tokenbody points at/api/oauth2/tokenfor 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 malformedthroughapi.one.ie,error code: 1101throughone.ie. An agent that assumes JSON on every response from this surface will fail to parse either. enforce_blockedis a declaredAuthErrorReasonthat nothing throws.