The Money Layer Is Not the Money Rail: Building a Payment Foundation That an Adversarial Audit Couldn't Break A team built @agentbadge/payments, a payment domain model that separates Payment, PaymentAttempt, PaymentReceipt and PaymentEvent entities from the rail that moves money, and reported 114/114 tests green after two adversarial AI audits and a live Stripe test-mode run. The audits surfaced 5 Critical, 6 High and 11 Medium issues in the first pass and 1 Critical, 6 High and 8 Medium in the second; the design requires a 9-check verification verdict before any payment reaches CONFIRMED, uses two-level idempotency and decimal-string money, and made FAILED a terminal state after an audit found a FAILED → PENDING resurrection edge. In the Stripe test-mode run, CONFIRMED → REFUNDED worked and concurrent refunds produced exactly one rail refund. The Money Layer Is Not the Money Rail: Building a Payment Foundation That an Adversarial Audit Couldn't Break @agentbadge/payments — a payment domain model that separates Payment/Attempt/Receipt/Event entities from whatever rail moves the money. Guarded state transitions, a 9-check verification verdict as the only door to CONFIRMED, two-level idempotency, decimal-string money — stress-tested by two adversarial AI audits and a live Stripe test-mode dogfood: CONFIRMED → REFUNDED, concurrent refunds → exactly one rail refund. 114/114 tests green. We built @agentbadge/payments — a payment domain model that treats Payment, PaymentAttempt, PaymentReceipt and PaymentEvent as durable entities separate from whatever rail actually moves the money. All state changes flow through two transition maps with guarded writes, every CONFIRMED requires a 9-check verification verdict, idempotency runs at two levels request fingerprint + claim-first side effects , and money is a decimal string end-to-end. Two adversarial AI audits 5 Critical + 6 High + 11 Medium, then 1 Critical + 6 High + 8 Medium and a live Stripe test-mode run later: CONFIRMED → REFUNDED, concurrent refunds → one rail refund, 114/114 tests green. Every payment integration we've written or reviewed eventually grows the same knot: the business entity that means "this customer owes us $5" gets tangled with the adapter that talks to Stripe, or a blockchain, or a PSP. The rail's status enum leaks into your domain. Your "refund" code calls the rail before it records that a refund is happening. Your "confirmed" flag is whatever the rail said last. The failure mode isn't hypothetical. It's the standard one: two state machines — yours and theirs — drifting apart, while your code pretends there's one. @agentbadge/payments EPIC-220 draws the boundary explicitly: | Entity | What it means | Owned by | |---|---|---| | Payment | The obligation: reference, amount, customer, status | domain | | PaymentAttempt | One try at settling it, on one provider+network | domain | | PaymentReceipt | Append-only proof that money moved, keyed to evidence | domain | | PaymentEvent | Append-only audit trail: from→to, actor, reason | domain | | PaymentEvidence | Normalized rail output: status, tx hash, amount, finality | adapter | The rail PaymentProvider interface: createPayment , verifyPayment , optional refundPayment produces evidence , never verdicts. Only the domain writes domain state. Why separate the money layer from the money rail Wait — that table is the why. So let me put the argument differently: the split isn't an abstraction fetish, it's a failure-isolation decision. When the rail's enum becomes your domain's truth, every retry, every race, every provider quirk becomes a domain bug you can't see. Keeping evidence and verdict in different hands is what makes the rest of this article testable at all. Controlled transitions: two maps, no free-form UPDATEs Payment and Attempt each get a transition map. Every status change goes through a guarded applyStatus expectedFrom, to at the store level — a concurrent writer who moved the row first returns null , and the loser re-reads and reports what is actually persisted. The payment machine post-audit : PENDING ──→ PROCESSING ──→ CONFIRMED │ │ ├──────→ DISPUTED ──→ CONFIRMED / REFUNDED │ │ └──────→ FAILED / EXPIRED └──────────→ EXPIRED FAILED, EXPIRED, REFUNDED, CONFIRMED = terminal Two details earned their place in the map: - FAILED is terminal. The audit found a FAILED → PENDING resurrection edge — a payment declared dead could quietly come back and hop to CONFIRMED with no attempt or receipt attached. Retry is now expressed as a new create, not a resurrection. - DISPUTED does double duty, deliberately. It's the review state for "rail says SUCCESS but our checks disagree" — captured funds that need a human — and it's the claim state for refunds: CONFIRMED → DISPUTED is taken before the rail refund call, so a concurrent second refund loses the claim instead of charging twice. Verification: nine checks between evidence and CONFIRMED verifyPayment in the service never trusts a rail status. It normalizes evidence and runs nine checks — reference, amount, currency, provider, recipient, network, finality, transaction-reuse, plus the rail's own terminal signal: | Check | Catches | |---|---| | reference match | confirming the wrong payment entirely | | amount match / currency match | partial payments, cross-asset confusion | | provider match | evidence from rail A applied to rail B's attempt | | recipient match | money arrived at an attacker-controlled address | | network match | same tx hash, wrong chain | | finality | tx exists but isn't confirmed enough | | tx not reused | one onchain tx confirming two payments | | status success | evidence claiming success it can't back | A clean verdict is the only path to CONFIRMED . Not a rail callback. Not a create-time status. Not a raw transitionPayment — the audit noticed those helpers were exported from the package barrel, so we un-exported them; the only public doors are the guarded persistence paths and the service. What happens when evidence and checks disagree The subtle part: a failed check means different things depending on whether money moved. - Retryable-only failures today: finality → PENDING . The tx exists, it just isn't deep enough yet. Confirming this as FAILED would lose a good payment; confirming it as CONFIRMED would front-run consensus. - Non-retryable failure on captured funds → payment DISPUTED . The money is there but something is wrong — wrong recipient, replayed tx, mismatched amount. Marking FAILED would lie the funds exist ; confirming would compound it. DISPUTED says "review me". - Failed check + no captured funds → FAILED or EXPIRED per evidence. Idempotency at two levels Level 1 — entity dedupe. createPayment looks up by reference then idempotencyKey before inserting. The audit's sharper point: the old code deduped on the key only — a caller reusing "ord-1" with a different amount silently got the first payment back. Now each row stores a JSON request fingerprint in payment.payload.request ; a dedupe hit with a mismatched fingerprint returns conflict , not a silent swap. Rail metadata checkout URL, session id is persisted on the attempt and replayed on dedupe, so a lost first response doesn't lose the checkout link. Level 2 — claim-first side effects. The refund path used to call the rail then try to update state. Two concurrent refunds = two rail charges. Now the service takes a guarded CONFIRMED → DISPUTED claim first, calls the rail with a deterministic idempotency key refund