The delegations row is polymorphic via a kind column
(buyer | seller | both). seller and both rows additionally
carry a seller_id (FK), a seller_actions_allowed array, and
product_allow / product_deny lists. The evaluator supports a
fourth action kind, seller_action, with denial reasons
seller_action_not_allowed, seller_not_owned_by_delegation,
product_denied, product_not_allowed, and
seller_action_requires_confirmation (a hard-deny).
Service-quote charges (acceptance and the fixed-price one-call
purchase) run the same evaluator as goods purchases. Two specifics:
(1) the cumulative daily/total spend sums are cross-rail — the
spend tracker counts goods orders (placed_at) and charged service
quotes (paid_at) together, in both
directions, so neither rail can bypass the other's caps; (2) the
evaluator sees service charges under the category namespace
service:<service_category>, so a delegation with a category
allow-list denies service spend unless those categories are listed
explicitly. Seller-side service actions (submit quote / start /
deliver) gate on the respond_quote seller action.
Delegation policy is the first-layer check on every state-changing
action. Payment envelopes are a second layer consulted only
after the delegation evaluator returns allow: true. They cap, scope,
and time-bound how much an agent can spend without further SCA.
Spend caps are live and enforced. This doc describes the schema and the evaluator.
Concept
A delegation is an immutable policy snapshot binding a principal-issued grant to one specific agent instance. It carries:
| Axis | Field | Meaning |
|---|---|---|
| Per-tx cap | per_tx_cap_pence |
Max value of any single chargeable action. |
| Daily cap | daily_cap_pence |
Sum of chargeable actions per UTC day. |
| Total cap | total_cap_pence (+ window) |
Cap over an explicit window [start, end]. |
| Categories allow | category_allow |
Strict allow-list (null means no allow-list). |
| Categories deny | category_deny |
Hard deny-list. Deny beats allow. |
| Sellers allow | seller_allow |
Strict allow-list of seller ids. |
| Sellers deny | seller_deny |
Hard deny-list. |
| Restricted goods | restricted_goods_policy |
forbid_all / forbid_age_restricted / allow_with_confirmation. |
| Confirmation threshold | confirmation_threshold_pence |
Above this, evaluator returns requires_confirmation. |
| Validity | valid_from, valid_until |
Timed scope. |
| Kind | kind |
buyer (default), seller, or both. |
| Seller binding | seller_id |
Required when kind in ('seller','both'). One delegation = one seller. |
| Seller actions | seller_actions_allowed[] |
Allow-list of seller actions. Full enum: list_create, list_update, list_archive, list_age_restricted, stock_update, ship, attach_tracking, cancel_order, accept_return, reject_return, respond_review, respond_dispute, respond_quote. Empty = no seller actions. |
| Product allow | product_allow |
Strict allow-list of product ids; NULL = anything not denied. |
| Product deny | product_deny |
Hard deny-list; deny beats allow. |
Cap currency
All money axes are integer minor units. Delegations carry no currency column, so a cap is a bare scalar whose unit comes from the charges it governs. That unit is pinned by the delegation's first charge: the earliest goods order or charged service quote under the delegation fixes the currency, and every later charge must match it.
A charge in any other currency hard-fails with
delegation_currency_mismatch (conflict, HTTP 409), carrying
delegation_id, delegation_currency, and charge_currency in the
error details. This mirrors payment envelopes, which are likewise
single-currency instruments and fail with envelope_currency_mismatch.
The rules in full:
- The pin applies only when the delegation has at least one
money-denominated cap (
per_tx_cap_pence,daily_cap_pence,total_cap_pence, orconfirmation_threshold_pence). An uncapped delegation — including the default self-delegation — has no implicit unit to protect and accepts any currency. - It is cross-rail: a gbp goods order pins the delegation for service charges too, and vice versa. Enforced at intent creation and again at confirm on the goods rail, and in Layer 1 of the service charge path.
- The daily/total spend sums are filtered to the pinned currency, so a delegation that accrued mixed charges before this rule existed still reports a single-currency total (and its off-currency charges are blocked from here on).
- A buyer who needs to spend in a second currency issues a second delegation. Delegations are immutable snapshots, so this is the same move as changing any other policy axis.
Why pinning rather than per-currency subtotals: a cap tracked separately per currency would hand each currency its own full allowance, turning a £100/day cap into £100 + $100 + €100 per day — the same bypass, re-shaped. Face-value summation across currencies (the previous behaviour) violated AGENTS.md Rule 3 outright.
Cap evaluation uses the tax-inclusive charge total on both rails (US sales tax is added on top of the listed price).
Self-delegation
Every principal gets one auto-created on sign-up, attached to their
auto-created self-instance. Default policy: unbounded caps,
restricted_goods_policy = allow_with_confirmation, no allow-lists,
no confirmation threshold. The principal can edit it (which always
creates a new snapshot — see "Immutability" below).
Immutability
Delegations are insert-only at the service layer. To change a policy:
- The principal saves a new policy.
createDelegationForInstanceruns a transaction that:- Locks the instance row (
SELECT … FOR UPDATE). - Marks the previous active delegation
is_revoked = truewith reasonsuperseded_by_new_policy. - Inserts the new row with
supersedes_delegation_idpointing at the previous.
- Locks the instance row (
- Audit logs record both the supersede and the create.
This keeps the audit log honest: any audit_log.delegation_id resolves
to the exact policy in force at the moment of the action.
At-most-one-active
Enforced inside the transaction (Postgres partial indexes can't
reference now()). Concurrent attempts serialise on the row lock.
Evaluator
evaluate(delegation, action, now):
- Pure: no DB, no time source, deterministic.
- Returns
{ allow: true, requires_confirmation: boolean, reason: null }on allow, or{ allow: false, requires_confirmation: false, reason: DenialReason }on deny. - Order (buyer-side
purchasepath): validity → per-tx → daily → total (inside window) → category deny → category allow → seller deny → seller allow → restricted goods → confirmation threshold. - "Deny beats allow" on both categories and sellers.
- The
seller_actionbranch runs immediately after the validity check and short-circuits the buyer-side path. Its order is: reject ifkind === 'buyer'(action_not_in_scope) → seller ownership (seller_not_owned_by_delegation) → action allow-list (seller_action_not_allowed) → product deny then product allow (product_denied/product_not_allowed) → restricted-goods gate onlist_create(hard-denies underallow_with_confirmationwithseller_action_requires_confirmation— seller-side actions never kick back for confirmation) → per-tx and daily caps for refund-bearing actions that carry anamount_pence.
Test coverage: 40+ table-driven cases, one per axis.
Mapping to payments primitives
The schema maps cleanly onto:
- Stripe SPT
usage_limits—per_tx_cap_pence↔max_amount,valid_until↔expires_at. Caps are currency-agnostic minor units (gbp, usd, and eur all supported); the per-charge currency is resolved at the payment layer, not on the delegation row. - Stripe Issuing card spend controls — caps + category deny/allow.
- PSD2 MIT mandate disclosure — the snapshot itself is the artifact the principal signed off on. See payments-agent.md for the full research context.
Known limitations
- Composing multiple delegations on one instance (one active per instance only).
- Inter-agent sub-delegation (the chain-of-authority case).
- Day-boundary timezone handling beyond UTC.
- Server-enforced max delegation lifetime.