Reference

Errors

Short, actionable error codes used across Routecraft.

Core codes use the RC namespace. Ecosystem packages own their codes under registered namespaces: @routecraft/ai uses AI (e.g. AI1001) and @routecraft/os uses OS (e.g. OS1001). See registerErrorCodes for adding a namespace.

Each error includes a code, message, a brief suggestion, and underlying error. A code is its owner's namespace followed by four digits: core codes follow RCcnnn where c is category and nnn is the number, and ecosystem packages use their registered namespace (AI1001). Adapters throw them with specific message and suggestion overrides via rcError(rc, cause, { message, suggestion }). When the framework logs an error, structured meta (rc, message, suggestion, causeMessage, causeStack) is included so you can search and alert in your log aggregator.

Range allocation

Within a namespace, the leading digit reserves a range for one subsystem, so two features landing in parallel cannot mint the same code. Claim the next range here before using it.

RangeOwnerCovers
RC****@routecraft/routecraftCore: routes, exchanges, adapters, auth, deferral. The digit after RC is the category, not a subsystem range.
AI1xxx@routecraft/aiAgent blocks, configuration, and runtime (run cancellation, deferral surface, the editor surface)
AI2xxx@routecraft/aiMCP boundary: tool results returned to a client
AI3xxx@routecraft/aiBuilt-in agent tools
OS1xxx@routecraft/osSubprocess execution and isolation tiers

The Retry column shows whether the retry wrapper will retry this error by default. Codes marked No typically represent permanent failures (bad input, configuration errors) that won't succeed on retry.

CodeCategoryMessageRetry
RC1001DefinitionRoute definition failed validationNo
RC1002DefinitionDuplicate route idNo
RC2001DSLInvalid operation typeNo
RC2002DSLMissing from stepNo
RC3001LifecycleRoute failed to startNo
RC3002LifecycleContext failed to startNo
RC5001AdapterStep execution failedYes
RC5002AdapterValidation failedNo
RC5003AdapterAdapter misconfiguredNo
RC5004AdapterNo handler availableNo
RC5010AdapterConnection failedYes
RC5011AdapterRequest timeoutYes
RC5012AdapterAuthentication failedNo
RC5013AdapterRate limitedYes
RC5014AdapterResource not foundNo
RC5015AdapterPermission deniedNo
RC5016AdapterSource payload parse failedNo
RC5017AdapterOptional peer dependency missingNo
RC5020AdapterAuthorization failed: token expired during processingNo
RC5021AdapterPrincipal enrichment failedNo
RC5022AdapterUserinfo sub invariant violatedNo
RC5023AdapterAuthorization failed: principal is not authenticNo
RC5024Adapterauthenticate() called with invalid claimsNo
RC5025RuntimeCircuit breaker is openNo
RC5026RuntimeConcurrency limit exceededYes
RC5028AdapterCache provider failedYes
RC5029AdapterCache key derivation failedNo
RC5030AdapterResource changed (precondition failed)No
RC5031RuntimeExchange dropped before completionNo
RC5032RuntimeUnsupported step outcomeNo
RC5033AdapterDedupe key derivation failedNo
RC5034AdapterActor not permittedNo
RC5035AdapterSubject not permittedNo
RC5036AdapterDelegation chain too deepNo
RC5037AdapterDelegation refused by mayActNo
RC5038AdapterInsufficient authority (recoverable)No
RC5058DefinitionInvalid shutdown configurationNo
RC5059RuntimeManagement API request refusedNo
RC5060RuntimeRoute is not dispatchableNo
RC5061AdapterHTTP client response body exceeds maxBodySizeNo
RC5062RuntimeRemote instance unreachableYes
RC5063RuntimeRemote instance refused the credentialNo
RC5064RuntimeRemote dispatch failedNo
RC5066DefinitionDeferral store is missing a contract memberNo
RC9901RuntimeUnknown errorYes
RC1003DefinitionError code registration failedNo
RC1004DefinitionContext already stoppedNo
RC5018AdapterHTTP source request rejectedNo
RC5019AdapterHTTP server bind failedNo
RC5039AdapterHTTP webhook signature verification failedNo
RC5040DefinitionResume-token signing secret not configuredNo
RC5041RuntimeResume token rejectedNo
RC5042RuntimeExchange cannot be persisted for deferralNo
RC5043AdapterPrincipal restored from a deferralNo
RC5044RuntimeDeferral store operation failedNo
RC5045RuntimeDeferral store busyYes
RC5046RuntimeDeferral not foundNo
RC5047RuntimeDeferral expiredNo
RC5048RuntimeDeferral continuation changedNo
RC5049RuntimeDeferral result rejectedNo
RC5050RuntimeDeferral deniedNo
RC5051DefinitionDefer not supported at this positionNo
RC5052DefinitionDeferral runtime not configuredNo
RC5053DefinitionOps plugin misconfiguredNo
RC5054RuntimeDeferral cancelled by run abortNo
RC5055RuntimeResume credential not bound to this callNo
RC5056RuntimeResume refused by the route's authorize hookNo
RC5057RuntimeDeferral sequence header unusableNo
AI1001AdapterAgent block resolution failedNo
AI1002AdapterAgent block name collisionNo
AI1003AdapterAgent block misconfiguredNo
AI1004AdapterSkills source could not be resolvedNo
AI1005AdapterAgent run cancelledNo
AI1006AdapterAgent deferral unavailable on this surfaceNo
AI1007AdapterAgent deferral state invalid at rehydrationNo
AI1008AdapterDeferred agent thread replacement refusedNo
AI1009AdapterModel context window exceededNo
AI1010AdapterAgent session record could not be read or writtenNo
AI1011AdapterA tool asked to defer an agent session turnNo
AI1012AdapterAgent session store failedYes
AI1013AdapterNo editor surface on this exchangeNo
AI1014AdapterThe editor surface disconnected mid-turnNo
AI1015AdapterThe editor never advertised this capabilityNo
AI1016AdapterThe editor refused or failed the callNo
AI1017AdapterSession override not offered by the agentNo
AI1018AdapterThe editor's answer did not match the protocolNo
AI1019AdapterA surface update did not match the protocolNo
AI2001AdapterMCP tool output violated its declared schemaNo
AI2002AdapterMCP tool declined the requestNo
OS1001AdapterIsolation tier unavailableNo
OS1002AdapterCommand execution failedNo
OS1003AdapterCommand timed outYes
OS1004AdapterIsolation cannot satisfy the requestNo
RC5065RuntimeRequest validation failedNo

Showing 94 of 94 codes


RC1001

Route definition failed validation

Why it happens
The route is missing required fields, most commonly a source.

Suggestion
Ensure a source is defined: start with from(adapter) and then add steps.

Example

craft().id('my-route').from(timer())

RC1002

Duplicate route id

Why it happens
Two or more routes share the same id.

Suggestion
Ensure each route id is unique or set routeOptions.id.

Example

craft().from(timer()).id('users');
craft().from(timer()).id('orders');

RC1003

Error code registration failed

Why it happens
An ecosystem package called registerErrorCodes() with an invalid namespace, a code that does not match its namespace, or a namespace already claimed by a different package. The RC namespace is reserved for core.

Suggestion
Namespaces must match /^[A-Z][A-Z0-9]{1,7}$/ and every code must be the namespace followed by exactly four digits (e.g. AI1001). If two installed packages claim the same namespace, report the collision to both package owners; consumers cannot resolve it locally.

RC1004

Context already stopped

Why it happens
context.start() was called on a context that has already been stopped. A context is single-use: its route controllers are built once and are gone after a stop, so a restart would report ready over routes that can no longer serve, and background work such as the deferral sweeper would retire records into them.

Suggestion
Build a fresh context from your config instead of restarting a stopped one. The process is the real restart unit; craft run already behaves this way.

RC2001

Invalid operation type

Why it happens
Either a step received unsupported input (e.g. split() on a non-array), or from() was called with no sources, or with multiple sources but without a preceding input({ body }). Multi-ingress routes share one pipeline, so they need one shared input schema to validate and normalize every channel to the same body type.

Suggestion
Use a supported operator and verify the step name. For a multi-ingress route, declare input({ body }) before from(), or expose each channel as its own single-source route.

Example

// split requires an array
craft().from(simple(['a','b'])).split()

// multi-ingress requires a shared input schema
craft()
  .id('my-route')
  .input(MyBodySchema) // required with multiple sources
  .from(direct(), mcp())
  .to(handler)

