# FOS-3 — the new exit-4 escalation can be masked by yesterday's failed BUY, and its only test reads the source as text: the one exposure-left-on alarm has two ways to stay silent

status: open · raised panel run 21 (2026-09-11, change-review catch on PR #154 — does NOT count toward the frontier cadence) · class: loudness & safety (G2, but the exit-4 path is trading-contract, not observability) · effort ~0.75 pd · horizon: live daily — the AVB streak proves failed de-risking orders are not hypothetical

---

**Plain-language summary for an owner reading one paragraph.** Last week's
IGD-1 leg-3 ruling built the escalation this shop had been missing: `thales
run` now exits 4 — redding CI — when a de-risking sell fails for a symbol
that was NOT already failing, so a NEW stuck exit pages instead of joining
the furniture. Two defects in the build, found by this run's change review.
First, "already failing" is side-blind: if symbol X had a failed **BUY**
yesterday (a symbol halt or one broker-wide buying-power rejection day
poisons every symbol at once) and X's **SELL** fails today for the first
time, today's exposure-left-on event exits 0 and lands only in the digest —
the ruling's letter is met, its rationale (a failed buy leaves no exposure;
the sell does) is not. Second, the only test pinning the escalation reads
`cli.py` AS TEXT and asserts substrings — it fails if the code is deleted,
but passes if the code is present and neutered (an exception before the
raise, a later try-wrap, reordering that makes it unreachable). The single
alarm for "the book cannot get out of a name" is guarded by a detector that
cannot fail in its primary failure mode.

## Mechanism — ground truth verified this run at HEAD `fa55f0d`

- `src/thales/execution/failed_streaks.py:73-78` — `failed_symbols_on`
  returns every failed symbol for the prior session with **no side
  condition**; `fresh_derisking_failures` (:111-116) filters today's
  de-risking failures on `symbol not in failed_prev_session`. A prior-day
  failed buy therefore suppresses a first-ever failed sell. The existing
  test (:71 region) only exercises a sell-history `prev_failed` — the
  buy-masking case was never considered. Contrast: the unreadable-history
  fallback (`cli.py:1152-1156`) correctly fails safe (unknown → all NEW),
  so the side-blindness is an oversight in otherwise fail-safe-aware code.
- `tests/test_execution/test_failed_streaks.py:83-92` — reads
  `src/thales/cli.py` as a string and asserts substrings between
  `"Orders FAILED:"` and `"Exit 4: state is persisted"`. Its own docstring
  admits why ("the run command builds a live broker"). A blanket
  `try/except` around `cli.py:1143-1176`, an exception between computing
  `fresh` (:1157) and `raise typer.Exit(4)` (:1176), or reordering all
  pass green. The behavioral pieces of `failed_streaks.py` ARE tested;
  the escalation wiring is not.
- The converse defect direction errs loud, not silent — a HALT day (no
  orders attempted) resets `prev_failed`, so a standing streak
  re-escalates as NEW after any halt — and is accepted as-is.

## Fix shape (propose-only)

Make the masking set side-aware: only prior **de-risking** failures mask
(at `failed_symbols_on` or its call site). Build a thin injectable-broker
harness for `cli.run` (SimulatedBroker + tmp state dir + Typer CliRunner —
`src/thales/execution/simulated.py` already exists, so this is real work,
not a wish) and **delete** the text-grep test.

## Pre-registered kill criterion / done-when

Two behavioral tests, both red today:
1. Symbol X fails a BUY on session N−1 and a first-ever SELL on session N
   → run exits **4** (currently 0).
2. CliRunner-invoked `thales run` with an injected broker exits 4 on a
   fresh de-risking failure, 0 on a standing one, and treats unreadable
   history as all-NEW — verified by mutation: wrapping the escalation
   block in `try/except` or removing the raise must red the suite.

Done when the text-grep test **no longer exists** and the behavioral tests
run in the pre-trade gate (exit-4 is the run command's contract, not
observability). Keeping the grep test "alongside" does not satisfy this
row.
