Skip to main content

Why a second credential

A grant lets an agent call tools. Some tools decide: approve a customer, decline an application, delete a monitoring enrolment, release a payout. For those, “the agent had permission” is not an acceptable answer. A prompt that says “never approve without a human” is not a control, and neither is a button in a console that an API call can bypass. A decision grant is a token that only a named, step-up authenticated person can cause to be minted, bound to one exact action on one case, and usable once. The platform that runs the agent cannot mint one: it has no API for approving, for signing an approver in, or for choosing which identity provider approvers use.

Declaring that a tool needs a decision

In the tool manifest (schema 0.6):
requires_decision is not allowed on a read tool. four_eyes_on lists the decisions that need two approvers. decision_fields lists arguments, beyond the core action, that the decision must also bind. A grant can also require decisions: a tool listed in the grant token’s urn:grantex:decision:v1 entry needs a decision grant even when the manifest does not declare requires_decision, and a decision in either four_eyes_on list needs two approvers.

What a decision grant approves

Not the bytes of a tool call, but its semantic action:
plus amount when there is one and the declared decision_fields (for example "extra": {"currency": "GBP"}). The token carries the action and its RFC 8785 hash. When the agent calls the tool, the enforcer derives the action from the call’s arguments and compares hashes. The agent may re-plan its payload, add a timestamp or reorder fields and the decision still holds; it may not change the case, the decision, the subject, the amount or a declared field. Anything else the call means is not approved. If a tool’s effect depends on an argument that is not bound, declare it in decision_fields.

Who can approve, and how

  1. The service administrator allow-lists, per developer, the OpenID Connect identity providers whose users may approve. The developer API key cannot do this.
  2. The platform creates a decision request with its API key: the action, the connector, the case version, the memo text and the policy score the approver must review, the manifest’s four_eyes_on, and the agent the decision is for (agentId, its Grantex agent id; grantId, its grant). The response contains approvalPage, a link to the auth service.
  3. The approver opens that page in their browser and signs in with an allow-listed identity provider, with a strong method (for example a passkey). The auth service runs the sign-in itself (authorization code with PKCE, state and nonce) and checks step-up from acr / amr and auth_time. The session lives in an HttpOnly cookie on the auth service and lasts for a configured window (one hour by default); no API ever returns it.
  4. One click per decision. The page shows the memo, the policy score and the exact action. The approver clicks approve; the auth service measures how long the decision was on screen, refuses approvals faster than a minimum, and mints the decision grant.
  5. The agent’s grant token fetches the minted grant. The platform fetches the grants with the grant token of the agent the request names (getGrants / get_grants) and passes them with that agent’s tool call. enforce() (or an MCP server) verifies them and consumes them at the auth service with that agent’s grant token; only then does the call proceed. With the agent binding on (DECISION_GRANT_AGENT_BINDING=true, see Binding decisions to the requesting agent), this is the only way to get them: the developer API key alone never receives a decision grant, GET /v1/decisions/requests/{id} reports decisionGrantsReady and the approvals but not the tokens, and another agent presenting them is refused (wrong_agent).
  6. Everything is in the audit chain: who configured the identity provider, each sign-in, the approval (approver, identity provider, authentication method, dwell time, action, memo and policy score hashes), each release of the grants to an agent, consumption, and every refusal.
Strong authentication happens once per session and each decision is a deliberate, recorded click: a passkey prompt for every action produces exactly the rubber-stamping this feature exists to prevent. Asking for a four-eyes decline from a platform:
Fetching the grants once approved, with the agent’s grant token:
A decision the platform takes for itself, naming no agent, is never released to an agent: the platform consumes it by request id (grantex.decisions.consumeRequest(requestId, { action, caseVersion }), or consume_request in Python; POST /v1/decisions/requests/{id}/consume), with the same checks as any consumption, and the grant never leaves the auth service. A request that names an agent cannot be consumed that way. Fetching with the agent’s grant token and consuming by request id work whether or not the agent binding is on, so a platform can move to them first and turn the binding on afterwards.

The four-eyes model

For a decision listed in four_eyes_on, two different people must approve.
  • The first approval mints a grant marked four_eyes: {approvals_required: 2, position: 1}. Presented alone it is refused (four_eyes_incomplete).
  • The second approval is minted only after the first, by a different approver, and names the first (first_jti, first_sub). Approvers are identified by identity provider and subject; the auth service also refuses a second approver with the same verified email (same_approver).
  • The call presents both grants. Both are consumed together, or neither is.
  • Four eyes is enforced when approving, in offline verification (a grant that says two approvals are needed is never accepted alone, whatever the manifest says) and when consuming.
Four eyes compares identities. It cannot tell that two accounts without a shared verified email belong to one person; configure identity providers to require verified emails where that matters.

Validity

A decision grant is valid until the first of:
  • it is consumed: one grant authorises one call;
  • the case changes: the platform registers a new case version (for example a hash of the evidence behind the decision) and unconsumed grants for the old version are revoked;
  • 24 hours pass, or its request expires;
  • the request is cancelled.
Consumption spends the grant even if the response is lost or the tool call fails afterwards; a person then has to approve again. Make the tool call idempotent per decision request.

Enforcing

Python:
TypeScript:
wrap_tool / wrapTool, enforceMiddleware and the FastAPI GrantexEnforcer accept the decision grants and case version too (the FastAPI dependency reads grants from the grantex-decision-grant header and needs a case_version callback that reads your own case state). MCP servers use grantexDecisionVerifier from @grantex/mcp-auth (see mcp-auth); clients send grants in the grantex-decision-grant header and receive a decision_required challenge without one.

