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.
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
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
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.
DepositEvent bus
pure core · single-writer shellOne 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()).
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
Effect log
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.
- 1
anchorage_deposits.status = 'SUCCESS'— custody confirms the funds landed. Gate:AnchorageSuccess. - 2On-chain confirmations ≥ per-chain finality threshold. Advisory
FinalityAlertevents surface progress; they never drive state. A-3431/A-3649 - 3Attribution is
ATTRIBUTEDand 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_attemptsrow for this deposit's idempotency key. See §05.
Finality probe
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
deposit_credit_attempts
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.
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.
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.
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.
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.
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.
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.
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.
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).
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.
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.
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).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.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.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
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
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
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
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.
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.
Fund sweeping
Withdrawal automation
Return-to-sender
Failed(*) volume makes manual returns an ops burdenTRM automated screening
Self-serve whitelisting
AttributionUnknown manual-review volume dominates opsMulti-chain / multi-asset
ANCHORAGE_POLL_ASSETS = [USDC]Formal TLA+ specs
docs/tla/CreditGate.tla etc., but the specs aren't in the tree — the invariants live in proptest + the integration test today.Crash-recovery re-drive
anchorage_deposits + deposit_status_log on boot. A-3977 — high priority but sequenced after the happy path.Observability & alerting
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.
/db/ PR — all P1 tables + FKs — into the high-risk queue on day one.PR #1 · the only serial gatedeposit_status_log.PR #8 · non-compressibleauto_credit_enabled = true. A config change, not a deploy.PR #9 · auditable event