Contract: tenant-facing error catalogue
| Version | 0.6 (F7, #346: RENOX_TENANT_SUSPENDED). 0.5 (F7, #318: RENOX_STORAGE_QUOTA, RENOX_STORAGE_TYPE, RENOX_STORAGE_TOO_LARGE, RENOX_STORAGE_KEY, RENOX_STORAGE_NOT_FOUND, RENOX_IMAGE_RATE, RENOX_WEBHOOK_SIGNATURE, RENOX_AI_QUOTA, RENOX_AI_UNAVAILABLE, RENOX_PDF_LIMIT for ops.md 1.7.0). 0.4 (F5, #272: RENOX_REALTIME_LIMIT, RENOX_REALTIME_BACKPRESSURE, RENOX_TOOL_INPUT, RENOX_TOOL_SCOPE, RENOX_TOOL_CONFIRM, RENOX_POLICY_INVALID; RENOX_FORBIDDEN and RENOX_DB_SCOPE completed for ops.md 1.5.0). 0.3 (F6, #251). New codes are added as rows (minor). Renaming or removing a code, or changing its HTTP status, is breaking. History: "Changelog" at the bottom |
| Producers | crates/renox-gateway (P3), crates/renox-cell (P5), crates/renox-runtime (P1), crates/renox-data (P2); from F3 control/api (P12, deploy-api.md) and cli/ (P11) |
| Consumers | sdk/ (P10) error classes; cli/ (P11) and the dashboard (P13), which quote these rows; the public docs; tenants' own API clients |
| Related | ops.md section 13 (which codes ops throw and which the platform answers with), meter.md (status), docs/23-failure-modes.md (when each failure happens) |
| Checker | bun run docs/contracts/validate.ts: every code used in ops.md, deploy-api.md, schemas/deploy-api.yaml and docs/23-failure-modes.md has exactly one row here, and ctx.d.ts uses exactly the codes of ops.md section 13 |
Everything a tenant or an end user sees when the platform fails, refuses, or stops something is one of the codes below. It is never a stack trace, a file path, a hostname or IP of ours, an SQL statement, or a secret (docs/23-failure-modes.md rule 2; docs/24-threat-model.md). This file is the superset: ops.md defines the codes that cross the op boundary, and this catalogue adds the gateway, deploy, and availability codes. It is what the dashboard, the CLI, and the public docs quote.
1. Response body
Function (API) routes
Every error response the platform produces on a function route has a JSON body:
{ "error": { "code": "RENOX_CPU_LIMIT", "request_id": "01JBQ3Z7MA0000000000000001", "message": "The request used more CPU time than its limit." } }
Content-Type: application/json; charset=utf-8,Cache-Control: no-store, andx-renox-request-idwith the same id as in the body.codeis a code from section 3 whose HTTP status is not "—".messageis the fixed English text from the "Message" column, never built from tenant code, request input, or an internal error.request_idlets a developer find the full detail in their logs.Retry-Afteris set where the row says so.- The TypeScript shape is
ErrorResponseBodyinctx.d.ts. - The body never contains
details, a stack, or the code of a thrown error. An uncaughtRENOX_DB_AUTHreaches the client asRENOX_INTERNAL; the original code goes to the tenant's log only.
A Response that tenant code returns itself (for example a 404 or a 400 built by the handler) is passed through untouched. Only platform-produced errors follow this format.
Page (HTML) routes, F2
A platform error on a page route (artifact.md section 3, "Page routes") is answered with HTML, chosen in this order:
- The tenant's
pages/_error.html: the template withkind: "error"inmanifest.templates. - The platform default page, if the app has no error template, if rendering it fails, or if it hits a limit itself: a static HTML page with the HTTP status, the message, and the request id, and no tenant content. It is never rendered with tenant code or tenant templates. It is the same page the gateway already sends for its own HTML errors (413), and the page every collateral request of a recycled isolate gets (
ops.mdsection 10).
Inputs. The error template receives exactly four variables, and nothing else:
| Variable | Type | Value |
|---|---|---|
status | integer | The HTTP status from section 3 |
code | string or null | The code from section 3; null only for the 404 page below |
request_id | string | Same as x-renox-request-id |
message | string | The fixed English text from the "Message" column |
No loader runs: pages/_error.ts is a build error (renox check), and the template sees no ctx, no session, no loader data of the failed request, no params, and no detail of the cause (limit values, stack, SQL). Layouts and partials may be used, so the page can look like the rest of the app.
Render budget: a fixed error-page budget, decided here (#93 proposed "a tiny fixed fuel budget", version 0 of this file said "the normal render limits"; the fixed budget wins). The error page renders exactly when a request has already failed, often under load (RENOX_CPU_LIMIT, RENOX_BUSY). With the normal limits, a heavy _error.html would double the cost of every failure, and a flood of failures would become a flood of renders. So the error template gets the same small budget on every tier, independent of what the failed request used:
| Limit | Value |
|---|---|
| Fuel | 10,000 units proposed |
| Output | 64 KiB |
Include and extends depth | 4 |
| Render time | 10 ms |
Hitting any of them falls back to the platform default page, with the original status and code (never RENOX_RENDER_LIMIT), and logs one RENOX_RENDER_LIMIT line with template: "pages/_error". #93 confirms the fuel number with a test against renox-template's fuel unit (#38) and records it here as a patch. What the error render uses is added to the failed invocation's render_fuel and render_ms (meter.md).
404 page. A GET or HEAD that matches no route, on an artifact with templates, whose Accept header prefers text/html, gets the template with kind: "not_found" (pages/_404.html), with status: 404, code: null, the request id, and the message "Page not found.", under the same fixed budget. Without a _404.html it falls back to _error.html, then to the default page. A 404 like this has no meter record (no tenant code runs, meter.md section 1). A Response with status 404 returned by load is a tenant response and passes through untouched.
When the route kind is not known
The gateway can fail a request before a route is matched (RENOX_BODY_TOO_LARGE, RENOX_HOST_MISMATCH, RENOX_UNAVAILABLE, and from 0.6 RENOX_TENANT_SUSPENDED). It answers with the platform default page when the request's Accept header prefers text/html, and with the JSON body otherwise.
2. What is logged
Every row below produces one line in the tenant's log sink (ops.md section 4) unless it says otherwise. The line carries the standard fields (ts, tenant_id, deployment_hash, request_id, module, level) plus code and the fields listed in the row. These lines are what renox logs and the dashboard show the developer. Secret values are redacted from every field (ops.md section 3). Fields that would reveal our infrastructure (node names, internal addresses, file paths) go only to the platform's operator logs, never to the tenant sink.
Thrown codes (section 3, "Thrown into tenant code") are logged when they end a request uncaught, as part of the RENOX_INTERNAL line (cause_code, cause_message, redacted stack). If tenant code catches them, logging is up to the tenant. Exceptions: RENOX_FETCH_BLOCKED is always logged by the platform (docs/24-threat-model.md rule 4), and so is RENOX_DB_AUTH, because a refused table is a security-relevant event even when caught.
3. Catalogue
Columns: HTTP = status of the platform response ("—" = never a response by itself: thrown into tenant code, and 500 RENOX_INTERNAL if uncaught). Message = the fixed text in the response body and on the default page. Logged = fields added to the tenant log line. What to do = the guidance the dashboard, CLI, and docs give the developer.
Request limits and platform responses
| Code | Phase | HTTP | When it happens | Message (end user) | Logged (tenant developer) | What to do | Refers to |
|---|---|---|---|---|---|---|---|
RENOX_CPU_LIMIT | F1 | 503 | The request's JavaScript used more CPU time than the tier limit (50 ms free / 500 ms paid), or module evaluation exceeded its budget. The isolate is recycled, and its other in-flight requests end with this code too | The request used more CPU time than its limit. | route, cpu_ms, limit_ms, phase (request / module_load) | Move heavy work out of the request path (precompute, paginate, cache in ctx.db), check for loops over unbounded input, or upgrade the tier | ops.md §10; docs/04-isolation-security.md "Per-tenant limits"; failure mode "Infinite loop / CPU over limit" |
RENOX_OOM | F1 | 503 | The isolate reached its V8 heap limit (128 MB / 512 MB). The isolate is recycled, and its other in-flight requests end with this code too | The request ran out of memory. | route, peak_heap_bytes, limit_bytes | Stream large bodies instead of buffering them, limit query result sizes, avoid unbounded global caches | ops.md §10; failure mode "Unbounded allocation" |
RENOX_DEADLINE | F1 | 504 | The request's wall-clock deadline (30 s / 60 s) passed. Also thrown into pending ops of that request | The request took too long. | route, wall_ms, limit_ms, pending_ops (op names) | Look for slow fetch targets or long database work; set timeouts on outbound calls with ctx.deadline.remainingMs() | ops.md §5, §10; failure mode "Handler never resolves" |
RENOX_INTERNAL | F1 | 500 | The handler threw (including an uncaught thrown code below), returned something that is not a Response, module evaluation failed, or a route's export is missing | Internal error. Quote the request id when reporting it. | route, cause_code (if the cause was a RenoxError), cause_message, stack (redacted), phase | Read the logged cause and stack; catch expected errors and return your own Response | ops.md §6, §11; failure mode "Handler throws" |
RENOX_MODULE_EVAL | F2 | 500 | A route module evaluated on its first request (ops 1.4.0, lazy route modules) threw while evaluating, or lacks the route's export. Only that route fails; the isolate stays up and the failure is cached until the isolate is replaced | Internal error. Quote the request id when reporting it. | route, module, cause_message, stack (redacted); logged once per isolate | Fix the module's top-level code (it now runs inside the first request, under its deadline and limits); run renox check, which refuses a missing export before deploy | ops.md §11, §13; failure mode "Route module fails to evaluate on first use" |
RENOX_BUSY | F1 | 503, Retry-After: 1 | All concurrency slots of the deployment (50 / 500) stayed busy until the request's deadline | The service is busy. Try again shortly. | route, concurrency_limit, queued_ms (one summary line per second while it lasts, not one per request) | Find slow handlers that hold slots; raise the tier for more concurrency | ops.md §10; failure modes "Too many concurrent requests", "Flood from many IPs on one tenant" |
RENOX_BODY_TOO_LARGE | F1 | 413 | The request body exceeded the limit for the route kind (1 MB default) at the gateway, before any tenant code ran | The request body is too large. | limit_bytes, received_bytes (not metered, meter.md §1) | Upload large files through the storage module; raise the limit per route when available | ops.md §6; failure mode "Request body over limit" |
RENOX_RENDER_LIMIT | F2 | 500 | Template rendering ran out of fuel, iterations, output size, include depth, or render time | The page could not be rendered. | template, line, limit (fuel / iterations / output / depth / time) | Paginate the data passed to the template; reduce nested loops | docs/08-template-engine.md; failure mode "Template nested loops / huge output" |
RENOX_CSRF | F2 | 403 | A non-GET page action arrived without a valid CSRF token | The request could not be verified. Reload the page and try again. | route, action, reason (missing / mismatch) | Submit forms through the generated markup or htmx, which send the token; exempt webhook routes are automatic | docs/04-isolation-security.md "Web-layer protections"; failure mode "CSRF token missing or wrong" |
RENOX_FORBIDDEN | F5 | 403 | A Cedar policy denied the action, or nothing permitted it (fail closed): the route guard before any handler, ctx.authz.require (thrown; uncaught it still ends the request with 403, not 500), a realtime.subscribe (thrown in open; the connection closes with 1008), the tool check before run (a JSON-RPC error on /mcp), or explain outside dev mode | You do not have permission to do this. | action, resource (type and id), reason (deny / unknown_action / missing_attribute / evaluation_error / eval_limit / dev_only), policy (dev only), principal (id only); an audit row in renox__authz_audit when the action is sensitive | Check the role assignments and policies for the action; renox authz explain names the deciding policy in dev; a missing_attribute means the resource object lacks a declared [authz.entities] attribute | ops.md §17; docs/11-authorization.md; failure mode "Policy denies" |
RENOX_REALTIME_LIMIT | F5 | 503, Retry-After: 5 (at the upgrade); — on an open connection | A ws or sse upgrade over the tenant's or the user's connection cap (503, no tenant code runs); on an open connection: subscribe over the topics per connection, publish or send with a message over 64 KB (thrown), or an inbound flood (closed with 1008) | Too many realtime connections. Try again shortly. | reason (tenant / user / topics / message_size / flood), route; a platform close is logged with code and reason | Close idle connections on the client; subscribe to fewer topics per connection; send smaller messages; raise the tier for more connections | ops.md §18, "Limits"; failure mode "WebSocket message flood / oversized frame" |
RENOX_REALTIME_BACKPRESSURE | F5 | — (close 1008) | A connection's outbound queue (256 messages) was full: the fan-out closed the slow consumer with 1008, or send to it rejected (thrown) | — (the client sees close code 1008 and reconnects; Last-Event-ID replays the recent events on SSE) | conn, route, queued | Nothing in the handler: a slow client is closed by design; reduce the message rate per topic, or coalesce updates into fewer, larger fragments | ops.md §18, "Limits"; failure mode "WebSocket message flood / oversized frame" |
RENOX_HOST_MISMATCH | F2 | 421 | The TLS SNI name and the Host (or :authority) both resolve in the route table, to different tenants. Refused before routing; no cell is touched | The request was sent to the wrong host. | Not logged to any tenant sink, since the two names belong to different tenants. The operator log records both host names | Nothing in the app. Clients must send the same name in SNI and Host; browsers retry a 421 on a new connection by themselves | artifact.md §5; docs/24-threat-model.md "Host/SNI confusion" |
RENOX_TENANT_SUSPENDED | F7 | 503, Retry-After: 3600 | The hostname's route entry is suspended (artifact.md 1.5 section 5): the tenant is suspended for billing or abuse (deploy-api.md 0.5.1 sections 5, 20, 22). The gateway answers before routing with the platform's neutral page, which shows the tenant's display_name and nothing else; no cell is touched | This service is temporarily unavailable. | Not logged to the tenant sink (no request reaches the tenant); the operator log counts it per tenant | The owner pays the open invoice, upgrades, or answers the abuse ticket; the dashboard shows why | artifact.md §5, deploy-api.md §5 |
RENOX_UNAVAILABLE | F3 | 503, Retry-After: 5 | The worker cannot load the deployment: artifact not cached and object storage or the control plane is unreachable, or the cell is marked unhealthy. In the deploy API: no healthy node can take an activation, or Postgres or object storage is unreachable | The service is temporarily unavailable. | reason (artifact_unavailable / cell_unhealthy); deploy API: the deployment's last_error.stage = no_worker | Nothing in the app; the platform retries and alerts. Check the status page; retry the activation | failure modes "Control plane unreachable from the worker", "Object storage unreachable"; deploy-api.md §6 |
Thrown into tenant code
| Code | Phase | HTTP | When it happens | Message (end user) | Logged (tenant developer) | What to do | Refers to |
|---|---|---|---|---|---|---|---|
RENOX_FETCH_BLOCKED | F1 | — | fetch to a host that is not on the allowlist, with the wrong scheme or port, resolving to a blocked address, over 5 redirects, or with a forbidden method | — | Always logged: host, reason (never the resolved IP) | Add the host to [fetch] allow in renox.toml; private and metadata addresses cannot be allowed | ops.md §2; docs/24-threat-model.md SSRF rows; failure mode "fetch to blocked destination" |
RENOX_FETCH_FAILED | F1 | — | fetch failed for a network reason: DNS, connect, TLS, reset, redirect mode error, too many concurrent fetches, or too many headers | — | When uncaught: host, reason | Retry with backoff for transient reasons; check the remote certificate for tls | ops.md §2 |
RENOX_FETCH_EGRESS_LIMIT | F1 | — | Outbound bytes of the request (sent + received over all fetches) passed 10 MB free / 100 MB paid; the stream is aborted | — | When uncaught: host, limit_bytes | Request smaller ranges or pages from the remote API; stream instead of downloading whole files | ops.md §2; failure mode "fetch response too large / too slow" |
RENOX_DB_AUTH | F1 | — | The SQLite authorizer refused a table, view, ATTACH, PRAGMA, or function outside the module's prefix | — | Always logged: table, action, module | Use only your module's <module>__* tables and declared <dep>__public_* views | ops.md §1; docs/06-data.md; failure mode "SQL touching another module's table" |
RENOX_DB_BUSY | F1 | — | The write lock stayed taken for the busy wait (2 s, or the remaining deadline if shorter) | — | When uncaught: wait_ms | Keep write transactions short; do not fetch inside db.transaction | ops.md §1 |
RENOX_DB_TIMEOUT | F1 | — | A statement ran longer than 1 s (or the remaining deadline); the statement is interrupted and its transaction rolled back | — | When uncaught: elapsed_ms (the SQL text is not logged) | Add an index, add limit, avoid unbounded WITH RECURSIVE | ops.md §1; failure mode "Runaway query" |
RENOX_DB_QUOTA | F1 | — | The tenant file reached its size quota (100 MB free / 10 GB paid); writes fail, reads continue | — | When uncaught: quota_bytes; also a dashboard warning | Delete old data or upgrade the tier | ops.md §1; failure modes "Database file over quota", "Disk full on node" |
RENOX_DB_CONSTRAINT | F1 | — | A unique, foreign key, not-null, check, or primary-key constraint failed | — | When uncaught: kind | Expected in normal operation: catch it and return a 409 or 400 | ops.md §1 |
RENOX_DB_SQL | F1 | — | Syntax error, unknown table or column, or another SQLITE_ERROR | — | When uncaught: SQLite's message (no file path) | Fix the statement; run renox check, which lints SQL | ops.md §1 |
RENOX_DB_PARAM | F1 | — | A parameter has an unsupported type or value (undefined, NaN, object, bigint outside i64, lone surrogate), the count does not match, named parameters were used, or the SQL text or parameter count is over its limit | — | When uncaught: index of the parameter, reason | Pass null instead of undefined; use positional ? parameters | ops.md §1, §12 |
RENOX_DB_COLUMN_NAME | F1 | — | Two result columns have the same name | — | When uncaught: column | Alias the columns (select a.id as a_id, b.id as b_id) | ops.md §1 |
RENOX_DB_MULTI_STATEMENT | F1 | — | More than one statement was passed in one call | — | When uncaught | Run one statement per call; group them in db.transaction | ops.md §1 |
RENOX_DB_RESULT_LIMIT | F1 | — | A query returned more than 10,000 rows or 8 MB | — | When uncaught: limit (rows / bytes) | Paginate with limit and a cursor | ops.md §1 |
RENOX_DB_TX_STATE | F1 | — | A transaction was begun while one was already open on the request, or a handle was used after commit or rollback | — | When uncaught | Do not nest db.transaction; savepoints arrive in F4 | ops.md §1 |
RENOX_DB_UNAVAILABLE | F6 | 503, Retry-After: 30 | The tenant's SQLite file failed its integrity check on open or is being restored | The service is temporarily unavailable. | reason (integrity / restoring) | Nothing in the app; the platform restores from the replica and alerts | failure mode "SQLite file corrupted, WAL unreadable" |
RENOX_DB_SCOPE | F5 | — | Module code ran a statement on an owned table ([authz.ownership]) without db.scoped(), a scoped statement lacked the value the table needs, an INSERT set a different owner, or the rewriter could not prove the statement (an owned table in a subquery or CTE, INSERT … SELECT from an owned table) | — | Always logged, like RENOX_DB_AUTH: table, module, reason (unscoped / scope_missing / owner_mismatch / unprovable); never the SQL | Use db.scoped({ user_id, org_id }) for owned tables; pass the scope the table's owner and org columns need; keep owned tables out of subqueries, or read them in a separate statement | ops.md §17, "db.scoped()"; docs/11-authorization.md; failure mode "Unscoped query on an owned table" |
RENOX_NO_REQUEST | F1 | — | An op was called outside a live request: at module top level, or from a timer or promise that outlived its request | — | When uncaught at top level: as RENOX_INTERNAL with phase: module_load; later: one line with op | Do I/O inside handlers; do not keep work running after the response | ops.md §0 |
RENOX_SECRETS_CONSUMED | F1 | — | op_secrets_get_all was called a second time. Unreachable from tenant code through the public API | — | op | Read secrets from ctx.secrets | ops.md §3 |
RENOX_MODULE_UNDECLARED | F4 | — | Module code called a service of a module it does not list in [dependencies], ran a hook point it does not export, or emitted an event it does not export | — | Always logged: module (the caller), target (the module, hook, or event) | Add the dependency (and the [imports] entry) to module.toml, then renox install; renox check finds it before deploy | ops.md §15.2–15.4; manifest.md MF7; docs/24-threat-model.md "Module calling a service or hook it did not declare" |
RENOX_MODULE_SERVICE | F4 | — | A service, hook filler, or in-memory event handler of another module threw; the enclosing transaction was rolled back | — | When uncaught: module, service / hook / event, cause_code; the callee's own error is logged with the callee's module | Read the callee module's log line for the cause; catch it to answer the user, knowing the transaction is gone | ops.md §15.2; failure mode "Module service throws" |
RENOX_EVENT_PAYLOAD | F4 | — | ctx.events.emit got a payload that does not match the event's exported schema, is not JSON-serialisable, or is over 64 KiB | — | When uncaught: event, reason (schema / not_json / too_large); never the payload | Send what the event's payload schema in module.toml says; put large data in a table and send its id | ops.md §15.4; manifest.md section 2 |
| RENOX_JOB_NOT_FOUND | F6 | — | op_jobs_retry or op_jobs_cancel named an id that is not a job row of this tenant | — | When uncaught: id, op | Reload the list; the row was cancelled or pruned | ops.md §20 (1.6.0) | | RENOX_JOB_STATE | F6 | — | op_jobs_retry on a row that is not dead, or op_jobs_cancel on a running row | — | When uncaught: id, state, op | Retry only dead letters; wait for a running job to end before cancelling | ops.md §20 (1.6.0) | | RENOX_STORAGE_QUOTA | F7 | — | op_storage_put would take the tenant past its storage bytes | — | When uncaught: limit_bytes, used_bytes | Delete files or move to the paid tier | ops.md §21 (1.7.0) | | RENOX_STORAGE_TYPE | F7 | — | A content type outside [storage] allowed_types, one the sniffed bytes contradict, or SVG that was not sanitised | — | When uncaught: declared, sniffed | Upload a file of an allowed type; send SVG through the storage module's sanitiser | ops.md §21 (1.7.0) | | RENOX_STORAGE_TOO_LARGE | F7 | — | One file is over the tier's per-file limit | — | When uncaught: limit_bytes | Split or compress the file | ops.md §21 (1.7.0) | | RENOX_STORAGE_KEY | F7 | — | A key outside the pattern, with a .. segment, or under another module's prefix | — | When uncaught: key (truncated to 64 characters) | Use the module's own keys | ops.md §21 (1.7.0) | | RENOX_STORAGE_NOT_FOUND | F7 | — | No file under that key | — | When uncaught: key (truncated) | Check the key | ops.md §21 (1.7.0) | | RENOX_IMAGE_RATE | F7 | 429 | The tenant's image transform rate is spent (Retry-After) | Too many image requests. Try again shortly. | route | Use the allowed widths; variants are cached after the first request | ops.md §21 (1.7.0) | | RENOX_WEBHOOK_SIGNATURE | F7 | 401 | A webhook's signature is missing or wrong, or its timestamp is outside the tolerance; the handler does not go on | The request could not be verified. | provider, reason (missing / mismatch / expired); never the body or a header value | Check the secret WEBHOOK_<PROVIDER>_SECRET and the provider's endpoint settings | ops.md §22 (1.7.0) | | RENOX_AI_QUOTA | F7 | 429 | The tenant's monthly AI tokens are spent | The service is temporarily unavailable. | used_tokens, limit_tokens | Wait for the next month or move to the paid tier; use a key of your own ([ai] mode = "direct") | ops.md §23 (1.7.0) | | RENOX_AI_UNAVAILABLE | F7 | 503 | The model provider (or the platform gateway) did not answer or failed | The service is temporarily unavailable. | provider, status; never the provider's body | Retry later, or handle the error in the app | ops.md §23 (1.7.0) | | RENOX_PDF_LIMIT | F7 | — | A PDF would be over 200 pages or 20 MB | — | When uncaught: pages, bytes | Page the report, or produce several files | ops.md §24 (1.7.0) |
RENOX_DB_UNAVAILABLE has an HTTP status because it fails the request before any tenant code runs (the cell cannot open the file). When it happens during a request it is thrown like the other RENOX_DB_* codes.
App MCP (/mcp, F5)
Answered as JSON-RPC errors by the cell's /mcp endpoint (ops.md section 19): the HTTP status below and a body { "jsonrpc": "2.0", "id": …, "error": { "code": -32000, "message": <the fixed message>, "data": { "code": "RENOX_…", "request_id": … } } }. None reaches tenant code; none writes a meter record (the tool's run did not start). A denied Cedar action on a tool is RENOX_FORBIDDEN (above), in the same JSON-RPC form.
| Code | Phase | HTTP | When it happens | Message (end user) | Logged (tenant developer) | What to do | Refers to |
|---|---|---|---|---|---|---|---|
RENOX_TOOL_INPUT | F5 | 400 | tools/call arguments fail the tool's declared input schema | The tool input is not valid. | tool, details [{ field, reason }] (never the argument values); client.name | The agent reads details and retries; the tool's input schema is what tools/list published | ops.md §19 |
RENOX_TOOL_SCOPE | F5 | 403 | The agent token's scopes do not cover the tool (tools:* or tools:<name>) | The token does not allow this tool. | tool, token (id only), client.name | The user grants the tool's scope to the agent in the auth module's consent screen; tools/list shows only covered tools | ops.md §19; docs/20-ai-agents-mcp.md section 5 |
RENOX_TOOL_CONFIRM | F5 | 409 | A confirm: true tool was called without the confirmation token of its preview, or with one that expired (10 minutes), was already used, or belongs to other arguments or another agent token | The action needs a fresh confirmation. | tool, reason (missing / expired / replayed / mismatch), client.name | Call the tool without a confirmation to get a preview and a new token, show the preview to the user, then call again with the token and the same arguments | ops.md §19, "Confirmation"; docs/20-ai-agents-mcp.md section 2, "Confirmation" |
Client events (dev overlay only)
| Code | Phase | HTTP | When it happens | Message (end user) | Logged (tenant developer) | What to do | Refers to |
|---|---|---|---|---|---|---|---|
RENOX_CLIENT_EVENTS_DROPPED | F2 | — | The gateway dropped browser events sent to /__renox/events: body or batch over its limit, per-IP or per-tenant rate, sampling, or the daily cap. The endpoint still answers 204, so the browser never sees it | — (production never shows it) | One summary line per minute while it lasts: reason (body_size / batch_size / ip_rate / tenant_rate / sampled / daily_cap / invalid), dropped (count). Shown in the dev overlay's "Browser" tab | Fix the error that floods the endpoint; lower extra sizes; disable client events with [client] events = false | client-events.md; failure mode "Client-event flood (bug or abuse)" |
Deploy (control plane and CLI)
| Code | Phase | HTTP | When it happens | Message (end user) | Logged (tenant developer) | What to do | Refers to |
|---|---|---|---|---|---|---|---|
RENOX_DEPLOY_REJECTED | F3 | 422 (deploy API) | An uploaded artifact failed validation: any of rules V1–V16 (bundle over 20 MB, route conflict, unknown permission key, path escape, hash mismatch, ...) or the upload rules T1–T8 (link or device entry, over 100 MiB or 10,000 entries, compression ratio over 50:1, extraction over 30 s, ...) | The artifact failed validation. | Deployment event with rule (V1–V13, T1–T8), path or field, reason; the deployment state becomes rejected | Run renox check locally, which applies the same rules, and fix what it reports | artifact.md §6; deploy-api.md §4; failure modes "Bundle over 20 MB, route conflict, unknown permission key", "Tarball decompression bomb / too many files" |
RENOX_ACTIVATION_FAILED | F3 | 422 (deploy API) | Activation stopped on the owning node: the artifact could not be fetched, a migration file failed, module evaluation failed, the probe answered 5xx or hit a limit, or the node did not answer within 60 s. Nothing switched; the previous deployment keeps serving | The deployment could not be activated. | Deployment event with stage (fetch / load / migrate / probe / timeout), module, file, sqlite_error, http_status; the deployment returns to validated with last_error | Fix the failing migration file or handler and deploy again; applied migration files are not re-run | deploy-api.md §6; failure modes "Migration fails half-way during activation", "New deployment fails its first request" |
RENOX_POLICY_INVALID | F5 | 422 (deploy API); renox check exit code 1 | A Cedar policy file (policies/app/*.cedar, a module's policies/*.cedar) does not parse, or does not validate against the merged schema (an unknown action or entity type, a type error), or the merged schema has a duplicate entity type or action (artifact.md V24); nothing is loaded | The policies failed validation. | Deployment event with rule: "V24", path (the policy file), reason (the parser's or validator's message, which names policy text the tenant wrote, never ours); the deployment state becomes rejected | Run renox check, which prints the same message with the file and line; declare the action in [authz] actions or the entity in [authz.entities] / policies/schema.cedarschema | artifact.md §3, "Policies", §6 V24; ops.md §17; docs/24-threat-model.md rule 3 (fail closed) |
RENOX_CLI_WORKER_UNVERIFIED | F3 | — (CLI, exit code 3) | The CLI refused to run a renox-worker binary whose sha256 differs from the pinned release or whose sigstore signature or provenance does not verify | — | CLI output only: expected_sha256, actual_sha256, the binary path on the developer's own machine | Re-download the worker (renox dev fetches the pinned release) or pass a binary from an official release; never disable the check | docs/24-threat-model.md F, "Tampered release"; #129 |
Control API (deploy API, F3 and F6)
Answers of control/api to tenants, nodes and operators (deploy-api.md §1, §2, §12). The body is the JSON of section 1 plus, where deploy-api.md §12 says so, a details list; it never contains a secret value, a token, a server path, or an internal address. These codes are not thrown into tenant code and are not written to the tenant log sink; mutating requests are audit-logged instead (deploy-api.md §2).
| Code | Phase | HTTP | When it happens | Message (end user) | Logged (tenant developer) | What to do | Refers to |
|---|---|---|---|---|---|---|---|
RENOX_VALIDATION | F3 | 400 | A request body, parameter or header is malformed: bad JSON, an unknown field, a value outside its pattern or range, a secret value under 8 bytes, a non-.map file in a sourcemap upload (422 there) | The request is not valid. | Audit row with the status; details { field, reason } in the response | Fix the field named in details; the CLI validates before sending | deploy-api.md §1, §9 |
RENOX_UNAUTHENTICATED | F3 | 401, WWW-Authenticate | No token, an unknown, expired or revoked token, or a token of the wrong kind | Authentication is required. | Not logged per request; repeated failures per IP are counted | renox login again or create a new token; check the token has not expired | deploy-api.md §2; docs/24-threat-model.md E, "Leaked CLI or CI token" |
RENOX_TOKEN_SCOPE | F3 | 403 | The token is valid but lacks the operation's scope, or a node token names another node | The token does not allow this action. | Audit row with the token id and the action | Create a token with the needed scope (deploy:<app>, secrets:write, ...) | deploy-api.md §2 |
RENOX_NOT_FOUND | F3 | 404 | The app, deployment, secret, domain or token does not exist in the caller's tenant (another tenant's resources are also 404) | Not found. | — | Check the app name and hash (renox deploy --list) | deploy-api.md §2; docs/24-threat-model.md E, IDOR row |
RENOX_CONFLICT | F3 | 409 | A state transition the state machine does not allow, an app name or hostname already taken, another activation of the app in progress, a suspended tenant, an unknown node heartbeating, or a late prepare_result | The request conflicts with the current state. | Audit row; details { field, reason } | Read the current state (GET the resource) and retry the right action; wait for the running activation | deploy-api.md §5, §6, §10 |
RENOX_RATE_LIMITED | F3 | 429, Retry-After | The token's request or upload bucket is empty, or too many unauthenticated requests came from one IP | Too many requests. Try again later. | — | Wait for Retry-After; batch work in CI instead of polling | deploy-api.md §2; docs/24-threat-model.md E, "Deploy API abuse" |
RENOX_BACKUP_UNAVAILABLE | F6 | 503, Retry-After: 30 | A backup action (status aside) cannot run: replication is off for the app's database, the replica or the owning node is unreachable, or no generation exists yet (deploy API 0.3, exportBackup, restoreBackup, importDatabase, moveTenant; node listener restore) | No backup is available for this database right now. | Audit row with the action; details { field, reason } (replication: none, node: unreachable, replica: unreachable, generation: none) | Wait and retry; a database with replication off has no restore points (free tier turns it on by default from F6) | deploy-api.md §14, §15; failure modes "Replica lagging / Litestream behind", "Node dies, restore fails" |
RENOX_RESTORE_POINT_UNKNOWN | F6 | 422 | A point-in-time restore asked for a time no generation covers: in the future, before the oldest generation, outside retention, or in a gap between generations | No backup covers the requested time. | Audit row; details { field: timestamp, reason } with the oldest and newest restore points | Pick a time inside one of the generations that GET /v1/apps/{app}/backups lists | deploy-api.md §14 |
RENOX_IMPORT_REJECTED | F6 | 422 | importDatabase refused the file: the target app is not a staging app, the file is not a SQLite database, fails PRAGMA quick_check, or has a table, view, index or trigger name the authorizer would refuse (no <module>__ prefix) | The database file cannot be imported. | Audit row; details { field, reason } (app / file / integrity / table) | Import only into an app created with staging: true; run renox check on the schema; use <module>__ table names | deploy-api.md §14 |
RENOX_TENANT_MOVING | F6 | 503, Retry-After: 30 | The tenant is being moved by an operator (move to a microVM in progress): the node answers the tenant's requests with it, and the API refuses another tier change, move, export or import until the move ends | The service is being moved. Try again shortly. | One line per minute while it lasts, source: platform, kind: move | Nothing in the app; the move ends within minutes or is rolled back | deploy-api.md §17 |
4. Rules for adding a code
- Codes are
RENOX_followed by upper-case words joined by_. A code names a cause the tenant can act on, not an implementation detail. - A new code needs a row here (all columns), and, if it crosses the op boundary, a row in
ops.mdsection 13 and a type inctx.d.ts, in the same PR.validate.tsfails otherwise. - A code listed with a later phase is reserved: nothing may produce it before that phase, and the row is completed by the issue that implements it.
- The fixed messages are in English. Localised messages (EN/AR,
docs/21-uae-gcc-strategy.md) are a later addition keyed by code. Thecodeis the stable identifier, never the message.
Open
- Localisation of messages. The default page and the JSON
messageare English in F1. Arabic messages keyed by code andAccept-Languageare expected with the F2 default page; the mechanism (a second fixed table) is not designed yet. - Rate limit (429). Gateway rate limiting (
docs/23-failure-modes.md, "Request flood from one IP") answers 429 withRetry-Afterand no code. The control API answers 429 withRENOX_RATE_LIMITED(F3); using the same code at the gateway would make it consistent with the others. This needs #22 to decide whether rate-limit responses carry a body at all, since a body costs bytes under a flood. RENOX_BUSYlog volume. One summary line per second is a proposal for #28 to confirm.RENOX_TENANT_SUSPENDEDand crawlers (0.6). A 503 withRetry-Aftertells search engines to come back rather than drop the pages. A suspension that lasts weeks would still lose ranking; whether a long suspension moves to 410 is the operator's decision and not in F7.
Changelog
| Version | Issue | Changes |
|---|---|---|
| 0 | #91 | First catalogue (F1) |
| 0.1 | #152 | Page (HTML) routes: the four error-template inputs, the fixed error-page render budget, the 404 page, and the fallback order (section 1). New rows: RENOX_HOST_MISMATCH (421, F2), RENOX_CLIENT_EVENTS_DROPPED (F2, dev overlay only) |
| 0.2 | #50 | Deploy API (F3): section "Control API" (RENOX_VALIDATION, RENOX_UNAUTHENTICATED, RENOX_TOKEN_SCOPE, RENOX_NOT_FOUND, RENOX_CONFLICT, RENOX_RATE_LIMITED); RENOX_ACTIVATION_FAILED, RENOX_CLI_WORKER_UNVERIFIED; RENOX_DEPLOY_REJECTED gains T1-T8 and its message; RENOX_UNAVAILABLE covers the deploy API |
| 0.3 | #251 | Deploy API 0.3 (F6): RENOX_BACKUP_UNAVAILABLE, RENOX_RESTORE_POINT_UNKNOWN, RENOX_IMPORT_REJECTED, RENOX_TENANT_MOVING in "Control API" |
| 0.4 | #272 | Ops 1.5.0 (F5): RENOX_REALTIME_LIMIT and RENOX_REALTIME_BACKPRESSURE in "Request limits"; the new section "App MCP" with RENOX_TOOL_INPUT, RENOX_TOOL_SCOPE, RENOX_TOOL_CONFIRM; RENOX_POLICY_INVALID in "Deploy"; the RENOX_FORBIDDEN and RENOX_DB_SCOPE rows completed (reasons, logged fields, the uncaught-is-403 rule) |
| 0.5 | #318 | Ops 1.7.0 (F7): the storage, image, webhook, AI and PDF codes |
| 0.6 | #346 | Artifact 1.5 and deploy API 0.5.1 (F7): RENOX_TENANT_SUSPENDED, the neutral suspension page the gateway answers for a suspended route entry |