Money
arrives.
Something
must credit. — Treasury engine, P1 · deposits

Pure functional core, single-writer imperative shell. Two Anchorage pollers already watch /v2/transactions and /v2/deposit-attributions and write observation-only rows. P1 lights up the path from a landed USDC deposit to a customer credit — behind a three-gate diamond join (Anchorage SUCCESS, attribution, screening), an idempotent credit ledger, and an append-only audit trail. Dark by default: the credit executor runs in shadow mode, logging the decision it would have made, until soak evidence earns the flip. The hard part isn't the code — it's a review queue with two swamped owners on /db/. This plan frontloads around it.

Project
Treasury engine
Umbrella
A-3192 · automate USDC deposits
Lead
Loc Nguyen
Crate
rs/treasury-engine
Pattern
functional core / imperative shell (mirrors liquidation-engine)
Today
2 pollers live · pipeline proven-in-isolation, unwired
Default at deploy
auto_credit = shadow · gate = all-required
01

The two switches

P1 introduces two operator controls, both keyed in a new system_settings table A-3451 · planned. anchorage_poller_enabled starts/stops ingestion without a redeploy (today the poller is gated only on ANCHORAGE_API_KEY presence). auto_credit_enabled is the money switch — and it ships in shadow, not off. Every credit the gate would fire is computed, logged, and audited; nothing moves a balance until a written go-live decision flips it. neither setting exists in the tree yet

Credit executor mode SHADOW

The full pipeline runs. The gate evaluates every deposit and the executor emits the credit it would apply — written to deposit_status_log as a shadow decision and posted to Slack for ops to compare against their manual call. No customer balance changes. This is the only mode P1 ships in; Live is a deliberate, auditable flip after the soak criteria in A-3981 are met.

source of truth · system_settings · re-read each poll tick & each gate evaluation

02

The money-path map

Click any node. Green nodes are live on main today; gold-dashed nodes are P1 work. Anchorage is the source of custody truth; the DepositEvent bus fans one deposit out to parallel gates; the credit executor is the only writer of current_balances, and it fires exactly once per deposit.

