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_useis 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_assetsis 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.