RC2002

Missing from step

Why it happens
Steps were added before defining a source.

Suggestion
Start the route with from and a valid source adapter.

Example

craft().from(timer()).transform(x => x)

RC3001

Route failed to start

Why it happens
The route's abort controller was already aborted or an adapter could not initialize.

Suggestion
Ensure the route isn't aborted before start(). Verify adapter configuration.

Example

const ctx = await new ContextBuilder().routes(myRoute).build();
await ctx.start();

RC3002

Context failed to start

Why it happens
Invalid configuration, duplicate ids, or missing sources.

Suggestion
Validate plugin exports and global configuration.

Example

const ctx = await new ContextBuilder().routes(validRoutes).build()
await ctx.start()

RC5001

Step execution failed

Why it happens
A step in the pipeline threw (process, transform, filter, tap, destination, etc.). The framework wraps plain Errors with this code and preserves the original message.

Suggestion
Read the error message and suggestion in the log; check adapter documentation. Use rcError("RC5010", cause, { message, suggestion }) for connection failures, RC5013 for rate limits, etc., so users get a specific docs page.

RC5002

Validation failed

Why it happens
Framework-enforced schema validation failed on something the instance itself produced: the route's .output() schema before the primary destination fires (routes to the error handler on failure), a validate() step, an aggregator that received an empty array, or any validator that threw.

The route's .input() schema is RC5065 instead, because a caller's bad payload is the caller's fault and this code's cases are the instance's. A transport that answers a request maps the two to different statuses.

Suggestion
Adjust the schema or coerce input; check data shapes. For Zod: use z.object(), z.looseObject(), or z.strictObject() as appropriate.

RC5003

Adapter misconfigured

Why it happens
Adapter was used in the wrong role (e.g. dynamic endpoint as source), required options are missing, or the adapter does not support this usage. Also raised at context.start() when a directTool names a route declared direct({ internal: true }), and when one endpoint declares itself both internal and a discoverable capability (e.g. two direct sources on one route, one internal and one not).

Suggestion
Check required options and correct role usage (.from() vs .to()). Example: direct sources take no endpoint string (.from(direct()) or .from(direct(options))); dynamic endpoints are only valid on destinations (.to(), .tap()). For a directTool naming an internal route, expose a boundary route carrying .input(), .description() and .authorize(), and point the tool at that.

RC5004

No handler available

Why it happens
A producer sent to a direct endpoint but no consumer route is subscribed, or the consumer route has stopped.

Suggestion
Ensure the consumer route is running before sending. Check route startup order and that endpoint names match.

Example

craft().id('my-endpoint').from(direct()).to(log());
craft().id('producer').from(simple('message')).to(direct('my-endpoint'));

RC5010

Connection failed

Why it happens
Network unreachable, connection refused, DNS failure, or service not running.

Suggestion
Check network, DNS, ports, and firewall; verify the service is running.

RC5011

Request timeout

Why it happens
The operation exceeded its deadline: a .timeout() wrapper (step or route scope) expired before the wrapped work settled, or an adapter hit a network deadline (e.g. ETIMEDOUT).

Suggestion
Increase the timeout or configure retry with backoff. Registered retryable: true, so a wrapping .retry() re-attempts timeouts by default.

RC5012

Authentication failed

Why it happens
Two cases share this code:

  • An upstream service rejected the request: invalid credentials, expired token, or a 401 response.
  • A route's .authorize() guard ran (or .validate(authorize(...)) mid-pipeline) and the exchange carried no authenticated principal. The source did not resolve one and no .process() step attached a custom one.

Suggestion

  • For upstream-API failures: verify API keys, tokens, audience/issuer, and credential rotation. Check that the auth header is reaching the destination.
  • For in-route failures: configure auth: on the source (e.g. mcp({ auth: jwt(...) })) so the source emits a principal, or attach a custom principal in a .process() step before the authorize() validator runs. See .authorize().

RC5013

Rate limited

Why it happens
Service returned 429 or quota exceeded.

Suggestion
Reduce request frequency or configure retry with backoff.

RC5014

Resource not found

Why it happens
The resource does not exist (e.g. 404, model ID not found, endpoint or queue name wrong).

Suggestion
Check that the resource exists (model ID, endpoint, queue name).

RC5015

Permission denied

Why it happens
Two cases share this code:

  • An upstream service denied the operation (e.g. 403 from access control or IAM).
  • A route's .authorize() guard ran (or .validate(authorize(...)) mid-pipeline), the exchange had a principal, but the principal was missing a required role, or a custom predicate returned false. A missing scope is not this code: it raises RC5038, because a role or predicate failure states something about who the subject is (permanent under current credentials), while a missing scope is recoverable through a consent or grant flow.

Suggestion

  • For upstream denials: check IAM, ACLs, and scopes granted to the credential.
  • For in-route denials: grant the principal the missing role(s) at your IdP, or relax the .authorize() requirement. The error message lists the missing roles. See .authorize().

RC5016

Source payload parse failed

Why it happens
A source adapter that converts raw bytes into a structured body (json, html, csv, jsonl, mail) could not parse the input. With the default onParseError: 'fail', the adapter defers parsing to the route's pipeline so the failure is observable per exchange and the route's .error() handler can recover. Causes include malformed JSON, structurally-invalid CSV rows (mismatched columns), broken HTML matching, or malformed MIME.

Suggestion

  • Wire .error() on the route to log, repair, or quarantine the bad payload, then return a fallback value to keep the pipeline alive.
  • Switch onParseError per adapter to control behaviour:
    • 'fail' (default): the exchange fails; the route handles it. Streaming sources continue to the next item.
    • 'abort': the source aborts on the first parse failure (atomic-load semantics).
    • 'drop': the bad item fires route:exchange:dropped with reason: 'parse-failed' (lossy ingest with structured observability).
  • For CSV chunked, inspect the row number on the captured error to identify the malformed row.

RC5017

Optional peer dependency missing

Why it happens
An adapter with a driver declared as an optional peer dependency was used, but the package is not installed. Examples: cron() requires croner, html() requires cheerio, mail() requires imapflow / nodemailer / mailparser, and the agents() / skills() markdown loaders in @routecraft/ai require yaml for front-matter parsing. The package itself loads without these peers; the error fires lazily on first use of the adapter so unrelated routes never need the drivers.

Suggestion
Install the package the error message names. For example:

bun add croner   # or: npm install croner

The error message names the adapter (cron, html, ...) and the missing package, so the install line is copyable from the log. If you see this for a feature you do not use, find the route or capability that imports the adapter and remove it.

RC5018

HTTP source request rejected

Why it happens
The HTTP source (http({ path, method }) via defineConfig({ http })) could not service a request at the adapter boundary. It covers an oversized request body (returned to the client as 413 Payload Too Large) and a body that cannot be parsed for its declared Content-Type (400 Bad Request, e.g. malformed JSON or multipart).

Suggestion
For 413, raise http: { maxBodySize } or have the client send a smaller payload. For 400, fix the request body so it matches the Content-Type.

RC5019

HTTP server bind failed

Why it happens
A named server (defineConfig({ servers })) could not bind its configured port/host during context start. The message names the server (servers.<name>: bind failed on host:port). The usual cause is EADDRINUSE (another process, or another listener in the same process, already owns the port) or EADDRNOTAVAIL (the host is not one this machine can bind). Declared duplicate binds are caught earlier at build time; RC5019 is the runtime authority for conflicts the build-time check cannot see (wildcard vs specific addresses, other processes).

Suggestion
Free the port or choose another via servers: { <name>: { port } } (use 0 to let the OS assign one), and check that host is an address this machine can bind. Surfaces that should share one listener mount on the same named server instead of declaring their own.

RC5020

Authorization failed: token expired during processing

Why it happens
A mid-pipeline .validate(authorize(...)) (or the pre-from .authorize() guard) ran on an exchange whose principal carries an expiresAt (Unix epoch seconds) that is beyond the configured clockToleranceSec window. The token was valid when verify ran at the route boundary, but a long-running step in between (LLM call, slow downstream, queue wait) outlived the credential. The framework refuses to authorize once the tolerance-adjusted expiry is exceeded.

The check is also raised fail-closed when either expiresAt or clockToleranceSec is non-finite (NaN, Infinity); a numeric-coercion bug must not silently bypass the guard.