poll /v2/* persist Ingested resolve gates on fully-gated log transitions Anchoragecustody · deposits + screen deposit + attribution pollerslive · cursor watermarks anchorage_depositsobservation-only · SUCCESS ≠ credit DepositEvent bustokio broadcast · pure core gate subscribersAnchorageSuccess ·Attribution · Screening credit executoridempotent · sole balance writer deposit_status_logappend-only · auditSlack · email
▷ Tap a node

DepositEvent bus

pure core · single-writer shell

One deposit fans out to three gates in parallel; the pure core joins them. apply(record, event) → Apply is total, replayable, and proptestable — Apply is NoOp | Applied | Rejected. Invalid states are unrepresentable: credit before all gates pass can't be expressed — credited() is a fallible chokepoint that ensure!(record.fully_gated()).

Core: pure, no I/O Milestone view derived, not stored broadcast(100) — subscribers land incrementally
03

Drive a deposit

Resolve each gate. Watch the milestone DepositState derive from the DepositRecord, the credit chokepoint refuse until fully_gated(), and the effect log record every Apply. The core is monotonic (a resolved gate never flips), fail-fast (one failed gate freezes the diamond into Failed), and credit-irreversible. This is the model in deposit_pipeline/{record,state,event}.rs today — unit- and integration-tested, not yet wired to the bus. proven-in-isolation

Ingested
row seen · gating
Credited
balance moved once
Failed
a gate said no
Notified
terminal · ops+client
Anchorage
unresolved
Attribution
unresolved
Screening
unresolved

Effect log

04

What makes a credit safe

fully_gated() is necessary but not sufficient. The header comment on anchorage_deposits is explicit: status='SUCCESS' is not auto-credit-safe. The gate is the full conjunction below — and on-chain finality is our own check, not Anchorage's word. Per-chain thresholds are an open decision A-3649. Drag confirmations to see the verdict flip.

  • 1anchorage_deposits.status = 'SUCCESS' — custody confirms the funds landed. Gate: AnchorageSuccess.
  • 2On-chain confirmations ≥ per-chain finality threshold. Advisory FinalityAlert events surface progress; they never drive state. A-3431/A-3649
  • 3Attribution is ATTRIBUTED and the source wallet is a whitelisted customer wallet. Gate: Attribution. A-3435 live
  • 4Compliance screening passed — Anchorage's screen, or TRM if compliance requires it. Gate: Screening. A-3979 decides
  • 5No prior deposit_credit_attempts row for this deposit's idempotency key. See §05.
Finality probe
ETH
ETH·SOL·BTC
12
≥ 12
FINAL — gate condition 2 satisfied for ETH (≥ 12 confirmations).
05

Never credit twice

The credit executor writes the current_balances update and the deposit_credit_attempts row in one Postgres transaction. A UNIQUE constraint on idempotency_key turns any retry — same handler re-running, or a concurrent one — into a no-op. This is CreditedIrreversible plus credit() idempotency, asserted in the pipeline integration test. Fire, retry, and race the executor below. deposit_credit_attempts · A-3429

customer balance · current_balances 0.00 no credit applied
deposit_credit_attempts
idempotency_keyoutcomeΔ balance
no attempts yet — fire a credit
06

Where it can bite

Money-touching failure modes and the invariant or mechanism that contains each. Shadow mode buys the soak window in which most of these surface without a balance ever moving.

credits wrong / twicemisses a depositoperational
critical

Double credit on retry

Executor re-runs after a partial write and moves the balance again.

Contained: UNIQUE(idempotency_key) + single-txn insert. Second attempt → already_credited no-op.

critical

Credit to wrong account

Deposit attributed to the wrong customer via a stale wallet map.

Contained: attribution gate requires ATTRIBUTED + whitelisted anchorage_customer_deposit_wallets; else Failed(AttributionUnknown) → manual review.

critical

Credit unscreened funds

Sanctioned/illicit funds credited before compliance signs off.

Contained: Screening is a required gate; fail-fast freezes to Failed(ScreeningFailed). Decision A-3979 sets the screen of record.

critical

SUCCESS ≠ final

Anchorage reports SUCCESS before the chain is final; a reorg reverses it.

Contained: condition 2 is our own confirmation count vs a per-chain threshold (A-3649), independent of Anchorage.

missed deposit

Cursor gap / late rows

Whole-UTC-day filter + a late-visible transaction slips past the watermark.

Contained: overlap_window re-covers each window; cursor advances only after a successful persist; failure re-drives.

missed deposit

Poller wedged

Anchorage errors or a panic silently stops ingestion.

Contained: run_durably! restart w/ backoff; last_error persisted per cursor. Gap: alerting on stalled cursor — A-3980.

operational

Audit gap on restart

In-flight state lost on crash; transitions unrecorded.

Contained: append-only deposit_status_log + re-drive from anchorage_deposits on boot. Gap: crash-recovery — A-3977.

operational

Gate-vs-ops disagreement

Shadow verdict differs from ops' manual call during soak.

Contained: every shadow decision is a deposit_status_log row + Slack thread; divergence is the go-live signal (A-3981).

operational

Stuck deposit

Non-terminal state exceeds an SLA; nobody notices.

Contained: manual-review page (A-3444) lists Failed(*); runbook A-3982. Gap: stuck-state alert — A-3980.

07

The review bottleneck

The roadmap risk isn't the code — it's that the P1 critical path runs through the /db/ high-risk lane at the front, and that lane has two swamped CODEOWNERS. The move is to collapse the schema reviews into one batch, then develop in the fast lanes while it drains. Each lane below is priced by who reviews it and how much they must check.

/db/ high-risk2 owners · slow · serial
All Treasury DDL. Six queued XS tickets — deposit_status_log (A-3450), deposit_credit_attempts (A-3429), system_settings (A-3451), the processed_at rename (A-3983), deferred FKs (A-3445). Frontload: batch into one additive-only "Treasury P1 schema" PR — nothing reads these tables yet — so one review runs in parallel with all downstream work instead of serializing it. FK deferral is now moot (A-3193 merged).
rs/ backendnormal review · fast
With the DDL applied locally via testcontainers, wire the pipeline unblocked: Anchorage mock (A-3651), status-log writer (A-3436), credit gate (A-3442) + executor (A-3443), and the unscoped end-to-end integration test + shadow-mode run that turn review-queue dead time into soak time.
gui/ frontendfrontend reviewers · not the 2
Greenfield — no Anchorage code in gui/ today. Ship mock-first against the Zod types (A-3430): deposits page, DepositTimeline (A-3427), AdminFeatureToggle (A-3428), manual-review page (A-3444). Each merges as a screenshot; each flips to real with a one-line api/admin.ts swap when the backend lands.
docs/ zero-reviewno owner · auto-merge
No CODEOWNERS gate. Land the ops runbook (A-3982), go-live criteria (A-3981), and the two decision memos that can delete work: A-3979 (does Anchorage's screen satisfy compliance? — if yes, collapses the TRM milestone) and A-3649 (per-chain finality). External-party latency means start these now.
ax-demo configoff-repo · no gate
Not gated by this CODEOWNERS at all. Finish ANCHORAGE_API_KEY plumbing + a demo-mode-with-mock story so deposits are demoable on ax-demo with zero review friction. Composer registration (A-4269) is already staged.
08

The two decisions that shape scope

Two non-code decisions gate the design and can each erase weeks of engineering. Both have external dependencies (compliance, chain research), so they start on day one — a memo framing the question costs a day and lives in the zero-review docs lane.

Screen of record

A-3979 · owner: compliance

Anchorage already screens deposits and returns a DepositAttribution. Does that screen satisfy our regulatory obligations, or do we need our own TRM Labs check?

If Anchorage suffices → the entire TRM integration (A-3425, A-3432, A-3646, A-3647, A-3648) collapses and the Screening gate reads Anchorage's verdict directly. If not → TRM becomes a P2 subscriber behind the same gate.

Finality policy

A-3649 · owner: eng + risk

How does Anchorage SUCCESS map to our finality requirement? Per-chain confirmation thresholds, and what happens when Anchorage and our own verifier disagree.

Unblocks → safe credit gating across chains (condition 2). Zero code to decide; it's a written threshold table the chain-verification subscriber (A-3431) reads. Until it lands, Live stays dark on any multi-chain deposit.

Idempotent credit ledger

industry-standard · settled

Every serious money mover records an append-only attempt ledger keyed by an idempotency token, and makes the balance move + ledger write atomic.

Our shape → deposit_credit_attempts with UNIQUE(idempotency_key), written in the same transaction as current_balances. No novelty here — the risk is only in not doing it.

Shadow-then-flip

convergent practice · settled

Automated money paths ship dark, run in shadow against human decisions for a soak window, and flip on written criteria — not a silent config change.

Our shape → auto_credit_enabled defaults to shadow; A-3981 defines the flip criteria (N deposits, gate-vs-ops agreement, zero balance incidents). The flip is an auditable event.

09

What P1 refuses to ship

Each deferred item carries a revisit trigger — the observable that flips it from "later" to "now" — and the failure mode being accepted (hover to reveal). Scope stays frozen; the follow-up lives here with its trigger attached.

P3

Fund sweeping

Move credited funds from the Anchorage deposit vault to operating/cold storage. Needs signing-key access + secure key storage. A-3230.
revisit when deposit vault balance crosses an ops-defined ceiling
accepted funds sit in the deposit vault; manual sweep in the interim
P3

Withdrawal automation

The outbound mirror of deposits — the whole reason keys matter. Epic A-3653. Strictly after the deposit path is trusted.
revisit when deposit path is Live and soaked; withdrawal volume justifies it
accepted withdrawals stay manual through admin tooling
P3

Return-to-sender

Automated return of rejected/unattributable deposits. A-3652. Until then the runbook (A-3982) documents the manual process.
revisit when Failed(*) volume makes manual returns an ops burden
accepted ops returns funds by hand, logged in status-log
P2

TRM automated screening

Own wallet/transaction screening via TRM Labs. Conditional on A-3979 — may be unnecessary if Anchorage's screen suffices. A-3195/A-3425/A-3432.
revisit when compliance rules Anchorage's screen insufficient
accepted Screening gate reads Anchorage's verdict in P1
P2

Self-serve whitelisting

Customers submit their own deposit-source wallets instead of ops chasing over Slack. A-3229 — customer-facing surface, larger review scrutiny.
revisit when AttributionUnknown manual-review volume dominates ops
accepted ops backfills the wallet map; unknowns → manual review
P2

Multi-chain / multi-asset

Beyond USDC and the initial chains. A-3654. Gate conditions generalize; finality table (A-3649) grows per chain.
revisit when a non-USDC asset or new chain is prioritized for deposits
accepted poller filters to ANCHORAGE_POLL_ASSETS = [USDC]
rigor

Formal TLA+ specs

Doc-comments reference docs/tla/CreditGate.tla etc., but the specs aren't in the tree — the invariants live in proptest + the integration test today.
revisit when the credit join or poller-overlap logic grows a case tests can't cover
accepted invariants enforced by tests, not model-checked
rigor

Crash-recovery re-drive

Rebuild in-flight pipeline state from anchorage_deposits + deposit_status_log on boot. A-3977 — high priority but sequenced after the happy path.
revisit when before the Live flip — soak must include a restart drill
accepted shadow mode tolerates re-drive gaps; no balance at risk
ops

Observability & alerting

Stuck-state alerts, poller-stall detection, credit-outcome counters. A-3980 — the surface ops watches during soak; feeds the go-live criteria.
due before soak begins — shadow evidence needs metrics
accepted until then, monitoring is manual via the admin page
10

Nine PRs, one HR review, dark until #9

Each PR small and single-lane; the app is functional after every merge; no PR depends on a future PR's behavior. The binary ships in shadow — flipping to Live is a system_settings change, not a deploy. The one serial dependency is the schema batch (#1); everything after it runs in the fast lanes in parallel. ▷ Tap any row for the gate that lets it start.

Phase 01BatchOne additive /db/ PR — all P1 tables + FKs — into the high-risk queue on day one.PR #1 · the only serial gate
Phase 02ParallelBackend wiring ∥ GUI mock-first ∥ decision memos — all in the fast lanes while #1 drains.PR #2–#7 · concurrent
Phase 03ShadowCredit executor runs dark; soak evidence accrues in deposit_status_log.PR #8 · non-compressible
Phase 04FlipGo-live criteria met → auto_credit_enabled = true. A config change, not a deploy.PR #9 · auditable event