ContractId)Date: 2026-06-10
DB
and
SDK
support
for
dated
futures
exists
(instruments.expiration
in db/postgres/1.sql),
but
we
still
cannot
list
a
dated
future
without ceremony.
The
blocker
is
how
instruments
map
to
TigerBeetle:
InstrumentId
(rs/sdk-internal/src/instrument_id.rs)
is
an
odd
u32
that is
the
TigerBeetle
ledger
number.
Each
instrument
consumes
a
ledger
pair: the
odd
ID
is
the
position-quantity
ledger,
ID+1
(even)
is
the
basis
ledger.
rs/sdk-internal/src/constants.rs
(22
perps
today).
Listing
anything
new means
editing
the
table,
recompiling,
and
redeploying
every
consumer.
user_ulid | ledger_id (UserId::derive_tb_account_id),
i.e.
[user 64b | zero 32b | ledger 32b]. Bits
63–32
were
once
earmarked
for
subaccounts;
that
plan
is
dropped
and
the bits
are
free.
For
monthly
futures
(e.g.
COMPUTEDESK-H100-JAN-2026),
minting
one InstrumentId
per
contract
month
means
a
code
change
and
deploy
per
listing, unbounded
growth
of
dead
ledger
pairs
(TB
never
deletes
accounts
or
ledgers), and
—
because
TigerBeetle
transfers
must
stay
within
one
ledger
—
calendar contracts
of
the
same
product
that
can
never
transact
atomically
with
each other
(no
atomic
rolls,
no
spread
settlement).
Recycling a fixed set of IDs (e.g. 12 monthly slots) was considered and rejected: TB account history is immortal, so reuse aliases two different economic contracts onto the same accounts, and customer-facing symbol reuse without a year is ambiguous on statements and trade confirms.
sdk-internal
change,
no
TigerBeetle
provisioning,
no deploy.
risk-engine2/INVARIANTS.md
remain
enforced
(G1–G3, U1–U5,
P1–P4),
including
across
restart/recovery.
InstrumentId
becomes
ProductIdRedefining
"instrument"
to
mean
the
underlying
would
invert
industry convention:
in
FIX,
the
Instrument
component
is
the
specific
tradable contract
(maturity,
strike,
put/call
are
instrument-level
fields);
CME's vocabulary
is
product
(ES,
CL)
vs
contract
(ESH6);
EP3
and
our
own per-contract
instruments
table
follow
suit.
Since
the
Phase
1
sweep
touches every
call
site
anyway,
the
odd
u32
is
renamed
ProductId
at
the
same time.
(UnderlyingId
was
considered
and
rejected
—
the
underlying
of COMPUTEDESK-H100-JAN-2026
is
the
H100
rental
index;
COMPUTEDESK-H100
is
the product.)
The
remainder
of
this
document
uses
ProductId
for
the
odd
u32
and InstrumentId
only
when
referring
to
today's
code.
ContractIdThe
odd
u32
(ProductId,
née
InstrumentId)
identifies
a
product
(e.g. COMPUTEDESK-H100),
not
a
contract.
A
new
64-bit
ContractId
identifies
a tradable
contract:
// ax-sdk-internal
/// A tradable contract: a product plus a nominal contract date.
/// Perpetuals are the `date = 0` case.
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, ...)]
#[serde(transparent)]
pub struct ContractId(u64);
impl ContractId {
pub fn new(product: ProductId, date: ContractDate) -> Self {
Self((date.yyyymmdd() as u64) << 32 | product.ledger_id() as u64)
}
pub fn product(&self) -> ProductId; // low 32 bits
pub fn date(&self) -> ContractDate; // bits 62..32, YYYYMMDD
pub fn is_perp(&self) -> bool; // date == 0
}
impl From<ProductId> for ContractId { /* perp: date = 0 */ }The
date
is
the
nominal
contract
designator,
not
the
expiration
schedule. It
is
the
listed
date
as
an
immutable
name
(20260130
for
JAN-2026);
the authoritative
expiration TIMESTAMPTZ
stays
in
Postgres
and
may
be
adjusted after
listing
(holiday
rules
etc.)
without
touching
identity.
Full
YYYYMMDD rather
than
YYYYMM
keeps
weeklies
and
dailies
representable
later.
Ord
on
the
packed
u64
sorts
by
date
then
product,
which
is
a
sensible term-structure
ordering;
nothing
persists
a
sort
order
today
(position
caches and
risk
state
are
rebuilt
from
TB/EP3
on
startup),
so
the
re-keying
is
safe.
bits 127..64 UserId (unchanged)
bit 63 scheme tag — RESERVED, always 0 in this RFC
bits 62..32 contract discriminator — interpretation depends on the tag
bits 31..0 ProductId (odd; even = basis twin) — invariant across schemes
Under
tag
0
(this
RFC),
the
discriminator
is
the
nominal
contract
date
as YYYYMMDD
(0
for
perpetuals)
—
self-describing
and
derivable
from (user, product, date)
with
no
lookup.
Two
layout
rules
are
normative
for any
future
scheme:
ProductId.
This
is
what
keeps
ledger derivation,
the
odd/even
parity
discriminator,
and
the
basis
+1
twin uniform
across
perps,
dated
futures,
and
anything
later.
ContractId.
Options
thereby
inherit
per-series
basis
accounts
and
the whole
transfer
machinery
for
free;
only
futures
keep
a
hexdump-readable discriminator.
derive_tb_account_id
takes
a
ContractId
instead
of
an
InstrumentId:
pub const fn derive_tb_account_id(self, contract: ContractId) -> u128 {
self.to_ulid().0 | contract.0 as u128
}Properties that make this safe and migration-free:
date = 0
(perpetual)
encoding.
derive_from_tb_account_id
is
untouched
—
it
masks
off
the
entire
low 64
bits,
so
user
recovery
is
independent
of
what
we
encode
there (rs/sdk-internal/src/user_id.rs:110).
+1
trick
survives.
Basis
account
=
qty
account
+
1
flips only
the
low
bit
of
the
(odd)
product
ID;
the
date
bits
are
unaffected. All
+1
arithmetic
(types.rs
basis
encodes, get_account_vwap_and_cum_realized_pnl,
resume()'s existing_accounts.get(&(account.id + 1)))
works
unchanged.
YYYYMMDD ≤ 99991231 < 2^27,
so
the
top
bits
of
the date
field
are
structurally
zero.
Bit
63
is
reserved
now
as
the
scheme
tag for
the
options
encoding
sketched
above.
Do
not
spend
it.
| Today | Proposed | |
|---|---|---|
| Qty ledger (odd u32) | one per contract | one per product |
| Basis ledger (even) | qty + 1 | qty + 1 (unchanged) |
| USD ledger | 1
(USD_ASSET_ID) |
1 (unchanged) |
| Contract identity | the ledger number | account-ID bits 62..32 + ledger |
| Listing a contract | new ledger pair + table entry + deploy | a Postgres row |
All
transfers
remain
intra-ledger
exactly
as
today:
a
trade's
qty
leg
posts between
two
qty
accounts
of
the
same
contract
(same
ledger,
same
date
bits), the
basis
leg
between
their
basis
twins,
fees/PnL
on
the
USD
ledger.
The transfer
schema
—
codes,
discriminants,
user_data_64
timestamps, user_data_128
packed
decimal
pairs,
linked-batch
atomicity
—
is
unchanged.
Additionally,
at
account
creation
we
mirror
the
nominal
date
into
the account's
user_data_32
(currently
zero/unused
on
every
account).
TB's query_accounts
can
filter
on
user_data_32
+
ledger
server-side,
so per-contract
reconciliation
sweeps
stay
one
query
per
contract
instead
of
a client-side
decode
over
the
whole
ledger.
The
study
of
the
TB
layer
(main's
rs/sdk-internal/tigerbeetle/
and
cortex PR
#1756)
found
every
place
contract
identity
is
currently
derived
from
a ledger
number.
They
split
into
two
patterns:
Transfer
decoding
(ax-tigerbeetle/src/types.rs,
TryFrom<tb::Transfer>):
TbTrade
(types.rs:227),
TbBasis
(types.rs:182),
TbBasisReset (types.rs:162)
derive
instrument_id
from
transfer.ledger.
These
fields become
contract_id: ContractId,
decoded
as:
product
from
the
ledger (as
today),
date
from
either
account
ID's
bits
62..32
—
every
transfer carries
both
account
IDs,
so
no
information
is
lost.
The
decoder
ensure!s the
two
accounts
agree
on
the
date
bits,
which
doubles
as
a
read-side cross-contract
tripwire.
types.rs:95, transfer.ledger % 2)
survives
unchanged
—
product
IDs
stay
odd,
basis ledgers
stay
even.
types.rs:50,
USD
ledger)
is
unaffected.
Account-scan
recovery
(risk-engine2/src/tigerbeetle_driver.rs::resume, identical
logic
in
cortex
PR
#1756):
InstrumentId::from_u32(account.ledger)
to key
instrument
params,
mark
prices,
and
the
user-position
map.
These
become: product
from
account.ledger,
date
from
account.id
bits
62..32, combined
into
the
ContractId
that
keys
the
maps.
query_accounts
scan
strategy
is
unchanged
—
recovery never
needed
to
enumerate
ledgers
a
priori,
only
to
classify
each
account, which
it
can
still
do
from
(ledger, code, id).
Write-side
guard.
The
structural
firewall
we
lose
—
TB
rejecting
a transfer
between
accounts
of
different
contracts,
because
they
no
longer
sit on
different
ledgers
—
is
replaced
at
the
single
choke
point
all
postings
go through:
prepare_trade
already
asserts
maker/taker
fills
agree
on instrument;
this
extends
to
ContractId,
and
the
account-ID
derivation
makes it
impossible
to
construct
a
qty/basis
leg
spanning
two
dates
without
failing the
decoder's
ensure!
and
the
reconciliation
sweep.
Deliberate
cross-month transfers
(rolls,
spread
settlement)
become
possible
—
a
feature
per-contract ledgers
forecloses
permanently
—
but
require
their
own
transfer
codes
and
are out
of
scope
here.
From
INVARIANTS.md,
the
four
that
key
off
per-contract
identity:
Σ position_qty = 0
per
instrument):
holds
per
contract
because qty
transfers
only
ever
pair
two
same-contract
accounts.
The
TB-structural version
("each
ledger
nets
to
zero")
coarsens
to
per-product
—
still true
(a
sum
of
per-contract
zeros)
but
no
longer
the
per-contract
check itself.
The
per-contract
check
moves
to:
in-memory
hot-path
enforcement (unchanged
—
RiskState
is
keyed
by
ContractId),
plus
the
recon
sweep filtering
accounts
by
(ledger, user_data_32).
net_position_basis = Σ position_basis):
per-(user,
contract)
basis accounts
still
exist
(the
date
bits
make
them
distinct
accounts),
so unchanged.
(product, date)
—
carries
over
verbatim.
check_all_invariants()
and
the
scenario/real-world
test
pattern
(write events
→
fresh
resume()
→
exact
state
equality)
remain
the
enforcement vehicle,
now
exercised
with
mixed
perp
+
dated
fixtures.
The
compile-time
INSTRUMENT_IDS
table
is
replaced
by
data:
-- db/postgres/1.sql
CREATE SEQUENCE product_id_seq START 1001 INCREMENT 2; -- odd forever; values
-- are never reissued,
-- even on rollback
CREATE TABLE products (
product_id INTEGER PRIMARY KEY DEFAULT nextval('product_id_seq'),
name TEXT NOT NULL UNIQUE, -- e.g. 'COMPUTEDESK-H100'
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT product_id_odd CHECK (product_id % 2 = 1)
);
ALTER TABLE instruments
ADD COLUMN product_id INTEGER REFERENCES products,
ADD COLUMN contract_date INTEGER NOT NULL DEFAULT 0; -- YYYYMMDD, 0 = perpA
product
ID
is
burned
into
TigerBeetle
account
IDs
forever
the
moment
the first
account
is
created
with
it,
so
the
registry
must
be
append-only. Defense
in
depth,
all
declarative
in
1.sql:
SELECT, INSERT
on
products
only; REVOKE UPDATE, DELETE, TRUNCATE
from
everything
but
the
schema
owner.
No service
code
path
can
mutate
a
row,
buggy
or
otherwise.
BEFORE UPDATE OR DELETE
row
trigger
and
a BEFORE TRUNCATE
statement
trigger
(TRUNCATE
bypasses
row
triggers)
that unconditionally
RAISE EXCEPTION 'products is append-only'.
Even
the migration
role
errors;
the
only
path
through
is
a
code-reviewed
one-off migration
that
drops
the
trigger,
edits,
and
recreates
it
—
deliberately convoluted.
This
covers
name
too:
renaming
a
product
re-labels
all history
keyed
through
it
and
deserves
the
same
ceremony.
nextval
is
non-transactional
—
an
aborted insert
burns
the
value
rather
than
letting
the
next
insert
reuse
it
— and
serializes
concurrent
listings
without
max + 2
races.
resume() already
fails
hard
on
a
ledger
with
no
known
product;
the
recon
sweep additionally
asserts
every
ledger
seen
in
the
account
scan
resolves
to
a products
row
matching
the
mapping
cached
at
service
startup.
An
edit that
somehow
survives
layers
1–3
pages
loudly
instead
of
silently re-labeling
history.
(Verify at implementation time that our Atlas setup diffs triggers/functions declaratively; if not, the triggers ship as a one-off migration file.)
symbol → ContractId
becomes
a
runtime
map
loaded
from
Postgres
(and
kept fresh
the
same
way
instrument
params
already
are
—
cortex's database_monitor
replicates
the
instruments
table
via cortex_publication;
order-gateway
loads
at
startup
and
on
instrument refresh).
The
12
current
instrument_id_for_symbol
/ symbol_for_instrument_id
call
sites
(order
intake,
EP3
position
resolution, margin
check,
formatting)
switch
to
this
registry
and
return/accept ContractId.
Seeding:
the
22
existing
perps
get
products
rows
with
their
current
IDs (explicit
inserts;
the
sequence
then
starts
above
them
via
setval),
with contract_date = 0
on
their
instruments
rows.
For
one
release
the
static table
remains
as
a
startup
consistency
assertion
against
the
registry;
then it
is
deleted.
New
product
IDs
come
only
from
the
sequence
in
the
listing flow;
USD_ASSET_ID = 1
stays
reserved
in
code.
From the codebase inventory (exhaustive sweep, 2026-06-10):
| Surface | Sites | Change |
|---|---|---|
Map/cache
keys
(OrdMap/BTreeMap<InstrumentId, _>) |
~33 | re-key
to
ContractId:
OG
position_cache,
check_margin,
open_orders;
RE2
RiskState,
open_orders_margin,
stress |
| Ledger decode/recovery | 8 | tigerbeetle_driver.rs:129,137,147;
types.rs:50,162,182,227
(per
above) |
derive_tb_account_id
callers |
17 | signature
takes
ContractId;
USD
accounts
derive
via
ContractId::from(USD_ASSET_ID) |
| Symbol↔︎ID resolution | 12 | dynamic
registry
returning
ContractId |
| Protocol/IPC serialization | 6 fields | protocol.rs:938,
RE2
events.rs
—
u32
→
u64
(#[serde(transparent)]);
internal-only
surfaces,
coordinate
OG/cortex
deploys |
| Test fixtures | ~15 | hardcoded
odd
IDs
keep
working
via
From<InstrumentId>;
add
dated-contract
fixtures |
Explicitly
unaffected:
the
public
SDK
(rs/sdk
—
symbol-keyed
throughout), Postgres
and
ClickHouse
schemas
(symbol-keyed),
the
Python
reference
engine (symbol-keyed),
EP3
(string
instrument
IDs;
per-contract
symbols
are
EP3's normal
case),
and
all
TB
data
at
rest.
Interaction
with
cortex
PR
#1756:
the
PR
predates
the
ax-tigerbeetle micro-crate
extraction
and
needs
a
rebase
regardless;
it
modifies
the
same decode
paths
this
RFC
changes
(and
fixes
a
main-branch
decode
bug
where DEPOSIT/WITHDRAWAL/FUNDING
all
decode
to
Deposit).
Sequencing:
rebase #1756
first,
land
ContractId
on
top
—
or
fold
the
ContractId
decode changes
into
the
rebase.
Decide
with
the
cortex
owner.
Nothing in the system today ever closes a position except trading to zero — there is no expiry mechanism. Before the first dated listing we need (spec'd separately):
(user, contract) position:
a
settlement-qty
transfer
against
an
AX_SETTLEMENT
system account
driving
qty
to
zero
(Σ
qty
=
0
per
G1,
so
AX_SETTLEMENT
also
ends flat),
plus
the
PnL
+
BasisReset
pair
at
the
settlement
price.
New
transfer discriminants;
same
linked-batch
machinery.
query_accounts(ledger, user_data_32 = date)
confirms
every
qty
and
basis
account
for
the contract
has
zero
posted
balance
and
there
are
no
resting
orders
(GTC included).
Per the project's lifecycle-testing rule, both must be exercised under client disconnect, server-initiated disconnect, crash/restart, and recovery before rollout.
Mint
a
unique
InstrumentId
per
contract
(+
dynamic
registry).
The
u32 space
(2³¹−1
odd/even
pairs;
TB's
only
ledger
constraint
is
non-zero)
would never
exhaust
for
futures.
Rejected
because
(a)
same-product
contracts
on different
ledgers
can
never
transact
atomically
in
TB
—
rolls
and
calendar spread
settlement
are
permanently
structural
impossibilities;
(b)
ledger churn:
every
listing
provisions
new
ledger
pairs
and
new
AX_PNL
basis accounts
with
no
cleanup
path;
(c)
it
still
requires
the
entire
dynamic registry,
so
it
saves
only
the
ContractId
re-keying
—
the
smaller
half
of the
work
—
while
spending
the
larger
architectural
option.
For
options
the per-series
version
of
this
alternative
also
burns
the
keyspace
at
scale; the
proposed
encoding
is
the
same
shape
options
will
need
anyway.
Recycle a fixed slot set (12 monthly IDs per product). Rejected: TB accounts are immortal, so reuse aliases two contracts' history onto one account; correctness then hinges on a provably-exactly-zero drain of every position and basis account at each recycle fence, with any dust silently poisoning the next year's contract; and the symbol itself becomes ambiguous on customer statements.
Put
the
contract
date
in
user_data_32
only
(account
IDs
unchanged beyond
uniqueness
salt).
Rejected:
account
IDs
must
be
derivable
— touch_account,
recovery
pairing,
and
basis
+1
all
depend
on
computing
an account
ID
from
(user, contract)
without
a
lookup.
The
ID
is
the
natural home;
user_data_32
is
the
query-side
mirror,
not
the
identity.
derive_tb_account_id doc
comment
and
a
const;
fix
the
stale
"(e.g.
subaccounts)"
note.
ContractId;
re-key
maps, protocol
fields,
and
the
TB
encode/decode
paths;
all
contracts
are
perps (date = 0),
so
every
derived
account
ID
and
transfer
is
bit-identical
to today.
Scenario
+
real-world
resume-equality
tests
prove
it.
products
+
columns,
runtime map,
startup
assertion
against
the
static
table
for
one
release,
then delete
the
table).
COMPUTEDESK-H100
monthlies.
PERP_QTY/PERP_BASIS
(8000/8001)
for
dated contracts
—
the
codes
describe
the
accounting
role,
and
contract-ness
lives in
the
ID
—
or
mint
FUT_QTY/FUT_BASIS
so
account
scans
can
classify product
type
without
decoding
IDs?
Leaning
keep-and-rename (POSITION_QTY/POSITION_BASIS);
resume()
branches
only
on
code
+
user.
products
insert:
allocation
itself
is
settled
(the sequence),
but
which
admin
surface
drives
a
new-product
listing
—
admin-cli, api-gateway
admin
route,
or
a
manual
reviewed
migration
—
is
open.
COMPUTEDESK-H100-JAN-2026 vs
CME-style
month
codes)
—
orthogonal
to
this
RFC
but
should
be
fixed
in the
listing
spec.