Back to Blog
Technical Spec

Mandate Matching and Evidence Receipts for Agent Checkout

A merchant-side design for checking whether a verified agent bought inside its granted scope - and keeping a record your systems can replay

Know Your Agent (KYA)July 16, 202613 min read

Verified AI agents are going to start arriving at your checkout carrying a signed mandate - a token that says which principal authorised them, for how much, at which merchants, and until when. Verifying that token proves the agent is who it says it is. It does not tell you whether this specific purchase is inside the scope the principal actually granted.

That gap is the design problem this article covers. If your checkout or risk service is going to accept agent-initiated orders, it needs to do three things at request time: turn whatever authorisation the agent presents into a shape it can evaluate, check the proposed transaction against every field of that authorisation, and write a record it can replay weeks later when someone disputes the order. The companion piece, The Agent Was Authorised. The Purchase Wasn't., covers why this matters. This one is for the team that has to build it.

The problem, in one transaction

A customer tells an agent to “restock the office, keep it under $500.” The agent presents a valid mandate, pays with a valid credential, and submits a $480 order. Identity checks pass. Payment checks pass. But the cart includes a premium espresso machine that was never in scope. Nothing in a conventional fraud stack fires, because nothing in a conventional fraud stack is comparing the cart to the agent's granted authority. That comparison is the piece you have to add.

Step 1 - Normalise the mandate

Agents will show up carrying different authorisation formats: a KYA trace JWT, an AP2 mandate, or something proprietary. Rather than special-casing each one downstream, translate them all into a single internal shape. Everything after this step reads the normalised object, not the wire format.

// NormalizedMandate - the one shape the rest of the pipeline reads
{
  "mandateVersion": "1.0",
  "sourceFormat": "kya-trace | ap2 | proprietary",
  "principalBindingHash": "sha256:...",
  "agentCredentialHash": "sha256:...",
  "operatorId": "op_...",
  "issuedAt": "2026-07-16T14:03:00Z",
  "expiresAt": "2026-07-16T15:03:00Z",
  "revocationStatus": "active | revoked | unknown",
  "scope": {
    "merchant":         { "allow": ["merch_1288"] },
    "category":         { "allow": ["office_supplies"] },
    "amount":           { "max": 50000, "currency": "USD" },
    "cumulativeBudget": { "max": 50000, "currency": "USD", "window": "P7D" },
    "quantity":         { "max": 25 },
    "recipient":        null,
    "shippingGeo":      { "allow": ["US"] },
    "timeWindow":       { "notBefore": "...", "notAfter": "..." },
    "substitution":     { "tolerance": "none" }
  }
}

The important detail is the null. A field the source format did not populate stays null - it is not back-filled with a default that happens to allow the transaction. “The principal never said” and “the principal allowed it” have to stay distinguishable, because they lead to different decisions.

Mapping the two common formats into that shape looks like this:

Normalised fieldKYA traceAP2 mandate
merchant / recipientaudallowed_merchants / allowed_payees
amountkya.limits.max_amount_centspayment.amount_range.max
time windowiat / exppayment.execution_date
frequency / recurrencekya.scopepayment.agent_recurrence
revocation statustrace registry lookupmandate.revoked

Where a format has no field for something, that field normalises to null. A format's silence is preserved, not papered over.

Step 2 - Match the transaction, field by field

Now compare the proposed transaction against each field in scope. Every field resolves to one of four results. The rule that matters: a field the mandate did not specify is not a pass.

ResultMeaning
MATCHThe transaction is provably within scope on this field.
VIOLATIONThe transaction provably exceeds or contradicts the mandate on this field.
UNSPECIFIEDThe mandate is silent on this field. No authority was granted, so none is assumed.
UNVERIFIABLEEvidence needed to evaluate the field is missing, unreadable, or untrusted.

The fields worth evaluating:

merchant             recipient
product category     shipping geography
SKU / attributes     time window
quantity             substitution tolerance
transaction amount   fulfillment conditions
cumulative budget    delegation chain
frequency            revocation status
/ Note

UNSPECIFIED and UNVERIFIABLE never quietly collapse into MATCH or VIOLATION. They travel into the decision step as themselves, so “allowed” and “not addressed” can be handled differently. Most of the real risk hides in that difference.

Step 3 - Where this runs

This is a synchronous check on the authorisation path, before you commit the order - the same place your existing fraud and AVS calls sit. It is a pure function over three inputs (mandate, transaction, policy) plus two lookups that touch state: revocation status and cumulative-budget usage. Everything else is local computation, so the whole evaluation is sub-second and horizontally scalable.

  agent-initiated order
        |
  (1) verify        agent, operator, principal binding, signature
        |
  (2) normalize     mandate  ->  NormalizedMandate
        |
  (3) match         transaction vs each scope field
        |
  (4) classify      MATCH | VIOLATION | UNSPECIFIED | UNVERIFIABLE
        |
  (5) policy        apply merchant's versioned acceptance policy
        |
  (6) decide        ACCEPT | REVIEW | DECLINE | STEP_UP
        |
  (7) receipt       sign an evidence receipt (inputs + decision)
        |
  (8) link          bind later fulfillment / refund / dispute
                    events to the receipt, without mutating it

Stages 1–4 and 6 are deterministic. Stage 5 is the only place your business rules live, and it is versioned so a past decision can be replayed against the exact policy that produced it. Stages 7–8 are what make a decision defensible after the fact.

Step 4 - Decide

Reduce the per-field results to a single decision by fixed precedence. Your policy does not change the precedence - it only labels which fields are blocking, which are required, and which can be waived.

Condition (highest precedence first)Decision
A blocking field is VIOLATIONDECLINE
A non-blocking VIOLATION, or a required field that is UNSPECIFIEDSTEP_UP
UNVERIFIABLE on a field you cannot waiveREVIEW
Every required field is MATCHACCEPT

STEP_UP is not terminal. A satisfied step-up (the principal re-confirms out of band) re-enters at stage 6 with stepUpApproval = PRESENT, which can turn an otherwise-blocking UNSPECIFIED or a non-blocking VIOLATION into ACCEPT. A step-up that is denied or times out becomes DECLINE. Every transition lands on the receipt.

The whole decision is a pure function you can unit-test exhaustively:

function decide(mandate, txn, policy) -> (decision, results, reasons):
    results = {}
    for field in policy.required:
        if mandate.scope[field] is null:
            results[field] = UNSPECIFIED
        else if not evidence_available(txn, field):
            results[field] = UNVERIFIABLE
        else if within_scope(txn[field], mandate.scope[field]):
            results[field] = MATCH
        else:
            results[field] = VIOLATION

    reasons = reason_codes_for(results)

    if any(results[f] == VIOLATION for f in policy.blocking):
        return (DECLINE, results, reasons)
    if any(results[f] == UNVERIFIABLE for f in policy.nonWaivable):
        return (REVIEW, results, reasons)
    if any(results[f] == VIOLATION for f in results)
       or any(results[f] == UNSPECIFIED for f in policy.required):
        return (STEP_UP, results, reasons)
    return (ACCEPT, results, reasons)

The policy is just config

Stage 5 is a versioned document, not code. Product or risk owns it; engineering ships a new version, never a new branch. A minimal policy:

{
  "policyVersion": "2026-07",
  "required":    ["merchant", "amount", "category",
                  "recipient", "revocation"],
  "blocking":    ["merchant", "amount", "revocation"],
  "nonWaivable": ["delegationChain"],
  "stepUp": {
    "channel": "principal_push",
    "timeoutSeconds": 120
  }
}

Because the decision records policyVersion, you can change the rules tomorrow and still explain exactly why last week's order was accepted - you replay it against 2026-07, not against today's config.

The request/response contract

The whole thing fits behind one endpoint that your checkout calls before capture. Hand it the mandate the agent presented, the transaction you are about to authorise, and the policy version to evaluate under:

POST /v1/checkout/evaluate
Content-Type: application/json

{
  "mandate": "<kya-trace-jwt | ap2-mandate>",
  "transaction": {
    "amount": 48000,
    "currency": "USD",
    "merchantId": "merch_1288",
    "lineItems": [
      { "sku": "PAPER-A4", "category": "office_supplies", "qty": 10 },
      { "sku": "ESPRESSO-9", "category": "kitchen_appliances", "qty": 1 }
    ],
    "recipient": "warehouse_3",
    "shippingGeo": "US"
  },
  "policyVersion": "2026-07"
}

You get back the decision, the per-field breakdown, machine-readable reasons, and a receipt handle:

200 OK

{
  "decision": "DECLINE",
  "results": {
    "merchant":   "MATCH",
    "amount":     "MATCH",
    "category":   "VIOLATION",
    "recipient":  "MATCH",
    "revocation": "MATCH"
  },
  "reasonCodes": ["CATEGORY_OUTSIDE_SCOPE"],
  "stepUpRequested": false,
  "receiptId": "rcpt_01J8Z...",
  "policyVersion": "2026-07"
}

Reason codes are a stable enum so your checkout can branch on them without parsing prose:

AMOUNT_EXCEEDED            CATEGORY_OUTSIDE_SCOPE
MERCHANT_NOT_ALLOWED       RECIPIENT_NOT_DECLARED
QUANTITY_EXCEEDED          SUBSTITUTION_OUTSIDE_SCOPE
GEO_OUTSIDE_SCOPE          BUDGET_WINDOW_EXCEEDED
DELEGATION_REVOKED         DELEGATION_CHAIN_UNRESOLVED
MANDATE_EXPIRED            SIGNATURE_INVALID

If you are already running KYA, several of these are live today: the pre-dispute side-channel emits SCOPE_EXCEEDED, AMOUNT_EXCEEDED, and WRONG_ITEM against agent orders, and trace tokens already translate to AP2 mandates - so the normalise-and-match steps run against formats KYA already speaks.

The evidence receipt

Every evaluation - accept or decline - produces a signed receipt. This is the artifact you reach for two weeks later when the order is disputed. Store hashes, not raw mandates or PII:

{
  "receiptId": "rcpt_01J8Z...",
  "agentCredentialHash": "sha256:...",
  "principalBindingHash": "sha256:...",
  "mandateHash": "sha256:...",
  "transactionHash": "sha256:...",
  "merchantPolicyVersion": "2026-07",
  "results": {
    "merchant":   "MATCH",
    "amount":     "MATCH",
    "category":   "VIOLATION",
    "recipient":  "MATCH",
    "revocation": "MATCH"
  },
  "decision": "DECLINE",
  "reasonCodes": ["CATEGORY_OUTSIDE_SCOPE"],
  "stepUpRequested": false,
  "timestamp": "2026-07-16T14:03:02Z",
  "signature": "ed25519:...",
  "links": []
}

Treat receipts as append-only. Later events - fulfilment, refund, the dispute itself - attach to links by hash; the original object is never rewritten. A record showing what agent appeared, what authority it presented, which policy version ran, and why you decided as you did is a much stronger position in a dispute than reconstructing intent from application logs and screenshots after the fact.

Worked examples

In each case, identity, delegation, and payment are all valid. Only the match against scope differs.

/ Ex 1

Undeclared espresso machine

Scope allows office_supplies up to $500. The $480 cart includes a kitchen_appliances SKU. Amount MATCH, category VIOLATION; category is blocking → DECLINE, CATEGORY_OUTSIDE_SCOPE.

/ Ex 2

Silent recipient

Mandate declares amount, category, geography, but leaves recipient null. The order ships to a third party. Recipient is UNSPECIFIED; policy requires it → STEP_UP, RECIPIENT_NOT_DECLARED. The receipt records absence, not denial.

/ Ex 3

Revoked mid-session

Every scope field MATCHes, but the revocation lookup returns revoked with a timestamp before checkout. Revocation is a blocking VIOLATIONDECLINE, DELEGATION_REVOKED. A conventional stack would have let this through.

/ Ex 4

Substitution beyond tolerance

Scope sets substitution.tolerance = "none". The agent swaps an out-of-stock item for a pricier equivalent under the amount cap. Amount MATCH, substitution VIOLATION (non-blocking) → STEP_UP, SUBSTITUTION_OUTSIDE_SCOPE.

/ Ex 5

Unresolvable delegation chain

A sub-agent presents a delegation whose upstream link will not resolve to the principal. Scope fields may all MATCH, but delegation chain is UNVERIFIABLE and non-waivable → REVIEW, DELEGATION_CHAIN_UNRESOLVED.

/ Ex 6

Clean accept

Every required field - merchant, amount, category, budget, geography, recipient, revocation - is MATCH ACCEPT. The receipt still records the full result set, so an accept is exactly as auditable as a decline.

Threat model and failure modes

The check is only as good as its inputs and its own integrity. The cases to design against:

Threat / failureHow the design handles it
Forged or replayed mandateSignature and binding-hash verification in stage 1; expired or replayed tokens fail before matching runs.
Over-broad self-issued mandateMatching says nothing about whether scope was legitimately granted; that is what principal binding and delegation-chain checks bound.
Relying on omission to slip throughCore rule: UNSPECIFIED never becomes allow. Omission escalates to step-up or review, it does not pass.
Revocation race (revoke lands after fulfilment)Revocation is a first-class field checked at decision time; the receipt timestamps what was known then.
Receipt tampering or back-datingReceipts are signed and append-only; downstream events link by hash and cannot mutate the original.
Cumulative-budget lookup unavailableBudget field returns UNVERIFIABLE; if non-waivable it routes to REVIEW rather than silently accepting.

Two limits worth stating plainly. This check answers “did the transaction match the authority presented,” not “was that authority legitimately granted” - the second depends on principal binding upstream. And a receipt is a record, not a ruling: it strengthens your position in a dispute, it does not decide liability.

What you need to build it

Nothing here requires exotic infrastructure. You need signature and binding verification for the mandate formats you accept, a normaliser per format, the pure matching-and-decide function, a versioned policy store, and an append-only signed-receipt store with a hash-link index. The matching function is the easy part; the discipline is in never letting UNSPECIFIED or UNVERIFIABLE quietly become a yes.

Wire This Into Your Checkout

KYA already issues signed traces, flags scope and amount problems on agent orders pre-dispute, and speaks AP2. If your architecture team wants to add mandate matching and signed receipts to an existing checkout, we will build the integration with you.

Talk to Engineering