RFC: Cortex consolidating trade/transaction/risk onto one transactional core

Date: 2026-06-23 Status: Draft for decision Author: Andrew Lee (with Claude)

TL;DR

The original pitch for cortex ("a TigerBeetle-based risk engine") undersold the actual problem. The real motivation is not that TigerBeetle (TB) is a tidy ledger it is that our dropcopy money-state pipeline is split across ClickHouse (CH) and Postgres (PG) with no cross-database atomicity, and we pay for that split in two recurring ways:

  1. A recurring bug class with a deferred real fix, plus a standing reconciliation tax. Each torn-CH/PG-write incident does get fixed (A-3112 is in prod; A-3790 merged) but they get fixed individually, with band-aids, while the architecture keeps manufacturing new ones. The correct idempotency fix is explicitly blocked on a migration (see Pillar 1). Meanwhile we maintain a whole coping apparatus a recon-engine posting invariant breaches to incident.io, an admin-cli reconcile suite (positions/trades/transactions/open_orders), a DropcopyRestartFence, three per-batch dedup sets none of which would exist if the write were atomic.

  2. A forced durability-vs-latency tradeoff. To keep order placement fast we made the CH write fire-and-forget (wait_for_async_insert=0) after place_order p50 hit ~275ms at the cash open (A-3508). Money mutations funnel through a single global Postgres advisory lock. We are trading away durability and throughput to hold latency a tradeoff a purpose-built transactional store removes.

This RFC reframes the decision as three options, separating the durable win (consolidation onto one transactional source of truth) from the contentious choice of which store:

Recommendation: Commit to the consolidation (shared by Options 1 and 2): one writer owns the tradeledgerprojection path, money has exactly one transactional SoT, and CH becomes a rebuildable projection rather than a place data can be lost. Then settle TB-vs-Postgres empirically by running TB in shadow mode and measuring it against a hardened Postgres path before granting either authority. Do not do a flag-day cutover of money state.


Background: what actually exists today (verified on main @ 2026-06-23)

Naming first, because three things wear the word "risk"/"trade":

The two write paths and where atomicity breaks:

EP3 dropcopy ──► trade-engine2 ──► (1) CH positions
                                   (2) CH trades
                                   (3) CH transactions  ┐ eager, before commit
                                   (4) PG resume_token  ┘ single PG txn
              api-gateway / settlement-engine ──► transaction-engine
                                   (a) CH transactions  ┐ eager
                                   (b) PG current_balances + commit, under
                                       pg_advisory_xact_lock(8675309)  ─ GLOBAL lock

