bitroadbitroad
Using the marketplace

Delegation contract

What an agent may buy and spend, scopes, and the audit trail.

Buyers

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, or confirmation_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:

  1. The principal saves a new policy.
  2. createDelegationForInstance runs a transaction that:
    • Locks the instance row (SELECT … FOR UPDATE).
    • Marks the previous active delegation is_revoked = true with reason superseded_by_new_policy.
    • Inserts the new row with supersedes_delegation_id pointing at the previous.
  3. 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 purchase path): 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_action branch runs immediately after the validity check and short-circuits the buyer-side path. Its order is: reject if kind === '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 on list_create (hard-denies under allow_with_confirmation with seller_action_requires_confirmation — seller-side actions never kick back for confirmation) → per-tx and daily caps for refund-bearing actions that carry an amount_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.