Skip to content

feat(guards): add rail 18, the settlement-currency hard rail - #164

Merged
eaitbrahim merged 3 commits into
mainfrom
feat/settlement-currency-rail
Aug 5, 2026
Merged

feat(guards): add rail 18, the settlement-currency hard rail#164
eaitbrahim merged 3 commits into
mainfrom
feat/settlement-currency-rail

Conversation

@eaitbrahim

Copy link
Copy Markdown
Contributor

Implements R1 from docs/experiments/2026-08-05-coinbase-asset-class-feasibility.md.

The hole

The study found by execution that keel's rails can be fooled by a non-spot product id:

  • guards._asset("ADA-28AUG26-CDE") returns "ADA" — which is allowlisted on the live config.
  • So rail 1, the un-overridable halal allowlist gate, passes a Coinbase futures contract.
  • The only thing that stopped a live BUY was rail 13, incidentally (quote_currency_of returns "CDE", no such balance exists, fails closed). But rail 13 is BUY-only and is skipped entirely in paper mode.
  • Verified: a SELL of ADA-28AUG26-CDE passed every rail on the real live config.

The fix

Rail 18 — settlement-currency. Vetoes any intent whose quote_currency_of(product_id) is not in config.settlement_currencies (new field, default {USD, USDC}).

It is the only rail that gates the instrument class rather than the trade, and it does so without keel needing an instrument model it doesn't have: quote_currency_of returns "CDE" for every futures contract and None for every equity id (a 64-char hash), so one comparison rejects both classes.

Deliberately not in LIVE_STATE_RAILS — it runs on both sides in every mode, because it needs no broker and no account state. That is the entire point: paper-exemption is what left the hole open.

Verified against the real ~/keel/config.live-sandbox.yaml:

product SELL/offline SELL/live BUY/offline BUY/live
ADA-28AUG26-CDE VETO VETO VETO VETO
GOL-25NOV26-CDE VETO VETO VETO VETO
equity hash id VETO VETO VETO VETO
ADA-USD passes passes passes passes

Accepted behaviour change

With the default set this also rejects ~120 non-USD/USDC spot pairs Coinbase lists (BTC-EUR, ETH-GBP, *-USDT). Nothing in the live deployment reaches one — every rule is BASE-USD and all three configs set quote_currency: USD — and settlement_currencies is the escape hatch. Flagged in the rail's comment so it doesn't read as an oversight.

Note on the venue reconciliation

_reconcile_settlement_currencies checks the configured set against the adapter's declared BrokerCapabilities.quote_currencies, but it is dormant on the live path today: the live broker is data.cb_client.CoinbaseClient, which declares no capabilities (the declaration lives on the not-yet-wired CoinbaseAdapter). It activates when the broker port migration lands. Rail 18 does not depend on it. This is stated in the docstring rather than left to be discovered.

Verification

1697 tests pass (1678 before + 19 new), ruff check . clean. TDD order held — new tests were run failing first.

🤖 Generated with Claude Code

eaitbrahim and others added 3 commits August 5, 2026 11:18
The 2026-08-05 Coinbase asset-class feasibility study (R1) verified by execution
that a SELL of `ADA-28AUG26-CDE` -- a Coinbase futures contract -- passed EVERY
rail on the real live config. `guards._asset` reduces it to `ADA`, which is
allowlisted, so rail 1 waves it through; the only rail that stopped the BUY
(rail 13) is BUY-only and skipped entirely in paper.

Rail 18 vetoes any intent whose `quote_currency_of(product_id)` is not in
`config.settlement_currencies`. It runs on BOTH sides and in EVERY mode --
deliberately NOT in `LIVE_STATE_RAILS`, since it needs no broker and no live
account state, which is exactly what made the compensating rail insufficient.
It fails closed on an unresolvable id (every 64-hex equity product) and returns
a violation string rather than raising, so a malformed id can never turn a veto
into a crashed agent cycle.

The allowed set is config, not a hardcode: `settlement_currencies` (default
{USD, USDC}, non-empty, uppercased at parse) is the operator's escape hatch.
`executor._reconcile_settlement_currencies` runs at the top of `_run_order` and
raises `ConfigError` if that set is not a subset of the adapter's declared
`BrokerCapabilities.quote_currencies`, so config cannot widen the rail past what
the venue settles in; it is a no-op with no broker (the paper path) or a broker
that declares nothing.

Accepted behaviour change: the default set also rejects the ~120 non-USD/USDC
SPOT pairs Coinbase lists (BTC-EUR, *-USDT, ...). Blast radius on the deployment
is nil -- every live rule is BASE-USD and all three configs set quote_currency:
USD.

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

Independent-review findings on the rail-18 branch.

