feat(status): surface rail-17 attestation expiry before it vetoes, and a rules enable verb (#340) - #345
Merged
Merged
Conversation
…d a rules enable verb (#340)
… will not run; corrupt-state read guarded; UNKNOWN corner kept word-for-word
eaitbrahim
added a commit
that referenced
this pull request
Aug 19, 2026
…ession semantics in the engine (#370 B1) (#385) * feat(port): session-aware venues — market clock in the port, closed-session semantics in the engine (#370 B1) FR-9 (PRD §3, keel-broker-alpaca): equities are not 24/7. Until now the feed-staleness rails and the agent's cycle semantics assumed a 24/7 venue, so a weekend or market holiday would report a closed equities feed as STALE (the crypto semantics false-positiving) and cycles would log staleness-gated noise. The port gains a first-class session concept: - `BrokerCapabilities.session_bound: bool` — REQUIRED, not defaulted. Crypto venues (fake/coinbase/robinhood) declare False; alpaca declares True. A default would be an answer to a question only the venue knows, and the likeliest default (24/7) is exactly the one that reads a closed equities venue as a stale feed. - `Broker.market_clock() -> SessionState` (`open` | `closed` | `clock_unavailable`). The alpaca implementation REUSES Phase A's `/v2/clock` read (`is_market_open()` now delegates to it, so the two can never disagree); the 24/7 adapters answer `OPEN` as a constant with no network call (proven by the no-transport adapter tests). - The conformance suite holds all four adapters to the new surface. Fail-closed clock rule: a session-bound venue's clock that cannot be read (transport error, missing transport, no clock in the response) answers `clock_unavailable` — never an exception, never a guess of open. The engine belt-and-braces the same way: a third-party adapter that raises instead of answering still fails closed; the cycle never crashes on a clock read. Engine consumption (`keel/agent.py::run_once`): - The session gate sits directly after the kill-switch and skips EXACTLY like it — no poll, no evaluation, no orders — logging `agent.cycle_skipped` with `reason: market_closed` (INFO; a weekend is expected) or the distinct `reason: market_clock_unavailable` (WARNING; "could not know" and "we know it is shut" are different operator facts). The kill-switch still outranks. - Each cycle records the venue's answer into `agent_state` (`market_session`/`market_session_ts`), so the broker-free surfaces can render it without a clock call of their own. - `fetch --check` (still offline) reads that recording via `agent.recorded_market_closed`: a stale-but-present series renders as the distinct `CLOSED` state and does NOT count as missing/stale for the exit code — a weekend must not page an operator. `missing` still alerts (a closed venue serves history), `clock_unavailable` does NOT defuse alerts (fail-closed for trading, fail-loud for alerting), and the recording is trusted only for rail 12's feed-heartbeat window (`interval_sec * FEED_STALENESS_CYCLES`) so a dead agent cannot silence alerts forever. - `keel status`/`keel tui` render a distinct session line under the kill-switch line (the #345 rail-17 precedent; no paper carve-out — the session gate skips paper cycles too). Crypto unchanged, guaranteed at every seam: a broker that does not implement the port (today's `CoinbaseClient`) or declares `session_bound=False` is never asked its clock, writes no session state, and every existing behavior, output, and test stays byte-identical. Test-first: the conformance, adapter, agent, freshness, fetch-CLI, status and TUI tests were written red first (collection failures on the missing `SessionState`/`session_bound`/`market_clock`/`MarketSessionStatus` plus failing CLOSED/exit-code assertions) and drive the implementation. Refs #370 * fix(agent): session recording on every path, venue-namespaced state, fail-loud clocks Ten adversarial-review findings on PR #385 (branch feat/370-session-awareness): - Record the venue session (state/ts/effective interval) BEFORE the kill-switch return: a halted-but-healthy deployment keeps tracking the venue clock, so a weekend under a kill switch no longer false-positives STALE and a Friday pre-close halt no longer freezes market_session=open all weekend. - Namespace the session record by venue (market_session:{venue} plus ts and interval twins); recorded_market_closed takes the venue it is answering for, so one venue's CLOSED never defuses another's staleness and two deployments with different cadences carry their own trust windows. - Alpaca market_clock: a 2xx body without a usable boolean is_open is CLOCK_UNAVAILABLE, never CLOSED (closed defuses alerting forever; a malformed body must not buy that silence). Only an actual bool answers OPEN/CLOSED. - _venue_session validates market_clock()'s answer is a SessionState: a None-returning third-party adapter fails closed with the distinct market_clock_unavailable skip instead of AttributeError killing the loop. - The trust window derives from the RECORDED cycle interval (loop/monitor --interval overrides included), falling back to config only when absent. - TUI freshness styling is session-aware: closed AND inside the trust window renders behind series muted, not warn -- same source of truth as the session line; no-data cells keep warn (a closed venue still serves history). - recorded_market_closed documents the pre-close staleness attenuation (bounded silence until reopen) with an arc test: alerts pre-close, silenced during closure, re-alerts once the record expires post-reopen. - Trust-window boundary tests (age == window defuses, window+1 does not, fresh-nonzero-age defuses, junk ts does not) with a frozen clock. - Truthful fetch wording: the summary says 'market closed -- staleness does not alert' when closed-explained staleness exists, and a plain fetch no longer calls a behind cache 'all series current' to justify the skip. - keel monitor --loop is session-aware: closed venues skip polling (logged once per state change) and record the session so fetch --check stays quiet over the weekend; crypto venues poll exactly as before.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #340
What & why
On 2026-08-14 rail 17 vetoed the only live DCA signal because the withdrawal-capability
attestation had expired; as of 2026-08-17 both deployments' attestations were weeks stale, so
rail 17 was halting entries everywhere — and nothing surfaced that until the veto fired. This
PR makes the staleness VISIBLE before it vetoes, records the weekly refresh habit beside the
other account-level obligations, and closes the re-enable gap #339 depends on. The typed
re-attestation stays a human terminal action BY DESIGN — this is visibility and cadence, never
automation.
keel statusrail-17 line (also rendered bykeel tui, styledalerton every haltedstate): a new
WithdrawalAttestationStatuson the existingStatusReport, resolved throughthe executor's own
_withdrawals_enabledand aged with the executor's ownWITHDRAWAL_ATTESTATION_TTL_SEC— never a restated 7 days — so the display cannot call anattestation fresh on the very cycle the rail vetoes it:
rail 17 (withdrawal capability): attested, expires in 3drail 17 (withdrawal capability): EXPIRED 12d ago -- entries halted; re-attest with keel withdrawals attestrail 17 (withdrawal capability): never attested -- entries halted; re-attest with keel withdrawals attest... SUSPENDED -- entries halted; re-attest with keel withdrawals attest --enableddocs/operator-runbook.md, item 3 beside the USDC-rewards note):the weekly re-attestation habit, naming the consequence of missing it (rail 17 fails closed
on unknown → live DCA buys vetoed, the 2026-08-14 event) and why the typed confirmation must
stay human — a scheduled job must never release a §65.4 halt.
keel rules enable <rule_id>: the inverse ofrules disable's WRITE. Verified thatdisablerecords nothing about prior status (it stamps onlydemoted_at), soenablerestores to
candidate— the bottom of the ladder — and prints the path onward(
rules promote, gated;--forceas the documented bypass for paper-forwards that cannever reach the min_trades floor, e.g. DCA). The docstring documents that a rule disabled
from
livelands atcandidate, notlive. This is the supported CLI path Re-measure under the honest cost model: simulate re-run, DCA dip ablation, paper DCA re-enabled #339's"re-enable the disabled paper DCA twins" needs.
Tests-first evidence
Red run (all 11 new/extended assertions failing on the pre-change code, none for an import
error — the rail-17 gather tests fail on the missing
withdrawal_attestationfield, therender/CLI tests on the missing line, the enable tests on
No such command 'enable'):The TUI rail-17 styling tests were likewise seen red (
5 failed) before the TUI line wasadded.
Gates (all must pass)
uv run ruff checkclean —All checks passed!uv run mypyclean —Success: no issues found in 238 source filesuv run pytest -qgreen —2945 passed, 1 skipped in 35.38sScope check
leave checked only if true, and if so: cite the source and open the discussion
BEFORE review (CONTRIBUTING.md, "Governance: rulings vs. machinery").
No rail, guard, or classification changes: rail 17's veto logic is untouched; this PR only
reads the same state through the executor's own resolver. Stale comments claiming
disabledis terminal were updated (
rules.pydisable docstring,promotion.py_PROMOTE_NEXTnote)to name the new operator verb.