Buy-Side
Spend Guard
Spend Guard is the buy-side of the Fidacy engine. Where the sell-side asks whether a merchant should accept an agent payment, Spend Guard asks whether the consumer actually authorized this purchase, within the limits they set, for the merchant they intended, on the rail they approved. When an agent is prompt-injected or hijacked, the attacker controls what the agent thinksit is doing; Spend Guard is an external, deterministic gate that cannot be talked past from inside the agent. It runs outside the model, enforces the consumer's mandate in code, and returns a signed, auditable verdict, before any money moves.
How to use Spend Guard
Spend Guard is off-by-default and requires no new endpoint. Pass a spending_mandate alongside the AP2 mandate in your normal POST /v1/assess call. When the field is absent the response is byte-identical to today, the buy-side gate does not run.
The spending_mandate must carry policy_owner: "consumer" (the default). The engine runs the buy-side checks, composes the result with the sell-side decision (most-restrictive wins), and returns the normal verdict with an additive spend_guard object appended.
curl -X POST https://api.fidacy.com/v1/assess \
-H "Authorization: Bearer fky_live_…" \
-H "Content-Type: application/json" \
-d '{
"mandate": {
"vct": "urn:ietf:params:ap2:payment",
"payee": { "id": "merch_acme", "name": "Acme Office Supplies" },
"payment_instrument": { "type": "card_debit" },
"payment_amount": { "amount": 4999, "currency": "USD" }
},
"spending_mandate": {
"policy_owner": "consumer",
"subject": { "user_id": "usr_123", "agent_id": "kya_thumbprint_…" },
"per_transaction_max": { "amount": 10000, "currency": "USD" },
"daily_max": { "amount": 50000, "currency": "USD" },
"monthly_max": { "amount": 200000, "currency": "USD" },
"velocity": { "window": "1h", "max_count": 5 },
"allow": { "merchants": ["merch_acme", "merch_staples"] },
"deny": { "merchants": ["merch_casino"] },
"require_human_confirmation_above": { "amount": 7500, "currency": "USD" },
"rails_allowed": ["card_debit", "card_credit"],
"expires_at": "2026-12-31T23:59:59Z"
}
}'Response shape (with Spend Guard active)
The top-level decision is the composed verdict (most restrictive of sell-side and buy-side). The additive spend_guard object carries the buy-side verdict alone so you can distinguish why a decision changed.
{
"decision": "approve",
"score": 14,
"assessmentId": "asmt_…",
"riskPayloadJws": "eyJhbGciOiJFZERTQSJ9…",
"spend_guard": {
"evaluated": true,
"decision": "approve",
"reasons": [],
"unevaluable": [],
"deferred": []
},
"outcome": { … }
}When a buy-side check fires, spend_guard.reasons lists every violation with a code, a severity (deny | review), and a human-readable message. If you sent fields that are not yet enforced, they appear in spend_guard.deferred rather than silently passing or silently failing.
The spending mandate schema
All monetary amounts are in minor units (integer) with an ISO-4217 currency code, the same unit convention as the AP2 mandate. All fields are optional unless noted.
Enforced now (stateless, slice 1)
| Field | Type | Behavior on violation |
|---|---|---|
policy_owner | literal "consumer" | Required marker. Defaults to "consumer". Validates the mandate is buy-side. |
subject | { user_id?: string, agent_id?: string } | Identity the spend history is keyed to (user_id preferred, else agent_id). Required for the stateful limits below, a stateful limit set with no resolvable subject degrades to review (never approve). |
per_transaction_max | { amount: int, currency: string } | Amount exceeds cap → deny. Currency mismatch → review (cannot compare safely). Amount absent → review. |
allow.merchants | string[] | Non-empty allowlist. Purchase merchant not in list → deny. No identifiable merchant under an active allowlist → deny. |
deny.merchants | string[] | Explicit blocklist. Purchase merchant matched by id or name → deny. |
require_human_confirmation_above | { amount: int, currency: string } | Amount exceeds threshold → review (forces human step-up regardless of sell-side decision). Currency mismatch → review. |
rails_allowed | string[] | Non-empty allowlist of instrument types matched against payment_instrument.type. Rail not in list → deny. Rail unreadable → deny. |
expires_at | ISO-8601 datetime string | Mandate past this instant → deny. Unparseable value → review (fail-safe). |
Enforced now (stateful, slice 2)
These limits aggregate the consumer's own purchase history, keyed to subject. They require a resolvable subject; when one is present the engine evaluates them and they no longer appear in spend_guard.deferred. Spend is counted against the limit only when the final decision is approve (money authorized), review and deny consume no budget. History is scoped per (subject, currency, live/test).
| Field | Type | Behavior on violation |
|---|---|---|
daily_max | { amount: int, currency: string } | Prospective: prior approved spend in the last 24h + this purchase exceeds the cap → deny. Currency mismatch → review. |
monthly_max | { amount: int, currency: string } | Prospective: prior approved spend in the current calendar month (UTC) + this purchase exceeds the cap → deny. Currency mismatch → review. |
velocity | { window?: string, max_count: int } | Approved-purchase count within the rolling window (default 1h; e.g. 30m, 24h) reaches max_count → review (human step-up, a burst may be legitimate). No resolvable subject → review. |
daily_max, monthly_max, and velocity need a resolvable subject to look up spend history. If you send one of these with no subject, the engine cannot evaluate it and degrades to review (recorded in spend_guard.unevaluable), it is never silently passed.Decision → action
The composed decision field (top-level on the response) reflects the most-restrictive combination of sell-side and buy-side verdicts. Deny on either side is always the final word.
| Decision | Meaning | Recommended action |
|---|---|---|
approve | Both sell-side and buy-side cleared the payment. | Proceed. Forward the signed mandate (risk_data injected). Receipt in riskPayloadJws. |
review | A buy-side step-up threshold was crossed (require_human_confirmation_above or velocity) or an amount could not be evaluated against a cap. | Pause the agent. The engine creates a pending confirmation and emits spend.review_required; resolve it with POST /v1/confirmations/{id}. A confirm records the spend and lets the purchase proceed. |
deny | A hard buy-side limit was breached: cap exceeded, merchant blocked, rail not allowed, mandate expired. | Block the payment. Log the spend_guard.reasons for the audit trail. No money should move. |
deny cannot be turned into an approve by Spend Guard; a buy-side deny overrides a sell-side approve. Ambiguity (unknown amount, unreadable rail) always degrades toward review, never toward approve.Limitations (current slice)
MCC and category fields are not evaluable
The AP2 payment mandate carries payee (id, name) and payment_instrument (type), but it does not carry an MCC code or merchant category. As a result:
- ·
allow.mccsandallow.categoriescannot be evaluated against the mandate. They are recorded inspend_guard.unevaluableand never silently treated as a pass. - ·
deny.categorieshas the same limitation, unevaluable, reported transparently. - ·Merchant matching (
allow.merchants/deny.merchants) usespayee.idorpayee.name, whichever the mandate supplies.
spend_guard.unevaluable and, where a cap or allowlist is involved, degrades conservatively to review.Mandate & confirmation endpoints
Beyond the inline spending_mandate on /v1/assess, two dedicated endpoints let you persist mandates and resolve human step-ups. Both require the assess:write scope and are tenant-isolated.
POST persists a spending mandate (the body is the same schema documented above) and returns its id, resolved subject, and status. A mandate with no resolvable subject is rejected with 400 subject_required. GET lists active mandates; DELETE soft-expires one (status flips to expired and it drops out of the active list).
Resolves a pending human step-up created when a purchase composed to review. Body: { "decision": "confirm" | "deny" }. A confirmrecords the spend (it now consumes the consumer's daily/monthly budget) and emits spend.confirmed; a deny emits spend.denied. Resolving an already-resolved confirmation returns 409, so a retry can never double-count the spend. A confirm on a confirmation with no recordable amount is rejected with 422 unledgerable_confirmation.
Spend webhooks
Subscribe a webhook endpoint to these event types to drive your own step-up UX and notifications. Deliveries are EdDSA-signed and idempotent per event id (verify with the public JWKS, same as every other Fidacy webhook).
| Event | Fires when |
|---|---|
spend.review_required | A purchase composed to review and a pending confirmation was created. Payload carries the confirmation_id, subject, amount, and reasons. |
spend.confirmed | A human resolved a confirmation with confirm via POST /v1/confirmations/{id}. |
spend.denied | A purchase composed to deny on the buy-side, or a confirmation was resolved with deny. |
spend.*) only run when the mandate carries a resolvable subject. Without one, the buy-side verdict still composes into the decision (fail-safe), but no rows are written and no webhook fires.