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"
},
"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 |
| 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 |
| 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.
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.
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.