Skip to content

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.

  1. Schema: capital_events.basis_delta_rlusd REAL (nullable; added via _ensure_column).
  2. Write-path: record_capital_event() accepts basis_commit and persists basis_delta_rlusd.
  3. Read-path: StateManager.get_net_basis_rlusd() — authoritative denominator.
  4. Script: scripts/inject_capital.py — atomic injection, --dry-run.
  5. 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().

_ensure_column(conn, "capital_events", "basis_delta_rlusd", "REAL")
  • Nullable. Existing rows stay NULL; get_net_basis_rlusd() treats NULL as 0 via COALESCE.
  • Idempotent (existing helper contract).
  • No index — net_basis is 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_commit explicitly MUST NOT take a price_rlusd (basis is already RLUSD-denominated). Reject at validator.
  • basis_commit explicitly MUST have asset = 'RLUSD'. Reject otherwise.
  • Withdrawal without basis_delta_rlusdValueError. 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 --amount only (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_commit with source_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:

  1. flag-031: add basis_delta_rlusd column migration — schema + 4 migration tests
  2. flag-031: classify_withdrawal_basis pure function — function + 7 pure tests
  3. flag-031: accept basis_commit and basis_delta_rlusd in record_capital_event — validation + write + 10 tests
  4. flag-031: get_net_basis_rlusd read-path — method + 6 tests
  5. flag-031: inject_capital.py with atomic transaction — script + 6 tests (incl. forced-failure rollback)
  6. 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):

  1. flag-031: live-DB dry-run verification — run inject_capital.py --dry-run --amount 50 against a copy of live_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

  1. 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.
  2. 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?
  3. basis_commit with basis_delta = 0: I allow it (it's harmless and could be useful for marker rows). Anyone want it rejected?
  4. get_net_basis_rlusd cache: 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