Skip to main content

Wallets

Programmable wallets — per-agent fund pools with mandate-gated debits, multi-rail funding (Pix, TED, USD wire, USDC, BRLA), and automatic reconciliation. Part of the AgentGate managed tier.

5 min read · updated

Wallets

A wallet is a per-agent fund pool. The agent can spend only from this pool — never from the org's main account — and every spend is mandate-gated. Multi-rail funding (Pix, TED, USD wire, USDC, BRLA) lands on the same ledger; reconciliation against provider receipts runs automatically.

Wallets are part of AgentGate, the commerce-governance capability set in the managed tier. Self-host customers get a single-tenant reference wallet; the multi-tenant ledger, recon engine, and accounting export ship in the managed tier.

F2.M4 Programmable Wallets shipped in April 2026. The companion design doc is docs/designs/DESIGN-F2.M4-programmable-wallets.md in the codespar-web repo.

Why wallets

Without a wallet primitive, agent spend either goes through the org's main account (no segregation) or against an abstract policy budget (no real funds, no real settlement). Both fail the auditor test for regulated enterprise customers.

A wallet adds three things at once:

  1. Funds segregation — the agent has a per-pool balance the operator funded explicitly. Cross-tenant transfers are blocked at the database layer.
  2. Mandate gating — every debit references a signed mandate with cap + expiry. The HMAC verification + cap check + the wallet's own available-funds check are three independent gates; bypassing all three requires a coordinated bug in three packages.
  3. Reconciliation — every debit pairs with a provider receipt. The recon engine flags unmatched debits and orphan receipts so the operator sees mismatches without reading raw logs.

The non-overridable invariants

Four invariants enforced at the database layer (not app code):

InvariantHow it's enforced
Wallet debits are mandate-gatedwallet_ledger CHECK constraint: kind IN ('hold','debit') requires mandate_id IS NOT NULL
Cross-tenant operations are blockedEvery wallet route filters by (org_id, project_id) from authContext; foreign keys cascade via org_id
Negative balance is impossiblewallet_balances CHECK: balance_minor >= 0 AND available_minor >= 0 AND available_minor <= balance_minor
Reconciliation anomalies require human reviewDebits older than grace with no matching provider event land in wallet_recon_anomalies; resolved by the operator, not the engine

App-layer bugs cannot bypass these. A retried hold that would overdraw trips the CHECK; the transaction rolls back; the runtime returns a typed InsufficientFundsError.

Lifecycle

                    ┌──────────────┐
                    │  fund        │  webhook from provider →
                    │  (kind=fund) │  bridge inserts ledger row
                    └──────┬───────┘  → balance + available go up


                    ┌──────────────┐
                    │  hold        │  gateway reserves available
                    │  (kind=hold) │  → available drops, balance same
                    └──────┬───────┘

              ┌────────────┴────────────┐
              ▼                         ▼
    ┌──────────────┐          ┌──────────────┐
    │  debit       │          │  release     │  abort path:
    │  (commit)    │          │  (rollback)  │  available restored,
    └──────┬───────┘          └──────┬───────┘  no money moved
           │                         │
           ▼                         ▼
    balance + available        balance + available
    drop by amount + fee       unchanged

Every entry is append-only. Corrections post a new kind=reverse row; existing rows are never updated except for reconciled_at (set by the recon engine).

Funding rails

The funding bridge is provider-agnostic: every webhook adapter normalizes payments to commerce.payment.{succeeded,refunded,failed} and the same code path posts to the wallet ledger. A new provider is one adapter entry — wallet code untouched.

RailProviderCurrencyAuth
PixAsaasBRLAPI key (access_token header)
CardStripeUSD / BRLwebhook signing secret
Card / PixMercado PagoBRLHMAC over id;request-id;ts
Card / PixZoopBRLHTTP Basic
Stablecoin onrampUnblockPayUSDCHMAC over body
Stablecoin onrampBRLA DigitalBRLAHMAC over body

To bind a connection as a funding source, see POST /v1/wallets/:id/funding-sources. The dashboard panel at /dashboard/wallets/{id} does this visually.

Execute lifecycle

POST /v1/wallets/:id/execute runs the full gateway lifecycle:

policy_check → mandate_check → wallet_hold → route_select →
payment_execution → wallet_settle (debit | release) → budget_record

Each step produces an audit entry. The dashboard's Execute modal renders the full audit trail inline — every gate (pass / fail / skip) with the detail string. This is the primary differentiation surface against Stripe Issuing or Brex when walking a CFO through a denied transaction.

OutcomeHTTPWallet impact
completed200hold + release + debit (+ optional fee)
requires-approval (insufficient funds)402no hold posted; top up to retry
denied (policy or mandate fail)403no hold posted
failed (route or execute)422hold + release (no debit)

Reconciliation

A two-pass periodic engine (every 60s):

  1. Match debits to events — unreconciled kind=debit rows older than the grace window get matched against events.provider_event_id. Hit → stamp reconciled_at. Miss → flag as debit_without_receipt.
  2. Flag orphan receiptscommerce.payment.* events with no matching wallet ledger row are flagged as receipt_without_debit against the connection's bound wallet.

When the webhook never arrived (provider lost the event, delivery URL misconfigured), the engine can fall back to provider-side fetch — querying the provider's API directly for the receipt. Behind a per-environment flag (WALLET_RECON_PROVIDER_FETCH=on).

The dashboard recon panel auto-collapses on clean wallets — when there's nothing to triage, there's no panel. When the engine flags, the panel surfaces with Resolve / Dismiss buttons inline.

Crash-recovery sweeper

Every 5 minutes a sweeper scans for holds older than mandate.expires_at OR a 24h hard ceiling, with no matching release or debit. Each survivor gets an automatic release. This catches:

  • gateway crashed between hold and the settle step
  • provider execute hung past timeout, no settle event arrived
  • operator-side bug stranded a hold

A hold that the provider DID charge gets released by the sweeper AND surfaces in the recon engine as a debit-without-receipt orphan. The operator manually reconciles.

Stablecoin entry path (M4.2)

USDC and BRLA enter via webhook adapters (UnblockPay and BRLA Digital). Both rails land on the same ledger as fiat — an enterprise agent operating in BRL + USDC sees one wallet_balances table with multiple currency rows.

The execute path does not yet route stablecoin currencies — calling POST /v1/wallets/:id/execute with currency=USDC or currency=BRLA returns 400 currency_not_routable. The offramp execute path (BRLA→Pix-out, USDC→fiat) ships in M4.4.

Self-host parity

Per the VISION five-point MIT commitment, self-host customers get the OSS reference wallet: single-tenant, file-backed double-entry ledger, no settlement automation. The managed tier adds:

  • Multi-tenant ledger with per-tenant scrypt-derived encryption keys
  • Reconciliation engine (event match + provider-side fetch)
  • Crash-recovery sweeper
  • Per-tenant accounting export (CSV / SAP / NetSuite / Conta Azul)

Both tiers enforce the same four invariants. The managed tier adds operational primitives, not safety primitives.

Next

Edit on GitHub

Last updated on