Policy model

A mandate is the delegated financial authority for one agent. Its policy is an immutable, versioned JSON document; every decision records the exact version and policy hash it was evaluated against.

Schema

{
  "currency": "GBP",                 // ISO currency of all limits (minor units)
  "mode": "ENFORCE",                 // informational; the mandate row's mode governs
  "allowed_assets": ["USDC"],        // assets the agent may spend
  "allowed_counterparties": [],      // optional allowlist of counterparty external_ids
  "denied_counterparties": [],       // hard-blocked external_ids
  "per_transaction": {
    "autonomous_limit": 75000,       // above this → escalate to a human
    "absolute_limit": 250000         // above this → reject outright
  },
  "budgets": [                       // at most one per period type
    { "period": "DAY",   "limit": 150000 },
    { "period": "MONTH", "limit": 500000 }
  ],
  "approval": {
    "above_amount": 75000,           // optional extra escalation threshold
    "new_counterparty_requires_approval": true,
    "reservation_on_escalation": true,   // hold budget while a human decides
    "expires_after_minutes": 60          // approval link lifetime
  },
  "allowed_purposes": ["software"],  // empty = any purpose
  "schedule": {                      // optional operating window
    "timezone": "Europe/London",
    "days": ["MON","TUE","WED","THU","FRI"],
    "start": "08:00",
    "end": "18:00"
  },
  "identity_attestation": {          // optional; see "Identity attestations"
    "required": true,
    "protocols": ["AFSP"],           // empty = any protocol
    "trusted_issuers": ["bank.example"], // empty = any issuer
    "bind_agent": true               // attestation agent_id must equal agent_external_id
  },
  "effective_at": "2026-08-01T00:00:00Z",
  "expires_at": "2026-09-01T00:00:00Z"
}

Validation invariants: autonomous_limit ≤ absolute_limit; expires_at > effective_at; budgets positive; unknown fields rejected.

Evaluation order (deterministic)

# Rule On failure Effect
1–2 Request schema, org/API-key status 401/422 API layer
3 Agent active AGENT_INACTIVE reject
4 Mandate exists MANDATE_NOT_FOUND reject
5 Mandate published MANDATE_NOT_PUBLISHED reject
6 Not revoked MANDATE_REVOKED reject
6b AP2 version on the allow-list (if ap2 sent) AP2_UNSUPPORTED_VERSION reject
6b AP2 mandates parse AP2_MALFORMED reject
6b Chain roots and Checkout JWT signed by registered keys AP2_SIGNER_UNKNOWN reject
6b Signatures, hop bindings and disclosures verify AP2_SIGNATURE_INVALID reject
6b Every iat/exp current (60 s skew) AP2_EXPIRED reject
6b Payment pays for this checkout AP2_CART_PAYMENT_MISMATCH reject
6b Open-mandate claims and constraints hold AP2_CONSTRAINT_NOT_MET reject
6b Every constraint is one mnd8t evaluates AP2_CONSTRAINT_UNSUPPORTED reject
6b Closed Payment Mandate not already decided on AP2_MANDATE_REUSED reject
7 Within effective/expiry MANDATE_NOT_EFFECTIVE / MANDATE_EXPIRED reject
8 Within schedule OUTSIDE_PERMITTED_SCHEDULE reject
9 Asset allowed ASSET_NOT_ALLOWED reject
10 Counterparty not denied/blocked COUNTERPARTY_BLOCKED reject
10b Destination bound to counterparty DESTINATION_NOT_AUTHORISED reject
10b Destination verifiable DESTINATION_UNVERIFIED escalate
11 Purpose allowed PURPOSE_NOT_ALLOWED reject
11b Valuation asserted VALUATION_NOT_ASSERTED reject
11c Identity attestation present (if required) IDENTITY_ATTESTATION_MISSING reject
11c Attestation current IDENTITY_ATTESTATION_EXPIRED reject
11c Attestation protocol and issuer accepted IDENTITY_ATTESTATION_UNTRUSTED reject
11c Attestation names this agent IDENTITY_ATTESTATION_AGENT_MISMATCH reject
12 ≤ absolute limit ABSOLUTE_LIMIT_EXCEEDED reject
12b ≤ asset absolute limit ASSET_ABSOLUTE_LIMIT_EXCEEDED reject
13 Daily budget fits DAILY_BUDGET_EXCEEDED reject
13b Asset daily budget fits ASSET_DAILY_BUDGET_EXCEEDED reject
14 Monthly budget fits MONTHLY_BUDGET_EXCEEDED reject
14b Asset monthly budget fits ASSET_MONTHLY_BUDGET_EXCEEDED reject
15 Counterparty approved COUNTERPARTY_NOT_APPROVED escalate
16 ≤ autonomous / approval threshold HUMAN_APPROVAL_REQUIRED escalate
17 Final APPROVED_WITHIN_MANDATE approve

