Orion → Vesper, Atlas, Katja — FLAG-031 implementation plan¶
When: 2026-04-17
From: Orion
Subject: FLAG-031 (basis model) — scope, design, and sequencing. Requesting sign-off before first line of code.
Prereq: FLAG-030 branch fix/flag-030-capital-overlay @ 37692b0 — landed, push pending.
Scope¶
Five surfaces. Nothing more. No runtime behavior change until the injection script is used.
- Schema:
capital_events.basis_delta_rlusd REAL(nullable; added via_ensure_column). - Write-path:
record_capital_event()acceptsbasis_commitand persistsbasis_delta_rlusd. - Read-path:
StateManager.get_net_basis_rlusd()— authoritative denominator. - Script:
scripts/inject_capital.py— atomic injection,--dry-run. - Script:
scripts/write_synthetic_initial_basis.py— one-shot historical backfill.
rebuild() is NOT touched. Dashboards / metrics queries migrate to net_basis in a follow-up commit behind a read-only helper — listed at end.
1. Schema migration¶
Single column add. Lives next to the FLAG-030 _ensure_column calls in initialize_database().
- Nullable. Existing rows stay NULL;
get_net_basis_rlusd()treats NULL as 0 viaCOALESCE. - Idempotent (existing helper contract).
- No index —
net_basisis a full-table sum, capital_events is small forever.
Tests: column exists post-init; re-init is no-op; NULL on pre-existing rows; _ensure_column adds once.
2. Write-path changes¶
2a. record_capital_event() — validation and basis_delta¶
Current: validates event_type ∈ {deposit, withdrawal}, asset ∈ {XRP, RLUSD}, positive finite amount, XRP requires price_rlusd. All at write time (FLAG-008 rule). I'm keeping that pattern.
Changes:
- Accept event_type = 'basis_commit' in addition to the existing two.
- Add optional kwarg basis_delta_rlusd: Optional[float] = None.
- New validation rules, per event_type:
| event_type | asset allowed | price_rlusd | basis_delta_rlusd | amount |
|---|---|---|---|---|
| deposit | XRP or RLUSD | XRP required, positive finite | auto-computed if None; if caller passes a value, honored (override path). RLUSD: +amount. XRP: +amount * price_rlusd. |
positive finite |
| withdrawal | XRP or RLUSD | XRP required | MUST be provided by caller (negative or zero). Computed by classify_withdrawal_basis + negated. Written as-is. |
positive finite |
| basis_commit | RLUSD only | must be None | MUST be provided by caller (any finite sign). | = \|basis_delta_rlusd\| (enforced — magnitude mirrors the delta for invariant auditability) |
basis_commitexplicitly MUST NOT take a price_rlusd (basis is already RLUSD-denominated). Reject at validator.basis_commitexplicitly MUST haveasset = 'RLUSD'. Reject otherwise.- Withdrawal without
basis_delta_rlusd→ValueError. Forces callers to classify first.
Rationale for forcing caller-provided basis_delta_rlusd on withdrawal: per FLAG-031 spec line 69 — classification must use pre-event equity and pre-event basis ONLY. If record_capital_event read state internally to classify, the read would race the write and "pre-event" would depend on transaction ordering. Caller-owns-classification keeps the boundary crisp.
2b. New pure function: classify_withdrawal_basis¶
Free function on StateManager (or module-level if that's cleaner — I'll defer to Vesper). Pure — no DB reads. Easy to test.
def classify_withdrawal_basis(
withdrawal_rlusd: float,
equity_before_rlusd: float,
basis_before_rlusd: float,
) -> tuple[float, float]:
"""
Split a withdrawal into (profit_portion, principal_portion), RLUSD-denominated.
profit_portion = min(withdrawal, max(equity_before - basis_before, 0))
principal_portion = withdrawal - profit_portion
The caller applies the sign to compute basis_delta_rlusd = -principal_portion.
"""
Tests (all pure, no DB): - pure profit (equity_before > basis_before + withdrawal) → full profit, zero principal - pure principal (equity_before == basis_before) → zero profit, full principal - drawdown (equity_before < basis_before) → zero profit, full principal (basis floor holds) - mixed (equity_before > basis_before but equity_before - basis_before < withdrawal) → split - zero withdrawal → (0, 0) - withdrawal > equity_before (should never happen in practice, but cap test) → still returns full withdrawal as principal; no negative basis returned - precision: 1e-12 edge values don't round to nonzero
2c. Tests for record_capital_event¶
- deposit RLUSD auto-computes basis_delta = +amount
- deposit XRP auto-computes basis_delta = +amount × price_rlusd
- deposit with caller-override basis_delta → override stored
- withdrawal RLUSD with caller-provided basis_delta = -1.23 → stored as -1.23
- withdrawal without basis_delta_rlusd → ValueError
- basis_commit RLUSD with basis_delta=+50, amount=50 → row written
- basis_commit with price_rlusd != None → ValueError
- basis_commit with asset='XRP' → ValueError
- basis_commit with amount != |basis_delta| → ValueError
- basis_commit with basis_delta = 0 → allowed (documents "zero event"; harmless)
3. Read-path¶
StateManager.get_net_basis_rlusd()¶
def get_net_basis_rlusd(self) -> float:
row = self._conn.execute(
"SELECT COALESCE(SUM(basis_delta_rlusd), 0.0) AS nb FROM capital_events"
).fetchone()
return float(row["nb"])
No boundary filter. net_basis is global — the synthetic_initial_basis row covers the historical portion, and going forward every new event carries its own basis_delta. Unlike get_capital_delta_total, there's no "pre-engine vs post-engine" split to make because basis is an accounting quantity, not an inventory quantity.
Tests: - empty table → 0.0 - one deposit RLUSD → +amount - one deposit XRP + one withdrawal (principal) → deposit_rlusd - principal - one basis_commit → +delta - mixed (all three) → algebraic sum - NULL basis_delta (pre-migration row) treated as 0.0
4. scripts/inject_capital.py¶
CLI: python scripts/inject_capital.py --db <path> --amount <rlusd> [--dry-run] [--source-note <str>] [--tx-hash <str>]
- Positive
--amountonly (this is explicitly the injection script; withdrawals get their own later if needed). - Asset hard-coded to RLUSD for first cut (this is what Katja is injecting; XRP injection path can extend later).
Dry-run output¶
Exact labels per FLAG-031 spec line 70:
=== FLAG-031 injection dry-run ===
DB: /tmp/live_copy.db
Amount: +50.00 RLUSD
net_basis_before = <current>
net_basis_after = <current + 50.00>
paper.pnl_starting_value_rlusd (before) = <current>
paper.pnl_starting_value_rlusd (after) = <current + 50.00>
capital_events row that WOULD be written:
event_type = deposit
asset = RLUSD
amount = 50.00
basis_delta_rlusd = +50.00
source_note = <source-note or 'manual injection'>
tx_hash = <tx-hash or NULL>
No changes made.
Real run¶
Single transaction. Order locked per FLAG-031 spec line 74: (1) write capital_event, (2) update engine_state, (3) commit.
with _transaction(conn) as tx:
state.record_capital_event(
event_type='deposit',
asset='RLUSD',
amount=amount,
basis_delta_rlusd=+amount, # RLUSD deposit
source_note=source_note,
tx_hash=tx_hash,
)
prev = float(state.get_engine_state('paper.pnl_starting_value_rlusd') or 0.0)
state.set_engine_state(
'paper.pnl_starting_value_rlusd',
str(prev + amount),
)
# commit on context exit
Both writes share _transaction. If either raises, both roll back. This is exactly the atomicity FLAG-032 requires to keep ΔTOTAL_PNL = 0 post-injection.
Output on success: prints the same dry-run block but with Wrote capital_event id=<uuid> and exit 0. On failure: prints the exception and exits 1; no partial state.
Tests¶
- dry-run prints both required labels; no DB mutation (verify via pre/post SHA-256 of db file)
- real run: both writes present post-commit
- forced failure in engine_state write (monkey-patched to raise) → capital_event NOT persisted (transaction roll-back verified)
- idempotency is NOT a property of this script — running twice is legitimately 2 injections. Tested by running twice and asserting 2 rows, net_basis += 2·amount.
--dry-run --amount -10→ rejected (positive only)--amount 0→ rejected
5. scripts/write_synthetic_initial_basis.py¶
CLI: python scripts/write_synthetic_initial_basis.py --db <path> --amount <rlusd> [--dry-run]
- Historical backfill only. Writes one row of type
basis_commitwithsource_note = 'synthetic_initial_basis'. - Idempotency guard: before writing,
SELECT 1 FROM capital_events WHERE source_note = 'synthetic_initial_basis' LIMIT 1. If present: print "synthetic_initial_basis already present; no-op" and exit 0. - Does NOT touch
engine_state. This is a data reconstruction — the starting_value was set historically in its own path. (Contrast with inject_capital.py, which models a new live event and must keep TOTAL PNL stable.) - Does NOT alter ledger or inventory. Basis is an accounting column, independent of inventory.
The --amount value (open question for Atlas/Katja, below)¶
The correct value depends on what net_basis should read for the pre-FLAG-031 history. Two candidates:
- (a) RLUSD-equivalent of the two pre-engine deposits valued at deposit-time prices. From live DB:
39.27 XRP × 1.3314 + 85.00 RLUSD = 52.28 + 85.00 = 137.28 RLUSD. This matches the current NET DEPOSITS tile exactly. - (b) Current mark-to-market RLUSD equivalent. Higher or lower depending on drift.
I default to (a) — basis is committed capital valued at commitment time, and this keeps the dashboard's NET DEPOSITS tile (which aggregates capital_events) in lockstep with net_basis immediately after backfill. Needs Atlas/Katja sign-off before the script is run against live DB.
Tests¶
- empty DB: first run writes one row with exact fields (event_type='basis_commit', asset='RLUSD', source_note='synthetic_initial_basis', basis_delta_rlusd=+amount, price_rlusd IS NULL)
- second run on same DB: no-op, row count unchanged, exits 0
--dry-run: no writes; idempotency check still runs and prints correct status--amount <negative>: rejected (must be positive — historical commit is always ≥ 0)- synthetic row does NOT count into
get_capital_delta_total(already enforced by FLAG-030 exclusion of basis_commit; regression test here)
Branch / commit sequencing¶
Branch: feat/flag-031-basis-model, stacked on fix/flag-030-capital-overlay @ 37692b0.
Commits, in order, each green before the next:
flag-031: add basis_delta_rlusd column migration— schema + 4 migration testsflag-031: classify_withdrawal_basis pure function— function + 7 pure testsflag-031: accept basis_commit and basis_delta_rlusd in record_capital_event— validation + write + 10 testsflag-031: get_net_basis_rlusd read-path— method + 6 testsflag-031: inject_capital.py with atomic transaction— script + 6 tests (incl. forced-failure rollback)flag-031: write_synthetic_initial_basis.py one-shot migration— script + 5 tests
Then before any live run, a verification commit (not landed; documented in the commit log):
flag-031: live-DB dry-run verification— run inject_capital.py--dry-run --amount 50against a copy oflive_post_s32.db(with synthetic_initial_basis pre-applied). Assert the four FLAG-032 invariants:
Δ NET DEPOSITS = +50 ✓ (via get_net_deposits_rlusd)
Δ TOTAL VALUE = +50 ✓ (balance * mid + RLUSD)
Δ TOTAL PNL = 0 ✓ (total_value - paper.pnl_starting_value_rlusd)
Δ TRADING VALUE = 0 ✓ (same — TRADING VALUE is the renamed TOTAL PNL)
If any invariant fails: STOP. Do not push. Report numbers back to team.
What I am NOT doing in this flag¶
- Touching
rebuild(). FLAG-030 boundary already excludes basis_commit correctly; that's the only interaction. - Migrating dashboard queries. Callers of "initial capital / deposits" switch to
get_net_basis_rlusd()in a follow-up commit after FLAG-031 lands and the synthetic backfill is applied — those are audit-required changes and belong in their own surface per spec line 67. - Adding a withdrawal CLI. We don't have a withdrawal use case this week; specifying and testing that script is separate work.
- Automating basis_commit. Per spec: manual only. There is no scheduler.
Open questions — requesting a call on each¶
- Synthetic basis amount: confirm
137.28 RLUSD(deposit-time valuation) vs something else. I want Atlas and Katja both on record here since the number becomes the history denominator. - record_capital_event vs new record_basis_commit method: I'm adding basis_commit to the existing method. Alternative: separate method to keep the validator branches simple. Vesper — preference?
basis_commitwithbasis_delta = 0: I allow it (it's harmless and could be useful for marker rows). Anyone want it rejected?get_net_basis_rlusdcache: first cut is a full-table SUM per call. Capital_events is tiny and reads are rare, so this is fine. Flag for FLAG-031+1 if we ever want per-asset net_basis or snapshot caching.
Timeline¶
All six code commits + tests: one focused session. Live-DB dry-run verification: same day if Atlas/Katja answer Q1. Push deferred to Katja's terminal as usual.
Nothing lands on main until FLAG-030 lands first — this branch is stacked and will rebase cleanly.
— Orion