RFC: Live Instrument Add Without Restart (EP3 + Bitnomial)

Date: 2026-07-03

Status: Draft

Background

Listing a new instrument today ends with a coordinated restart of every venue-facing service, on both venues. Each loader reads its catalog once at startup and never again:

Service Startup load Live refresh today
api-gateway InstrumentManager::load_from_ep3 (rs/api-gateway/src/matching_engine/ep3.rs:52) index_instrument_specs_task patches only additional_product_specs via the instruments logical-replication replica (rs/api-gateway/src/index.rs:88-112); everything else is startup-only
order-gateway catalog + id map + margin params (rs/order-gateway/src/lib.rs:227-276) none
marketdata-publisher load_from_ep3 (rs/marketdata-publisher/src/lib.rs:453,498) none
settlement-engine load_from_ep3 (rs/settlement-engine/src/lib.rs:677) none
trade-engine2 instrument_multipliers_from_ep3 (rs/trade-engine2/src/lib.rs:38) none
risk-engine (legacy) risk_instruments_from_ep3 (rs/risk-engine/src/lib.rs:172) none
risk-engine2 a library, not a binary: OpenOrdersMarginComputer / RiskState hosted by order-gateway and api-gateway, seeded once via SetInstrumentParams at host startup none

The Bitnomial (AIEX) side mirrors the same shape across its five loaders api-gateway (rs/api-gateway/src/instruments/bitnomial.rs), md-btp (ProductCatalog, whose doc comment is explicit: "No refresh adding/removing/re-pointing instruments needs a restart, kept in lockstep with the api-gateway", rs/md-btp/src/btp/catalog.rs), bitnomial-order-gateway, risk-engine-bitnomial, and trade-engine-bn. A-4040 documented the restart-at-roll runbook and added a startup-log symbol(product_id) inventory to each loader; the Bitnomial expiring-contracts RFC (bitnomial-expiring-contracts.md, work item 2) deliberately deferred the refresh mechanism to this RFC.

Two process-wide cells make "just reload" a non-starter as written:

And one gap makes the listing flow itself unsafe to do live: admin-cli creates EP3 instruments directly in InstrumentState::Open (rs/admin-cli/src/instruments.rs:220). The moment EP3 knows the instrument, it matches orders for it there is no window in which the instrument exists but cannot trade.

What already fails closed

The safety-critical property this RFC needs is mostly already true, just undocumented and untested as a contract:

That last behavior is the accidental version of this whole RFC: today the only catalog-refresh mechanism is process restart, and trade-engine2 already uses restart-as-refresh to self-heal. The design below turns that into a deliberate, graceful mechanism.

Prior art to reuse

Goal

  1. A newly-listed instrument reaches every loader with no restart, on both venues.
  2. The instrument is not orderable until every engine that admits or books orders has loaded its spec and this is provable from a fail-closed guarantee, not from deployment choreography.
  3. One code path: the refresh path is the startup path, re-run. No second "diff" loader that can drift from what a restart would produce.
  4. Additive only: reconcile may add instruments, never remove or mutate loaded ones.

Non-goals

Design

Four pieces: (1) refresh = re-run the startup load; (2) a self-paced reconcile schedule; (3) the lifecycle gate separating "in the catalog" from "open for orders"; (4) the fail-closed contract plus an adoption check. Safety comes from (4) alone; (1)(3) are what make the system converge quickly enough that fail-closed rejects are a transient, not an outage.

1. Refresh is the startup load, re-run

Each loader gains a reconcile_instruments_task that calls the same function it already calls at startup (load_from_ep3, instrument_multipliers_from_ep3, load_ax_mapped_catalog, ) and installs the result additively into its in-memory catalog.

Why this shape and not an incremental diff protocol:

Additive-apply rule. Reconcile only inserts symbols not already loaded. If the freshly-loaded catalog disagrees with a loaded entry (changed spec) or omits one (deleted row), the reconciler logs and raises an alert but does not touch the loaded entry mutation and removal are out of scope, and a silent in-place change to a live instrument's margin or multiplier is exactly the bug class this rule exists to make impossible. (additional_product_specs keeps its existing live-patch path in api-gateway, which predates this RFC.)

Failure rule. A reconcile that errors or times out installs nothing and retries next tick. Never install a partial result; the previous catalog stays authoritative.

