Orion Investigation — Inventory Corridor Guard¶
To: Vesper (she/her)
CC: Katja (Captain), Atlas (he/him)
From: Orion (he/him)
Date: 2026-04-19
Re: feat/inventory-corridor-guard — pre-code investigation Q1–Q5 per your tasking memo
Status: INVESTIGATION ONLY — no branch created, no code written. Working on fix/reconciler-disappeared-order-conservative (post-FLAG-037, the effective head state the corridor guard will stack on top of after merge).
Summary¶
All five questions answered below. One ruling request for Q4. No spec deviations. Proposed 4-commit plan matches the suggested commit plan in your tasking. Ready for green light.
Q1 — Inventory state in tick loop¶
Accessor: self._inventory.get_snapshot(mid_price) -> InventorySnapshot. Implementation at neo_engine/inventory_manager.py:334-370.
Fields returned (models.py:170-184 — InventorySnapshot):
xrp_balance: float— total cached XRP balance (fills + capital-events overlay)rlusd_balance: float— total cached RLUSD balance (fills + capital-events overlay)xrp_value_in_rlusd: float—xrp_balance × mid_price(0.0 ifmid_price <= 0)total_value_in_rlusd: float—xrp_value + rlusd_balancexrp_pct: float—(xrp_value / total_value) × 100— already computed, on the right denominatordrift_pct: float—xrp_pct − target_xrp_pct
Live vs. last reconciler snapshot: LIVE. get_snapshot() reads the in-memory balance cache. That cache is:
- Initialised by
rebuild()at startup frominventory_ledger+ capital-events overlay (rebuild()atinventory_manager.py:95). - Updated on every fill by
apply_fill()(inventory_manager.py:210). - Updated by the reconciler writing a realignment row + delta rebuild on truth check (FLAG-036 / D2.2).
Call sites in _tick (main_loop.py):
- line 2095 — pre-trade inventory (mid from
telemetry.latest_tick_mid_price, fallback 0.0) — fed toRiskEngine.evaluate()for the existingmax_xrp_exposure/max_rlusd_exposureHALT checks. - line 2292 — post-reconciliation inventory using current tick
mid_price = snapshot.mid_price or 0.0— this is the one the corridor guard will consume. - line 2579 — post-strategy telemetry snapshot, same
mid_price.
Same source as pre-trade gate? YES. The pre-trade gate in execution_engine.py:309 (_check_inventory_truth_gate) reads inventory_truth.mode from engine_state — it does not re-query inventory at submit time. The inventory balances behind the gate's DEGRADED flag come from the same InventoryManager cache. There is a single source of inventory truth (the manager's cache, rebuilt at startup and updated per-fill).
Conclusion: Read from the same inventory local already computed at main_loop.py:2292. No new call, no new cache. inventory.xrp_pct, inventory.xrp_balance, inventory.rlusd_balance, inventory.total_value_in_rlusd are all already on this object — no arithmetic needed in the guard for the composition check.
Q2 — Mid price availability¶
Field: snapshot.mid_price: Optional[float] (MarketSnapshot in neo_engine/market_data.py:72).
Normalised in _tick: at main_loop.py:2291 — mid_price = snapshot.mid_price or 0.0. This is the exact variable name already in scope for the entire post-reconciliation block, including the proposed Step 8.5c insertion point.
Same mid as quote construction? YES.
snapshot.mid_priceis the v1 CLOB mid(best_bid + best_ask) / 2(docstringmarket_data.py:58).strategy_engine.calculate_quote(snapshot, inventory)is called atmain_loop.py:2336and usessnapshot.mid_price(andsnapshot.clob_mid_price) internally for anchor + quote offset math.inventory = self._inventory.get_snapshot(mid_price)at line 2292 uses the same normalisedmid_price, soinventory.xrp_pct/inventory.xrp_value_in_rlusdare consistent with the strategy's view.
Inside if snapshot.is_valid(): (line 2335), is_valid() requires both best_bid > 0 and best_ask > 0 (not crossed). Therefore mid_price = (best_bid + best_ask) / 2 > 0 is guaranteed inside that block — snapshot.mid_price cannot be None or 0 here. Defensive mid_price <= 0 handling still warranted (see Q4).
Q3 — Insertion point¶
Confirmed: Step 8.5c, main_loop.py:2477 (after line 2476, before line 2478).
Sequence inside if snapshot.is_valid()::
line 2336 intents = self._strategy.calculate_quote(snapshot, inventory)
...
line 2462 Step 8.5 — anchor saturation guard → (intents, _asg_triggered_now)
line 2474 Step 8.5b — directional drift guard → (intents, _ddg_triggered_now)
line 2477 ─── PROPOSED: Step 8.5c — inventory corridor guard
line 2478 if not intents: ← no-intents diagnostic log
line 2494 Step 9 — submit intents
Ordering rationale:
- Both prior guards use
_enter_degraded_mode(...)(idempotent —main_loop.py:1294). Three guards calling it in the same tick is safe — the mode key is written once, subsequent calls only updatedegraded_reasonand don't re-cancel orders. - Each guard owns an independent one-shot flag (
_anchor_guard_triggered_this_session,_drift_guard_triggered_this_session). The corridor guard will get_corridor_guard_triggered_this_session. - The
intentslist is threaded through all three guards; each returns[]on trigger. A corridor-guard trigger should suppress any intents that the anchor + drift guards let through, matching the existing pattern. - Placing corridor last keeps the "flow then composition" order from Atlas's priority sequencing (anchor → drift → composition).
No ordering conflict with the inventory variable: inventory is defined at line 2292 — outside and before the if snapshot.is_valid(): block. It is fully populated (live-cache + post-reconciliation) by the time Step 8.5c runs. Same for mid_price.
Signature: _evaluate_inventory_corridor_guard(self, intents: list, inventory: InventorySnapshot, mid_price: float) -> tuple[list, bool]. Passing inventory and mid_price explicitly mirrors how the anchor + drift guards read their inputs via self._anchor_error_window / DB watermark respectively — but here the input comes from locals already in scope rather than a stateful deque, so explicit parameters keep the evaluator pure and trivially testable (unit tests can feed arbitrary InventorySnapshot instances without standing up a live tick).
Q4 — Mid price None handling — RULING REQUEST¶
Proposed ruling: A (fail-open — skip composition check). Hard RLUSD floor fires unconditionally because it doesn't depend on mid.
Full proposal:
- Composition check (
min_xrp_pct/max_xrp_pctcorridor): requiremid_price > 0ANDinventory.total_value_in_rlusd >= min_portfolio_rlusd. If either fails, skip the composition check this tick —_corridor_ticks_outsideis NOT incremented (but also not reset; treated as a "no-data" tick). No trigger. - Hard RLUSD floor (
min_rlusd_floor): runs unconditionally, independent ofmid_price. Only requiresinventory.rlusd_balance— a cached balance that doesn't depend on market data. Fires DEGRADED immediately on breach.
Rationale for fail-open on composition:
- Position of the guard: inserted inside
if snapshot.is_valid():which guaranteesmid_price > 0. The fail-open path is defensive, not routine — it covers the degenerate future case where someone lifts the evaluator outside that block, or market_data returns a valid snapshot with a zero mid (shouldn't happen but we do not trust that). - Mirrors anchor guard semantics.
_evaluate_anchor_saturation_guarddrops None-valued ticks from the rolling window — it doesn't fire on missing data, it waits. The corridor guard's counter_corridor_ticks_outsideshould behave the same way (not-incremented on missing data, so we neither false-trigger nor false-reset). - Composition is undefined without mid. If
mid_price == 0,xrp_value_in_rlusd == 0(perget_snapshot()atinventory_manager.py:348-351), soxrp_pct == 0— which would false-trigger "below min_xrp_pct". Fail-open avoids this. - Atlas invariant preserved by the RLUSD floor. "If the engine cannot prove alignment with reality, it does not act." The RLUSD floor is the hard safety — if rlusd drops below
min_rlusd_floor, DEGRADED fires regardless of what the market is doing. The composition check is the softer, persistence-based layer that rightly waits for reliable data.
Alternative B (fail-closed — trigger DEGRADED on missing mid): I do not recommend this. With corridor_lookback_ticks=3, a single market_data hiccup at tick cadence 4s would DEGRADE a healthy session 12s after the first bad tick. Market-data transients already exist (stale ledger, RPC failures) and they have their own HALT paths in the risk engine (HALT_REASON_RISK_STALE_LEDGER, HALT_REASON_RISK_GATEWAY). Stacking another DEGRADED trigger on the same signal is noisy without benefit.
Flagged for Vesper ruling. I will code Option A by default and pivot on your ruling before committing.
Q5 — Existing inventory bounds (double-fire audit)¶
Audit result: NO double-fire risk. One advisory overlap noted.
| Source | Check | When it fires | Action | Interaction with corridor guard |
|---|---|---|---|---|
RiskConfig.max_xrp_exposure |
xrp_value_in_rlusd > max_xrp_exposure |
Step 2 risk check (tick) | HALT + stops main loop | Different scale and different action. max_xrp_exposure = 1000.0 (live) / 150.0 (post-injection cap). Hit only on overexposure to the upside in absolute terms. Corridor guard operates on percentage composition within portfolio and on a much smaller RLUSD scale. They can coexist — HALT always wins. |
RiskConfig.max_rlusd_exposure |
rlusd_balance > max_rlusd_exposure |
Step 2 risk check (tick) | HALT + stops main loop | Same story — absolute cap, not composition. No overlap. |
StrategyEngine._maybe_buy_intent |
inventory.rlusd_balance < order_size |
Intent generation | Blocks a single BUY intent (returns None) |
Advisory-only; pre-corridor. Runs in strategy, before Step 8.5c. A low RLUSD balance would block BUY intents at the strategy layer (one intent refused); corridor min_rlusd_floor is the stronger stop (cancel all, stop quoting). No conflict — they are different severity levels of the same underlying signal, and the corridor is strictly more restrictive. |
StrategyEngine._maybe_sell_intent |
inventory.xrp_balance < required_xrp |
Intent generation | Blocks a single SELL intent (returns None) |
Same — advisory strategy-layer block, preceding the corridor guard. No conflict. |
OrderSizeConfig.max_size_pct_of_portfolio + _HARD_CAP_PCT = 0.15 (strategy_engine.py:72) |
Per-intent size cap | Intent generation | Caps per-intent size | Per-intent sizing, not composition. Different domain. No overlap. |
InventoryConfig.target_xrp_pct |
Drift calc in get_snapshot() |
Each tick | Informational only (drift_pct field) |
Feeds skew_multiplier for quote asymmetry. Not a guard. The corridor guard's min/max_xrp_pct wraps around target_xrp_pct as a symmetric-ish corridor (25/50/75 with default target_xrp_pct=50). No double-fire. |
alerts.inventory_drift_warning_pct |
Drift monitor | Monitoring loop | Discord alert only | Alerting only, no state change. No overlap. |
Parameters circuit_breaker_inventory_drift_pct: 60.0 + circuit_breaker_inventory_drift_window_seconds: 60 |
Parameter set | NOT WIRED (reviewed main_loop.py and risk_engine.py — no reference). |
Dead-ish config | Historical parameter-set field, no evaluator calls it. Noted for cleanup in a future audit but irrelevant to this branch. |
Conclusion: The corridor guard occupies a previously-empty slot — composition-based DEGRADED trigger on real wallet state. The existing bounds are either absolute HALT gates (strictly stronger, different domain) or intent-level advisories (strictly weaker, don't prevent the composition from drifting). No enforcement to supersede; no trigger to deduplicate against.
Proposed State / Invariants¶
New MainLoop instance attributes (in __init__, alongside existing guard state at main_loop.py:219-260):
# Phase 7.3 — Inventory corridor guard state. DEGRADED trigger on
# composition (xrp_pct outside [min_xrp_pct, max_xrp_pct]) or hard
# RLUSD floor. Companion to anchor saturation (price regime) and
# directional drift (fill flow) — corridor watches COMPOSITION.
self._corridor_ticks_outside: int = 0
# One-shot — set BEFORE side effects per Vesper 2026-04-19 ruling
# pattern (matches anchor + drift guards).
self._corridor_guard_triggered_this_session: bool = False
Reset on session boundary — I need to check whether anchor/drift guards reset their flags on new session and mirror that pattern (or confirm the flag is intentionally process-lifetime only). Will handle as part of C2 implementation; flagging now for transparency.
New config dataclass (in neo_engine/config.py, matching anchor/drift precedent):
@dataclass(frozen=True)
class InventoryCorridorGuardConfig:
enabled: bool = True
min_xrp_pct: float = 25.0
max_xrp_pct: float = 75.0
corridor_lookback_ticks: int = 3
min_rlusd_floor: float = 30.0
min_portfolio_rlusd: float = 50.0
Top-level Config field inventory_corridor_guard: InventoryCorridorGuardConfig = field(default_factory=...). Parser block follows the existing anchor/drift pattern at config.py:762-796. Validator _validate_inventory_corridor_guard():
min_xrp_pct >= 0,max_xrp_pct <= 100,min_xrp_pct < max_xrp_pctcorridor_lookback_ticks >= 1min_rlusd_floor >= 0min_portfolio_rlusd >= 0
Rejects inverted corridors at startup (fail-closed on config parse error).
YAML block — add to all three config files (config/config.yaml, config/config.example.yaml, config/config_live_stage1.yaml, config/config_live_session1.yaml) alongside the existing anchor_saturation_guard / directional_drift_guard / reconciler_conservative blocks. Exact YAML matches your tasking spec block.
Commit Plan¶
Matches your tasking suggestion:
feat(config): InventoryCorridorGuardConfig dataclass + YAML defaults (Phase 7.3)feat(main_loop): inventory corridor guard state + evaluation (Step 8.5c)feat(db,main_loop): corridor guard circuit_breaker_events persistence + WARNING logtest(guard): inventory corridor guard — 10+ tests
(C3 collapses into C2 for the drift guard / anchor guard branches in practice. Happy to follow either convention — leaving as 4 commits to match your spec, will consolidate if cleaner.)
Test Plan (≥ 10 tests in tests/test_inventory_corridor_guard.py)¶
Matches your tasking spec one-for-one:
- Guard inactive below
min_portfolio_rlusd— no trigger regardless of composition. - Composition inside corridor — no trigger, counter stays at 0.
- Outside corridor for
lookback_ticks - 1ticks — no trigger yet. - Outside corridor for
lookback_ticksticks — DEGRADED triggered withcondition_triggered="composition". - Outside then back inside before lookback completes — counter resets to 0, no trigger.
rlusd_balance < min_rlusd_floor— immediate DEGRADED withcondition_triggered="rlusd_floor", no lookback wait.mid_priceNone / 0 — per Q4 ruling (A default): composition check skipped, counter not advanced, RLUSD floor still evaluated.- One-shot dedup — second consecutive trigger tick does not double-insert
circuit_breaker_events(verified by row count). enabled: false— guard returns(intents, False)unconditionally; no state mutation.circuit_breaker_eventsrow assertions — both composition and rlusd_floor conditions write the correctbreaker,session_id,contextpayload keys.- Windows teardown — any test using a real
StateManagerfollows the D2/FLAG-037 pattern: explicitsm.close()thentmpdir.cleanup()in LIFO order undertry/finally. (Seetest_reconciler_conservative.pyfixture for the pattern I will reuse.)
Optional 12: max_xrp_pct breach (symmetry sanity — corridor test only covers the below-min path unless I add the above-max counterpart).
Constraints Acknowledged¶
- Use real inventory state (same source as pre-trade gate, not derived state) — ✅ reading
inventory.xrp_pct/inventory.xrp_balance/inventory.rlusd_balancestraight from the post-reconciliationget_snapshot()call at line 2292. - DEGRADED only, not HALT — ✅ using existing
_enter_degraded_mode(...); noreturn Falsefrom_tick. - Parameters configurable in YAML, no hardcoded thresholds — ✅
InventoryCorridorGuardConfigwith all five knobs. - Use existing
_enter_degraded_modeandrecord_circuit_breaker_event— ✅. - No pre-creating the branch during investigation — ✅ on
fix/reconciler-disappeared-order-conservativenow;feat/inventory-corridor-guardwill be created only at C1 commit time. - No
*.patchglob in PowerShell apply instructions — ✅ apply instructions in the delivery memo will useGet-ChildItem ... -Filter "*.patch" | Sort-Object Name | ForEach-Object { git am $_.FullName }. - Always include defensive
git branch -Dbeforegit checkout -b— ✅. - Windows teardown fix required in any integration tests — ✅.
- No strategy tuning — ✅ guard only, no offset / spread / size changes.
Open Questions for Ruling¶
Q4 ruling (blocking): Fail-open (A) vs fail-closed (B) on mid_price None/0. I recommend A. Please confirm before I begin C2.
Q5 follow-up (non-blocking, informational): circuit_breaker_inventory_drift_pct / circuit_breaker_inventory_drift_window_seconds in ParameterConfig (config.py:390-391) appear unwired — no evaluator calls them in main_loop.py / risk_engine.py. Noting for a future config-wiring-pass follow-up; not in scope here.
Ready to Proceed¶
Waiting on Q4 ruling. On green light I will:
- Remain on
main(or current post-merge head) until all four commits are ready. - Create
feat/inventory-corridor-guardat C1 commit time. - Deliver a 4-commit patch bundle + delivery memo with standard apply instructions.
- Target: ≥10 tests (11+ likely), full reconciler/guard/telemetry regression green before patch submit.
— Orion