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.

POST/v1/assess

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)

FieldTypeBehavior on violation
policy_ownerliteral "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.merchantsstring[]Non-empty allowlist. Purchase merchant not in list → deny. No identifiable merchant under an active allowlist → deny.
deny.merchantsstring[]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_allowedstring[]Non-empty allowlist of instrument types matched against payment_instrument.type. Rail not in list → deny. Rail unreadable → deny.
expires_atISO-8601 datetime stringMandate 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).

FieldTypeBehavior 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.
Subject required. 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.

DecisionMeaningRecommended action
approveBoth sell-side and buy-side cleared the payment.Proceed. Forward the signed mandate (risk_data injected). Receipt in riskPayloadJws.
reviewA 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.
denyA 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.
Composition invariant. The buy-side gate can only tighten the sell-side verdict, never relax it. A sell-side 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.mccs and allow.categories cannot be evaluated against the mandate. They are recorded in spend_guard.unevaluable and never silently treated as a pass.
  • ·deny.categories has the same limitation, unevaluable, reported transparently.
  • ·Merchant matching (allow.merchants / deny.merchants) uses payee.id or payee.name, whichever the mandate supplies.
No silent passes.An unevaluable check is never treated as "passed". If the engine cannot evaluate a dimension it records it in 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/v1/spending-mandates
GET/v1/spending-mandates
DELETE/v1/spending-mandates/{id}

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).

POST/v1/confirmations/{id}

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).

EventFires when
spend.review_requiredA purchase composed to review and a pending confirmation was created. Payload carries the confirmation_id, subject, amount, and reasons.
spend.confirmedA human resolved a confirmation with confirm via POST /v1/confirmations/{id}.
spend.deniedA purchase composed to deny on the buy-side, or a confirmation was resolved with deny.
Spend-guard side effects (recording the spend, creating a confirmation, emitting 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.