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. AndPortfolio.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 fabricatedpct: 0sitting 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):
- pct required on non-hold actions. A buy/sell without
pctdrops as a malformed element, siblings survive. - 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 ...). - 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. 🦀