ADR-002 — Polymorphic positions over per-class position tables
Status: Accepted (POC). Date: 2026-05-28.
Context
The substrate spans four asset classes whose mark-to-market mechanics, contract terms, and lifecycle events differ deeply: a NatGas well has decline-curve economics and royalty/severance tax math; a reinsurance treaty has loss-attached, reinstatement, and reinstatement-premium logic; an SFR portfolio has NOI and cap-rate-driven appraisal; a crypto derivative has notional, mark price, and unrealized PnL with margin posting. Yet every one of them is a position — a quantity, a holder, an instrument, a market value, a mark lineage, a cost basis, and (in this design) a bitemporal validity window.
Cross-asset queries are the substrate's reason for existing. "What does SRHG own as of 2025-12-31 across all asset classes?" is Q1 of the demo set. "Reconcile every position to ABOR" is Q4. "What does LTRe own through the SRAM fund-of-funds graph?" is Q2. If positions live in four different tables, every cross-asset query is UNION ALL across them — joins to entity, to instrument, to ABOR, to lookthrough, all duplicated four ways. The substrate-first thesis ("downstream analytics doesn't have to compensate for a wrong canonical layer") falls apart when even basic queries require asset-class case analysis.
The opposing concern is type safety. A per-class position table can enforce that a well position has working-interest and severance fields, that a treaty position has reinstatements_consumed, etc. A polymorphic position with per-class detail tables linked by position_id cannot guarantee in the engine (DuckDB doesn't enforce 1:1 relationships) that every position row has the matching detail row.
Decision
One position table holds the common surface — position_id, instrument_id, holding_entity_id, as_of_date, quantity, quantity_unit, cost_basis_usd, market_value_usd, valuation_method, valuation_source, mark_series_id, mark_observation_ts, mark_vintage, plus bitemporal columns.
Four detail tables (position_well, position_treaty, position_sfr, position_crypto_deriv) hold asset-class-specific fields. Each detail row's primary key is position_id referencing the parent.
Asset class is recoverable via position.instrument_id → instrument.asset_class. No discriminator column on position (it would denormalize and could drift from instrument.asset_class).
The 1:1 invariant is enforced by application logic (synth modules write parent + detail together; failure on detail aborts the parent). T15 has no gate that directly catches an orphan parent row; the gap is acknowledged and a CHECK (EXISTS (SELECT 1 FROM position_<class>)) trigger is on the Phase 2 list.
Consequences
Positive:
- Cross-asset queries are uniform. Demo Q1 is one
JOIN position p ON instrument iwith aGROUP BY i.asset_class. ABOR reconciliation readspositiondirectly with no per-class fan-out. The lookthrough pipeline traverses positions as a single set. - Adding a fifth asset class (private credit, FX, etc.) is one detail table + one ingestion module. The substrate surface is unchanged.
- Mark lineage (
mark_series_id,mark_observation_ts,mark_vintage) lives on the common surface — every position, regardless of class, can be traced back to the observation that produced its market value. T15 G6 codifies this as a hard gate.
Negative:
- DuckDB doesn't enforce that
positionhas a matching per-class detail row. A code path that inserts topositionand forgets the detail leaves an orphan, and no T15 gate currently catches it. Mitigation: synth modules write parent + detail together in the same code path; a Phase 2 CHECK trigger (or avw_position_with_detailview that INNER JOINs onposition_id) would add engine-level enforcement. This is the single load-bearing weakness of the polymorphic design. - Per-class queries pay a join. A "give me all CAT_XOL treaty positions" query is
position JOIN position_treaty JOIN instrument_treaty. Three-way joins on 350 rows are free in DuckDB; at production scale they'd want covering indices. - A future asset class with no per-class state (e.g., pure cash positions) creates an empty detail table. Not a real problem; just noise in the schema.
Alternatives Considered
- Per-class position tables (
position_well,position_treaty, ...) as primary tables. Every cross-asset query becomes UNION ALL. The lookthrough pipeline either runs four times (once per class) or pre-computes a unified view. Loses the substrate-first promise. - One position table with all asset-class fields as nullable columns. Wide, sparse schema. NRI is NULL for everything that isn't a well; reinstatements_consumed is NULL for everything that isn't a treaty. Hard to evolve (every new asset class adds columns that are NULL for all prior rows) and the type-safety claim ("a well position has NRI") is now table-wide convention rather than schema constraint.
- JSON detail column on position. Loses the ability to query per-class fields in pure SQL without
JSON_EXTRACTeverywhere. DuckDB has good JSON support, but the substrate's promise ("anyone can write the SQL") suffers when half the interesting fields are inside a JSON blob.