There is no transaction spanning CH and PG (there can't be), and ClickHouse has no multi-table transactions either. Every numbered/lettered step above is an independent failure point.


Pillar 1 Cross-DB atomicity: a recurring class, fixed only with band-aids

Caveat up front (these are not open bugs): each incident below has been fixed. The argument is not "we have live torn-write outages." It is that the same bug class keeps recurring across engines and across the years that each fix is a point patch rather than the structural fix, and that the structural fix is explicitly deferred because the current architecture makes it migration-heavy. That is the signature of an architecture fighting itself, not of a few stray bugs.

The class, instance by instance

Bug Status What happened How it was "fixed"
A-3112 In Production since 2026-04-30 (closed, not reopened) Proactive dropcopy reconnect mid-flush read fresh CH writes with a stale PG resume_token, replayed phase-2, and re-emitted already-committed transactions. ~8 dup txns/day in prod; ~69k/day under forced reconnect. A third dedup set (prior_transaction_reference_ids) on top of the existing two, a drain-fence (DropcopyRestartFence), and the reconcile-transactions cleanup tool. Band-aids.
A-3790 Merged 2026-06-22 (a new bug, not a reopen) The Bitnomial trade engine (btnl-trade-engine) hit the same torn-write class: crash between CH trade/position writes and the PG fee/PnL+watermark commit silently dropped fees and realized PnL. Per the issue: the EP3 engine handles this as "a first-class recovery case The Bitnomial port's dedup is all-or-nothing on ExecID, so the recovery logic is missing." Ported trade-engine2's recovery branch into the BN engine by hand.
A-2801 Reverted 2026-03-05 Cross-chunk trades silently dropped; a position-dedup fix attempt "introduced a serious correctness bug" and was reverted. Rolled back; problem left standing.
A-2478 Fixed 2026-01-14 (PR #977) Dropcopy NewExec arriving before the REST handler registered the order order stuck Pending forever. Modeled in TLA+ (tlaplus/LESSON_24.md). Optimistic insertion.

Three things this table shows that "is it fixed?" misses:

  1. The real fix is blocked on a migration. A-3112's own writeup says the correct idempotency fix is to "make sequence_number content-deterministic [so] CH ReplacingMergeTree actually work[s] as the idempotency layer, per the original intent. Requires a migration." So the intended design (CH dedups by content) has never actually held; what ships instead is an ever-growing pile of in-memory dedup sets. The architecture is why the right fix keeps getting punted.

  2. Every new writer re-pays the tax. A-3790 is A-3112's cousin reappearing in the Bitnomial port, because each engine re-implements torn-write recovery by hand and a port forgot a branch. The next venue will re-pay it again.

  3. A fix for one torn-write bug introduced another (A-2801, reverted). This logic is subtle enough that patching it is itself a source of correctness bugs.

The permanent coping machinery (all of it exists because writes aren't atomic)

Characterizing the cost: it is not one outage; it is (a) a recurring stream of silent-money correctness bugs that are hard to even detect, (b) the engineering cost of building/maintaining recon + repair tooling, and (c) an on-call/operational tax (drift alerts, manual reconciliation runs, snoozing false positives). Every new writer (Bitnomial, future venues) re-pays it A-3790 is the Bitnomial trade engine re-hitting the same torn-write class trade-engine2 already fought.


Pillar 2 Performance: we are trading durability for latency

The user's intuition ("CH+PG writes are slow; TB might be much faster") is directionally right, but the precise shape matters.

What we don't yet have: a clean steady-state number for "CH+PG write per fill." We have the metric hooks (og_dropcopy_ch_insert_ms, og_dropcopy_batch_processing_ms) and the transaction-engine PG path, but no documented p50/p99. Action: pull these from prod before sizing the bet (see Next steps).

The TB comparison. TB commits batched linked transfers (56 per trade: qty, basis, maker/taker fee, maker/taker PnL/basis-reset all-or-nothing via AccountFlags::Linked), is designed for ~10^6 transfers/sec, has no global advisory lock, and is durable on commit. The point is not that AX needs 10^6/s it doesn't. The point is that TB lets us have durability AND low latency AND atomicity simultaneously, instead of picking two. That is the actual performance argument, and it is stronger than "TB is faster."


How consolidation fixes both pillars (and what it does not fix)

The structural move independent of store is:

  1. One service owns dropcopy trade pairing money mutation projection (subsuming trade-engine2's write path and transaction-engine into cortex).
  2. One transactional SoT for money: a single atomic commit per trade containing qty + basis + fees + realized/finalized PnL. No torn write because there is one write.
  3. Idempotent, deterministically-keyed mutations (trade_id-derived) so replay is a no-op, not a double-commit deleting the dedup-scan / fence machinery.
  4. ClickHouse demoted to a rebuildable projection of the SoT + the dropcopy stream. A lost CH insert becomes a replay, not data loss. This is the single most important reframe: today CH holds authoritative-ish data (realized PnL on trades) that cannot be reconstructed, which is why a torn write loses money.

What it does NOT fix / must be designed around:


The contentious part: TigerBeetle vs. just hardening Postgres

Consolidation is necessary but store-agnostic. The genuinely debatable question is which store is the single SoT.

Option 1 Postgres as the single money SoT.

Option 2 TigerBeetle as the single money SoT (cortex as designed).

The honest framing: Option 1 fixes most of the pain (atomicity, recoverable projection, idempotency) with zero new infra most of our incidents (A-3790, A-3112, A-2801) are torn CH/PG writes that a single-SoT + rebuildable-projection design closes regardless of which store. Option 2 additionally buys invariant-enforcement-in-the-store and a throughput ceiling we don't currently need, at a real and permanent operational cost. TB's marginal benefit over a hardened Postgres path is not yet quantified.


Recommendation

  1. Commit to consolidation now (the shared win). Make one service own dropcopy trade money-mutation projection; make money have exactly one transactional SoT; make ClickHouse a deterministic, rebuildable projection. This kills the torn-write class and lets us start deleting recon/fence/dedup machinery. Reuse the risk-engine2 math crate so the service and the OG hot path compute margin identically.

  2. Decide TB-vs-Postgres empirically, not by vibe. Stand up the SoT behind a trait so the projection/consumer code is store-agnostic. Run TigerBeetle in shadow mode (write the ledger in parallel; reconcile continuously against the authoritative path; non-authoritative, doesn't block trading). Measure: commit latency/throughput vs. a per-account-locked Postgres path, recovery time, and f64/Decimal fidelity under real load.

  3. Gate granting TB authority on: (i) a sustained clean shadow-reconciliation window, (ii) finishing the api-gateway + settlement-engine transaction integration cortex skipped, and (iii) a TB version we accept for custody-adjacent state ( 1.0 or an explicit risk acceptance). No flag-day cutover of money state.

  4. Either way, retire the divergent branch. If we proceed, rebuild cortex on main incrementally rather than re-syncing michael/cortex-reborn (we've paid that tax once). If we don't proceed within a quarter, shelve explicitly and delete the branch; keeping the feature-flagged TB code on main (as today) is a fine zero-cost resting state that preserves the option.


Next steps (cheap, do before committing headcount)

Open questions

References (verified on main @ 2026-06-23)