Skip to content

guides

Intended Documentation

Error Patterns and Remediation

The real error codes the Intended runtime returns — auth, validation, decision, token, and rate-limit failures — with the exact HTTP statuses, the fail-closed semantics, and how to handle each.

Error Patterns and Remediation#

Intended is fail-closed: when a request cannot be authenticated, validated, or evaluated, it is denied rather than allowed through. This page lists the error codes the runtime actually returns and how to handle each.

Warning

Error body shape is not uniform across routes. Some return a flat { "error": "<CODE>", "message": "…" }; others nest as { "error": { "code": "…", "message": "…" } }. Read the HTTP status first — it is the reliable signal — and treat the body as supplementary. Do not assume a request_id or details field on every error.

Authentication and authorization#

These come from the auth gate before any route handler runs. The full credential model is in API Authentication.

StatusCodeCauseWhat to do
401UNAUTHORIZEDNo valid credential.Check the Authorization: Bearer intended_live_… header and the key value.
401INVALID_PORTAL_SESSIONIdle or expired portal session.Re-authenticate the session.
403TENANT_MISMATCHCredential's tenant ≠ requested tenantId.Send the correct x-tenant-id / body tenantId.
403FORBIDDENAuthenticated but missing the required permission.Grant the named scope/role to the key (e.g. intent:create).
403IP_NOT_ALLOWEDSource IP not on the tenant allowlist.Call from an allowlisted address.
403MFA_REQUIREDPrivileged portal role without MFA.Complete MFA on the session.

Validation#

StatusCodeCauseWhat to do
400VALIDATION_ERRORThe body failed schema validation.Fix the request. For POST /intent, the body is an IntentRequest: tenantId, actor{id,type}, targetSystem, proposedAction, riskContext{…, github{…}}. targetSystem must equal "<github.owner>/<github.repo>".

Note

There is no action / resource / context triple. The /intent request is an IntentRequest with a required riskContext.github target. Sending the wrong shape produces 400 VALIDATION_ERROR.

Decision outcomes#

A decision is not an error — DENIED is the system working correctly. But it surfaces as HTTP 403, so handle it deliberately.

StatusauthorityDecision.decisionMeaningWhat to do
200APPROVEDAuthorized; token minted.Verify the token, then execute.
202ESCALATEDNeeds human approval; no token.Route to your approval queue.
403DENIEDNot authorized (default-deny on no match).Surface the rationale; do not retry as-is.
500AUTHORITY_LOOP_FAILEDEvaluation failed internally.Fail closed — the action was not authorized. Retry with backoff; if persistent, contact support.

Info

No matching policy is a deny, not an error. The engine is default-deny: an action that matches no policy is denied. If you expected an approval, author a policy that covers the intent pattern.

Token verification#

These are returned by @intended/verify's verifyToken (as the reason field), or surfaced by a connector's BaseAdapter on a rejected execution. The complete list and the verification flow are in Verify Decision Tokens.

ReasonCauseAction
KID_MISMATCHToken kid ≠ pinned key id.Refresh the tenant keys; re-pin the kid.
SIGNATURE_VERIFICATION_FAILEDBad signature / wrong key / issuer / audience.Reject; possible tampering or wrong key.
TOKEN_TENANT_MISMATCHToken tenant ≠ expected.Reject; token replayed across a boundary.
TOKEN_ADAPTER_MISMATCHToken adapter ≠ this execution.Reject; wrong token for this adapter.
TOKEN_EXPIREDPast expiresAt.Re-submit POST /intent for a fresh token.
TOKEN_TTL_EXCEEDS_LIMITTTL beyond the 300s cap.Reject; tokens cannot exceed 300s.

A token is also single-use. A connector rejecting a replay (nonce already consumed) returns a rejected execution result with the nonce-consumption reason — do not retry the same token; obtain a new decision.

Rate limiting#

StatusCodeCauseWhat to do
429RATE_LIMITEDPer-IP limit exceeded (global 500/min, stricter on auth endpoints).Back off with exponential delay; retry.

Retry strategy#

Retry only transient failures. Use exponential backoff with jitter for 429 and 5xx; never retry 400 / 401 / 403 unchanged.

ts
async function submitWithRetry(intent: unknown, maxRetries = 3): Promise<Response> {
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    const res = await fetch("https://api.intended.so/intent", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${process.env.INTENDED_API_KEY}`, // intended_live_…
        "x-tenant-id": "tenant_acme_prod",
        "Content-Type": "application/json",
      },
      body: JSON.stringify(intent),
    });

    // 429 and 5xx are retryable; everything else is terminal (including 403 DENIED).
    if ((res.status === 429 || res.status >= 500) && attempt < maxRetries) {
      const base = Math.min(1000 * 2 ** attempt, 30_000);
      await new Promise((r) => setTimeout(r, base + Math.random() * base * 0.1));
      continue;
    }
    return res;
  }
  throw new Error("Max retries exceeded");
}

Retryable vs terminal#

  • 400, 401, 403, 404 — terminal. Fix the request (a 403 DENIED decision is a correct, terminal outcome).
  • 429 — retryable. Back off.
  • 500, 502, 503 — retryable. Transient. (500 AUTHORITY_LOOP_FAILED is fail-closed: the action did not happen.)

Next steps#

Error Patterns and Remediation | Intended