Date: 2026-07-03
Status: Draft
AX ships two exchange editions with opposite expiry semantics: the AX edition over EP3, and the AIEX edition over Bitnomial. Today the AIEX edition lists only perpetuals. This RFC scopes the work to list and operate dated, expiring contracts on AIEX (backed by Bitnomial) and to harmonize that behavior with the EP3-backed AX edition.
Naming,
kept
precise
throughout:
an
AIEX
instrument
carries
an
AX-style display
symbol
(e.g.
XAU-PERP)
that
masks
an
underlying
Bitnomial
product (e.g.
product
6662)
via
additional_product_specs.btp_product_id.
The
symbol is
AX-shaped;
the
contract
underneath
is
Bitnomial.
All
expiring-contract
work in
this
RFC
lives
on
the
AIEX/Bitnomial
side
unless
it
explicitly
names
EP3.
Two prior RFCs bound this one and are assumed read:
dated-futures-and-options-internal-reforms.md —
widens
InstrumentId
to
u64
and
adds
the
products
registry
so
listing
a dated
contract
is
pure
data.
This
has
largely
landed
on
aiex-main: InstrumentId
is
a
u64
packing
[date bits 62..32 | product bits 31..0] with
perps
as
date = 0
(rs/sdk-internal/src/instrument_id.rs:71-87),
and the
instruments
table
already
carries
expiration TIMESTAMPTZ (db/postgres/1.sql:109),
an
immutable
contract_date INTEGER
(:142),
a product_id
FK
into
products
(:139),
and
an
identity-freeze
trigger
over (symbol, product_id, contract_date)
(:157-170).
So
the
ID
and
DB
model are
done;
this
RFC
is
about
the
exchange-facing
lifecycle
and
settlement,
not remodeling
identity.
bitnomial-dcm-integration.md
—
the copy-forked
ax-bitnomial
+
bitnomial-order-gateway
+
md-btp
architecture. Its
"Products"
section
scoped
only
"futures
and
perpetual
futures,"
treating symbol
as
display
identity
and
product_id
as
the
routing
key.
This
RFC extends
that
to
the
dated
case.
The anchor tickets: A-3486 (done) built the Bitnomial instrument-load pipeline, and A-3404 (Andrew Lee, In Progress) is "Support dated expiring futures on AX (compute MVP)."
EP3
(AX
edition)
—
AX
must
drive
settlement.
EP3
models
dated
futures
fully: instruments
carry
InstrumentType::Future
+
maturity_date,
and
a
lifecycle state
machine
Pending → Open/Preopen/Suspended/Halted → Expired → Terminated. But
—
per
Connamara,
quoted
in
A-3404
—
EP3
does
not
auto-resolve
futures.
At expiry
the
instrument
flips
to
EXPIRED
and
positions
are
left
untouched;
AX must
pick
the
final
price,
walk
every
open
position,
and
book
the
cash
legs itself.
A-3404
landed
the
protocol
shape
for
this
(PR
#2054): SetFinalSettlementPriceRequest
(stage
a
price
before
expiry), FinalizeExpirationRequest
(idempotent,
requires
state
Expired),
and GetFinalSettlementPriceResponse
with
a
finalized_at
timestamp (rs/sdk-internal/src/protocol.rs:413-433).
These
types
have
zero implementers
today
—
the
finalizer
worker,
admin
endpoints,
and
GUI
are
not in
the
tree.
Bitnomial
(AIEX
edition)
—
Bitnomial
drives
settlement.
Bitnomial
is
the DCM/clearinghouse.
Each
dated
contract
is
its
own
product_id
with
type: "future",
month/year,
first_trading_day,
final_settle_time, product_status: forthcoming | active | expired,
and
settlement_method: Deliverable.
Bitnomial
lists
forthcoming
contracts,
expires
them
at final_settle_time,
and
performs
final
settlement
itself.
Expiring contracts
are
Bitnomial's
normal
state
—
a
live
catalog
pull
returned
~5,360 products
(492
futures,
4,106
options,
718
spreads,
only
18
perpetuals)
—
so perps
are
the
exception,
not
the
rule.
So harmonization is asymmetric: on EP3 we originate settlement; on Bitnomial we mirror a settlement Bitnomial already performed. The shared surface is A-3404's protocol primitives, which must become engine-pluggable at the price-source seam.
The
Bitnomial
REST
parser
already
reads
everything
we
need: RawProductSpec
(rs/bitnomial/src/rest/products.rs:39)
parses
kind (#[serde(rename = "type")]),
product_status
(:44),
month
(:75),
year (:77),
and
final_settle_time
(:73).
We
then
throw
them
away:
the api-gateway
instrument
builder
treats
the
mapped
Bitnomial
product
as
"purely
a tick/data
backing;
its
kind
is
intentionally
ignored
…
perpetual
semantics
(no expiration)
come
from
the
AX
row" (rs/api-gateway/src/instruments/bitnomial.rs:112-116),
and
the
built instrument
takes
expiration: row.expiration
from
Postgres
(:148),
which
is NULL
for
every
AIEX
instrument
today.
The
mapping
itself
is
static
and
singular:
one
AIEX
symbol
↔︎
one
Bitnomial product_id,
stored
in
the
bitnomial_product_config
JSONB
column
on instruments
(db/postgres/1.sql:137;
BitnomialProductConfig
in rs/sdk-internal/db/src/entities.rs:846-850;
extracted
via DbInstrument::bitnomial_product_id()
at
:826-830;
set
via
admin-cli's BitnomialInstrumentSpec.btp_product_id).
contract_date
and
expiration,
driven
by
Bitnomial
spec
fields
rather
than hand-set
NULLs.
forthcoming/active/expired)
on
the AIEX
order
path,
in
risk,
and
in
the
GUI.
InstrumentId/products/contract_date
model
—
landed.
Six pieces. Items 1–2 are listing/catalog mechanics; 3–4 are the lifecycle and settlement core; 5 is surfaces; 6 is a product decision that gates 3–4.
Today
one
static
AIEX
symbol
maps
to
one
Bitnomial
product_id.
In
the
dated world
a
Bitnomial
series
(a
base_symbol,
e.g.
Solana
Hecto)
fans
out
into many
contracts
—
SOUHU26
(Sep-2026),
SOUHZ26
(Dec-2026),
…
—
each
its
own product_id
with
its
own
final_settle_time.
We
need
one
AIEX
instruments row
per
contract,
where:
contract_date
is
derived
from
the
Bitnomial
month/year
(nominal
designator, YYYYMMDD),
matching
the
internal-reforms
encoding.
expiration
is
derived
from
final_settle_time.
bitnomial_product_config.btp_product_id
points
at
that
contract's product_id
(unchanged
mechanism,
one
row
per
contract
instead
of
one
total).
Because
contract_date
and
product_id
are
frozen
by
the
identity
trigger (db/postgres/1.sql:157-170),
each
contract
row
is
immutable
identity
once created
—
exactly
the
guarantee
dated
contracts
need.
This
needs
a
listing
procedure:
given
a
base_symbol,
pull
the
Bitnomial
specs for
its
forthcoming/active
contracts
and
materialize
the
rows.
Open question
below
on
whether
that
is
admin-gated
(admin-cli,
matching
today's BitnomialInstrumentSpec
flow)
or
automated
from
the
catalog.
Every exchange-facing loader loads its catalog once at startup and never refreshes:
ProductCatalog
—
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:77-81).
InstrumentManager
(rs/api-gateway/src/index.rs:96).
load_from_ep3()
at
startup (rs/order-gateway/src/lib.rs:227-233);
the
Bitnomial
OG
mirrors
this
shape.
One
perpetual
never
rolls,
so
startup-only
is
fine
today.
Quarterly
(or
monthly) contract
churn
makes
it
untenable:
a
newly-listed
forthcoming
contract,
or
a contract
flipping
to
expired,
must
reach
every
loader
without
a
coordinated manual
restart.
The
mechanism
is
not
re-specified
here:
the
add
side
(a
newly-listed contract
reaching
every
loader
live)
is
specified
cross-engine
in live-instrument-add-without-restart.md (A-4046)
—
per-loader
reconcile
that
re-runs
the
startup
load
additively,
a self-paced
schedule
whose
eager
tick
is
driven
by
each
contract's
own lifecycle
data
(forthcoming
status,
expiration
proximity)
rather
than
a roll
calendar,
the
forthcoming → active
window
as
the
not-yet-tradable
gate, and
a
fail-closed
guarantee
replacing
the
"lockstep
across
loaders"
invariant the
md-btp
comment
relies
on
—
mid-roll
skew
degrades
to
rejects/holds,
never to
one
service
routing
a
contract
another
has
expired.
What
remains
this
RFC's
scope:
the
expire
side
of
a
roll.
A
contract flipping
to
expired
is
a
catalog
mutation,
which
A-4046
explicitly excludes
(its
reconcile
is
additive-only)
and
gates
on
items
3–4
below
— lifecycle
enforcement
and
final-settlement
mirroring.
Until
those
land, expiry
at
roll
keeps
the
A-4040
restart
runbook;
new-contract
listing
stops needing
a
restart
as
soon
as
the
shared
mechanism
ships
on
the
AIEX
loaders.
Consume
product_status,
which
we
currently
parse
and
discard:
expired
contracts
at
the
order-gateway
admission path
(and
ideally
surface
a
typed
reject
reason,
per
the
band-reject
work).
expired/inactive
contracts
from
listings
so
clients
don't
see dead
markets
—
the
same
bug
class
as
EP3's
stale-instrument
listing
issues.
forthcoming
/ active
/
expired).
At
final_settle_time
Bitnomial
settles
the
contract
and
publishes
a settlement_price
(via
/product/data).
AIEX
must
book
that
result
into
its own
ledgers
and
positions
so
client
statements,
positions,
and
PnL
reconcile.
Reuse A-3404's protocol shape, but flip the price source:
SetFinalSettlementPriceRequest
before expiry
(AX
chooses
it).
settlement_price)
and
fed into
the
same
FinalizeExpirationRequest
path.
Mechanically,
finalization
walks
every
nonzero
(user, contract)
position
and drives
qty
to
zero
against
a
settlement
system
account
at
the
settlement
price, booking
the
PnL
+
basis-reset
legs
—
the
mechanism
sketched
in
the internal-reforms
RFC's
"Companion
work:
expiry
settlement"
section,
which
is
the same
machinery
the
EP3
side
needs.
This is the harmonization crux and requires coordination with Andrew (A-3404): the finalizer worker does not exist yet on either side. Build it once, with an engine-pluggable price source — operator-staged for EP3, exchange-read for Bitnomial — rather than two parallel finalizers. The A-3404 protocol types are the shared contract; today they have zero implementers, so the seam is still free to design.
Per
the
project's
lifecycle-testing
rule
(CLAUDE.md),
finalization
must
be exercised
under
client
disconnect,
server-initiated
disconnect,
crash/restart, and
recovery
before
rollout
—
an
idempotent
FinalizeExpirationRequest
keyed
on contract
state
is
what
makes
crash-mid-settlement
safe.
Instrument.expiration
already
flows
to
clients (rs/api-gateway/src/instruments/bitnomial.rs:148
passes
row.expiration),
so once
listing
writes
a
real
expiration
the
wire
carries
it
for
free.
GUI
work:
The maker/taker sims already price via a nominal expiration, so simulated dated markets are testable end-to-end.
Bitnomial
futures
are
settlement_method: Deliverable.
AIEX
must
decide
what
a client
holding
to
expiry
experiences:
This is a product/ops decision, not an engineering one, but it gates the lifecycle window (3) and the settlement mechanism (4): force-flatten means 4 rarely fires; cash-mirror means 4 is the primary path. Decide before building 3–4.
| Surface | Change |
|---|---|
| Instrument listing (admin-cli / api-gateway) | Series
→
per-contract
rows;
derive
contract_date/expiration
from
Bitnomial
month/year/final_settle_time
(already
parsed) |
| Catalog loaders (api-gateway, OG, md-btp, risk-bn, trade-bn) | Adopt
the
shared
live-add
reconcile
(live-instrument-add-without-restart.md);
expiry
mutation
keeps
the
runbook
until
items
3–4
land |
| Order-gateway admission | Block/close-only
on
expired/near-expiry
product_status |
| Settlement engine | New finalizer worker (shared with EP3, engine-pluggable price); A-3404 types gain their first implementer |
| GUI | Dated symbols, expiration column, market-state pill, expired-market UX |
| Postgres | None
—
expiration,
contract_date,
product_id,
identity
trigger
already
exist |
InstrumentId
/
SDK |
None
—
u64
packing
already
landed |
Explicitly
unaffected:
the
identity
model,
the
TB
ledger
layout,
and
the
wire shape
of
Instrument.expiration
(a
NULL
becomes
a
real
value;
no
schema
change).
live-instrument-add-without-restart.md (A-4046);
this
RFC
only
consumes
it
(item
2)
and
keeps
the
runbook
for
the expiry-mutation
side
its
scope
excludes.
BitnomialInstrumentSpec to
fan
a
series
into
contracts),
an
api-gateway
admin
route,
or
an
automated catalog
sync?
Automation
is
tempting
given
Bitnomial's
churn
but
risks
listing
a contract
before
AIEX
ops
has
priced/margined
it.
month/year/final_settle_time/product_status
are present
in
the
demo
env.
Verify
before
building
against
them.
/product/data.settlement_price
vs
a FIX/drop-copy
settlement
message.
Which
is
authoritative
and
how
late
does
it arrive
relative
to
final_settle_time?
live-instrument-add-without-restart.md; still
open
for
expiry
(a
mutation):
does
expiry-at-roll
wait
for
items
3–4, or
does
the
runbook
carry
it
indefinitely?
Sync with Andrew on A-3404 (finalizer worker unbuilt, design PR #1888 closed), resolve the deliverable-settlement decision (item 6), then file an AIEX epic with tickets tracking items 1–5, each referencing the shared A-3404 primitives.