What "install" means per loader:

Loader Install
api-gateway insert into the Arc<RwLock<InstrumentManager>> it already holds
order-gateway insert into instrument manager; on_set_instrument_params for new ids only
symbol↔︎id map InstrumentIds stops being a set-once OnceLock: it becomes a swappable registry (ArcSwap or RwLock) whose refresh asserts the new map is a superset of the old (grow-only bijection)
trade-engine2 insert into instrument_multipliers
risk-engine (legacy) insert into CurrentData instruments
settlement-engine, marketdata-publisher insert into their InstrumentManagers
md-btp ProductCatalog::replace_all (exists today), constrained to superset results
bitnomial-order-gateway, risk-engine-bitnomial, trade-engine-bn same shape as their EP3 counterparts

2. Self-paced reconcile schedule

No monthly/quarterly cadence is hardcoded anywhere. Each loader polls at a pace derived from its own catalog's data:

Both ticks are bounded: a reconcile carries a timeout, and eager mode has a cap so a stuck forthcoming contract cannot hot-loop a loader forever.

3. "In the catalog" is not "open for orders"

Reuse each venue's own lifecycle; do not invent an AX-side state machine.

EP3. admin-cli's synchronize changes to create instruments with state: Pending instead of Open (rs/admin-cli/src/instruments.rs:220). EP3 itself rejects orders for Pending instruments, so closure is venue-enforced no AX component needs to consult a flag. Opening is a deliberate second step: flip Pending → Open via UpdateInstrumentStateRequest once the adoption check (below) passes.

Bitnomial. The venue owns forthcoming → active; AX cannot gate it. The AX-side gate is the fail-closed rule: an instrument absent from the bitnomial-order-gateway's catalog is rejected at admission before any order reaches the venue. The listing flow inserts the AX rows while the venue product is still forthcoming wherever possible, so adoption completes before the venue starts matching. If a contract is listed late (already active), nothing breaks clients receive rejects until adoption completes, never a defaulted margin.

Listing order (normative).

  1. Insert the Postgres products/instruments rows.
  2. Ensure the venue instrument exists EP3: create as Pending; Bitnomial: the venue already lists it.
  3. Wait for every loader to adopt (reconcile ticks).
  4. Open EP3: state flip to Open; Bitnomial: the venue flips active on its own schedule.

The Postgres row always precedes venue-open. This ordering is compatible with the loaders as they stand: load_from_ep3 skips a DB row with no EP3 instrument and errors on an EP3 instrument with no DB row, so at every point in the sequence each loader either loads the full spec or cleanly skips it.

4. Fail-closed contract and the adoption check

The contract (normative): no engine may default a spec it has not loaded. An order for an unknown instrument is rejected before matching; a fill for an unknown instrument is held, never booked with assumed parameters. This is the entire safety argument everything else in this RFC is liveness.

Per engine, this means pinning today's accidental behavior as tested contract:

Adoption check. Extend A-4040's startup-log inventory into a queryable surface: each loader exposes its loaded instrument set (a GET /health/instruments inventory symbols + loaded-at). The listing flow gains admin-cli instruments await-adoption <symbol>, which polls every loader's inventory until all contain the symbol, then (EP3) flips Pending → Open, or (Bitnomial) reports ready ahead of the venue's own flip.

The adoption check is deliberately not safety-critical: if an operator flips early, or the venue opens before adoption completes, engines that haven't adopted still fail closed rejects, not corruption. The check exists so that opening an instrument doesn't greet the first customers with a reject storm. Belt (fail-closed, per engine, enforced in code) and suspenders (adoption gate, operational, enforced in the listing flow).

State space: order arrival × engine refresh × engine restart

The claim to prove: the order path stays closed until every engine has loaded the new instrument's spec, under any interleaving of order arrivals, per-engine reconcile ticks, and engine restarts.

Axes (independent):

Monotonicity lemma (prunes restarts). Per engine, catalog adoption is monotone. Reconcile only adds (additive-apply rule). A restart reloads the full current Postgres + venue set, which is a superset of anything the engine had loaded before (rows are never deleted in scope removal is a non-goal). So the only E-transition is E E+, E+ is absorbing while the row exists, and restart is just reconcile with an empty prior it can only advance adoption. "Every engine E+" is therefore a stable property: once reached, no interleaving of restarts and ticks regresses it. This lemma is what lets the grid below treat "engine restarts mid-rollout" as one row instead of multiplying every other row by it.

