All documentation
Reference Verified 2026-09-06

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.

StatusBody shapeCauseFix
200outcome envelopereached dispatchread outcome, not the status
400problem+jsonempty receiver segmentcheck the path
400envelope_missingpayload posted at the top levelwrap it in data
400validationpayload failed the receiver’s Zod schemaread field, expected, example
401invalid_tokena one-/osk_ token matched no rowmint a new key
402unauthorized + reasongrant-capability only — workspace over limit or suspendedsettle billing
403problem+jsonisGatewayRequest refused the callersend a Bearer, or call api.one.ie
403unauthorized + reasongrant-capability only — no mint_capabilityask an owner
404HTMLany method other than POST — the route exports POST onlyuse POST
404{"error":"Not found"}on api.one.ie, a path outside its 14 substrate prefixesdrop the /api prefix, or call one.ie
429rate_limitedwindow ceiling exceededwait Retry-After seconds
500text/plaina percent-malformed receiver segmentpercent-encode the colon as %3A, never a bare %
503unauthorized + reasongrant-capability only — the authority tree could not be readretry

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:

RouteStatusBody
POST /api/signal/:receiver401{"error":"unauthorized"}
POST /api/mark/:edge, POST /api/warn/:edge401{"error":"unauthorized","reason":"no_session"}
POST /api/mark/:edge, POST /api/warn/:edge400problem+json, type: https://one.ie/errors/bad-edge
POST /api/mark/:edge, POST /api/warn/:edge403{"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.

ShapeContent-TypeEmitted by
RFC 7807 problemapplication/problem+jsonroute-edge transport refusals; the mark/warn bad-edge 400
Plain JSON 4xxapplication/jsonenvelope, validation, token and rate-limit guards; mark/warn auth
200 + outcomeapplication/jsoneverything 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.

401403
Meaningthe token matched no recordthe caller is real but may not do this
Emitted byverifyWorldKey found no rowisGatewayRequest refused the request shape
Shape{"error":"invalid_token",…}problem+json, title forbidden
Fixmint a new keysend 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.

ConstantValueBucket
RATE_LIMIT_WINDOW_MS60_000the window
RATE_LIMIT_PER_KEY120a verified bearer or session actor
RATE_LIMIT_PER_IP300source IP
RATE_LIMIT_MEDIA_PER_IP20/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.

DecisionStatusReason string
grantedvia: controls | rung | delegation
determined no403action_not_permitted
could not read503authority_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.

ReasonStatusThrown when
no_session401no session, or no world_keys row for the key
action_not_permitted403tierOf(role) < minTier(action), or a world key reaching across groups
ip_not_allowed403the group policy’s ip_allowlist excludes the caller
method_not_allowed403the caller’s auth provider is not in allowed_methods
billing_over_limit402workspace is over_limit/floored, role below admin, write action
billing_suspended402workspace is suspended, role below admin, tier ≥ 1
audit_write_failed503the owner-audit write failed in enforce mode
authority_undetermined503decide() could not read the tree
enforce_blockeddeclared 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

OutcomeMeansProduced by
resultthe handler ran and returned, or threwdispatchReceiver resolved; a throw becomes result: {error, forbidden:false}
failurerefusedauth-label refusal, requiresAttestedCaller refusal, identity_undetermined, a handler throw whose message starts with forbidden, grant_failed
dissolvednothing ran, and nothing willtoxic entry tag, no_handler, unknown_receiver
timeoutno outcome arrived in the windowawaitOutcomeHttp 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"}
reasonMeans
no_handlerthe receiver is declared but has no in-process handler
unknown_receiverthe 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).

FieldPresent when
erroralways — "validation"
receiveralways
fieldthe first issue has a path
hintthe first issue’s message, or the missing-envelope hint
expectedthe first issue carries an expected
gotthe first issue carries a received
examplethe 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.

CodeCaseStream
0successJSON or ok k=v on stdout
0a 200 carrying outcome: failure or dissolvedbody on stdout
1unknown commanderror: unknown command '…' on stderr
1missing required argumenterror: missing required argument '…' on stderr
1any non-2xx from the APIMETHOD url → status then the body, on stderr
1--data is not valid JSONerror: --data must be valid JSON on stderr
1doctor / whoami / eval failing a checkhuman text
1an unhandled throwstderr, or {"ok":false,"error":…} on stdout under --json
childone dev, one deploy, one shipthe 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.

StatusThrownClass
401, 403yesAuthError
400, 422yesValidationError
429yesRateLimitError
408, 504yesTimeoutError
client abortyesTimeoutError, status 408
any other non-2xxyesSubstrateError
200noan 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:

SettingDefaultEffect
request deadline8000 msaborts and throws TimeoutError
retry.maxAttempts1no retry unless configured
retryable statuses429, 502, 503, 504, no-statuseverything 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

  • 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. 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/plainURI 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.