Precedence: any hard failure → REJECT; otherwise any escalation trigger → ESCALATE (one escalation carrying all triggered reasons); otherwise APPROVE. Rules 3–6 stop evaluation (no policy context exists); rules 7–16 all evaluate so responses carry the complete reason set. Rule 6b runs only when the request carries AP2 mandates; it stops evaluation only when the mandates could not be read at all (unsupported version, malformed), since then there is no proposal for rules 7–16 to evaluate.

AP2 mandates are evidence

The ap2 checks are run by the AP2 adapter (packages/ap2-adapter) before the engine, at the decision time, against signer keys in mnd8t's own store. The engine receives only the mapped proposal and the check results. A failed check is a hard REJECT; a passing set adds no authority, so an AP2 payment the user signed is still refused, or escalated, by everything in rules 7–16. The payee (payee.id of the closed Payment Mandate) is evaluated as the counterparty, so rules 10, 10b and 15 apply to it unchanged.

Stateful budget behaviour

  • Usage counted = consumed amounts + active reservations + unreconciled reservations.
  • Excluded = released/expired reservations, rejected decisions, shadow decisions.
  • Approve → reservation (24 h TTL). Escalate → provisional reservation if reservation_on_escalation. Confirm → reserved becomes consumed. Cancel / reject / expiry / revocation → released — unless the artifact was claimed, in which case see below.

Claimed but unconfirmed

Once an executor claims an artifact, mnd8t can no longer assume the money stayed put. If that artifact then passes its expiry with no /confirm and no /fail, releasing the reservation on a timer would report real spend as unspent and let a later action exceed the true limit.

So the timer does not release it. Instead:

Object Becomes Effect
reservation UNRECONCILED budget stays held, still counted against limits
artifact RECONCILIATION_REQUIRED not EXPIRED — the outcome is unknown, not negative

Both are resolvable, and must be, or the hold is permanent:

Action Result
POST /v1/decision-intents/:id/confirm reservation → CONSUMED; the spend is recorded
POST /v1/authorisation-artifacts/:id/fail reservation → RELEASED; the budget returns
POST /v1/decision-intents/:id/cancel 409 — cancelling releases budget, so it is refused while the execution is unaccounted for
mandate revocation reservation stays UNRECONCILED; revocation stops future authority, it cannot retract money already gone

/confirm accepts a RECONCILIATION_REQUIRED artifact even though it is past its expiry — by definition it is, and rejecting it would leave the executor that actually moved the money with no way to say so.

  • Concurrency: budget rows are locked (FOR UPDATE) before evaluation, so two concurrent requests can never jointly exceed a limit.

Who states the value

Rules 12–14 evaluate the policy amount, so whoever states it decides whether the limits bind. Asset-denominated limits (below) remove that dependency for a mandate operating in one asset. A budget that aggregates across assets still needs a conversion, and by default the caller of the decision supplies it.

Set require_asserted_valuation: true and the figure must come from a credential holding the valuations:assert scope — which the AGENT preset does not carry. The agent references a valuation; it cannot mint one.

# your backend, holding valuations:assert
POST /v1/valuations
  { "asset": "USDC", "asset_amount_minor": 675000000,
    "policy_amount_minor": 50000, "policy_currency": "GBP" }
  → { "id": "val_9f2…", "expires_at": "…" }

# your agent, holding only AGENT scopes
POST /v1/decision-intents
  { …, "valuation_id": "val_9f2…" }

A valuation is bound to its exact asset and quantity, so one minted for 1 USDC cannot be spent on 10,000. It expires (default 5 minutes, maximum 1 hour) rather than being single-use, so an idempotent retry of the same action still works while a stale favourable rate is not worth harvesting.

Failing this rule is a REJECT with a signed receipt, not a bare error — the decision and its reason are evidence like any other. The receipt records whether the valuation was asserted and by which credential, so a reader can tell the figure did not come from the agent.

What this does not do. It relocates the trust; it does not create it. If the asserting backend runs on the same host as the agent, or shares its compromise, nothing has improved. And mnd8t still does not check whether the number is right — only that the agent did not choose it.

Identity attestations

Two questions come before an agent's payment, and different systems answer them. Who is this agent, who is it acting for, and did the principal authorise this session? That is the Identity layer, and admission protocols such as AFSP answer it. Is this particular action within what the principal delegated? That is the mandate.

mnd8t keeps them apart. An attestation from an admission layer is presented on each decision request as identity_attestation, and is never written into a mandate:

"identity_attestation": {
  "protocol": "AFSP", "protocol_version": "0.1",
  "issuer": "platform.afsp.org",
  "clearing_institution": "inst_sunrise",
  "agent_id": "procurement_agent_12",
  "session_id": "sess_123", "package_id": "pkg_…",
  "clearance_decision": "PROCEED_TO_KYC",
  "action_classes": ["INITIATE", "EXECUTE"],
  "not_before": "2026-10-01T09:00:00Z", "not_after": "2026-10-01T09:30:00Z",
  "single_use": true,
  "token_digest": "sha256:…"
}