The check is distinct from RC5012 (no principal at all) and RC5015 (principal failed a role / predicate check; a missing scope is RC5038) so clients can react accordingly: an RC5020 signal almost always means "refresh and retry," whereas RC5015 is a permanent denial under the current credentials.

Suggestion

  • The client should refresh the bearer and retry the request.
  • To recover server-side, restructure the pipeline so authorize() runs before the slow step, or attach a fresh principal in a .process() step before the validator.
  • If your source-side verifier (jwt() / jwks()) sets a clockToleranceSec, pass the same value to authorize({ clockToleranceSec }) so the boundary and mid-pipeline checks agree on a token's validity window.
  • If the principal genuinely has no expiry (e.g. an API key with infinite lifetime), leave expiresAt unset on the Principal so the check is skipped.

RC5021

Principal enrichment failed

Why it happens
The userinfo option on mcpPlugin({}) could not enrich the verified principal. Causes include: a non-2xx response from the userinfo endpoint (rate limit, bearer scope insufficient, IdP outage), a network error reaching the userinfo or OIDC Discovery URL, malformed JSON, or a Discovery document that does not advertise a userinfo_endpoint. The framework is fail-closed: any enrichment error rejects the request rather than authorize on a partial principal.

Suggestion

  • Inspect the underlying cause attached to the error: it names the URL and HTTP status.
  • Check that the bearer token has the scopes the IdP requires for /userinfo (typically openid, email, profile).
  • If the IdP does not advertise OIDC Discovery (or advertises it without a userinfo_endpoint), pass an explicit userinfo: "https://..." or a function variant.
  • Verify outbound network access from the MCP server to the IdP.

RC5022

Userinfo sub invariant violated

Why it happens
Per OIDC Core §5.3.2, the userinfo response MUST carry a sub claim equal to the verified token's sub. The framework throws RC5022 when the response is missing sub or when it differs from the token's sub. This guards against a compromised userinfo endpoint impersonating a different user on the principal, or a misconfigured userinfo URL paired with the wrong issuer.

This check applies only to URL and OIDC-discovery userinfo modes; the function variant is trusted by contract (the caller owns the backend).

Suggestion

  • Verify the userinfo URL matches the issuer of the bearer token. A common cause is configuring a userinfo URL for a different tenant or realm.
  • Do not silence this error. If a legitimate IdP returns a non-standard subject under a different field, switch to a function-mode userinfo and map the response yourself.

RC5023

Authorization failed: principal is not authentic

Why it happens
authorize() found a principal on the exchange, but it was not established by a trusted origin. Authenticity is conferred only by a source-side verifier (jwt() / jwks() / oauth()) or by an explicit mint (.authenticate() / the authenticate() helper), which register the principal in a private set. A plain object written directly onto headers["routecraft.auth.principal"] (for example via .process() or .header()), or a copy made from an existing principal ({ ...ex.principal, roles: ['admin'] }, which is a different object and so not in the set), is treated as self-asserted and rejected. This makes establishing identity an explicit, greppable act and prevents a route from silently forging or escalating identity.

The check is distinct from RC5012 (no principal at all), RC5015 (an authentic principal that lacks a required role or fails a predicate), and RC5038 (an authentic principal that lacks a required scope), so you can tell "forged / self-asserted" apart from "missing a role" and "missing a grantable scope."

Suggestion

  • Mint the identity with the .authenticate() operation (or the authenticate() helper for mid-pipeline / custom-source use), which brands and freezes the principal.
  • Let a source verifier attach it: mcp({ auth: jwt(...) }) / jwks(...) / oauth(...).
  • In a custom source adapter that verifies identity itself, brand the resolved principal with markAuthentic before attaching it.
  • Do not assign a plain object to the principal header and do not spread an existing principal to change its roles; both produce a non-authentic principal.

RC5024

authenticate() called with invalid claims

Why it happens
Two cases, both programming errors at the mint call site:

  1. No subject. The claims have no subject, or an empty-string subject. Every minted identity must name the stable identity it represents, so the mint fails fast rather than producing an anonymous "authenticated" principal.
  2. Delegation state passed to a mint. The claims carry actor or grantId. Establishing identity and establishing who is acting for it are separate operations: authenticate() mints, delegate() delegates. Without this guard, spreading an already-delegated principal back through authenticate() would produce an authentic delegated identity while skipping every invariant delegate() enforces (the mayAct consent check, the scope intersection, truthful chain nesting).

Note that mayAct is accepted at mint. It describes the subject (who may act on their behalf), like roles, and is legitimately established when identity is resolved from a directory or a grant store.

Both are distinct from RC5023, which fires later at authorize() when a principal reached the check without being established by a trusted origin.

Suggestion

  • Pass a non-empty subject: authenticate({ subject: sender.address, roles: [...] }).
  • To delegate, mint first and then delegate: delegate(authenticate(claims), actorClaims, { scopes, grantId }).
  • If the source cannot identify the caller, return undefined from the .authenticate() resolver to leave the exchange anonymous instead of minting an empty identity.

RC5025

Circuit breaker is open

Why it happens
A route-scope or step-scope .circuitBreaker() exceeded its failure threshold and tripped open, so it is fast-failing subsequent calls without running the protected work until the cooldown elapses (then it admits a probe). Also raised when a half-open breaker is already at its probe capacity.

Suggestion

  • Wait for cooldown to elapse; the breaker then probes with a half-open call and closes on success.
  • Configure a fallback to return a degraded result instead of throwing.
  • Raise failureThreshold or cooldown if the breaker is too sensitive.
  • Not retryable: an immediate retry would hit the same open breaker.

RC5026

Concurrency limit exceeded

Why it happens
A route-scope or step-scope .concurrency() bulkhead is at capacity and is failing the exchange fast rather than admitting more simultaneous work. In reject mode this fires the moment all max slots are busy; in the default queue mode it fires only when the wait line has also reached maxQueue.

Suggestion

  • Raise max, or switch to the default queue mode to apply backpressure instead of dropping.
  • Cap the wait line with maxQueue for a middle ground between waiting forever and rejecting immediately.
  • Handle it in .error() to shed load deliberately (for example, respond 503).
  • Retryable: a slot frees as soon as in-flight work completes, so an enclosing .retry() (which sits outside the bulkhead) can back off and re-acquire one.

AI1001

Agent block resolver failed

Why it happens
A block's value resolver function threw, returned a non-string, or could not be invoked (no CraftContext available on the exchange). For mode: "inject" blocks this aborts the dispatch with AI1001; for mode: "progressive" blocks the same AI1001 is reported back to the model as a tool error so it can self-correct.

This also fires when client.forward() is invoked from a resolver running on an exchange with no bound route (typically synthetic exchanges in tests).

Suggestion

  • Return a string (or Promise<string>) from the resolver. Throwing inside an inject resolver hard-fails the agent dispatch, so handle expected errors and return a sensible fallback string.
  • For progressive resolvers, the model will see the error message and may retry; a descriptive message helps the model self-correct.
  • When using client.forward() in tests, dispatch the agent through a real route so the exchange has a route binding, or construct the exchange via DefaultExchange with a populated route context.

AI1002

Agent block / tool name collision with the reserved _block_ prefix

Why it happens
A block name, or a user tool whose final provider-facing name starts with the framework-reserved _block_ prefix used by synthetic block-loader tools. The reservation covers the whole _block_ namespace, not just _block__load__, so future synthetic-tool kinds can land without another breaking reservation.

In practice this means fn ids, because a fn id reaches the provider verbatim. Capabilities and MCP tools acquire a direct__ / mcp__ wire prefix during resolution, so a route or remote tool named _block_thing resolves to direct___block_thing and never enters the reserved namespace.

Also fires on duplicate block keys, empty-string block keys, or any other block-name collision detected at construction or dispatch.

Suggestion

  • Rename the offending block, fn, or route. The _block_ prefix is for framework use only.
  • For block keys, the Blocks record key is the block name; ensure it is a non-empty string and unique within the agent (defaults are merged in by name, with false removing).

AI1003

Agent block misconfigured

Why it happens
A block's shape is invalid at construction:

  • mode is not "inject" or "progressive".
  • A progressive-mode block is missing the required description.
  • value is neither a string nor a function.
  • lifetime is set to a value other than "dispatch" or "context".
  • The skills({ source }) builder was called with a missing or empty source, an invalid mode, or an invalid lifetime.

