Payment mandates and transaction receipts¶
Audience: operators who let an agent pay for something mid-run (a metered data API, a paid search endpoint, a compute-credit top-up) and need one auditable answer to "who authorized this charge, under what limit, and did the run stay inside it?"
What: bernstein payment-mandate issues a signed spending authorization, and records every transaction attempt under it — approved or refused — as a chain-anchored receipt that verifies offline.
Boundary: Bernstein never moves money. It authorizes, bounds, and proves; the actual settlement stays with whatever external payment rail you already use. The deliverable is the verifiable authorization record, not a wallet. See src/bernstein/core/payments/.
The two artefacts¶
| Artefact | What it is | Identity |
|---|---|---|
SpendMandate | An operator-issued, Ed25519-signed authorization with a bound scope. | sha256 of the signed body (mandate_hash). |
TransactionReceipt | The record of one transaction attempt under a mandate. | sha256 of the receipt body (receipt_hash). |
A mandate is signed with the install's existing agent-card keystore key — no new key surface is introduced. Each receipt is appended to the lineage spine with a detached-JWS .jws sidecar and mirrored as a payment.authorized / payment.refused audit event carrying the chain digest captured at decision time. Strip the lineage signature or the audit chain and a receipt is just a JSON file; anchored, it is an offline-verifiable proof.
Presence mode — the structural distinction¶
A mandate is issued in one of two presence modes, and the mode changes what the signature covers:
| Mode | The signature binds | Enforcement |
|---|---|---|
human_present | A concrete transaction envelope: an exact amount + recipient the operator signed off in the loop. | No per-transaction cap; cumulative spend cannot exceed that one concrete amount (effectively single-shot). |
delegated | A bounded envelope the agent transacts under: a max amount, an expiry, optionally a per-transaction cap and an allowed-category set. | Several transactions allowed while their cumulative total stays inside the bound. |
The mode is a signed field. Every receipt records which mode authorized it.
Money and text encoding¶
- Amounts are string-encoded integer nano-units (
1e-9of a major unit), rounded half-even exactly once when the amount is first encoded. No float ever enters a signed payload, and every comparison and cumulative sum is exact integer arithmetic. recipientand category strings must already be NFC; a non-NFC string is rejected, never silently normalized, so the signed bytes equal the input bytes.currencyis an ISO-4217-style three-letter uppercase ASCII code.
Refusals are first-class receipts¶
An out-of-scope request is refused, and the refusal is itself a signed, chain-anchored receipt with a closed-enum reason hash-bound to the mandate:
refusal_reason | Trigger |
|---|---|
bad_signature | The mandate signature does not verify. |
wrong_presence_mode | The request's presence mode differs from the mandate's. |
expired | now is past the mandate's not_after. |
wrong_recipient | The request recipient differs from the mandate's. |
over_max_amount | The amount exceeds max_amount (or the per-transaction cap). |
cumulative_exceeded | This amount plus prior authorized spend would exceed max_amount. |
When several checks fail, the reported reason follows a fixed precedence: bad_signature → wrong_presence_mode → expired → wrong_recipient → over_max_amount → cumulative_exceeded, so the decision is deterministic. A request whose currency does not match the mandate's is a malformed request (a hard error), not a spend-policy refusal.
Cumulative-spend safety¶
Cumulative spend is aggregated on read from an append-only receipt ledger keyed on mandate_hash. The read-aggregate-decide-append sequence runs under an exclusive file lock, so two concurrent workers sharing one mandate can never both observe a stale total and each admit spend that, together, exceeds the cap.
CLI¶
# Issue a delegated mandate: up to $100, $25 per transaction, expires at <unix>.
bernstein payment-mandate issue \
--presence-mode delegated \
--max-amount 100.00 --currency USD \
--recipient vendor:acme-data-api \
--per-tx-cap 25.00 --allowed-category data \
--not-after 2000000000
# Inspect a stored mandate and verify its signature offline.
bernstein payment-mandate show <mandate_hash>
# Attempt a transaction; emits an anchored receipt (authorized or refused).
bernstein payment-mandate spend \
--mandate <mandate_hash> \
--amount 20.00 --to vendor:acme-data-api \
--category data --presence-mode delegated
# Verify a receipt entirely offline.
bernstein payment-mandate verify --receipt <receipt_hash>
spend exits 0 when authorized and 1 when refused (the refusal receipt is still recorded). verify exits 0 when every check passes and 1 otherwise.
Offline verification¶
payment-mandate verify recomputes, with no live process:
- the mandate's Ed25519 signature;
- that the receipt is bound to that mandate (
mandate_hash); - the receipt's lineage entry, its content hash, its operator HMAC, and its detached JWS sidecar;
- the full audit-chain HMAC;
- the
payment.authorized/payment.refusedevent mirroring the receipt.
It then reports the bound scope (amount, recipient, expiry) the decision was checked against. Tampering with the receipt body, the mandate scope, or the chain digest — or stripping the .jws sidecar or the audit event — makes verification fail, and a bare receipt file cannot be replayed as an authorization.
Interop¶
External signed-mandate schemes plug in through a narrow MandateAdapter protocol (to_external / from_external). The core ships two scheme-agnostic adapters — a bernstein-native one and a generic JWS pass-through — and blesses no external scheme as canonical. A concrete settlement path (issue #2528) is one adapter over this surface.
On-disk layout¶
<workdir>/.sdd/payments/
mandates/<mandate_hash>.json signed mandates
receipts/<receipt_hash>.json anchored receipts
ledger.jsonl append-only receipt ledger (cumulative aggregation)
<workdir>/.sdd/lineage/ lineage spine + .jws sidecars
<workdir>/.sdd/audit/ HMAC audit chain
<workdir>/.bernstein/keys/ operator agent-card signing keypair