Skip to content

feat(status): surface rail-17 attestation expiry before it vetoes, and a rules enable verb (#340) - #345

Merged
eaitbrahim merged 2 commits into
mainfrom
feat/340-rail17-visibility
Aug 17, 2026
Merged

feat(status): surface rail-17 attestation expiry before it vetoes, and a rules enable verb (#340)#345
eaitbrahim merged 2 commits into
mainfrom
feat/340-rail17-visibility

Conversation

@eaitbrahim

Copy link
Copy Markdown
Contributor

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 status rail-17 line (also rendered by keel tui, styled alert on every halted
    state): a new WithdrawalAttestationStatus on the existing StatusReport, resolved through
    the executor's own _withdrawals_enabled and aged with the executor's own
    WITHDRAWAL_ATTESTATION_TTL_SEC — never a restated 7 days — so the display cannot call an
    attestation fresh on the very cycle the rail vetoes it:
    • rail 17 (withdrawal capability): attested, expires in 3d
    • rail 17 (withdrawal capability): EXPIRED 12d ago -- entries halted; re-attest with keel withdrawals attest
    • rail 17 (withdrawal capability): never attested -- entries halted; re-attest with keel withdrawals attest
    • (plus the fresh-deliberate case) ... SUSPENDED -- entries halted; re-attest with keel withdrawals attest --enabled
  • Runbook cadence note (docs/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 of rules disable's WRITE. Verified that
    disable records nothing about prior status (it stamps only demoted_at), so enable
    restores to candidate — the bottom of the ladder — and prints the path onward
    (rules promote, gated; --force as the documented bypass for paper-forwards that can
    never reach the min_trades floor, e.g. DCA). The docstring documents that a rule disabled
    from live lands at candidate, not live. 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

  • Tests written first, seen failing for the right reason

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_attestation field, the
render/CLI tests on the missing line, the enable tests on No such command 'enable'):

FAILED tests/commands/test_status.py::test_rail17_attested_fresh_shows_time_remaining
FAILED tests/commands/test_status.py::test_rail17_expired_names_the_halt_and_the_fix
FAILED tests/commands/test_status.py::test_rail17_never_attested_is_said_as_such
FAILED tests/commands/test_status.py::test_rail17_suspended_attestation_still_names_the_halt
FAILED tests/commands/test_status.py::test_rail17_freshness_uses_the_executors_own_ttl
FAILED tests/commands/test_status.py::test_rail17_line_sits_beside_rail11
FAILED tests/commands/test_status.py::test_status_command_runs_read_only_and_prints_key_facts
FAILED tests/commands/test_status.py::test_status_command_json_flag_emits_parseable_json
FAILED tests/test_cli.py::test_rules_enable_restores_a_disabled_rule_to_candidate
FAILED tests/test_cli.py::test_rules_enable_on_a_non_disabled_rule_is_an_error
FAILED tests/test_cli.py::test_rules_disable_then_enable_round_trip_lands_at_candidate
9 failed, 2 passed, 95 deselected

The TUI rail-17 styling tests were likewise seen red (5 failed) before the TUI line was
added.

Gates (all must pass)

  • uv run ruff check clean — All checks passed!
  • uv run mypy clean — Success: no issues found in 238 source files
  • uv run pytest -q green — 2945 passed, 1 skipped in 35.38s

Scope check

  • This PR touches a rail or a default classification — checked means it DOES;
    leave checked only if true, and if so: cite the source and open the discussion
    BEFORE review (CONTRIBUTING.md, "Governance: rulings vs. machinery").
  • New dependency added (needs discussion first)

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 disabled
is terminal were updated (rules.py disable docstring, promotion.py _PROMOTE_NEXT note)
to name the new operator verb.

… will not run; corrupt-state read guarded; UNKNOWN corner kept word-for-word
@eaitbrahim
eaitbrahim merged commit 6fd884d into main Aug 17, 2026
4 checks passed
@eaitbrahim
eaitbrahim deleted the feat/340-rail17-visibility branch August 17, 2026 21:40
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.
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.

Rail-17 withdrawal attestation cadence — the 7-day TTL needs a visible refresh habit

1 participant