Refusals

Rolling out: decisions.required

enforce() denies by default (decisions_mode="enforce"). A platform rolling decision grants out per tenant maps its decisions.required flag to the mode: on means enforce; off means warn. warn is not a control: it does not deny a call to a decision tool that lacks a valid decision grant; it lets the call through and reports the denial that would have happened in result.would_deny (with any caps warn-mode warnings on the same call listed after it in result.would_deny_all / wouldDenyAll), so you can measure the effect before turning enforcement on. Valid grants presented in warn mode are still consumed.

Binding decisions to the requesting agent

A decision grant is a bearer credential: whoever holds it can present it. With DECISION_GRANT_AGENT_BINDING=true on the auth service, a decision an agent asked for belongs to that agent:
  • GET /v1/decisions/requests/{id} never returns the grants; it answers decisionGrantsReady and the approvals by jti.
  • POST /v1/decisions/requests/{id}/grants (getGrants / get_grants) releases them only against a live grant token of the agent and grant the request names. Every hand-out and every refusal is recorded in the audit chain; if the record cannot be written, nothing is released.
  • POST /v1/decisions/consume spends a request’s grants only when a live grant token of that agent and grant accompanies them (grantToken, as for getGrants). The auth service verifies the token itself (signature, expiry, revocation, grant status, developer) and takes the agent and grant from it, never from the body: agentDid, agentId and grantId, when sent, must be that token’s. A missing token, one that is not live, or another agent’s or grant’s, is refused (wrong_agent, 403), and the refusal is recorded with why (token_check) and what the body claimed. A request that names no agent is consumed as before, without a token; a token sent with it must still be live, and its agent is recorded.
  • Asking again for the same action and case version while a request is open for another agent or grant is refused (wrong_agent, 409) instead of answering with that request.
The setting is off by default. Off, request creation, GET and consumption answer as they did before the binding existed: the developer API key reads the grants once approved, and consumption records the agent and grant without comparing them. Only true and false are accepted; any other value makes the decision endpoints answer 503. enforce() in both SDKs, and grantexDecisionVerifier given the SDK’s consume, send the grant token they verified as grantToken, with its DID as agentDid (never as agentId) and its grant as grantId. An auth service from before the binding accepts only a Grantex agent id in agentId and ignores members it does not know, and one with the binding off reads neither grantToken nor agentDid, so the SDKs consume against every version. An SDK from before this change sends no grant token: against a service with the binding on, it cannot consume a decision that names an agent, and reports the refusal as consume_unavailable, which still denies the call. Before turning the binding on, move every platform that reads decisionGrants from GET /v1/decisions/requests/{id}:
  1. A decision the platform takes for itself (its request names no agent): consume it by request id, POST /v1/decisions/requests/{id}/consume with the action and case version, in place of reading the grants and presenting them. Use status and the approvals (or decisionGrantsReady once the binding is on) to tell whether it is ready; a consumption that is not ready is refused with unknown_grant or four_eyes_incomplete.
  2. A decision for an agent: name the agent (agentId, grantId) when asking for it, fetch the grants with that agent’s grant token (getGrants / get_grants), and consume them with the same grant token (grantToken), as enforce() does. Naming the agent in the body is not enough.
  3. Read subReason before the HTTP status when mapping refusals: wrong_agent arrives with 403, which is not an authentication failure.
  4. Use an SDK that sends grantToken (this release or later) wherever enforce() or grantexDecisionVerifier consumes decision grants.
Both endpoints in steps 1 and 2 work with the binding off, so each platform can move and be verified in production before the binding is turned on. What AgenticOrg governed cases must change is listed with that integration.

Operating

  • The auth service’s decision endpoints and pages are off until DECISION_GRANTS_ENABLED=true. PUBLIC_BASE_URL must be https and VAULT_ENCRYPTION_KEY set. Configure step-up with DECISION_STEP_UP_AMR / DECISION_STEP_UP_ACR and DECISION_STEP_UP_MAX_AGE_SECONDS, and the minimum dwell time with DECISION_MIN_DWELL_MS. Bind decisions to the requesting agent with DECISION_GRANT_AGENT_BINDING=true (default off; see above).
  • Register https://<auth service>/decisions/callback as the redirect URI of each approver identity provider.
  • Metrics: grantex_decision_grants_minted_total, grantex_decision_grants_consumed_total, grantex_decision_grants_rejected_total{stage,reason} (stages sign_in, request, approve, release, consume, case) and the grantex_decision_dwell_seconds histogram. Alert when dwell time collapses towards the minimum.

What this does and does not protect against

Decision grants stop the platform, an agent, a prompt injection or a re-planned payload from producing or using an approval nobody gave; with the agent binding on, stop another agent, or the developer API key alone, from using a decision an agent asked for; stop replay (used, other case, changed case, expired, other tenant); stop an approval being submitted from another site or with a forged dwell time; bind what the approver was shown; and stop one identity from satisfying four eyes. They do not protect against a compromised service administrator or identity provider, one person holding two identities without a shared verified email, misleading memo content written by the platform, rubber-stamping (made visible, not impossible), script injection on the auth service’s own origin, arguments the manifest leaves out of decision_fields, tools that act without calling enforce(), or a compromised auth service. When the auth service is unreachable, decisions cannot be consumed and calls are refused. The full profile, including every claim and the threat model, is the decision grant specification.
Last modified on September 27, 2026