**quote_currency and settlement_currencies are now cross-validated at load.** They describe the
same leg from two directions and nothing made them agree. `_history_product` builds every id keel
can name as `f"{asset}-{quote_currency}"`, and rail 18 vetoes any id whose quote leg is outside
`settlement_currencies` -- so `quote_currency: EUR` against the shipped default `{USD, USDC}`
loaded cleanly and then had EVERY order the deployment could construct vetoed, forever, with a
rail message naming a currency the operator never typed. That is the "silent unfixable rejection"
`_history_product`'s own docstring was written to prevent. `load_config` now raises `ConfigError`
naming both values and both fixes. All three deployment configs set `quote_currency: USD` with no
`settlement_currencies` key, so they are unaffected.

**`_reconcile_settlement_currencies` is removed, not patched.** It checked the configured set
against the adapter's `BrokerCapabilities.quote_currencies` and raised `ConfigError` inside
`_run_order`. Three verified reasons:

- It cannot fire in production. The only broker the live path constructs is
  `data.cb_client.CoinbaseClient`, which has no `capabilities()`; the declaration lives on the
  not-yet-wired `CoinbaseAdapter`. `broker=None` (paper) is a no-op too. Dead code that reads as
  a defence.
- It carried a latent position-trap. `agent.run_once` has no `except` -- only `try:`/`finally:`
  -- and `agent.loop` does not catch either, so a `ConfigError` out of `_run_order` kills the
  cycle. `_handle_exits` runs BEFORE entries, so the first EXIT order would raise and the cycle
  would die with the position unmanaged: no bracket, no stop roll, no scale-out. Rail 16's
  docstring condemns exactly this ("a breaker that blocked exits would trap capital in a losing
  position, inverting its own purpose").
- It broke the fake adapter out of the box: `keel_broker_fake` declares
  `quote_currencies={"USD"}`, which reconciles against the shipped `{USD, USDC}` default into an
  immediate `ConfigError`.

The check belongs with the broker-port migration that makes `capabilities()` reachable, and as a
LOAD-time check rather than a per-order raise. The feasibility doc's R1 block is corrected to say
so instead of describing it as shipped. Rail 18 is untouched and never depended on it.

Also: the operator-facing comment claimed rail 18 stops futures/equity products full stop; it
does so only while Coinbase keeps suffixing them with a non-currency venue code (`BTC-PERP-USD`
would parse as `USD` and pass, verified), so the qualifier is now in all three shipped configs
next to R2's residual. And `_parse_settlement_currencies` treats an explicit YAML null the same
as `[]` -- an absent key still defaults -- and shape-checks each code, so `['US D']` fails at load
rather than sitting in the set admitting nothing.

Regression proof: deleting rail 18's body fails all 8 `test_rail18_*` tests; restored, 1698 pass.

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

The shipped comment told operators "the venue must settle in whatever you
add here", which read as a guarantee. The check that would have enforced it
was removed in 7260a33 for being unreachable and for risking a mid-cycle
raise on the exit path, so nothing verifies it until the broker-port
migration exposes capabilities() on the live client. Say so.

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

Copy link
Copy Markdown
Contributor Author

Independent review applied

A fresh-context reviewer (which did not write the code) verified the rail and found one blocking issue plus a latent crash. Both resolved.

Rail 18 itself: approved as-is. The reviewer brute-forced all 1,114,112 Unicode codepoints looking for a case-folding collision with USD/USDC (none), tried ~70 id shapes, and confirmed the only way past is an id whose final dash-segment is literally a configured currency — no such Coinbase product exists, and the residual is already named in the study's R2. Tests proven real by mutation: deleting the rail turns 8 of them red, and nothing else in the suite catches it.

F1 (blocking) — fixed. quote_currency and settlement_currencies were never cross-validated. A config with quote_currency: EUR loaded fine, then _history_product built BTC-EUR, which rail 18 vetoed — every order that deployment could construct, at runtime, forever, with no load-time error. load_config now raises. All three live configs still load unchanged.

F2/F3 — _reconcile_settlement_currencies removed entirely. It could not fire in production (the live CoinbaseClient declares no capabilities()), it crashed the fake adapter on the shipped default, and its ConfigError would have propagated out of run_once — which has no except — killing the agent mid-cycle. Since _handle_exits runs before entries, the first exit order would have died with the position unmanaged: no bracket, no stop roll, no scale-out. That is precisely what rail 16's docstring condemns. Unreachable code with a latent position-trap doesn't belong on a live money path; it belongs with the port migration that makes it reachable.

F4/F5 — fixed. YAML null now errors instead of silently defaulting; currency codes get a shape check; the operator comment no longer overstates what the rail guarantees, and now says plainly that nothing verifies the venue's set.

1698 tests pass, ruff clean. Re-verified against the real ~/keel/config.live-sandbox.yaml: futures, commodity, perp and equity ids vetoed in all 4 side×mode combinations; ADA-USD and BTC-USDC pass in all 4.

@eaitbrahim
eaitbrahim merged commit b145515 into main Aug 5, 2026
1 check passed
@eaitbrahim
eaitbrahim deleted the feat/settlement-currency-rail branch August 5, 2026 15:40
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