Skip to content

Make the 01:20 UTC detector safe: gate entries on a confirmed bar, and stop losing the day-stamp - #174

Merged
eaitbrahim merged 4 commits into
mainfrom
fix/live-detector-hardening
Aug 7, 2026
Merged

Make the 01:20 UTC detector safe: gate entries on a confirmed bar, and stop losing the day-stamp#174
eaitbrahim merged 4 commits into
mainfrom
fix/live-detector-hardening

Conversation

@eaitbrahim

Copy link
Copy Markdown
Contributor

Follow-up to #172, from an adversarial review of it. #172 verified the clock-side invariant correctly — 9624 simulated firings, 402 runs on 402 distinct UTC dates, both DST transitions — but it traded a ~13-hour data-publication margin for 20 minutes, and nothing measured whether the data had actually arrived. This PR closes that, plus three ways the day-stamp could be lost.

Nothing is deployed. ~/keel still runs the old local-anchored script, so there is no live exposure today — but a release cut from main as it stands would create it.


Finding 1 (HIGH) — a late candle re-evaluates yesterday's bar, placing a duplicate order

turtle_breakout._completed_days withholds the just-closed daily bar until the 00:00–01:00 UTC hourly bar closes. Verified against the real function, at 01:20 UTC on UTC date X:

feed state effective newest daily bar
both series current X-1 — correct
ONE_HOUR one bar late X-2 — regressed
ONE_DAY not yet stored X-2 — regressed
(at the old 13:05 UTC slot, even 12 bars of hourly lag) X-1 — harmless

X-2 is the bar yesterday's 01:20 UTC cycle already evaluated. Nothing on the live path dedupes an entry — get_open_positions gates exits/reconcile/status but never entry, the signals table is never read back, client_order_id is a fresh uuid4, and the rails are dollar caps, not per-day counters. So that re-evaluation is a duplicate real-money order, not a delayed one.

Why nothing caught it: market_feed.poll_once writes nothing and raises nothing when the venue has no new bar; run_once set last_feed_ts = now_ts unconditionally, so rail 12 — which measures whether the poller ran, not whether data arrived — was blind; and market_feed.is_fresh is measured against the finest configured granularity (FIFTEEN_MINUTE live), never ONE_DAY. The cycle exited 0, the runner stamped the day, and there was no retry.

The fix

data/freshness.py already had expected_last_ts and the bars_behind arithmetic and zero references from agent.py, execution/ or strategy/. New entry_bar_ready() uses them: the gated series must be bars_behind == 0, and every finer configured series must have advanced past that bar's close.

That second condition is _completed_days's own condition stated generically, so the gate cannot read "ready" while _completed_days would still withhold the bar. It is also no stricter than it must be — it only asks that the finer series crossed the boundary, not that it is at its own newest bar, so an hourly series five bars late at 14:20 UTC still confirms fine. A bars_behind == 0 requirement on FIFTEEN_MINUTE would have left only 5 minutes of margin at the 01:20 trigger and blocked entries routinely.

Deliberately not reusing assess()/DEFAULT_TOLERANCE_BARS: that 2-bar tolerance exists so an operator-facing staleness alert does not fire on the normal forming-bar lag. A one-bar-late hourly series is exactly what duplicates an order, and the alert tolerance would wave it through.

Where the gate lives, and why not rail 12

In run_once, not as a rail. Two reasons:

  1. A rail cannot fail the cycle. Rails yield placed=False and a zero exit. The runner would still stamp the day, and the fresh bar would never be evaluated at all — trading a duplicate order for a silently missed day. The gate has to fail the cycle so the runner declines to stamp.
  2. A rail only runs when a signal fires. On the ~99% of days no signal fires, a stale bar would still be stamped as "done", and the real bar would go unevaluated.

Fixing rail 12 instead would be actively dangerous. Its threshold is interval_sec * 3 and it answers "did the poller run". Making last_feed_ts advance only on new data would, in a once-daily deployment, leave it ~24h stale every cycle and veto everything.

Semantics

  • Entries only. Exits still run for every rule, unfiltered — an open position's rule-driven channel exit runs in-process (the protective stop rests at the broker, the channel exit does not), and holding a losing position an extra cycle is strictly worse than a delayed entry.
  • Gated on the granularity the rule trades on. _entry_gate_granularity falls back to the coarsest configured granularity, diverging from engine._trading_granularity's finest-fallback on purpose: Dca declares no timeframe yet reads candles_by_tf[ONE_DAY] directly and keys its cadence off that bar's timestamp, so gating it on FIFTEEN_MINUTE would miss the same hazard entirely.
  • Nonzero exit. Single-cycle keel agent exits DATA_NOT_READY_EXIT (4), so the runner declines to stamp and one of the remaining 23 triggers retries an hour later — duplicate order → ≤60 minutes of delay. --loop is unchanged: it skips the cycle and tries again next interval.
  • Structured log. agent.entry_bar_not_ready names the granularity, expected ts, stored ts, bars_behind, reason and blocking series.

Findings 2/3/4 — three ways the day-stamp could be lost