Field names follow the AFSP v0.1 session token (§10.3) where AFSP defines one: platform_signature.signed_by → issuer, institution_clearance.institution_id → clearing_institution, institution_clearance.clearance_decision → clearance_decision, validity.not_before / not_after / single_use → the same names. The agent and action classes come from AFSP's consumer authorization token (§7.3: authorized_agent_id, scope.action_classes). AFSP tokens carry no principal identifier, because the consumer stays pseudonymous until KYC, so principal_id is optional and is used by protocols that disclose one.

A mandate says how it depends on that attestation through its optional identity_attestation section. With the section absent, a presented attestation is recorded in the Authority Record and never consulted. With it present, rule 11c checks that one was presented (if required), that it was current at decision time, that its protocol and issuer are ones the mandate accepts, and (if bind_agent) that it names the agent making the request. An AFSP attestation reporting a clearance_decision other than PROCEED_TO_KYC is untrusted: AFSP mints a session token only on that decision.

Three properties hold whatever the configuration:

  • An attestation never widens authority. A valid one grants nothing the rest of the mandate does not. Rule 11c can only add reasons to refuse.
  • Failures are hard. An admission the mandate depends on either holds or it does not, and a human approver is in no better position than the engine to vouch for an agent's identity on the spot.
  • mnd8t does not verify the attestation's signature. It records what was presented, with a digest of the raw token rather than the token, marked verification: "PRESENTED_UNVERIFIED". Verify it in your admission layer before calling mnd8t. AFSP v0.1 leaves its protocol binding (transport, endpoints, message formats) to later versions, so native verification waits for that. single_use is recorded, not enforced: mnd8t is not the token's relying party, and an idempotent retry presents the same token.

protocol is AFSP or OTHER. OTHER records any scheme not yet named.

Asset-denominated limits

Rules 12–14 evaluate valuation.policy_amount_minor — a valuation you supply. mnd8t does not fetch exchange rates, so if a caller can influence that figure, it can influence whether those limits bind: 10,000 USDC declared as £1 passes a £50,000 cap.

An asset-denominated limit is evaluated against the quantity that actually moves. There is no exchange rate between an asset and itself, so there is nothing to misstate.

{
  "allowed_assets": ["USDC"],
  "per_transaction": {
    "autonomous_limit": 75000,
    "absolute_limit": 250000,
    "by_asset": { "USDC": { "autonomous_limit": 900000000, "absolute_limit": 3000000000 } }
  },
  "budgets": [{ "period": "DAY", "limit": 150000 }],
  "asset_budgets": [{ "period": "DAY", "asset": "USDC", "limit": 20000000000 }]
}

Both are optional and additive — a mandate without them behaves exactly as before. Where both are present both must pass, so a currency cap and an asset cap constrain independently.

  • Limits are in the asset's minor units, not the policy currency's.
  • An asset naming anything outside allowed_assets is rejected at publish. It could never fire, so accepting it would mean shipping a limit that silently enforces nothing.
  • Exceeding an asset autonomous ceiling escalates through the existing HUMAN_APPROVAL_REQUIRED, not a separate code: the operator's question is the same one.
  • Budgets are tracked per asset, on the same accounting as the currency budgets — reservations included, released on cancel, consumed on confirm.

What this does and does not fix. It removes the valuation dependency for any mandate that operates in one asset. A cross-asset aggregate — "£50k/day across USDC and GBP" — still needs a rate, and still rests on the figure you send.

Shadow vs enforce

Shadow mode returns effective_decision: "OBSERVE" plus would_decide and full reasons, consumes no budget, creates no approvals, blocks nothing, and appends SHADOW_ONLY. Enforce mode is identical except decisions have teeth.

Counterparty status semantics

Registry status Policy allowlist Result
BLOCKED (or on denied list) — REJECT COUNTERPARTY_BLOCKED
APPROVED on list (or no list) passes rule 15
APPROVED off a non-empty list ESCALATE COUNTERPARTY_NOT_APPROVED
PENDING / unknown — ESCALATE when new_counterparty_requires_approval

Destination binding

Approving a counterparty establishes who may be paid. On its own it says nothing about where the money goes, so an approved counterparty and the destination on the request are evaluated together (rule 10b). Without that binding, an approved supplier's external_id could be presented alongside any address at all and the resulting authorisation artifact would name the supplier while paying somebody else — signed, and wrong.

Registered destination Request destination Result
set absent passes — no recipient asserted
set equal passes
set different REJECT DESTINATION_NOT_AUTHORISED
not set present ESCALATE DESTINATION_UNVERIFIED
— present, counterparty unknown rule 15 applies; 10b stays silent

The last two rows are the judgement call. If you approved a counterparty without pinning a destination, mnd8t has nothing to check the recipient against, so it escalates to a human rather than approving an unconstrained recipient or rejecting a request that may be perfectly legitimate. Register a destination on the counterparty to make those decisions autonomous.

A mismatch is a hard failure, so it is never downgraded to an escalation by a concurrent soft trigger.

POST /v1/mandates/:id/simulate accepts destination and applies the same rule, so a dry run cannot disagree with the live decision.

10 min read · Need something that isn't here? Browse all documentation