The surviving grid:

# L OG TE2 Event Outcome Why safe
1 L0 E E order OG rejects (unknown symbol) fail-closed choke point
2 L1 E any order OG rejects same
3 L1 E+ any order venue rejects (EP3 Pending / BTNL not yet active) closure is venue-enforced; no fill can exist at L1
4 L2 E any order OG rejects AX-side fail-closed is independent of venue state covers an early/mistaken open-flip
5 L2 E+ E order fill order admitted; fill held at TE2 (reconcile-then-bail) until TE2's own tick adopts no default; booking is delayed, never wrong. This cell is why the adoption gate waits for all engines, not just OG, before the flip
6 L2 E+ E+ order normal path goal state
7 any any any engine restart restart reloads the full catalog E+ monotonicity lemma; a restart can only advance adoption
8 L1L2 E+ E+ order races the open-flip OG forwards; EP3/venue accepts or rejects atomically the venue is the single serialization point for open/closed AX never caches "openness" on the order path
9 L1 any any reconcile tick sees a partial listing (row without venue instrument, or venue instrument without row) loader skips / errors that symbol, installs nothing partial inherited startup semantics; a spec is loaded whole or not at all
10 any any any reconcile errors / times out old catalog untouched; retry next tick failure rule: never install a partial result

Walking the standard hazard questions over the two interesting cells:

Closure claim, assembled: rows 14 show no order is admitted while any admission-side engine is E, and no fill can exist before L2. Row 5 is the only cell where an order is admitted while some engine is E; reaching it requires the open-flip to have preceded full adoption, which the listing flow forbids and fail-closed contains. Rows 710 show ticks, restarts, and failures only move engines toward E+ and never install partial state. Once all engines are E+, the lemma makes that stable.

Making the cells reachable in tests. Per-cell coverage needs the test harness to gate reconcile ticks and dropcopy delivery deterministically lifecycle_hooks pause/resume (rs/sdk-internal/async/src/lifecycle_hooks.rs) already provides the primitive, and btp-mock/EP3 test doubles need a "list instrument at runtime" affordance. The two must-have integration tests: cell 5 (admit with TE2 gated E, assert hold then adoption then booking) and cell 7 (kill and restart one engine mid-rollout, assert convergence to E+ and identical catalogs).

Blast radius

Surface Change
sdk-internal InstrumentIds OnceLock grow-only swappable registry
order-gateway reconcile task; additive SetInstrumentParams for new ids; inventory endpoint
trade-engine2 / trade-engine-bn reconcile task; reconcile-then-bail on unknown fill; inventory endpoint
risk-engine / risk-engine-bitnomial / risk-engine2 hosts reconcile task; unknown-id fail-closed tests
api-gateway / marketdata-publisher / settlement-engine / md-btp reconcile task (md-btp: schedule around existing replace_all); inventory endpoint
admin-cli create EP3 instruments as Pending; await-adoption; open-flip command
Postgres / SDK / GUI none no schema change, no wire change (inventory endpoint is internal)

Explicitly unaffected: venue behavior, the InstrumentId/products identity model, TigerBeetle, and the public SDK.

Rollout

  1. Pin fail-closed (no behavior change): tests for the three existing choke points, plus the RiskState unknown-id rule. This makes the safety property a contract before any refresh machinery exists.
  2. Registry mutability: InstrumentIds grow-only registry; additive margin-param path in order-gateway. Still no schedule a restart still loads everything, proving the additive path against the startup path.
  3. EP3 side live: reconcile tasks in the six EP3 loaders, inventory endpoints, admin-cli Pending + await-adoption + open-flip. First live add with no restart on AX.
  4. Bitnomial side live: the same reconcile in the five AIEX loaders, eager-tick driven by product_status/expiry. Retires the A-4040 restart-at-roll runbook for adds; the runbook remains for removal and mutation until the expiry-lifecycle work lands.

Per the project's lifecycle-testing rule, the reconcile tasks ship only after exercising client disconnect, server-initiated disconnect, crash/restart, and recovery paths (grid rows 7 and 10 are the restart/recovery cells).

Alternatives considered

Open questions