Finding 2 (HIGH) — a failed stamp write was swallowed. Reproduced: with a read-only logs dir the cycle ran, the stamp was absent, rc was 0, and the next trigger ran a second full cycle. Two layers now:

  • Pre-flight, before keel is invoked at all — probe that the stamp is persistable, and refuse to run a cycle if not. This is the layer that matters. Exiting nonzero after a cycle has run does not prevent the duplicate: the order is already placed. The only way to turn "duplicate real order" into "no trading plus a loud alert" is to refuse to trade when we cannot record that we traded.
  • Atomic write + read-back afterwards, as belt and braces. > "$STAMP" truncates first, so a torn write left an empty stamp — which reads as "never ran" and re-runs the day.

Finding 3 (MED) — empty date -u output disabled the detector permanently. On this hardware $((10#$(date -u '+%H'))) on empty input evaluates to 0, not an error, so an empty clock gave TODAY="", HOUR=0; a missing stamp also reads "", so [ "" = "" ] was true — "already ran", forever, with no alert. The unmodified script literally printed detector already ran this UTC day () -- skipping. Now validated before use (exit 64, nothing run, stamp untouched).

Finding 4 (MED) — the stamp compare was =, so a clock rollback re-evaluated a bar. A Mac booting with a bad RTC before NTP settles (RunAtLoad fires immediately) reads a past date, which != treats as "not today". Now strictly-less-than, making the stamp monotonic. A malformed stamp is refused (exit 65) rather than compared — "garbage" < "2026-08-06" is false and would read as "already ran" forever.

Also: the "N signal(s) PENDING — run the agent interactively" notification now fires only on a clean cycle. Per the script's own COROLLARY, running the agent by hand bypasses the stamp, so prompting for it off a failed cycle's partially-parsed output pointed the operator straight at a duplicate entry. An ordinary nonzero keel exit deliberately does not notify — it is expected and self-healing, and alerting 23 times a day would train the operator to ignore the alerts that do need a human. The policy is written into the header, along with exit codes 64/65/66.

Finding 7 — "no missed day" was stated flatly in the header and plist; it is conditional on the machine being powered on for at least one eligible trigger that UTC day. Both now say so.

Finding 10 — the PENDLOG line logged local, unlabelled time next to lines that are all UTC.

Finding 8 (flock) — documented, not fixed. flock is not installed on macOS (verified), launchd will not start a job already running, and the race that actually matters is a manual run, which bypasses the stamp regardless of any lock.


Finding 6 — the tests were testing a model, not the artifact

_run_gate re-implemented the shell gate in Python, so the year-long simulation proved nothing about the shipped script. Worse, _skip_before_sched_hour made the only two real-shell tests silently skip whenever CI ran between 00:00 and 01:00 UTC — on the sole barrier to duplicate orders.

  • The skip is gone. Every real-script test now shims date on PATH and injects its own instant.
  • The model is pinned to the artifact. Driving the real script over all 9624 triggers would add ~2.5 minutes to a 10-second suite, so the model is kept — but test_the_simulated_gate_matches_the_real_script replays the real script over a curated adversarial sequence (normal days, the sub-SCHED_HOUR trigger, both DST transitions including the twice-fired local 01:20, and the boot-after-outage catch-up) and requires it to agree with the model at every trigger.
  • _stored_series no longer hardcodes instantaneous, never-failing publication — the assumption that made this whole bug class invisible. It takes hourly_lag_bars/daily_lag_bars, and a new test pins the premise against the real _completed_days.

Verification

Every new test was confirmed to fail for the right reason before the fix.

  • Neutering only the entry gate (engine.evaluate(ready_rules, …)product_rules) makes all 5 new agent/CLI tests fail, with the turtle rule emitting a real ENTER Signal on ts=777600 — bar X-2, the already-traded bar.
  • Running the new schedule suite against the unmodified script: 10 failed, 21 passed, including rc=0 despite a "Permission denied" stamp write, and detector already ran this UTC day () -- skipping on an empty clock.

Independently re-verified end-to-end in a sandboxed copy of the script (live path asserted absent):

scenario cycles run rc notified
empty date -u 0 64 yes
read-only logs dir 0 (was 2, rc=0) 66 yes
happy path 1 0
second trigger, same UTC day 1 0
clock rolled back 200 days 1 0
clock corrected 1 0
next UTC day 2 0
corrupt stamp 0 65 yes
failed cycle printing signals=2 1 7 no PENDING prompt, no stamp

Stamp never truncated: forced write failure left yesterday's 2026-06-14 intact.

Gate: ruff clean · mypy clean (94 files) · 1953 passed (from 1924 on main).

Suite time went 10.3s → 19.5s. ~2.4s of that is the differential test; the rest is the 11 per-property real-script tests. That is the honest price of Finding 6 — the alternative is deleting artifact-level coverage of the only barrier against duplicate real-money orders.

What this does not do

It is not an entry dedupe. The day-stamp remains the only thing preventing two cycles in one UTC day from entering twice, and the two characterization tests in tests/test_agent.py still pin that hazard (they are unaffected — their config declares only ONE_DAY, so there is no finer series to confirm against). This PR removes the specific way a late candle could make the stamp's "one run per UTC day" guarantee re-evaluate an already-traded bar.

🤖 Generated with Claude Code

eaitbrahim and others added 3 commits August 6, 2026 19:03
…d feed

Anchoring the live detector to 01:20 UTC (#172) cut ~13h of lag, but it also cut the
data-publication margin from ~13 hours to 20 minutes -- and nothing measured whether the bar the
rule decides on had actually arrived.

THE BUG. `turtle_breakout._completed_days` withholds the just-closed daily bar until the
00:00-01:00 UTC hourly bar has closed. At 01:20 UTC on UTC date X, verified against the real
function:

    both series current       -> effective newest daily bar = X-1   (correct)
    ONE_HOUR one bar late     -> effective newest daily bar = X-2   (REGRESSED)
    ONE_DAY not yet stored    -> effective newest daily bar = X-2   (REGRESSED)

X-2 is the bar YESTERDAY's 01:20 UTC cycle already evaluated. So a late candle makes the cycle
re-enter a bar it has already traded. Nothing on the live path dedupes an ENTRY --
`get_open_positions` gates exits/reconcile/status but never entry, the `signals` table is never
read back, `client_order_id` is a fresh uuid4, and the rails are DOLLAR caps, not per-day
counters -- so that re-evaluation is a DUPLICATE REAL-MONEY ORDER, not a delayed one. At the old
13:05 UTC schedule even twelve hours of hourly lag was harmless; at 01:20 UTC one bar is not.

WHY NOTHING CAUGHT IT. `market_feed.poll_once` writes nothing and raises nothing when the venue
has no new bar. `run_once` sets `last_feed_ts = now_ts` unconditionally, so rail 12 -- which
measures whether the POLLER ran, not whether DATA ARRIVED -- is blind to it. And
`market_feed.is_fresh` is measured against the FINEST configured granularity (FIFTEEN_MINUTE
live), never against ONE_DAY. The cycle exited 0, the runner stamped the day, and there was no
retry.

THE FIX. `data/freshness.py` already had `expected_last_ts` and the `bars_behind` arithmetic but
had zero references from `agent.py`, `execution/` or `strategy/`. New `entry_bar_ready()` uses
them: the gated series must be `bars_behind == 0`, AND every FINER configured series must have
advanced past that bar's CLOSE. That second condition is `_completed_days`'s own condition
stated generically, so the gate cannot read "ready" while `_completed_days` would still withhold
the bar -- and it is no stricter than it has to be, since it only asks that the finer series
crossed the boundary, not that it is at its own newest bar (an hourly series five bars late at
14:20 UTC still confirms fine).

Deliberately NOT reusing `assess()`/`DEFAULT_TOLERANCE_BARS`: that two-bar tolerance exists so
an operator-facing staleness ALERT does not fire on the normal forming-bar lag. A one-bar-late
hourly series is precisely the condition that duplicates an order, and the alert tolerance would
wave it through.

ENTRIES ONLY. Exits still run for every rule, unfiltered: an open position's rule-driven channel
exit runs in-process (the protective stop rests at the broker, the channel exit does not), and
holding a losing position an extra cycle is strictly worse than a delayed entry.

WHY NOT FIX RAIL 12 INSTEAD. Rail 12's threshold is `interval_sec * 3`, and it answers "did the
poller run". Making `last_feed_ts` advance only on new data would, in a once-daily deployment,
leave it ~24h stale every single cycle and veto everything. Rail 12 is also a per-ORDER veto:
it yields `placed=False` and a ZERO exit, so the runner would still stamp the day and the fresh
bar would never be evaluated at all -- trading a duplicate order for a silently missed day.
The gate therefore lives in `run_once`, where it can fail the CYCLE.

Single-cycle `keel agent` now exits `DATA_NOT_READY_EXIT` (4) when an entry was withheld, so
`keel-live-run.sh` declines to stamp and one of the remaining 23 hourly triggers retries an hour
later -- converting "duplicate order" into "<= 60 minutes of delay". `--loop` is deliberately
unchanged: it skips the cycle and tries again next interval.

`_entry_gate_granularity` falls back to the COARSEST configured granularity, diverging from
`engine._trading_granularity`'s finest-fallback on purpose: `Dca` declares no timeframe yet
reads `candles_by_tf[ONE_DAY]` directly and keys its cadence off that bar's timestamp, so
gating it on FIFTEEN_MINUTE would miss the same hazard entirely.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…icate

The day-stamp in keel-live-run.sh is the ONLY thing standing between the live money path and
entering the same daily signal twice. With 24 triggers a day, one lost stamp is up to 23
duplicate entries in one UTC day. Four ways it could be lost, all reproduced before fixing:

FINDING 2 (HIGH) -- a failed stamp write was swallowed. `printf ... > "$STAMP"` discarded its
exit status, there is no `set -e`, and the script exited 0. With a read-only logs directory the
cycle RAN, the stamp was ABSENT, rc was 0, and the next trigger ran a SECOND full cycle
(reproduced: two cycles, rc=0 both times). Two layers now:

  * PRE-FLIGHT, before keel is invoked at all: write/read/remove a probe file next to the stamp
    and refuse to run a cycle if that fails. This is the layer that matters. Exiting nonzero
    AFTER a cycle has run does not prevent the duplicate -- the order is already placed and the
    next trigger still re-runs. The only way to turn "duplicate real order" into "no trading
    plus a loud alert" is to refuse to trade when we cannot record that we traded. Failing
    closed costs a trading day; a duplicate live entry is not recoverable.
  * ATOMIC WRITE + read-back afterwards, as belt and braces. `> "$STAMP"` truncates FIRST, so a
    torn write left an EMPTY stamp, which reads as "never ran" and re-runs the day; a temp file
    plus `mv -f` cannot leave the stamp truncated or partial.

FINDING 3 (MED) -- empty `date -u` output disabled the detector silently and permanently. On
this hardware `$((10#$(date -u '+%H')))` on empty input evaluates to 0, not an error, so an
empty clock gave TODAY="" and HOUR=0; a MISSING stamp also reads as "", so `[ "" = "" ]` was
true -- "already ran" -- forever, with no alert. The raw clock strings are now validated before
anything is computed from them (exit 64, nothing run, stamp untouched).

FINDING 4 (MED) -- the stamp compare was `=`, so a clock rollback re-evaluated a bar. A Mac
booting with a bad RTC before NTP settles (RunAtLoad fires immediately) reads a past date, which
`!=` treats as "not today": the cycle runs and stamps the bogus date, and when the clock
corrects forward the real date differs from that stamp so it runs AGAIN. The compare is now
strictly-less-than, which makes the stamp monotonic -- ISO dates sort lexicographically, so a
string compare is a date compare. A malformed stamp is refused (exit 65) rather than compared,
because `"garbage" < "2026-08-06"` is false and would read as "already ran" forever.

Also: the "N signal(s) PENDING -- run the agent interactively" notification now fires ONLY on a
clean cycle. Per this script's own COROLLARY, running the agent by hand BYPASSES the stamp, so
prompting for it off a FAILED cycle's partially-parsed output pointed the operator straight at a
duplicate entry. An ordinary nonzero keel exit deliberately does NOT notify -- it is expected and
self-healing (a retry an hour later), and alerting 23 times a day for it would train the operator
to ignore the alerts that do need a human. That policy is written down in the header.

FINDING 7 -- "no missed day" was stated flatly in both the header and the plist. It is
conditional on the machine being powered on for at least one eligible trigger that UTC day;
launchd does not re-run a trigger that passed while the machine was off. Both now say so.

FINDING 10 -- the PENDLOG line logged LOCAL, unlabelled time next to lines that are all UTC.

FINDING 8 (flock) -- documented, not fixed: flock is not installed on macOS (verified), launchd
will not start a job already running, and the race that actually matters is a manual run, which
bypasses the stamp regardless of any lock.

FINDING 6 (test quality) -- tests/test_schedule.py drove a Python RE-IMPLEMENTATION of the shell
gate, so the year-long simulation tested a model, not the artifact; and `_skip_before_sched_hour`
made the only two real-shell tests SILENTLY SKIP whenever CI ran between 00:00 and 01:00 UTC --
on the sole barrier to duplicate orders. The skip is gone: every real-script test now shims
`date` on PATH and injects its own instant. The model is kept (driving the real script over
9624 triggers would add ~2.5 minutes to a 10-second suite) but is now PINNED to the artifact by
test_the_simulated_gate_matches_the_real_script, which replays the real script over a curated
adversarial sequence -- normal days, the sub-SCHED_HOUR trigger, both DST transitions including
the twice-fired local 01:20, and the boot-after-outage catch-up -- and requires it to agree with
the model at every trigger.

`_stored_series` also hardcoded instantaneous, never-failing candle publication, which is the
assumption that made the late-candle bug class invisible. It now takes `hourly_lag_bars` /
`daily_lag_bars`, and a new test pins the premise against the real `_completed_days`: at 01:20
UTC one bar of hourly lag regresses the effective daily bar to X-2 -- the bar yesterday's cycle
already traded -- while at the old 13:05 UTC schedule the same lag is harmless. That is the
20-minute margin this schedule now runs on, and why agent.run_once gates entries on it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…rt a stuck detector

Three defects found by an adversarial review of this PR. The first is critical and was mine.

CRITICAL -- the entry gate placed orders and THEN failed the cycle, so the retry duplicated them.
The gate was a per-RULE filter inside the per-PRODUCT loop, and `engine.evaluate` +
`executor.execute` ran inside that same iteration. `blocked_entries` was only RECORDED, never
consulted before execution. So a product examined earlier had already placed real orders by the
time a later product's rule was found not ready. The cycle then exited 4, the runner correctly
declined to stamp, and the next hourly trigger re-ran the WHOLE cycle against the same daily bar
-- re-placing everything that had already traded:

    01:20  blocked=1 (XLM turtle)  -> exit 4 -> no stamp    BTC BUY orders: 1  ($50 DCA)
    02:20  blocked=0                                        BTC BUY orders: 2  <-- DUPLICATE
    03:20  vetoed by per_asset_concentration_cap            (the rails catch only the THIRD)

This is the modal shape, not an edge case: `poll_once` fetches per `(product, granularity)` and
`candles_by_tf` is per-product, so "P has published, Q has not" is exactly the condition at :20
past the hour, and the BTC DCA fires deterministically every 7 days. keel-live-run.sh's own
header states the principle this violated -- "Exiting nonzero AFTER a cycle has already run does
not prevent that duplicate ... refuse to trade when we cannot record that we traded".

`run_once` is now two passes. A PRE-PASS over every (product, rule) pair computes readiness and
`entries_allowed = not blocked_entries` BEFORE any execution; the MAIN PASS runs exits for every
non-stale product unconditionally, and evaluates entries only when `entries_allowed`. Entry
admission has to be atomic with respect to the cycle's exit status, because that status is a
single bit the runner uses to decide whether to stamp the UTC day -- a partially-executed cycle
plus a nonzero exit is the worst of both. The cost, stated plainly: one lagging product now
delays EVERY entry that cycle, including entries whose own data was fresh. That is the intended
trade -- a delayed entry is recoverable within <= 60 minutes by the next trigger, a duplicate
live entry is not -- and it is what makes the retry IDEMPOTENT, which is what gives "<= 60
minutes of delay" its meaning.

The stale-product skip deliberately still runs FIRST, so a dead venue or a delisted product is
skipped as stale before the freshness gate sees it and cannot halt the whole cycle's entries.

MED, a regression this PR introduced -- the monotonic compare turned a FORWARD clock excursion
into a permanent silent outage. Making the compare `<` fixed rollback and broke roll-forward:

    2026-08-06 01:20   runs, stamps 2026-08-06
    2035-01-01 03:20   (bad RTC before NTP, RunAtLoad) runs, stamps 2035-01-01
    2026-08-07..11     "stamp is not before today -- skipping"  rc=0, no alert, until 2035

Under the old `=` compare this self-healed the next day. A stamp strictly AHEAD of today is
corrupt state, not an ordinary "already ran", so it now notifies and exits 67 -- distinct from 65
(not a date at all) so an operator can tell them apart. A stamp EQUAL to today stays a silent
exit 0, so the common path does not start over-alerting. Rollback protection survives: the cycle
still does not re-run, it now alerts instead of skipping silently.

`tests/test_schedule.py` asserted in prose that `<` and `=` "diverge only under a clock
rollback". That claim was false -- they also diverge on a forward excursion -- and the false
claim is exactly why no test covered this. Corrected, and the differential test's curated
sequence now includes a forward jump.

MED -- nothing ever escalated a stuck detector. A nonzero keel exit deliberately does not notify
(it is normally a self-healing publication lag), but that meant 23 consecutive failing triggers
produced ZERO notifications, and a stuck detector was indistinguishable from a quiet day with no
signals. That matters more now that ANY unconfirmed product withholds the whole cycle. A
consecutive-failure counter next to the stamp now alerts at 3 in a row and every 3 after,
resetting on a verified clean stamp. It is guarded so a bug in it degrades to "stops escalating",
never to "stops trading" or "hides that a cycle failed".

Also: trimmed an overstated claim in tests/test_rule_manifest.py. It asserts the COMMITTED
manifest, not a deployment DB, so it catches a reseeded box's state being COMMITTED, not the
reseed itself. `rule_manifest.py apply` is what catches that live.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@eaitbrahim

Copy link
Copy Markdown
Contributor Author

Review round 2 — CRITICAL + regression fixed (bdef825)

Both defects confirmed and reproduced before fixing. The critical one was mine, and the review's diagnosis was exactly right.

CRITICAL — the gate placed orders and then failed the cycle

Confirmed in code: ready_rules was built per-product inside the loop, and engine.evaluate/executor.execute ran in that same iteration, so a product examined earlier had already placed real orders by the time a later product's rule was found not ready. blocked_entries was recorded, never consulted before execution.

run_once is now two passes: a pre-pass over every (product, rule) pair computes entries_allowed = not blocked_entries before any execution; the main pass runs exits for every non-stale product unconditionally and evaluates entries only when entries_allowed.

The stale-product skip deliberately still runs first, so a dead venue or delisting is skipped as stale before the gate sees it and cannot halt the cycle — that ordering is pinned by a new test.

Semantic change, documented in the code and stated plainly: one lagging product now delays every entry that cycle, including entries whose own data was fresh. That is the intended trade — a delayed entry is recoverable within ≤60 minutes, a duplicate live entry is not — and it is what makes the retry idempotent, which is what gives "≤60 minutes of delay" its meaning.

Verified by neutering only the admission bit (entries_allowed = True): 8 tests go red, including

AssertionError: BTC-USD's DCA entry was evaluated even though XLM-USD's was blocked this cycle:
  [Signal(rule_name='dca', product_id='BTC-USD', action=ENTER, side=BUY, ..., ts=864000),
   Signal(rule_name='dca', product_id='XLM-USD', action=ENTER, side=BUY, ..., ts=777600)]

— a real BTC BUY evaluated alongside a non-empty blocked_entries, i.e. the exact XLM/BTC-DCA shape from the review. All 8 restore green.

MED — forward clock excursion (a regression this PR introduced)

Reproduced through this PR's own sandbox harness: stamp 2035-01-01, then five consecutive real days at rc=0 with zero notifications. The old = compare self-healed the next day; the monotonic compare did not.

A stamp strictly ahead of today is corrupt state, not "already ran" → notify + exit 67 (distinct from 65 so an operator can tell "future stamp" from "not a date"). A stamp equal to today stays a silent rc=0 so the common path doesn't start over-alerting.

Independently re-verified:

sequence rc alerts
2026-08-06 01:20 0 0 (stamps 2026-08-06)
2035-01-01 03:20 0 0 (stamps 2035-01-01)
2026-08-07/08/09 67 each 1 each
same-UTC-day repeat 0 0 — control
rollback to 2026-02-01 67 1, no new cycle, stamp stays 2026-08-06

The stamp is never overwritten on the 67 path.

The false prose claim that < and = "diverge only under a clock rollback" is corrected — that claim is why nothing caught this — and the differential test's sequence now includes a forward jump.

MED — escalation on a stuck detector

This matters more now that any unconfirmed product withholds the whole cycle. Counter next to the stamp, guarded so a bug in it degrades to "stops escalating", never "stops trading":

failure 1 -> rc=4  alerts=0  counter=1
failure 2 -> rc=4  alerts=0  counter=2
failure 3 -> rc=4  alerts=1  counter=3     <- escalates
failure 4,5 -> rc=4 alerts=1  counter=4,5
failure 6 -> rc=4  alerts=2  counter=6     <- and every 3 after
clean cycle -> counter reset; next single failure silent again

Stamp never written during failures.

Also

Trimmed the manifest test's overstated claim: it asserts the committed file, not a deployment DB, so it catches a reseeded box's state being committed, not the reseed itself. rule_manifest.py apply is what catches that live.

Not addressed, deliberately

_entry_gate_granularity returning an unpolled granularity is now bounded rather than closed: it is unreachable in today's config (every rule gates on ONE_DAY, which is polled), and the escalation alert above means it can no longer fail silently — it would alert after 3 triggers instead of burning all 23 in silence. A startup validation would close it properly; I did not add one because it is a config-shape check that belongs with config loading, not in the cycle, and I did not want to widen this PR further after a critical miss.

Gate

ruff clean · mypy clean (94 files) · 1961 passed · bash -n clean · plist parses under strict XML. Live deployment untouched.

🤖 Generated with Claude Code

…e cycle, kill the mutants

Round-3 review. One MEDIUM with a money consequence, one test that asserted a discrimination it
did not make, and four fixes no test could kill.

MEDIUM -- the pre-flight proved the DIRECTORY was writable, not that $STAMP was REPLACEABLE. It
probed `$STAMP.preflight.$$`, a DIFFERENT path. With $STAMP made a DIRECTORY (a botched restore,
a `mkdir` typo) or marked immutable, the probe PASSED, keel RAN AND PLACED ORDERS, and only the
post-cycle `mv -f`/readback -- which actually targets $STAMP -- then failed. Reproduced over five
triggers in one UTC day:

    exit codes: 66 66 66 66 66
    CYCLES RUN: 5      <- each one places real orders

That is the exact "exiting nonzero AFTER a cycle has already run does not prevent the duplicate"
pattern the pre-flight's own comment rejects, and the prose claim "prove the stamp is
PERSISTABLE" was simply false. Two layers now:

  * the pre-flight ROUND-TRIPS through $STAMP itself -- write a temp file, `mv -f` it ONTO
    $STAMP, read $STAMP back -- using the stamp's own validated contents as the payload so a
    pass is a semantic no-op, and removing the stamp afterwards when there was none. Same
    reproduction now gives 66 x5 with ZERO cycles run. A stamp at mode 000 deliberately still
    works: a rename needs directory permission, not permission on the target inode.
  * a HALT SENTINEL for the residual window, where the stamp becomes unreplaceable BETWEEN a
    passing pre-flight and the post-cycle write. In that case a cycle has already run and may
    have placed orders, so the script drops a sentinel and every subsequent trigger refuses with
    exit 68 until a human clears it. That bounds the damage to the ONE cycle that already ran
    instead of 23. A lost trading day is recoverable; a duplicate live entry is not.

MEDIUM -- the atomic-write test did not test atomicity, and its docstring said it did. It forced
failure with `chflags uchg`, which fails at open(2) BEFORE truncation, so a plain `>` preserved
yesterday's stamp identically. Verified: replacing the whole temp+mv+readback block with a single
truncating `printf > "$STAMP"` left ALL 34 tests green. This is the same class of miss that let
the previous round's regression through -- a docstring asserting a discrimination the test does
not make -- so it is fixed on both sides: the behavioural test's docstring now states only what
it proves and explains why a behavioural test CANNOT discriminate here, and a new source-level
test pins the design (no bare truncating redirect onto $STAMP anywhere; temp path; `mv -f` onto
$STAMP; readback against $TODAY).

Every fix in this PR that a test could not kill is now pinned, each verified by performing the
mutation and watching a test go red:
  * plain `>` instead of temp+mv                  -> red
  * readback verification dropped                 -> red
  * `mv -f` replaced with `cp -f`                 -> red
  * hour-range check (`-gt 23`) -> `if false`     -> red
  * read_failcount numeric validation removed     -> red (5 of 6 parametrised cases)
  * exit-65 malformed-stamp branch removed        -> red, via a stamp that sorts BELOW today
    (`1999-1-1`); the previous test used one that sorts ABOVE and so fell into the 67 branch,
    which also refuses -- so the branch was load-bearing but the test was not
  * pre-flight round-trip removed                 -> red, via an IMMUTABLE regular-file stamp
  * halt sentinel gate removed                    -> red

Every `returncode != 0` assertion is now an exact code, so the operator-facing 64/65/66/67/68
distinctions the header argues for are actually pinned rather than merely asserted in prose.

Deliberately left redundant: the pre-flight's "exists but is not a regular file" check is not
individually killable, because the round-trip alone already detects a directory (verified: exit
66, zero cycles). It is kept for a clearer operator message and to stop `mv` littering a probe
file inside that directory, and is documented as belt-and-braces rather than as a sole detector.

Also, from the same review: `agent.run_once`'s comment claimed a blocked cycle places nothing. It
does not -- exits are exempt and can place a real SELL in the very cycle that withheld every
entry. Comment corrected, and the retry's exit-idempotence is now pinned by a test.

That test corrected the review's stated mechanism rather than confirming it. The claim was that
`_handle_exits` clearing `position_rule:<product>` is what stops a retry duplicating an exit.
Mutation says otherwise: removing that clear alone leaves the suite GREEN. An exit is a market
order, so its SELL is recorded `filled` immediately, and the audit-log qty netting in
`_held_position` -- implemented independently in BOTH `agent.py` and `execution/executor.py` --
already reads the position as closed on the retry, before `position_rule` is consulted. Only
breaking all three at once produces the duplicate SELL. The mechanism is triple-redundant;
`position_rule` clearing is load-bearing for something else entirely (stale bracket state
poisoning the NEXT position on that product), which is documented at its own call site.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@eaitbrahim

Copy link
Copy Markdown
Contributor Author

Review round 3 — all four items closed (68c2db6)

Both substantive claims reproduced independently before fixing. One item's stated mechanism turned out to be wrong, and I've corrected it rather than confirmed it — details below.

1 (MEDIUM) — pre-flight now proves $STAMP is REPLACEABLE

Reproduced exactly as reported, with $STAMP made a directory:

exit codes: 66 66 66 66 66
CYCLES RUN: 5      <- each one places real orders

Two layers:

  • Round-trip through $STAMP itself — write a temp file, mv -f it onto $STAMP, read back. Payload is the stamp's own validated contents, so a pass is a semantic no-op; when no stamp exists the probe is removed so the day stays unstamped. Same reproduction now gives 66 ×5 with 0 cycles run. Mode-000 still works (a rename needs directory permission, not permission on the target inode) and is pinned.
  • Halt sentinel for the residual window where the stamp breaks between a passing pre-flight and the write. A cycle has already run there, so every later trigger refuses with exit 68 until a human clears it — bounding damage to the one cycle that ran instead of 23.

2 (MEDIUM) — the atomicity test now discriminates

Confirmed the mutation myself: collapsing the whole temp+mv -f+readback block to a single truncating printf > "$STAMP" left all 34 tests green. Fixed on both sides — the behavioural test's docstring now claims only what it proves and explains why a behavioural test can't discriminate here (uchg fails at open(2), before truncation), plus a new source-level test pins the design.

4 (LOW) — exit codes and the three unkilled fixes

Every returncode != 0 assertion is now an exact code. The exit-65 point was real: the old test used a malformed stamp sorting above today, which lands in the 67 branch anyway. The discriminating case is one sorting below today (1999-1-1) — without the 65 branch that reads as "older than today" and runs a cycle.

Mutation ledger — every fix verified red

mutation result
plain > instead of temp+mv red
readback dropped red
mv -fcp -f red
hour-range -gt 23if false red
read_failcount validation removed red (5/6 params)
exit-65 branch removed red
pre-flight round-trip removed red
halt-sentinel gate removed red
both pre-flight guards removed red

One deliberate survivor, disclosed: the "exists but is not a regular file" check is not individually killable — I verified the round-trip alone already detects a directory (exit 66, zero cycles). It's kept for a clearer operator message and to stop mv littering a probe file inside that directory, and is documented as belt-and-braces rather than a sole detector. I chose that over inventing a contrived test to manufacture a red.

3 (LOW-MED) — comment fixed, and the stated mechanism was wrong

The comment is corrected: a withheld cycle withholds entries only and can place a real SELL.

But the claim that position_rule:<product> clearing is what makes the exit retry idempotent does not survive mutation. Removing that clear alone leaves the suite green. The real guard is the audit-log qty netting in _held_position, implemented independently in both agent.py and execution/executor.py — an exit is a market order, so its SELL is recorded filled immediately and the position already reads as closed on retry, before position_rule is consulted.

Only breaking all three together produces the duplicate — verified, a second SELL (order id 3, same qty and fill). So the retry is safe, and more redundantly than believed, but not for the stated reason. position_rule clearing is load-bearing for something else (stale bracket state poisoning the next position on that product), documented at its own call site. The test records the corrected mechanism rather than the assumed one.

Scope

Dropped the unrelated test_rule_manifest.py comment from 10 lines to 3.

Gate

ruff clean · mypy clean (94 files) · 1976 passed · bash -n clean · live deployment untouched.

Suite 10.3s → 33s. The growth is real-script subprocess invocations; the alternative is deleting artifact-level coverage of the barrier against duplicate real-money orders.

🤖 Generated with Claude Code

@eaitbrahim
eaitbrahim merged commit ada5dda into main Aug 7, 2026
1 check failed
@eaitbrahim
eaitbrahim deleted the fix/live-detector-hardening branch August 7, 2026 04:19
eaitbrahim added a commit that referenced this pull request Aug 7, 2026
… on Linux

`main` went red the moment #174 landed. The new real-script tests execute
the shipped `keel-live-run.sh` under `/usr/bin/sandbox-exec` (so a test run
can never fire a real notification on a machine that also trades real
money), with a `date` shim built on BSD `date -r` and stamp-failure cases
built on `chflags uchg`. None of those exist on Linux, so all 45
invocations died with FileNotFoundError on the ubuntu runner.

This is not a coverage question worth solving portably. The artifact under
test IS a macOS deployment -- launchd plus `osascript` -- and the script
only ever runs on the box that owns the launchd job. So on non-Darwin the
real-script tests SKIP, and the schedule INVARIANT (exactly one cycle per
UTC date, across both DST transitions) stays covered everywhere, because
that is proven by the pure-Python model tests, which are platform-neutral.

One runtime guard in `_run_script` covers all 45 call sites, since every
invocation already funnels through it. The five tests that reach for
`chflags` BEFORE running the script carry an explicit `@_macos_only` mark,
where the runtime guard would come too late to stop a FileNotFoundError.

Blocks the release otherwise: `release.yml` refuses to publish unless tests
pass, so 0.5.2 could not have been cut with main in this state.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
eaitbrahim added a commit that referenced this pull request Aug 7, 2026
* chore: bump to 0.5.2 for the UTC detector retiming and its entry gate

Patch: no schema change, no new capability. #172 retimed the live detector
to 01:20 UTC (cutting ~12h of detection lag), #174 made that safe, and #169
downgraded an unreachable venue from an ERROR traceback to a warning.

#174 is the reason this release exists rather than #172 alone. The retiming
trades a 13-hour data-publication margin for 20 minutes, and nothing on the
live entry path dedupes an order -- so the freshness gate in `run_once` that
withholds ALL entries when any bar is unconfirmed has to ship in the SAME
wheel as the schedule it protects. Installing this wheel is a prerequisite
for copying the new keel-live-run.sh/com.keel.live.plist to the deployment;
the shell files alone would be the unsafe half.

uv.lock relocked in the SAME commit, per 0.5.0 and 0.5.1. Verified with
`uv sync --frozen`, which accepted the lock and rewrote nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(tests): skip the macOS-only real-script tests instead of erroring on Linux

`main` went red the moment #174 landed. The new real-script tests execute
the shipped `keel-live-run.sh` under `/usr/bin/sandbox-exec` (so a test run
can never fire a real notification on a machine that also trades real
money), with a `date` shim built on BSD `date -r` and stamp-failure cases
built on `chflags uchg`. None of those exist on Linux, so all 45
invocations died with FileNotFoundError on the ubuntu runner.

This is not a coverage question worth solving portably. The artifact under
test IS a macOS deployment -- launchd plus `osascript` -- and the script
only ever runs on the box that owns the launchd job. So on non-Darwin the
real-script tests SKIP, and the schedule INVARIANT (exactly one cycle per
UTC date, across both DST transitions) stays covered everywhere, because
that is proven by the pure-Python model tests, which are platform-neutral.

One runtime guard in `_run_script` covers all 45 call sites, since every
invocation already funnels through it. The five tests that reach for
`chflags` BEFORE running the script carry an explicit `@_macos_only` mark,
where the runtime guard would come too late to stop a FileNotFoundError.

Blocks the release otherwise: `release.yml` refuses to publish unless tests
pass, so 0.5.2 could not have been cut with main in this state.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant