Mandate Verification at Scale: Validating Agent Authority in Real Time

Mandate Verification at Scale: Validating Agent Authority in Real Time

·

16 min read

Introduction

When a user tells an autonomous agent to "buy me coffee on the way home", the agent does not get a blank cheque. It gets a mandate: a signed, scoped, time-bound credential that says the agent may spend up to a certain amount, at a certain category of merchant, before a certain expiry. By the time the agent presents that mandate to a payment gateway, every payment has to be checked against the rules baked into it.

That sounds simple in isolation. Run it at the scale of a real agent ecosystem, where thousands of agents act on behalf of millions of users, and the verification path becomes the single hottest service on the network. Every transaction has to pass six checks: signature, issuer trust, scope, amount cap, freshness, and replay protection. Every check has to clear in a few milliseconds. And every wrong answer is either a fraud loss or a broken user experience.

This article walks through implementing the full mandate verification pipeline as streaming SQL on RisingWave, a streaming database that keeps materialized views continuously consistent with the input streams. The SQL below is verified end-to-end on RisingWave v2.8.0, with the actual output included so you can see exactly which attempt is allowed, denied, and why.

What Mandate Verification Means

A mandate, in the sense used by the Agent Payments Protocol (AP2) and similar emerging standards, is a structured credential issued by a user (or their bank, or their wallet) to an agent. It typically contains:

  • A unique mandate identifier
  • The agent the mandate is bound to
  • The user the mandate is acting on behalf of
  • A scope: merchant, category, or product class the mandate may pay
  • A maximum amount per transaction (and sometimes a total cap)
  • A validity window (valid_from, valid_to)
  • A signature from a trusted issuer

When the agent attempts a payment, the gateway must verify that the mandate is real, intact, and still valid for the specific attempt. The full check set looks like this:

  1. Signature check. The mandate's cryptographic signature has to verify against the issuer's public key. This is usually done by a key management service or HSM with FIDO-style hardware-backed keys (see the FIDO2/WebAuthn specifications for the underlying primitives).
  2. Issuer check. The signing issuer has to be on the trust list. A perfectly valid signature from a non-trusted issuer is still a denial.
  3. Scope check. The merchant on the payment attempt has to match the merchant or category encoded in the mandate's scope.
  4. Expiry check. The attempt timestamp has to fall inside [valid_from, valid_to].
  5. Freshness / amount cap. The amount has to fall under the per-transaction cap. (Some mandates also enforce a running total, which is just a windowed sum.)
  6. Replay protection. The same mandate cannot be presented many times in rapid succession. Replay attempts can come from a compromised agent, a buggy retry loop, or a man-in-the-middle.

Steps 1 and 2 are crypto. The signature itself is computed and verified by a separate service: this is what hardware security modules and FIDO authenticators are for, and you do not want to do that in SQL. But the result of those checks (a signature_valid boolean and an issuer_trusted boolean) is just data. So is the rest of the mandate. So all six checks can converge in one place: the streaming validation pipeline.

Why Synchronous Per-Transaction Validation Doesn't Scale

The naive design is a synchronous service. Each payment attempt arrives, the service makes a round trip to the mandate database, runs the six checks in code, and returns ALLOW or DENY. Most teams start there and most teams hit a wall.

The problems show up in three places.

Database hot spots. Every transaction does a primary-key lookup against the mandate table. That alone is fine. But the moment you add scope rules, allowlist tables, replay counters, or merchant category lookups, you are doing five or six queries per transaction. At 10,000 QPS, the connection pool and query planner become the bottleneck before the disk does.

State consistency. The replay counter is the worst offender. To enforce "no more than three uses of the same mandate within five minutes", you need either a fast write path on a counter table (which serializes through one row per mandate) or a Redis-style cache (which now has its own consistency story with the database of record). Both options are expensive.

Latency tail. A synchronous validator inherits the latency tail of every system it queries. P50 might be two milliseconds, but P99.9 against a busy mandate registry can blow past 200 ms. That tail is exactly when fraud likes to slip through, because timeouts often resolve to "fail open".

The streaming approach inverts this. Mandates stream in (or get pulled in via change data capture) and become rows in a continuously maintained table. Payment attempts stream in. A streaming database joins them, runs the checks, maintains the replay counters, and emits a decision per attempt. Every materialized view is incrementally updated, so verification becomes a single read of a pre-joined row instead of a fan-out of point queries.

Streaming Validation Architecture

The pipeline has two inputs and one output.

mandates stream ──┐
                  ├──▶ validation MV ──┐
attempts stream ──┤                    ├──▶ decisions MV ──▶ ALLOW/DENY queue
                  └──▶ replay MV ──────┘
  • Mandates stream. New mandates get registered, revoked, or updated. In production, this is a Kafka topic fed from the wallet or a CDC stream from the mandate registry. In the verified example below, we use a RisingWave table with direct inserts.
  • Attempts stream. Every payment attempt the agent makes. Also typically a Kafka topic from the gateway's intake.
  • Validation MV. Joins each attempt with its mandate and produces one boolean column per check (sig_ok, issuer_ok, scope_ok, expiry_ok, amount_ok).
  • Replay MV. Counts mandate uses within a sliding window per attempt.
  • Decisions MV. Combines both into an ALLOW / DENY verdict with a structured reason code.

The decisions view is the contract with the rest of the system. Sink it to Kafka and the gateway consumes it. Sink it to Redis and the gateway looks up by attempt_id. Either way, the decision is computed once, cached as a materialized view, and reused on every read.

Implementing the Six Checks in Streaming SQL

Every example below is verified against RisingWave v2.8.0. Run them in any RisingWave instance reachable on localhost:4566. The schema uses an aap14_ prefix throughout so it does not collide with anything already in your dev database.

The mandate registry

CREATE TABLE aap14_mandates (
    mandate_id VARCHAR PRIMARY KEY,
    agent_id VARCHAR NOT NULL,
    user_id VARCHAR NOT NULL,
    scope_merchant VARCHAR NOT NULL,
    scope_max_amount DECIMAL NOT NULL,
    valid_from TIMESTAMPTZ NOT NULL,
    valid_to TIMESTAMPTZ NOT NULL,
    signature_valid BOOLEAN NOT NULL,
    issuer_trusted BOOLEAN NOT NULL,
    status VARCHAR NOT NULL
);

signature_valid and issuer_trusted are populated by an upstream verifier service that does the actual cryptography and writes its result alongside the mandate. From SQL's point of view, they are just columns to filter on.

The attempt stream

CREATE TABLE aap14_payment_attempts (
    attempt_id VARCHAR PRIMARY KEY,
    mandate_id VARCHAR NOT NULL,
    agent_id VARCHAR NOT NULL,
    merchant VARCHAR NOT NULL,
    amount DECIMAL NOT NULL,
    attempt_time TIMESTAMPTZ NOT NULL
);

In production this would be a Kafka source subscribed to the gateway's attempt topic. Using a table with direct inserts here keeps the example reproducible.

Sample data: six mandates, sixteen attempts

INSERT INTO aap14_mandates VALUES
    ('mnd_001', 'agent_alpha',  'user_a01', 'amazon.com',     500.00,  '2026-05-01 00:00:00+00', '2026-06-01 00:00:00+00', true,  true,  'ACTIVE'),
    ('mnd_002', 'agent_beta',   'user_a02', 'doordash.com',   150.00,  '2026-05-01 00:00:00+00', '2026-05-05 00:00:00+00', true,  true,  'ACTIVE'),
    ('mnd_003', 'agent_gamma',  'user_a03', 'uber.com',       200.00,  '2026-05-01 00:00:00+00', '2026-06-01 00:00:00+00', false, true,  'ACTIVE'),
    ('mnd_004', 'agent_delta',  'user_a04', 'netflix.com',     50.00,  '2026-05-01 00:00:00+00', '2026-06-01 00:00:00+00', true,  false, 'ACTIVE'),
    ('mnd_005', 'agent_epsilon','user_a05', 'shopify.com',   1000.00,  '2026-05-01 00:00:00+00', '2026-06-01 00:00:00+00', true,  true,  'ACTIVE'),
    ('mnd_006', 'agent_zeta',   'user_a06', 'apple.com',      300.00,  '2026-05-01 00:00:00+00', '2026-06-01 00:00:00+00', true,  true,  'ACTIVE');

INSERT INTO aap14_payment_attempts VALUES
    -- mnd_001 valid attempts
    ('att_001', 'mnd_001', 'agent_alpha',   'amazon.com',    49.99,  '2026-05-06 10:00:00+00'),
    ('att_002', 'mnd_001', 'agent_alpha',   'amazon.com',   120.00,  '2026-05-06 10:05:00+00'),
    -- mnd_001 amount-overage (over 500 cap)
    ('att_003', 'mnd_001', 'agent_alpha',   'amazon.com',   899.00,  '2026-05-06 10:10:00+00'),
    -- mnd_001 scope-merchant mismatch (mandate is for amazon)
    ('att_004', 'mnd_001', 'agent_alpha',   'ebay.com',      75.00,  '2026-05-06 10:12:00+00'),
    -- mnd_002 expired (mandate ended 2026-05-05)
    ('att_005', 'mnd_002', 'agent_beta',    'doordash.com',  35.50,  '2026-05-06 09:00:00+00'),
    ('att_006', 'mnd_002', 'agent_beta',    'doordash.com',  42.00,  '2026-05-06 09:30:00+00'),
    -- mnd_003 invalid signature
    ('att_007', 'mnd_003', 'agent_gamma',   'uber.com',      18.50,  '2026-05-06 11:00:00+00'),
    -- mnd_004 untrusted issuer
    ('att_008', 'mnd_004', 'agent_delta',   'netflix.com',   15.99,  '2026-05-06 12:00:00+00'),
    -- mnd_005 valid attempts
    ('att_009', 'mnd_005', 'agent_epsilon', 'shopify.com',  250.00,  '2026-05-06 13:00:00+00'),
    ('att_010', 'mnd_005', 'agent_epsilon', 'shopify.com',  175.00,  '2026-05-06 13:05:00+00'),
    -- mnd_006 replay: 5 rapid-fire uses
    ('att_011', 'mnd_006', 'agent_zeta',    'apple.com',     19.99,  '2026-05-06 14:00:00+00'),
    ('att_012', 'mnd_006', 'agent_zeta',    'apple.com',     19.99,  '2026-05-06 14:00:30+00'),
    ('att_013', 'mnd_006', 'agent_zeta',    'apple.com',     19.99,  '2026-05-06 14:01:00+00'),
    ('att_014', 'mnd_006', 'agent_zeta',    'apple.com',     19.99,  '2026-05-06 14:01:30+00'),
    ('att_015', 'mnd_006', 'agent_zeta',    'apple.com',     19.99,  '2026-05-06 14:02:00+00'),
    -- mnd_006 valid one (after the rapid burst settles)
    ('att_016', 'mnd_006', 'agent_zeta',    'apple.com',     49.00,  '2026-05-06 15:30:00+00');

The dataset deliberately covers every failure mode described in the six-check list, plus a clean replay burst on mnd_006.

The validation view

This is the join. One row per attempt, one boolean per check.

CREATE MATERIALIZED VIEW aap14_validation_mv AS
SELECT
    a.attempt_id,
    a.mandate_id,
    a.agent_id,
    a.merchant,
    a.amount,
    a.attempt_time,
    m.scope_merchant,
    m.scope_max_amount,
    m.valid_from,
    m.valid_to,
    m.signature_valid AS sig_ok,
    m.issuer_trusted AS issuer_ok,
    (a.merchant = m.scope_merchant) AS scope_ok,
    (a.attempt_time BETWEEN m.valid_from AND m.valid_to) AS expiry_ok,
    (a.amount <= m.scope_max_amount) AS amount_ok,
    (m.status = 'ACTIVE') AS status_ok
FROM aap14_payment_attempts a
LEFT JOIN aap14_mandates m
  ON a.mandate_id = m.mandate_id;

Notice the use of a LEFT JOIN. If a payment attempt arrives for a mandate that has not been registered yet (or has been revoked and removed), the join produces a row with NULL on the mandate side. The downstream decision view treats those nulls as denials.

Verified validation output

 attempt_id | mandate_id | sig_ok | issuer_ok | scope_ok | expiry_ok | amount_ok
------------+------------+--------+-----------+----------+-----------+-----------
 att_001    | mnd_001    | t      | t         | t        | t         | t
 att_002    | mnd_001    | t      | t         | t        | t         | t
 att_003    | mnd_001    | t      | t         | t        | t         | f
 att_004    | mnd_001    | t      | t         | f        | t         | t
 att_005    | mnd_002    | t      | t         | t        | f         | t
 att_006    | mnd_002    | t      | t         | t        | f         | t
 att_007    | mnd_003    | f      | t         | t        | t         | t
 att_008    | mnd_004    | t      | f         | t        | t         | t
 att_009    | mnd_005    | t      | t         | t        | t         | t
 att_010    | mnd_005    | t      | t         | t        | t         | t
 att_011    | mnd_006    | t      | t         | t        | t         | t
 att_012    | mnd_006    | t      | t         | t        | t         | t
 att_013    | mnd_006    | t      | t         | t        | t         | t
 att_014    | mnd_006    | t      | t         | t        | t         | t
 att_015    | mnd_006    | t      | t         | t        | t         | t
 att_016    | mnd_006    | t      | t         | t        | t         | t
(16 rows)

Every check column is now a clean true/false. The five non-replay failures stand out: att_003 fails the amount cap, att_004 fails scope, att_005 and att_006 fail expiry, att_007 fails signature, att_008 fails issuer trust. The mnd_006 burst still looks valid by check columns because replay is not a per-row property, it is a windowed aggregate. That goes in its own view next.

Replay Protection With Watermarks and Dedup

Replay protection is the one check that cannot be expressed as a function of a single row. It depends on the rate of arrival.

The pattern that catches it is a self-join with a time predicate. For each attempt, count how many other attempts share the same mandate_id within a five-minute look-back window.

CREATE MATERIALIZED VIEW aap14_replay_check_mv AS
SELECT
    a.attempt_id,
    a.mandate_id,
    COUNT(b.attempt_id) AS uses_in_window
FROM aap14_payment_attempts a
JOIN aap14_payment_attempts b
  ON a.mandate_id = b.mandate_id
  AND b.attempt_time BETWEEN a.attempt_time - INTERVAL '5 minutes' AND a.attempt_time
GROUP BY a.attempt_id, a.mandate_id;

A few things to notice:

  • The window is anchored to the current attempt's timestamp, not the wall clock. That makes the result deterministic and replay-safe even when events arrive late.
  • b.attempt_time is bounded on both sides, so the join is a true rolling window rather than a full Cartesian product.
  • The view is incrementally maintained. When att_015 arrives, RisingWave does not recompute the count for every prior attempt: it joins the new row against the relevant five-minute slice and updates only what changed. This is the same incremental view maintenance pattern used elsewhere in the engine.

In a Kafka-sourced setup, watermarks on attempt_time give the engine a hint about how late events can be. Combined with primary keys on attempt_id, this gives you exactly-once dedup for free: a redelivered Kafka record with the same attempt_id is upserted, not double-counted.

Verified replay output

 attempt_id | mandate_id | uses_in_window
------------+------------+----------------
 att_001    | mnd_001    |              1
 att_002    | mnd_001    |              2
 att_003    | mnd_001    |              2
 att_004    | mnd_001    |              2
 att_005    | mnd_002    |              1
 att_006    | mnd_002    |              1
 att_007    | mnd_003    |              1
 att_008    | mnd_004    |              1
 att_009    | mnd_005    |              1
 att_010    | mnd_005    |              2
 att_011    | mnd_006    |              1
 att_012    | mnd_006    |              2
 att_013    | mnd_006    |              3
 att_014    | mnd_006    |              4
 att_015    | mnd_006    |              5
 att_016    | mnd_006    |              1
(16 rows)

att_014 and att_015 are the rows that exceed the threshold of 3. att_016, which happens 90 minutes later, falls outside the window and goes back to a count of 1: legitimate use after the burst dies down. This is the correct behavior. A blunt "deny everything from this mandate forever" rule would punish honest retries.

Outputting Validation Decisions

The final view is what the rest of the system consumes. It joins the validation columns with the replay count and produces a single decision plus a structured reason.

CREATE MATERIALIZED VIEW aap14_decisions_mv AS
SELECT
    v.attempt_id,
    v.mandate_id,
    v.agent_id,
    v.merchant,
    v.amount,
    v.attempt_time,
    v.sig_ok,
    v.issuer_ok,
    v.scope_ok,
    v.expiry_ok,
    v.amount_ok,
    COALESCE(r.uses_in_window, 1) AS uses_in_5min,
    CASE
        WHEN v.sig_ok = false THEN 'DENY'
        WHEN v.issuer_ok = false THEN 'DENY'
        WHEN v.expiry_ok = false THEN 'DENY'
        WHEN v.scope_ok = false THEN 'DENY'
        WHEN v.amount_ok = false THEN 'DENY'
        WHEN COALESCE(r.uses_in_window, 1) > 3 THEN 'DENY'
        ELSE 'ALLOW'
    END AS decision,
    CASE
        WHEN v.sig_ok = false THEN 'invalid_signature'
        WHEN v.issuer_ok = false THEN 'untrusted_issuer'
        WHEN v.expiry_ok = false THEN 'expired_mandate'
        WHEN v.scope_ok = false THEN 'merchant_scope_mismatch'
        WHEN v.amount_ok = false THEN 'amount_exceeds_cap'
        WHEN COALESCE(r.uses_in_window, 1) > 3 THEN 'replay_suspected'
        ELSE 'ok'
    END AS reason
FROM aap14_validation_mv v
LEFT JOIN aap14_replay_check_mv r ON v.attempt_id = r.attempt_id;

The CASE order matters. Crypto and trust failures shadow everything else: if the signature is bad, you do not care that the amount also exceeds the cap. The reason emitted is the most fundamental failure, not a list, which makes downstream alerting and analytics easier.

Verified decisions output

 attempt_id | mandate_id |   agent_id    |   merchant   | amount | decision |         reason
------------+------------+---------------+--------------+--------+----------+-------------------------
 att_001    | mnd_001    | agent_alpha   | amazon.com   |  49.99 | ALLOW    | ok
 att_002    | mnd_001    | agent_alpha   | amazon.com   | 120.00 | ALLOW    | ok
 att_003    | mnd_001    | agent_alpha   | amazon.com   | 899.00 | DENY     | amount_exceeds_cap
 att_004    | mnd_001    | agent_alpha   | ebay.com     |  75.00 | DENY     | merchant_scope_mismatch
 att_005    | mnd_002    | agent_beta    | doordash.com |  35.50 | DENY     | expired_mandate
 att_006    | mnd_002    | agent_beta    | doordash.com |  42.00 | DENY     | expired_mandate
 att_007    | mnd_003    | agent_gamma   | uber.com     |  18.50 | DENY     | invalid_signature
 att_008    | mnd_004    | agent_delta   | netflix.com  |  15.99 | DENY     | untrusted_issuer
 att_009    | mnd_005    | agent_epsilon | shopify.com  | 250.00 | ALLOW    | ok
 att_010    | mnd_005    | agent_epsilon | shopify.com  | 175.00 | ALLOW    | ok
 att_011    | mnd_006    | agent_zeta    | apple.com    |  19.99 | ALLOW    | ok
 att_012    | mnd_006    | agent_zeta    | apple.com    |  19.99 | ALLOW    | ok
 att_013    | mnd_006    | agent_zeta    | apple.com    |  19.99 | ALLOW    | ok
 att_014    | mnd_006    | agent_zeta    | apple.com    |  19.99 | DENY     | replay_suspected
 att_015    | mnd_006    | agent_zeta    | apple.com    |  19.99 | DENY     | replay_suspected
 att_016    | mnd_006    | agent_zeta    | apple.com    |  49.00 | ALLOW    | ok