Suggestion

  • Inject-mode blocks: { mode: "inject", value: <string | function> }.
  • Progressive-mode blocks: { mode: "progressive", description: "...", value: <string | function> }.
  • Use the BlockMode and BlockLifetime types exported from @routecraft/ai to catch typos at the type level.

AI1004

Skills source could not be resolved

Why it happens
A skills: ref in agent frontmatter did not resolve to a directory on disk:

  • A local ref (./skills, ../shared-skills) named a path that does not exist relative to the agent file that declared it.
  • An npm: ref named a package that is not installed in the project.
  • An npm: ref resolved to an installed package, but neither the plain subpath nor the package's skills/ root held the named folder.
  • An npm: ref used ../ segments that point outside the package root.

Suggestion

  • Local refs resolve against the agent file's own directory, not the project root. For an agent bundle that is the bundle folder.
  • Install the package the ref names; resolution reads node_modules only, with no network access at boot.
  • npm:<pkg>/<subpath> looks for <root>/<subpath> then <root>/skills/<subpath>; npm:<pkg> looks for <root>/skills then <root>. The error message lists the paths that were tried.

AI1005

Agent run cancelled

Why it happens
The dispatch's abort signal fired (a route stop, an elapsed .timeout(), or a forced shutdown; a graceful shutdown drains the run instead of cancelling it) and the agent loop stopped cooperatively: between turns at the cheap checkpoint, or mid-turn when the signal reached the model call or a tool handler. The error's cause is an AgentCancellationCause with typed turnsUsed and usage fields (cache tokens included when the provider reports them), so cost accounting and traces read the spend structurally instead of parsing a message; a partial result never masquerades as a finished AgentResult.

Suggestion
Cancellation is terminal: re-dispatch the work as a new exchange if it should run again. A run that deferred at a deferral BEFORE the abort survives independently; a run whose deferral raced the abort is refused or denied with RC5054, so no stale resume link is left behind.

AI1006

Agent deferral unavailable on this surface

Why it happens
ctx.defer() was called where no exchange can be durably deferred: a proxied MCP tool guard, a testFn dispatch, or an agent invoked over a synthetic exchange with no route binding. The refusal happens at the moment of the call, before anything could be written, so there is never a half-created record.

Suggestion
Dispatch the agent through a route: its exchange is then route-bound, the hosting .to() / .enrich() step carries a revivable defer site, and ctx.defer() defers. If this surface genuinely cannot defer (an MCP proxy guard), drop the deferral from the handler; a guard's job is admission, not waiting.

A background tool also reports this code when its downstream route returns a Deferred acknowledgment. In that case the downstream record already exists and stays pending; only background result delivery fails. Resolve the existing action through the application's approval flow instead of retrying it. The receipt's bearer token is withheld from the model and session inbox.

AI1007

Agent deferral state invalid at rehydration

Why it happens
A resumed exchange carried stepState this agent cannot re-enter. Either the persisted value is not the { agentId, messages, deferredToolCallId, turnsUsed } record the runtime writes, the thread does not contain the deferred tool call the payload should land on, or the record names a different agent than the one the route now dispatches. That last case exists because a by-name agent's registered options are not covered by the continuation hash (only step definitions are), so the persisted agentId is the identity the record can pin.

Suggestion
Restore the agent binding the record names, or treat the deferred work as lost and re-ask with a fresh deferral. The deferral was already claimed when this fired, so the failure is recorded as its terminal outcome and the deferred route's .error() handler receives it.

AI1008

Deferred agent thread replacement refused

Why it happens
A rewrite of a deferred run's message thread produced a thread the run could not be resumed from. Compaction is the caller this exists for: an agent defers at an approval, its conversation has outgrown the model's context window, and the only moment to shrink it is while the exchange is still deferred.

Refused: an empty thread or an entry that is not a message, a tool call with no result (or a result with no call), a result placed before the call that produced it, a missing or empty toolCallId, an id used twice, and a thread that no longer contains the deferred call the approver's answer lands on. All but the last are what a summariser breaks by dropping or reordering a message it judged uninteresting; every provider rejects the thread that comes out. The last is the reason the guard runs before the store is written rather than after: a thread missing the deferred call is only discovered at revival, as AI1007, by which point the approval has been spent and the run cannot be recovered. AI1008 is the recoverable half of that pair. It is raised by assertResumableThread while the rewrite is still in hand, nothing has been persisted, and the deferred run resumes from the thread it already had.

Suggestion

  • Drop a tool call and its result together, never one of the pair.
  • Keep the deferred call in the thread; summarise around it.
  • Leave the thread alone if it cannot be shortened safely. An uncompacted run that resumes beats a compacted one that cannot.

AI1009

Model context window exceeded

Why it happens
The provider refused the request because the prompt does not fit the model's context window. It is separated from every other dispatch failure because it needs the opposite reaction: a transient provider failure is worth retrying with the same input, and this one can only ever fail again.

Providers do not agree on how to say it. OpenAI-compatible endpoints carry a machine-readable context_length_exceeded; Anthropic, Google and the local runtimes only say it in prose, with no shared status code. The classifier reads the code where there is one and matches the known phrasings otherwise, deliberately narrowly: any other failure is rethrown untouched, with its own retryability intact.

Suggestion

  • Send less: compact the conversation, trim what the tool results in the thread carry, or drop older turns.
  • Move to a model with a larger window if the input genuinely cannot shrink.
  • Do not retry the same input. Nothing about it will fit on a second attempt.

AI1010

Agent session record could not be read or written

Why it happens
Every named agent session (agent(name, { session })) is one record in the deferral store, holding the transcript and the inbox. Either a stored record is not the shape the runtime writes (it was edited by hand, or two versions of @routecraft/ai share one store), or a write lost the compare-and-swap to another writer repeatedly, which takes a store under a load the in-process turn bound does not produce.

Suggestion

  • Inspect the record the message names; remove it to start that session over.
  • Check that one process, on one version of @routecraft/ai, owns the store.

AI1011

A tool asked to defer an agent session turn

Why it happens
A tool called ctx.defer() inside an agent dispatched with session. A session turn stores its transcript when it ends and is revived from the session record rather than from a deferred exchange, so there is no continuation an approval could resume into; the refusal lands in the tool, as a typed error the model reads, before anything is written.

Suggestion

  • Defer from a sessionless agent, where ctx.defer() defers the dispatch itself.
  • Move the approval into a route the agent calls as a tool, and let that route defer.

AI1012

Agent session store failed

Why it happens
The store that holds agent session records, chosen by sessions: { store } and the SQLite file at .routecraft/sessions.db by default, could not be opened, migrated, read or written. An explicitly configured path that cannot be opened raises it at startup; the default path raises it on the first session written. A store that is busy under another writer answers it too, and that call can be retried.

Suggestion

  • Check the path and the permissions on its directory, and that one process at a time holds the file.
  • Under Node, install better-sqlite3; without it the unconfigured default falls back to memory with a warning rather than raising this code.
  • Set sessions: { store: "memory" } deliberately for a context whose conversations may die with the process.

AI1013

No editor surface on this exchange

Why it happens
A route called surface() on a turn that has no editor behind it. The adapter reaches the client that is running THIS turn, and the turn is a plain exchange: it came from a timer, an HTTP request, a test, or any other source. There is no ambient editor to fall back on, and inventing one would mean a route that asks for a decision silently continuing without an answer.

Suggestion

  • Guard the call and take another path when there is no editor: .choice() on hasSurface(exchange) is the shape, and the same route then works both from an editor and from a schedule.
  • Dispatch the route from a turn that has an editor attached, which is any turn started through acpPlugin().

AI1014

The editor surface disconnected mid-turn

Why it happens
The turn started with an editor attached, and the connection was gone by the time the route called it, or went while the call was outstanding: the person closed the editor, the network dropped, or the process serving them exited. A call that was outstanding settles at once with this code, and any answer the person gave is lost with the connection. This is distinct from AI1013, where there never was one, because the fix is different.

A turn revived from a stored continuation after a restart reaches the editor the same way: the surface reference survives in the stored exchange, the connection it names does not, and the framework never re-sends what was outstanding when the process died.

Suggestion

  • Treat it as a person who walked away: finish what can be finished without them, or fail and let them start it again.
  • There is nothing to retry against on this exchange. A reconnected editor is a new surface and a new turn, and the answer this route wanted belongs to the person, not to the socket.

