Reliability series #22. PR #90.

A missing field is the smallest possible malformation. This week one cost a whole position.

The bug

The trading agent validates every action the LLM returns before execution — per-element drops, sibling preservation, the whole doctrine this series has been building. But the validation contract had a quiet hole: pct was optional on every action type, and the two executors interpreted an absent pct with two different fabrication conventions:

  • The buy path applied action.get('pct', 0) — a 0% order. It fails silently at the portfolio layer (“Order too small: 0 shares”), the intent evaporates, nothing is recorded.
  • The sell path did something worse: pct if pct > 0 else None. And Portfolio.sell(ticker, price, pct=None) has a documented default — sell_pct = 100.0. Full exit. Position deleted, exit cooldown armed, and the executed-trades record persisted with the fabricated pct: 0 sitting next to a 100% liquidation.

One omitted key, and the system liquidated an entire position while the ledger claimed the order size was zero. The scope of the failure (the whole position, the cooldown state, a lying record) exceeded the scope of the malformation (one missing field) by the entire dimension of the portfolio. This is the anti-Lipschitz property this series keeps meeting under different disguises.

Why two fabrications?

The interesting structural detail is that no single component was wrong. The validator applied an honestly-documented contract (“executor defaults a missing pct to 0”). Each executor applied a locally-reasonable default. Portfolio.sell’s None-means-full-exit is a sensible API convenience. The bug lived in the composition: two conventions, each defensible in isolation, that turned an omission into a maximum-blast-radius action on the sell side and a silent no-op on the buy side. The direction of the fabrication was arbitrary — 0 on buy means “do nothing”, 0 on sell meant “do everything” — decided by which coercion the line happened to contain.

The prompt contract was never ambiguous: pct is documented as part of buy/sell syntax; only hold is legitimately pct-less. Validation should have enforced the contract at the boundary instead of delegating the interpretation downstream.

The fix

Three changes at the single chokepoint (_validate_actions — both executors receive decisions only through get_trading_decision → parse_response):

  1. pct required on non-hold actions. A buy/sell without pct drops as a malformed element, siblings survive.
  2. Reasons, not just debris. Every drop site now carries a reason string, and the per-element ERROR log names the defect: dropped: {'ticker': 'SPY', 'action': 'sell'} (reason: missing pct on sell ...).
  3. Loudness on the all-dropped path. The drop logging moved ahead of the zero-survivor raise — previously, if every action was malformed, the per-element records never fired; only a count in an exception summary survived. The failure path had been quieter than the state it replaced.

The verification discipline, briefly

Same protocol as the rest of the series: four new discriminator tests fail on stashed pre-fix code with the correct symptom (malformed actions accepted), twenty-four healthy pins pass on both sides. The repro gate runs six scenarios and exits 1 pre-fix with eight named failures and zero tracebacks — a gate that limps through every scenario proving more than one that dies early. And the same-day audit classified all six .get("pct") sites repo-wide rather than bulk-fixing them: the executor defaults are unreachable for LLM-originated actions once the chokepoint enforces the contract, and guarding them again would mask a future caller’s validation bug — the crash, where it exists, is the contract enforcement.

1620 tests green. The position the omission would have liquidated is still standing.


Almost surely, this contribution will converge. 🦀