(16 rows)

Every category of failure shows up correctly:

  • att_003 over the cap, att_004 to the wrong merchant.
  • att_005 and att_006 past the mandate's expiry.
  • att_007 failed the signature check upstream.
  • att_008 was signed by a non-trusted issuer.
  • att_014 and att_015 exceed three uses inside five minutes.
  • att_016 is allowed because it falls outside the replay window.

A summary aggregation gives the decision distribution at a glance:

SELECT decision, reason, COUNT(*) AS attempts
FROM aap14_decisions_mv
GROUP BY decision, reason
ORDER BY decision, reason;
 decision |         reason          | attempts
----------+-------------------------+----------
 ALLOW    | ok                      |        8
 DENY     | amount_exceeds_cap      |        1
 DENY     | expired_mandate         |        2
 DENY     | invalid_signature       |        1
 DENY     | merchant_scope_mismatch |        1
 DENY     | replay_suspected        |        2
 DENY     | untrusted_issuer        |        1
(7 rows)

In production, sink aap14_decisions_mv to a Kafka topic the gateway already consumes, or to a PostgreSQL sink backing the gateway's decision API. Because the view is incremental, every new payment attempt produces a new decision row in milliseconds without a re-scan.

The same pipeline scales horizontally: each materialized view in this stack runs in parallel across compute nodes, so a 100x growth in attempt rate becomes a 100x growth in compute, not a re-architecture.

FAQ

What does mandate verification involve?

Mandate verification confirms that a payment initiated by an autonomous agent is authorized by the user. A complete check covers six things: signature integrity, issuer trust, scope (which merchant and product category), amount cap, freshness or expiry, and replay protection. Each check has to pass before the payment is allowed to settle.

How can mandate verification scale to high QPS?

At thousands of agentic transactions per second, synchronous lookups against an OLTP database become a bottleneck. A streaming database keeps the mandate state, scope rules, and replay counters as continuously updated materialized views, so each verification is a fast lookup against pre-joined, pre-aggregated state instead of a multi-step query.

What is replay protection in mandate verification?

Replay protection prevents the same mandate from being used many times in quick succession by a malicious or buggy agent. It is typically enforced by counting how many times a mandate has been presented within a short time window and rejecting attempts that exceed the allowed rate.

How does RisingWave validate mandates in real time?

RisingWave runs the validation logic as incremental materialized views over a stream of payment attempts joined with the mandate registry. Each new attempt updates the view immediately, producing an ALLOW or DENY decision with a structured reason, and the result can be sunk to a Kafka topic that the payment gateway consumes.

Conclusion

Agent-initiated payments only work if the verification path is both strict and fast. Strict, because a single missed check, expiry, scope, or replay, is a fraud loss. Fast, because every millisecond of latency in the verification path is a millisecond every transaction has to wait.

Streaming SQL collapses the six-check pipeline into three materialized views: a join, a windowed self-join for replay, and a decision view. Each is incrementally maintained, deterministic, and easy to inspect. The verified output above shows exactly how each kind of failure surfaces, with reasons clear enough to feed alerting, dashboards, and downstream analytics.

Ready to validate agent mandates at scale? Try RisingWave Cloud free.

Join our Slack community to ask questions and connect with other developers building real-time agent infrastructure.

Best-in-Class Event Streaming
for Agents, Apps, and Analytics
GitHubXLinkedInSlackYouTube
Sign up for our to stay updated.