Seven ways an action
does not execute.
Security infrastructure is learned through its refusals as much as its successes. Each state below is one the SDK or platform actually produces, with its real code, and every one of them ends the same way: nothing runs.
- 7 failure states
- 0 that execute
- Real codes and messages
- 01
The action violates the signed intentDENY
Evaluation
The proposed action falls outside a deterministic constraint - amount over the maximum, wrong tool, wrong counterparty, wrong environment, outside the time window.
Do not retry with the same parameters. The finding names the rule; surface it. A DENY is deterministic - no model output can convert it into an ALLOW, and asking again produces the same answer.
const decision = await client.evaluate({ ...proposal, amount: "18300.00" // intent maximum is 15,000 }); // decision.decision === "DENY" // decision.deterministic.findings[0].rule === "maximum_amount" // decision.semantic.status === "NOT_EVALUATED" - 02
A named human must decideREQUIRE_APPROVAL
Evaluation
The action is inside the intent's hard limits but crosses its approval threshold, or policy demands a human for this class of action.
Nothing executes until a human sets the status to Approved. Denied and Expired are terminal. AlternativeRequested means the approver proposed a different action - evaluate that one; do not treat it as consent for the original.
const decision = await client.evaluate({ ...proposal, amount: "7500.00" // above the 5,000 approval threshold }); // decision.decision === "REQUIRE_APPROVAL" // decision.approvalId → poll it, or subscribe const approval = await client.waitForApproval(decision.approvalId, { timeoutMs: 300000 }); // approval.status ∈ Pending | Approved | Denied | // AlternativeRequested | Expired - 03
The grant's window closed before redemptionexpired_grant
Verifier · platform: "Authorization expired."
Grants are short-lived by design. If the protected resource is slow to redeem, or the caller held the grant too long, it is no longer valid.
Evaluate again. There is no renewal path for a grant, on purpose - a long-lived grant is standing authority, which is what 2AYE exists to prevent.
try { await verifier.verify(grant, {tenantId, agentId, envelope}); } catch (error) { if (error.code === "expired_grant") { // "The authorization grant has expired." // Do not extend it. Re-evaluate; get a fresh decision. } } - 04
The grant was already redeemedconsumed_grant
Verifier · platform: "Authorization replay detected."
A grant is single-use. Presenting it a second time - by retry, by duplication, or by an attacker who captured it - fails at both the verifier and the platform.
Treat as a real signal, not noise. A replay from your own retry logic means the retry is wrong; a replay you did not send is an attack. Either way the second action does not run.
await client.consume({authorizationId: grant.id, agentId, envelope}); // first call: redeemed, state committed with its audit event await client.consume({authorizationId: grant.id, agentId, envelope}); // second call: platform refuses - "Authorization replay detected." // verifier.verify() on a consumed grant: code "consumed_grant" - 05
The grant was not signed by the published keyinvalid_signature
Verifier
The signature does not verify against the ES256 keys at /.well-known/verid-grant-keys - a forged grant, a tampered field, or a key the issuer never published.
Never fall back to trusting the caller. This is the check that makes the resource independent of everyone upstream. In production the verifier also refuses ephemeral development keys by default; do not switch that off to make an environment work.
try { await verifier.verify(grant, {tenantId, agentId, envelope}); } catch (error) { // error.code ∈ invalid_signature | unsigned_grant | unknown_key // | invalid_key_set | private_key_exposed // | ephemeral_key (production refuses dev keys) } - 06
The envelope differs from what was authorizedaction_mismatch
Verifier · platform: "Authorization does not cover the submitted parameters."
The grant covers one exact envelope. Any material change - a different amount, a different counterparty, even 13800 versus 13800.00 - is a different action.
Do not normalise your way around it. The comparison is a constant-time hash of the canonical envelope, and the textual scale of amounts is part of the binding on purpose. Evaluate the action you actually intend to run.
// authorized: amount "13800.00" // presented: amount "13800" ← different textual scale await client.consume({authorizationId: grant.id, agentId, envelope}); // platform: "Authorization does not cover the submitted parameters." // verifier: code "action_mismatch" // related: agent_mismatch · intent_mismatch · tenant_mismatch - 07
No human decided in timeApproval wait timed out
SDK · waitForApproval
waitForApproval polled until timeoutMs elapsed and the approval was still Pending. The SDK throws; it never approves on the caller's behalf.
A timeout is not a decision. Do not treat silence as approval, and do not run the action. Notify the approver channel and let the platform's own expiry mark the approval Expired if it lapses.
try { const approval = await client.waitForApproval(decision.approvalId, { timeoutMs: 300000, intervalMs: 1000 }); } catch (error) { // Error: "Approval wait timed out" // The approval itself may still be Pending on the platform, // or may have moved to Expired. Nothing has executed. }
Follow every refusal above from identity to evidence.
Each consequential action moves through the same six control points. See where 2AYE evaluates authority, requests human review, and preserves the outcome.
- 01IdentityWho or what is acting
- 02IntentSigned purpose and limits
- 03PolicyDeterministic evaluation
- 04ApprovalA named human when required
- 05ExecutionSingle-use grant, redeemed at the resource
- 06EvidenceReceipt joined to the audit chain