RFC: Dated Futures and Options Internal Reforms (ContractId)

Date: 2026-06-10

Background

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:

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.

Goal

  1. Listing a dated contract is pure data: a Postgres row plus an EP3 instrument. No sdk-internal change, no TigerBeetle provisioning, no deploy.
  2. Contract identity is unique forever no recycling, no aliasing.
  3. Existing perpetual accounts in TigerBeetle remain bit-identical zero data migration.
  4. All invariants in risk-engine2/INVARIANTS.md remain enforced (G1G3, U1U5, P1P4), including across restart/recovery.

Non-goals

Design

Naming: InstrumentId becomes ProductId

Redefining "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.

ContractId

The 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.

TB account ID layout

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:

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:

Ledger model

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.

Decode and recovery changes

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>):

Account-scan recovery (risk-engine2/src/tigerbeetle_driver.rs::resume, identical logic in cortex PR #1756):

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.

Invariants

From INVARIANTS.md, the four that key off per-contract identity:

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.

Dynamic symbol registry

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 = perp

Write-once product IDs

A 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:

  1. Privileges: application roles get 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.
  2. Append-only triggers: a 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.
  3. Sequence allocation: 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.
  4. Recon tripwire: TigerBeetle is the ultimate witness. 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 13 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.

Blast radius

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.

Companion work: expiry settlement and the delist fence

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):

  1. Final settlement: at expiration, for every nonzero (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.
  2. Delist fence: delist in EP3 only after 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.

Alternatives considered

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.

Rollout

  1. Phase 0 (now, one line): reserve bit 63 in the derive_tb_account_id doc comment and a const; fix the stale "(e.g. subaccounts)" note.
  2. Phase 1 (no behavior change): introduce 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.
  3. Phase 2: dynamic registry (Postgres products + columns, runtime map, startup assertion against the static table for one release, then delete the table).
  4. Phase 3: expiry settlement + delist fence (separate spec), dated fixtures through the full scenario/invariant/recovery suites.
  5. Phase 4: list COMPUTEDESK-H100 monthlies.

Open questions