Date: 2026-07-03
Status: Draft
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:
InstrumentId
map
is
a
OnceLock
that
may
be
set
exactly
once per
process
(InstrumentIds::set_global, rs/sdk-internal/src/instrument_ids.rs:50-57).
rs/order-gateway/src/lib.rs:251-276)
with
no
later SetInstrumentParams
source.
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.
The safety-critical property this RFC needs is mostly already true, just undocumented and untested as a contract:
order_margin_effect
errors
when instrument_id_for_symbol
or
the
instrument
lookup
misses (rs/order-gateway/src/check_margin.rs:46-55).
MarginComputerError::MissingInstrumentParams (rs/risk-engine2/src/open_orders_margin.rs:73-80).
multiplier_for bails
(rs/trade-engine2/src/state_machine.rs:60-63),
which
kills
the driver
task
and
restarts
the
engine.
On
restart
it
reloads
the
catalog
and replays
from
the
resume
token
—
so
a
fill
for
an
unknown
instrument
is held
(head-of-line,
with
the
engine
crash-looping
until
the
catalog includes
it),
never
dropped
and
never
booked
with
a
defaulted
multiplier.
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.
users,
api_keys, trading_accounts,
account_permissions,
and
instruments
into
in-memory replicas
(btreemapped::BTreeMapReplica);
index_instrument_specs_task
is the
proof
that
live
instrument-row
updates
can
reach
a
running
loader.
database_monitor
replicates the
instruments
table
via
cortex_publication,
and
the
internal-reforms RFC
already
specifies
"order-gateway
loads
at
startup
and
on
instrument refresh" (dated-futures-and-options-internal-reforms.md).
ProductCatalog
is
already
interior-mutable
with
a replace_all
swap
—
refresh-ready
in
shape,
just
never
called
after startup.
Pending,
InstrumentState
in rs/ep3/src/protocol/connamara.ep3.instruments.v1beta1.rs)
and
an
admin
RPC to
leave
it
(UpdateInstrumentStateRequest).
Bitnomial's
product
lifecycle has
the
equivalent
(product_status: forthcoming → active).
bitnomial-expiring-contracts.md items
3–4);
until
then
those
still
take
the
restart
runbook.
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.
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 |
No monthly/quarterly cadence is hardcoded anywhere. Each loader polls at a pace derived from its own catalog's data:
DbInstrument::query_all
plus
one
venue
list
call
a restart
makes.
Pending/Preopen,
Bitnomial
forthcoming),
or
any
contract
whose expiration
/
first-trading-day
falls
within
the
next
baseline
window
— poll
fast
until
the
state
settles.
Contract
rolls
thereby
drive
their
own refresh
cadence:
the
data
says
when
to
look,
not
a
calendar
constant.
instruments
logical-replication replica
already
delivers
row
inserts
to
api-gateway;
wiring
the
same
replica (or
LISTEN/NOTIFY)
into
the
reconciler
as
a
wake-up
makes
adoption near-instant.
A
nudge
is
strictly
a
latency
optimization
—
the
poll
is
the correctness
mechanism,
so
a
dead
replication
slot
degrades
latency,
never safety.
Ship
poll-only
first.
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.
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).
products/instruments
rows.
Pending;
Bitnomial: the
venue
already
lists
it.
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.
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:
rs/order-gateway/src/check_margin.rs:46-55).
Add
the
test.
MissingInstrumentParams
stays
an
error,
and RiskState
applies
the
same
rule
to
fills/marks
for
unknown
ids.
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).
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):
Pending
/
Bitnomial
forthcoming)
·
L2
venue open
(Open
/
active).
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 | L1→L2 | 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:
Pending → Open lands
on
whichever
side
EP3
says
it
lands
on.
No
AX
component
caches instrument
state
on
the
admission
path
today,
and
this
RFC
keeps
it
that way
—
the
only
openness
authority
is
the
venue.
Closure claim, assembled: rows 1–4 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 7–10 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).
| 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.
RiskState
unknown-id
rule.
This
makes
the
safety property
a
contract
before
any
refresh
machinery
exists.
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.
Pending
+
await-adoption
+
open-flip.
First
live add
with
no
restart
on
AX.
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).
LISTEN/NOTIFY
driving
catalog
updates).
Rejected
as
the
guarantee:
a dead
replication
slot
or
dropped
subscription
silently
stalls
adoption, and
the
existing
replica
tasks'
own
failure
mode
("replica
stream
ended prematurely")
would
become
a
listing
outage.
Poll
is
self-healing;
push
is kept
as
the
optional
nudge
on
top.
await-adoption
auto-flip
EP3
to
Open
or
stop
at
"ready"
and
leave the
flip
to
a
human?
Leaning
auto-flip
behind
an
explicit
--open
flag.
database_monitor
task
becomes
that
engine's
reconciler
—
confirm
the additive-apply
rule
carries
over
there.