Skip to content

security

Intended Documentation

Fail-Closed Controls

How Intended behaves when a decision cannot be established — default-DENY, the 500 AUTHORITY_LOOP_FAILED fail-closed path, single-use authority tokens, and adapter-enforced "no token, no action".

Fail-Closed Controls#

Intended is fail-closed: when a decision cannot be established, the runtime denies rather than allows. There is no best-effort or fail-open path. This page enumerates the exact behaviors — at the decision engine, at token issuance, and at the enforcement boundary — that a reviewer can verify against the code.

Fail-closed vs. fail-open#

Most authorization layers fail open: if the policy service is unreachable, the request passes. That prioritizes availability over correctness. Intended takes the opposite stance at every layer.

ScenarioFail-open (typical)Fail-closed (Intended)
No policy matches the intentAllow (no deny rule)Deny — absence of a matching allow is a deny
Authority evaluation throwsAllow with degraded loggingDenyPOST /intent returns 500 AUTHORITY_LOOP_FAILED
Token cannot be minted or signedAllow with warningDeny — no token is issued, so no action proceeds
Adapter cannot verify the tokenExecute anywayDeny — the adapter never runs the action
Token replayed or expiredAllow if signature parsesDeny — nonce already consumed / past expiry

Info

Fail-closed means availability of downstream actions depends on the health of the authority path. This is an intentional trade-off: Intended prioritizes never authorizing an action it cannot justify over maximizing uptime of the systems it governs.

Default-DENY at the decision engine#

The modern policy engine (evaluateAuthority) is rule-based and resolves conflicts by most-severe-wins:

text
DENY  >  REQUIRE_APPROVAL  >  ESCALATE  >  ALLOW

Two properties make this fail-closed:

  • No match → DENY. If no rule matches the intent, the engine returns DENY. The absence of a permitting rule is never an implicit allow.
  • Interpretation signals upgrade only. Signals from the interpretation layer (LIM) can only raise severity (e.g. nudge an ALLOW toward ESCALATE) — they can never downgrade a decision toward allow. A noisy or adversarial interpretation cannot weaken a deny.

Note

POST /intent reports the legacy decision vocabulary — APPROVED (200) / ESCALATED (202) / DENIED (403) — while the modern POST /authority/evaluate reports ALLOW / DENY / REQUIRE_APPROVAL / ESCALATE. Keep the two vocabularies distinct when handling responses.

The fail-closed path on /intent#

POST /intent maps outcomes to HTTP status deterministically. Crucially, any unexpected failure during evaluation produces a deny, not a pass-through:

OutcomeHTTPNotes
Approved and executed200Action ran; audit entries written
Escalated202Returns an escalationId / approvalRequestId
Denied403Explicit policy or default-deny
Approved but execution blocked403Decision allowed, enforcement still denied
Request invalid400 VALIDATION_ERRORMalformed IntentRequest
Any failure in the authority loop500 AUTHORITY_LOOP_FAILEDFail-closed: the action does not proceed

A 500 AUTHORITY_LOOP_FAILED is a safe failure — the runtime could not complete the decision, so it refused to authorize. Treat it as "denied due to system fault," not as a transient you can retry into an allow.

"No token, no action" at the enforcement boundary#

Even after an APPROVED decision, execution is gated by an Authority Token that the executing adapter must verify. The token is an Ed25519 JWT with a mandatory kid, a 300-second TTL (default and hard maximum), and a single-use nonce.

Token verification is the enforcement boundary
An Ed25519 authority token travels in the connector envelope to the adapter; the adapter resolves the kid to the tenant public key, checks the Ed25519 signature, tenant and adapter match, and expiry, then atomically consumes the single-use nonce before the action executes. Replay, expiry, and kid-mismatch all reject.

The adapter validates the token before any side effect. Replay, expiry, and key-id mismatch each reject the action — fail-closed by construction.

BaseAdapter.execute() (in @intended/connector-sdk) always validates before doing anything, and never runs the action if validation fails. The validation sequence is:

Decode the kid

Read the kid from the token header. A token without a resolvable kid is rejected.

Resolve the tenant public key

Look up the tenant's public key by kid. Keys are per-tenant Ed25519; rotation is expressed through kid.

Verify the Ed25519 signature with kid pinning

Verify the signature against the resolved key. The verifier pins the expected kid, so a token signed under a different key is rejected.

Assert tenant, adapter, and target match

Confirm the token's tenantId, adapterId, and adapterTarget match this adapter. A token minted for a different tenant or adapter cannot be replayed here.

Assert decision is APPROVED and not expired

Reject anything other than decision === "APPROVED", and reject tokens past expiresAt (≤300 s after issue).

Consume the single-use nonce

Atomically consume the nonce against the DB-unique @@unique([tenantId, nonce]) constraint. A replayed token whose nonce is already consumed is rejected. Only now does the action execute.

Each of these is a fail-closed gate: if any step cannot pass, the adapter raises a validation error and the action does not run.

Token issuance is fail-closed too#

A token is only minted for an APPROVED decision. If signing cannot complete — for example the tenant signing key is unavailable — no token is produced, so no downstream adapter can be satisfied, and the action cannot proceed. There is no "unsigned but trusted" fallback.

No global fail-open override#

Danger

Intended deliberately provides no global fail-open switch. Such a switch would become the single most valuable target for any attacker. To loosen behavior, change policy through the audited policy pipeline — a permissive policy is reversible and leaves a record, whereas a runtime bypass would not.

To widen what is permitted, the supported path is to author and deploy a more permissive policy through the normal, audited pipeline (see Deploy a Policy). That change is versioned, attributable, and reversible — and every decision it produces is still recorded in the hash chain.

Fail-Closed Controls | Intended