AI1015

The editor never advertised this capability

Why it happens
The editor said at initialize which client methods it serves, and the route called one that was not among them. Reading a file through the editor needs fs.readTextFile; running something there needs terminal. An editor that does not offer a capability is not broken, and calling it anyway would hang or fail obscurely, so the refusal happens before the call is sent and names both the capability and the method.

Suggestion

  • Use an editor that offers the capability, or branch on it: the message names exactly what was missing.
  • This is configuration rather than a defect. A route written for one editor is being run from another.

AI1016

The editor refused or failed the call

Why it happens
The call reached the editor and came back as a JSON-RPC error, or the turn was cancelled. On a cancel, a call outstanding at that moment is cancelled at the editor and settles with this code, and a call the route makes afterwards is refused with it without being sent. The editor's own error is on this error's cause when there is one.

A person declining a request arrives this way and is a normal outcome, not a fault: an approval prompt answered "no" is the system working.

Suggestion

  • Handle it with .error() and give the route a path for "the person said no".
  • Read the cause for what the editor actually reported; the framework does not interpret it.
  • Anything that must reach the editor after a cancel, a terminal to kill and release, is registered beforehand with surface.onCancel(); the framework sends it after the turn has answered.

AI1017

Session override not offered by the agent

Why it happens
A conversation asked to run on a model or a thinking level its agent does not list. Whoever writes the agent decides what may be changed about it, and the default is that nothing can be: an agent file naming one model: offers no choice, and one listing several offers exactly those.

Raised where the choice is written rather than where it would be used, so a stored conversation can never name a value the agent does not offer.

Suggestion

  • Add the value to the agent file's model: or reasoning: list if it should be on offer. Both accept one value or an ordered list, and the first entry is the default.
  • Check the caller is not sending a value it read from somewhere else; the message names the list the agent actually offers.

AI1018

The editor's answer did not match the protocol

Why it happens
surface() checks what the editor answers against the protocol's own schema for the method, before a route sees it, and this answer did not conform. The editor is a separate program on the person's machine, at whatever version of the protocol it implements, so a wrong shape is a defect or a version mismatch on the editor's side rather than in the route. The issues, one per field, are on the error's cause.

A permission answer never raises this code. One that cannot be trusted, malformed or naming an option that was never offered, reaches the route as the protocol's cancelled outcome, because the message whose job is to say no fails closed rather than loudly. See surface.

Suggestion

  • Handle it with .error() the way the route handles a refusal; the person's editor cannot answer this call correctly, so there is nothing to retry.
  • Read the cause for the field that was wrong, and check the editor's protocol version against the @agentclientprotocol/sdk version the instance runs.

AI1019

A surface update did not match the protocol

Why it happens
surface.notify() checks the update a route built against the protocol's session/update schema before sending it, and this one did not conform. Nothing was sent: an update the editor would refuse or misrender is stopped where it was built. The issues are on the error's cause.

Suggestion

  • Fix the callback so it returns a shape the protocol defines: a sessionUpdate discriminator the protocol names, with the fields that go with it.
  • The shapes come from the SDK's types, so a value that typechecks and still fails here was built from data at runtime; validate that data before building the update from it.

AI2001

MCP tool output violated its declared schema

Why it happens
A route exposed with .from(mcp()) declares .output({ body }), which the MCP server advertises as that tool's outputSchema, and the body it returned does not satisfy it. Clients parse structuredContent against the advertised schema, so the server refuses to publish the result and returns isError: true with the failing fields instead.

The route's own output validation reports a violation it catches as RC5002. AI2001 is the same contract enforced at the boundary that advertised the schema, covering results that reached the server without passing through that validation: a tool registered directly in the local tool registry, rather than by a .from(mcp()) route. A body the route already validated is not re-checked, because validation replaces the body with the schema's output and a transforming schema rejects its own output.

Suggestion

  • Fix the route so its result matches the declared shape, or widen .output() to describe what the route actually returns.
  • Drop the .output() declaration if the tool's result shape is genuinely open; nothing is advertised and nothing is checked.

AI2002

MCP tool declined the request

Why it happens
The route behind an MCP tool dropped the exchange instead of completing it: a .filter() rejected it, a .choice() matched no branch, or an error handler returned recovery.drop(). A dropped exchange resolves with the body it came in with, so there is no result to return and the request body is not one.

This is the MCP-side equivalent of RC5031 on the direct and forward surfaces. The call comes back as isError: true saying the tool declined, and never as a successful result carrying the caller's own arguments back.

Suggestion

  • Give the route a branch that produces a result the caller can use (an empty list, an explicit not-found shape) if the caller should receive a value.
  • Recover with a body in .error() rather than dropping, when the drop is an error path.
  • Keep the drop if declining is genuinely correct for that input; the client sees an error result, which is the honest answer.

RC5028

Cache provider failed

Why it happens
The .cache() wrapper's provider threw while reading a value or while a custom provider executed its backend operations. Typical cause: a remote cache backend (Redis, etc.) is unreachable. Also raised by MemoryCacheProvider.set if called with undefined (the cache-miss sentinel), which is a contract violation.

Suggestion
Inspect the underlying backend. Transient connectivity errors are retryable; consider wrapping the step with .retry() once that wrapper ships. If you hit the undefined set error, use null for an intentional empty value.

RC5029

Cache key derivation failed

Why it happens
The default .cache() key hashes JSON.stringify(body), which fails on bodies containing functions, symbols, circular references, or BigInt. Also raised when a custom key function throws.

Suggestion
Supply an explicit key function that returns a stable string identifier:

.cache({ key: (e) => String((e.body as { id: unknown }).id) })

This error is not retryable: the same body fails key derivation the same way every time.

RC5030

Resource changed (precondition failed)

Why it happens
A conditional write failed because the resource changed on the server since it was read (HTTP 412 / ETag mismatch, a mid-air collision). For example, two writers read the same CardDAV contact, the first commits, and the second's update/save is rejected because its If-Match ETag is now stale. This is not retryable: a blind retry sends the same stale precondition and fails again.

Suggestion
Re-read the resource to pick up the current state and ETag, re-apply your change, and write again.

RC5031

Exchange dropped before completion

Why it happens
A request/reply caller (client.sendDirect() or an error handler's forward()) dispatched into a route that discarded the exchange instead of completing it: a .filter() rejected it or an .error() handler returned recovery.drop(). (Source-side onParseError: 'drop' never reaches this path; it drops inside the source's read loop, which has no request/reply caller.) A dropped exchange has no response body, so resolving would hand the caller back its own request; the framework rejects instead. This is not retryable: the same input is dropped the same way every time.

Suggestion
If the caller should receive a value, recover with a body in .error() instead of recovery.drop(), or let the exchange pass the filter. If dropping is intended, catch the error and branch on error.rc === 'RC5031'.

RC5032

Unsupported step outcome

Why it happens
A step returned a StepOutcome the engine cannot schedule. In practice this only happens with a custom step: the built-in steps always return a supported kind. Either the kind is one this build does not know, or a defer outcome arrived without the request the executor defers from, which only the framework's own .defer() step can produce. This is not retryable: the same step returns the same outcome every time.

Suggestion
Return one of the supported outcomes from your step: continue, complete, drop, branch, or fanOut. To defer an exchange, use .defer() rather than hand-rolling the outcome: the executor needs the site the builder assigns to work out what would run on resume.

RC5033

Dedupe key derivation failed

Why it happens
The default .dedupe() key hashes JSON.stringify(body), which fails on bodies containing functions, symbols, circular references, or BigInt. Also raised when a custom key function throws.

Suggestion
Supply an explicit key function that returns a stable string identifier:

.dedupe({ key: (e) => String((e.body as { id: unknown }).id) })

This error is not retryable: the same body fails key derivation the same way every time.

RC5034

Actor not permitted

Why it happens
The exchange's principal carries an actor (a delegate, typically an agent, acting on the subject's behalf per RFC 8693 act semantics), and the route's authorize({ actor }) specification does not admit it. The default specification is 'none': a capability is not reachable through delegation unless it declares otherwise, so any delegated principal is rejected until the route names its permitted actor(s). Also raised in the inverse case: a route that requires an actor (for example actor: { profile: 'ai_agent' }) rejects a direct call. Only the outermost actor is considered; nested prior actors in a chain are audit data (RFC 8693 section 4.1).

