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_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