Suggestion
Declare the permitted actor(s) on the route, matching by the (issuer, subject) pair:

.authorize({
  scopes: ['mail:send'],
  actor: ['none', { subject: 'agent:zoe', issuer: 'https://agents.example.com' }],
})

or have the permitted party perform the call. This is permanent under the current declaration; no retry or ceremony changes it.

RC5035

Subject not permitted

Why it happens
The principal's subject does not satisfy the route's authorize({ subject }) constraint: wrong subject id, wrong issuer, or wrong entity profile (for example a route restricted to subject: { profile: 'ai_agent' } called by a human principal, or vice versa).

Suggestion
Check the route's subject constraint against the caller's identity. This is permanent under current credentials.

RC5036

Delegation chain too deep

Why it happens
The principal's actor chain is longer than the route's maxDelegationDepth (default 1, applied once the actor spec admits an actor at all). A re-delegated chain (user to agent to sub-agent) exceeds the default.

Suggestion
Have an agent closer to the subject perform the call, or raise maxDelegationDepth on the route deliberately. Only the outermost actor is a policy input; deeper chains add audit surface, not authority.

RC5037

Delegation refused by mayAct

Why it happens
delegate() was asked to mint an actor that the subject's mayAct list (RFC 8693 section 4.4) does not permit. The subject has not consented to this party acting on their behalf. Matching uses the (issuer, subject) pair, so a same-named actor from a different issuer is also refused.

Suggestion
Obtain the subject's consent through your grant flow, which adds the matching mayAct entry, then retry the delegation. Never widen mayAct without an explicit consent event.

RC5038

Insufficient authority (recoverable)

Why it happens
The principal is authentic and admitted, but lacks one or more scopes the route requires. Unlike a role failure (RC5015, permanent: no ceremony changes who the subject is), a missing scope is the one recoverable authorization failure: a consent or grant flow could add the scope and the call could be retried. This mirrors the RFC 9470 / RFC 6750 insufficient_scope challenge shape. For delegated principals, remember that scopes are intersected at every hop, so the missing scope may have been narrowed away by the delegation ceiling rather than absent from the subject. A route using authorize({ effective: true }) reads the subject's ring plus the outermost actor's, so a refusal there means neither ring carried the scope.

Suggestion
The cause error carries a machine-readable missing.scopes array and a missing.mode saying how to read it. With mode: "all" the array lists exactly the required scopes that are absent, and every one is needed. With mode: "any" it lists the whole accepted set of an anyScope check, of which any ONE would have opened the door, so a consent flow offers the caller the choice rather than requesting all of them. Feed it to your consent flow, or grant at the IdP, then retry. mode is optional on the type so an application that throws this shape itself keeps compiling; authorize() always sets it, and an absent value means "all".

RC5039

HTTP webhook signature verification failed

Why it happens
A route configured with http({ signature: {...} }) received a request whose signature header was missing, did not match the raw body, or (for the stripe-timestamped and standard-webhooks schemes) carried a timestamp outside the tolerance window. The request was rejected with 401 before any route step ran.

Suggestion
Check that the secret matches the provider's signing secret, the header name matches what the provider sends (e.g. x-hub-signature-256), and the prefix matches the provider's format (e.g. "sha256=" for GitHub). On standard-webhooks there is no header or prefix to configure, since the specification fixes webhook-signature, webhook-id and webhook-timestamp. A rejection there is missing signature header when any of the three is absent, invalid signature when no v1 entry matches the exact raw body, or signature expired when the timestamp is outside the tolerance. If deliveries pass through a proxy or middleware that re-encodes the body, the signed bytes no longer match; verification must see the exact wire bytes.

This error is not retryable: the same delivery fails verification the same way every time.

RC5040

Resume-token signing secret not configured

Why it happens
A context whose routes can reach a durable .defer() was built without a way to sign resume tokens. Resume tokens are the capability a deferred exchange hands its approver, so a context that cannot sign them cannot defer anything. The check runs while the context is being built, not on the first defer, so this surfaces at startup rather than on the first large payout.

Suggestion
Set the ROUTECRAFT_DEFERRAL_SECRET environment variable, or pass deferral: { secret } to defineConfig. Prefer the environment variable: a secret in a config file is a secret in version control. Generate one with openssl rand -base64 32.

At least 32 bytes are required, and a shorter secret raises this same code. A resume token is a bearer capability, so any holder of one token can guess the secret offline with no rate limit and no server involvement; a dictionary-strength value falls in seconds, after which an attacker can mint a token for any deferral.

The secret is deliberately never generated into the store. A store compromise must not also yield the ability to forge resume tokens, and a store-resident key stops working the moment a second node appears. testContext(), NODE_ENV=development and NODE_ENV=test mint an ephemeral in-memory key so tests and local iteration need no setup; that key does not survive a restart, and the framework warns when it is used.

RC5041

Resume token rejected

Why it happens
The token presented to .resume() was malformed, carried a signature that does not verify, or named a format version this build does not understand. The three are reported as one error on purpose: telling a caller which check failed tells an attacker how far a forgery got.

Suggestion
Resume with the exact token minted at defer time. If the token is genuine, check that every node shares one signing secret: a token signed with a different secret is indistinguishable from a forged one, and an ephemeral development key is regenerated on every restart.

RC5042

Exchange cannot be persisted for deferral

Why it happens
Deferral writes the exchange to durable storage, so its body and headers must be plain JSON data. The error names the exact path that failed. Refused values are functions, symbols, bigints, non-finite numbers, circular references, class instances, and anything carrying the framework's Secret brand. A class instance is refused rather than downgraded because what would come back is a plain object wearing the same fields with none of the behaviour, and the failure would surface as a broken method call after resume instead of at the defer.

Date is the exception: it round-trips faithfully through a tagged envelope, so a timestamp in the body is still a Date after resume.

Suggestion
Move the offending value out of the exchange before the defer point. Resolve it to a string, keep it in context.store (which outlives the exchange and is never serialized), or recompute it after resume. Blocks declared lifetime: "context" already live in the context store and are unaffected.

RC5043

Principal restored from a deferral

Why it happens
authorize() was given a principal that came back from durable storage with a resumed exchange. It is the recorded shape of an identity verified days ago, with nothing behind it now: no signature was re-checked, no expiry, no revocation lookup. The framework marks such principals restored precisely so this case is distinguishable, rather than letting a shape read off disk pass an authorization check.

Suggestion
Put the authorization on the resume ingress route, where the resuming principal is verified live, and read ex.deferral.resumedBy for the receipt. If a step after the defer point genuinely needs an authorized identity, re-verify it there with .authenticate() from a checked credential.

Distinct from RC5023 (a self-asserted plain object) because the fix differs: re-verify, do not mint.

RC5044

Deferral store operation failed

Why it happens
The deferral store could not complete a read or write. The common causes are a duplicate deferral id (a bug in id derivation, since ids are minted one per defer), a store file that is unwritable or out of disk, and a store file written by a newer Routecraft build than the one now running.

Suggestion
Read the message, which names which of those it was. A schema-version mismatch means you have downgraded: run the newer build, or point deferral.store.path at a fresh file.

This error is deliberately not retryable. None of its causes clear on a second attempt, so a .retry() wrapper must not spend its budget on them.

RC5045

Deferral store busy

Why it happens
Another writer held the deferral store's write lock for longer than the busy timeout (5 seconds). Almost always this means more than one process is writing the same store file: two app instances sharing a volume, or an operator tool running against a live deployment.

Suggestion
Unlike RC5044 this is transient, so it is registered retryable and a .retry() wrapper re-attempts it. If it persists, the store file has more than one writer: give each process its own store, or move to a backend built for concurrent writers once one ships.

RC5046

Deferral not found

Why it happens
The resume token verified, but the store holds no deferral under that id. Either the record was purged by retention, or this process is pointed at a different store than the one that deferred the exchange (an in-memory store after a restart, a different SQLite path), or the deferred route is not registered in the context doing the resuming.

Suggestion
Check that deferral.store names the same location on every node, and that the context running .resume() also has the deferred route registered: resume re-enters that route's pipeline, so it has to be there.

RC5047

Deferral expired

Why it happens
The deferral's ttl elapsed, so the deferred exchange is no longer resumable. Either a late resume arrived and was refused, or the background sweeper retired the deferral on its own schedule with no resume ever arriving, which is the case a ttl mostly exists for.

Suggestion
This is catchable rather than terminal: the deferred route's own .error() handler receives it and can notify and re-ask with a fresh deferral. Raise ttl on .defer() if the window is genuinely too short for the people it waits on.

RC5048

Deferral continuation changed

Why it happens
The steps after the defer point, or the declared schema, changed while the exchange was deferred. The stored approval no longer authorizes what would now run, so resuming is refused before any of those steps execute.

Editing a step BEFORE the defer point does not trigger this: those steps already ran, and changing them cannot affect what happens after the resume.

Suggestion
Catch it in the deferred route's route-scope .error() and re-ask. Note the hash also moves for edits that change emitted step source without changing behaviour (a formatting pass, different line endings, a build-settings change), so a deployment that defers approvals for days should pin those; see Configuration → deferral.

RC5049

Deferral result rejected

Why it happens
The payload handed to .resume() failed the schema declared on the deferring .defer().

Suggestion
The deferral is left resumable, so a corrected payload still works. Check the mapping function in .resume((ex) => ({ token, result })): it owns the SHAPE of the payload, while validation happens at revival because only the deferral knows the schema.

RC5050

Deferral denied

Why it happens
The deferral was marked denied before this resume arrived, typically because the run carrying the deferred exchange was cancelled.

Suggestion
A denied deferral is terminal. Re-submit the work as a new exchange rather than resuming.

RC5051

Defer not supported at this position

Why it happens
A .defer() was declared where the framework cannot durably defer and revive the exchange: inside a .split() that no .aggregate() balances, or inside a .multicast() path or .dispatch() target.

Suggestion
Split the work into per-item child capabilities, each its own exchange deferring independently, or move the defer onto the main flow or a .choice() branch of it. Raised at craft() build time, so it fails on the deploy that introduced it.

RC5052

Deferral runtime not configured

Why it happens
A route in this context can reach a durable .defer(), but nothing configured where deferred exchanges are stored or how resume tokens are signed.

Suggestion
Add deferral: {} to defineConfig to take the defaults, or deferral: { store, secret } to be explicit. It is deliberately not implicit: a durable defer that silently defers into memory loses everything it promised on the next restart.

deferral: {} applies defaults; it does not supply a signing secret. In production, set secret or the ROUTECRAFT_DEFERRAL_SECRET environment variable, or startup fails with RC5040 instead. An ephemeral key is minted only for tests and development, because a key that dies with the process makes every outstanding resume token unverifiable across exactly the restart deferral exists to survive.

RC5053

Ops plugin misconfigured

Why it happens
The ops config does not describe a surface the plugin can serve. The common causes, each with its own message: an indicator bound to a route id no route declares (the message lists the known ids), two indicators sharing a name, a value in ops.indicators that defineIndicator() did not produce, an invalid health.details value, health.details: 'when-authenticated' written explicitly with no validator in scope (no ops.auth and no servers.<name>.auth), or ops.auth: false, which is refused as a no-op because the health surface never walls.

A contributed resource is refused with the same code at registerOpsResource(): a name that is not one lowercase path segment (letters, digits and dashes), a name the mount serves itself (routes, events), or a name another contributor already registered on the context.

Suggestion
For a bound route, check the id against the route's .id(...); the binding is by id, not by variable name. For a duplicate, rename one indicator: names are the keys of the health report, so a duplicate would shadow one dependency with another. For a hand-rolled handle, declare it with defineIndicator({ name }): an object of the same shape has no ledger to report into, so every push through it would be silently dropped, and a dependency nobody is watching looks healthy. For the explicit gate with nothing to gate on, set ops.auth (or servers.<name>.auth), or say what you mean with health.details: 'always' or 'never'. For ops.auth: false, remove it; if the intent was serving details to every caller, that is health.details: 'always'.

For a resource name, pick one that matches ^[a-z][a-z0-9-]*$ and is neither routes nor events; for a duplicate, one contributor owns a name, so a second plugin that wants the same collection reads the first's registration off the context (OPS_RESOURCES) rather than registering again.

Pushing through a handle that is merely unregistered is not this error. It is deliberately inert, so health instrumentation can never kill the code it instruments.

RC5054

Deferral cancelled by run abort

Why it happens
The run raised a durable deferral while it was being cancelled: a route stop or an elapsed .timeout() aborted the run in the narrow window around the deferral. If the abort won the race, the exchange was never deferred; if the deferral won, the just-created deferral was immediately denied so its resume link is dead, and a token presented later reads RC5050. Either way the caller sees this error instead of a resumable acknowledgment, which keeps the two stories consistent: a run reported as cancelled must not be resumable later.

Suggestion
Nothing needs repair; the exchange's work simply did not defer. Re-dispatch it as a new exchange if it should run again. Note the boundary: this is about the cancellation race only. A deferred exchange whose process merely stops or restarts SURVIVES it; context.stop() never denies deferred work, because surviving the stop is the store's entire purpose.

RC5055

Resume credential not bound to this call

Why it happens
The token verifies and names a real deferral, but it was minted for a different call than the one the record is deferred on. A parallel batch of agent tool calls produces ONE deferral, while each handler mints its own credential: only the call that actually won the deferral may be resumed, so a losing sibling's recipient presenting their link lands here.

Suggestion
Nothing is wrong with the record; it is still resumable by the rightful credential. Design so at most one tool per turn can defer (one approval tool, or mutually exclusive guards) rather than handing two recipients two links backed by one deferral. The refusal is deliberately non-destructive: it leaves the record exactly as it found it, so the winning call's recipient can still resume.

RC5056

Resume refused by the route's authorize hook

Why it happens
The .resume({ authorize }) hook on the ingress route refused this principal. Who may resume a deferred run is your application's policy, not the framework's: the hook receives the live principal, the deferred principal snapshot, the raw submitted payload, and the record's context, and decides.

A hook that returns false, one that throws, and one that never settles before the route's own .timeout() all produce this one code with the same message. That is deliberate: a hook whose failures can be told apart from outside is an oracle for what it knows.

Suggestion
Read the boundary log. It records which of the three happened and carries any thrown cause, neither of which reaches the wire. The refusal is non-destructive and runs before the record's lifecycle is disclosed, so the record stays exactly as it was and a refused caller learns nothing about it; whoever does qualify can still resume.

A door that declares no hook is bearer by design: any holder of a valid token may resume. If you did not intend that, the fix is a hook, not a config flag.

RC5057

Deferral sequence header unusable

Why it happens
The framework-owned routecraft.deferral.sequence header carries a value the deferral counter cannot use: a non-numeric or negative value, or one at the exhaustion bound, refused before the counter can ever store a value whose own increment would leave the safe-integer range. Headers are a writable bag, so a step (or an external system the headers were round-tripped through) can mangle this one like any other.

It is a refusal rather than a reset on purpose. The deferral id derives from this counter, and resume tokens sign the id: a counter silently reset to zero re-derives an id an earlier deferral of the same exchange already used, and an old unspent resume link would then verify against the new deferral. The error is raised only on deferral surfaces (ex.deferral, or the deferral itself); routes that never touch deferral are unaffected by a mangled header.

Suggestion
Find what overwrote the header. The usual culprit is code that rebuilds a headers object by hand or passes headers through an external system that coerces values to strings. If the message reports exhaustion rather than a malformed value, treat it the same way: the bound is not reachable by deferring in a loop, so a plausible-looking huge value is still corruption.

RC5058

Invalid shutdown configuration

Why it happens
shutdown.timeout was set to something that is not a positive, finite duration: 0, a negative, NaN, or Infinity. It is refused while the context is being built rather than when the context stops, because a shutdown is the worst possible moment to discover the value was never usable.

The refusal is deliberate rather than a clamp. The two plausible readings of 0 are opposites: it looks like "no bound", and a clamp would make it behave as "force immediately", abandoning in-flight work the instant a stop begins.

Suggestion
Omit the key to take the 30-second default, or set a positive number of milliseconds below your platform's kill timer.

RC5059

Management API request refused

Why it happens
A paging argument on a management collection (GET /ops/routes) was refused rather than interpreted. Either the limit was not a positive integer within the server-side maximum, or the cursor could not be used: it was not a nextCursor this API minted, or it was replayed under a filter different from the one that produced it.

Both are refusals rather than best-effort readings. A limit silently clamped to the maximum leaves a caller unable to tell a truncated page from a complete answer, and it will read page one forever believing it has everything. A cursor honoured under a changed filter would hand back a page of a different result set with nothing on the wire to signal it.

Suggestion
Send a limit that is a positive integer within the documented maximum. Pass nextCursor back unchanged, and keep the filter parameters identical across a paged listing: changing a filter starts a new listing rather than resuming an old one under new terms.

RC5060

Route is not dispatchable

Why it happens
POST /ops/routes/{id}/exchanges targeted a route that exists but has no door for an exchange to arrive through. A direct() ingress is what makes a route id dispatchable; a route sourced from cron(), mail() or http() runs from its own trigger, and there is nothing for a dispatch to hand a message to.

A route declared direct({ internal: true }) raises the same code with a different message: it has a direct source and closed the door on purpose, so the refusal names the declaration instead of suggesting a source the route already carries.

This is distinct from a 404, which means no route by that id is registered at all. The two need different answers, so they are told apart rather than collapsed.

Suggestion
For a route with no direct source, add .from(direct()) if it should be callable, or dispatch to one that already is. For a route declared internal, dispatch the boundary route that fronts it; the internal route is only composable from another route. GET /ops/routes?dispatchable=true lists exactly the routes that will accept the call, and dispatchable is on every route representation.

RC5061

HTTP client response body exceeds maxBodySize

Why it happens
An http({ url }) call received a response larger than the route allows. The cap is maxBodySize, 10 MB by default, and it is the same name and the same number as the http plugin's inbound cap: one concept means one thing on both sides of the framework.

The message names both numbers and how the size became known. A Content-Length above the cap is refused before a single byte is read; a response that declares no length (a chunked transfer) is abandoned mid-stream the moment the running count crosses the ceiling, so the number reported is what had arrived by then rather than what the endpoint intended to send. Because the cap is enforced while the body streams, an oversized response bounds what the process spends, not merely what the route receives.

The body is never truncated to fit. Half a JSON document parses as though it were whole, and a route acting on it would be quietly wrong rather than loudly failed.

The cap applies to error responses too, so a 500 answering with a gigabyte is refused on the same terms. The size error names the status, so the underlying HTTP failure stays legible in the message.

Suggestion
Raise the ceiling on the call when the endpoint legitimately returns more than 10 MB: http({ url, maxBodySize: 50_000_000 }). Otherwise ask the endpoint for less, with a narrower query, a page, or a range request.

RC5062

Remote instance unreachable

Why it happens
A dispatch to a route imported through defineConfig({ remotes }) never got an answer from the remote instance. The connection was refused or the name did not resolve, the connection was lost after the request was sent, or the request timed out. The message names the remote and the route, and says which it was.

Suggestion
Check the remote's url and that the instance is running and serving its ops surface. Only a connection that never opened is safe to retry, and the code is marked retryable for it. A connection lost after the request was sent, or a timeout, is not: the remote may still be running the work it accepted, so a retry could run a non-idempotent route twice, and those carry retryable: false on the error itself.

RC5063

Remote instance refused the credential

Why it happens
The remote's door answered 401 or 403 to a dispatch on an imported route, so the problem is the credential and not the route. The message carries the door's own reason: no credential was presented (remotes.<name>.auth.token is unset or resolved to nothing), the credential was rejected, or it is valid but lacks the dispatch tier's scope.

Suggestion
A missing scope needs a token carrying it rather than a new sign-in; the message names the scope. When the remote advertises RFC 9728 metadata the message also names who issues acceptable tokens. The credential is read per request when auth.token is a function, so a rotated token takes effect on the next call without a restart.

RC5064

Remote dispatch failed

Why it happens
The remote instance answered a dispatch on an imported route with an error. Its own error code rides in the message and on cause, and is what to read first: the ops door sends the code and never the message, so the detail is in the remote's logs.

Suggestion
A RC5060 from the remote means the route stopped being dispatchable there (it declared direct({ internal: true }), or lost its direct source); anything else is the route's own failure, exactly as it would have surfaced to a caller on the remote. Read the remote's logs for the exchange, and fix the route where it lives.

RC5065

Request validation failed

Why it happens
A payload a caller supplied did not satisfy the route's .input() schema, on the body or on the headers. Validation runs at filter chain position #4, before any step of the pipeline, so nothing of the route has executed when this is raised and a route-scope .error() can recover it like any other chain failure.

This is the caller's fault rather than the instance's, which is the whole reason it is not RC5002. RC5002 also covers .output() validation, a mid-pipeline validate() step and an empty aggregation, none of which the caller can do anything about. Splitting them lets a transport answer the two differently: the ops dispatch mount returns 400 with the message for this code, where a route failure is a 500 carrying only the code.

Suggestion
Read the message: it names the route, the field and the rule that rejected it. craft ops routes <id> prints the route's full input schema on a running instance. Retrying an unchanged payload cannot succeed, which is why this code is not retryable.

RC5066

Deferral store is missing a contract member

Why it happens
A surface asked the deferral store for something the store this context was given does not implement. GET /ops/deferrals reads DeferralStore.list, which the two shipped backends provide; a store supplied through deferral: { store } is your own, and one written against an earlier version of the contract can be missing a member added since. The message names it.

Suggestion
Implement the member the message names, or drop back to a shipped backend (sqlite, or memory for tests). The surface refuses rather than answering an empty listing, because an empty listing and a listing the store cannot produce look identical to whoever is reading them, and one of them is a lie about what the instance is holding.

RC9901

Unknown error

Why it happens
Unexpected failure without a specific code.

Suggestion
Check logs and enable debug level.

OS1001

Isolation tier unavailable

Why it happens
shell() runs isolated by default and never silently downgrades to a weaker tier, because a route that believes it is contained and is not is worse than one that fails. The tier could not be established. For unshare the usual causes are a non-Linux host (the tier is Linux-only), a kernel with unprivileged user namespaces restricted (kernel.unprivileged_userns_clone=0, or apparmor_restrict_unprivileged_userns=1), a container whose seccomp profile blocks namespace creation, or util-linux unshare not being installed. For docker it is a daemon that did not answer: none running, or DOCKER_HOST pointing somewhere else.

Suggestion

  • Grant the privilege: allow unprivileged user namespaces, or relax the container's seccomp profile.
  • For the docker tier, start the daemon or point DOCKER_HOST at one.
  • Or write isolation: "none" at the call site to run without isolation deliberately and visibly.

There is no third option by design. shell() will not choose a weaker tier on your behalf.

OS1002

Command execution failed

Why it happens
The command ran and reported failure. This is the default because a route whose git push failed should not continue as though it had. The same code covers a command that could not be started at all, which is usually a program that is not installed or not on the PATH the call granted. On the docker tier it also covers an image that is not present on the daemon: shell() never pulls one, and the message names the image and the docker pull.

Suggestion

  • Where the command ran, read stderr and exitCode from the error's cause to see what it said. A command that never started has neither, and the cause is whatever the spawn itself raised.
  • For commands whose exit code is data rather than failure (grep with no match, diff with differences), pass failOnNonZero: false and read exitCode off the result.
  • If the program could not be started, check that it is installed and reachable through the environment the call declares. shell() grants only the documented baseline plus what the call asks for.

OS1003

Command timed out

Why it happens
The command exceeded the timeout given to shell() and was killed, along with anything it had spawned.

Suggestion

  • Raise the timeout if the work is genuinely long, or narrow what the command does.
  • A command that times out inside an isolation tier but not outside it is usually waiting on something the tier denies. Network egress is denied unless the call sets network: true.

OS1004

Isolation cannot satisfy the request

Why it happens
The tier was established, but an option the call set asks for something it cannot do. A tier refuses such an option rather than ignoring it, because a route that believes it is contained and is not is worse than one that fails.

The usual case is isolation: "none" with egress left denied. network defaults to denied and that tier contains nothing, so it cannot withhold the network; granting it silently would leave the call believing in a containment it never had. The other case is a container option (image, mounts, name) on a host tier: there is no container to apply it to, and a dropped image would let a command the author confined to a filesystem run on the host's.

Suggestion

  • Say what you mean at the call site: network: true beside isolation: "none" accepts egress out loud.
  • Or choose a tier that can deny it, which on Linux is unshare, and anywhere a daemon answers is docker.
  • The same applies to mapRootUser on a tier with no user namespace: drop it, or pick a tier that has one.
  • For a container option, write isolation: "docker" beside it, or drop it.
Previous
Linting