Skip to content

feat(guards): add rail 19, the spot-instrument rail (R2) - #167

Merged
eaitbrahim merged 2 commits into
mainfrom
feat/r2-spot-instrument-rail
Aug 5, 2026
Merged

feat(guards): add rail 19, the spot-instrument rail (R2)#167
eaitbrahim merged 2 commits into
mainfrom
feat/r2-spot-instrument-rail

Conversation

@eaitbrahim

Copy link
Copy Markdown
Contributor

Implements R2 from docs/experiments/2026-08-05-coinbase-asset-class-feasibility.md, closing the residual rail 18 (#164) deliberately left open.

The residual

Rail 18 checks the settlement leg. It does not check the instrument shape:

quote_currency_of("BTC-PERP-USD") == "USD"     -> rail 18 passes
_asset("BTC-PERP-USD")            == "BTC"     -> rail 1 passes (BTC is allowlisted)

Verified against the real live config — a derivative-shaped id with a legitimate final segment clears both shipped defences. Rail 19 is the only thing that stops it, and that is asserted as a test, in the same "the hole, stated as a test" style as rail 18's.

rail 1 rail 18 rail 19
BTC-PERP-USD, SELL and BUY, live and offline passes passes VETO 4/4
the 6 live deployment rules passes passes passes 4/4

A validator, not a classifier

A classifier (id → {spot, futures, equity}) must enumerate the shapes keel refuses, so it fails open on a shape Coinbase has not invented yet. A validator answers only "is this a well-formed spot id" and fails closed on novelty — the right posture for a spot-only halal agent, and it avoids building the id→class classifier the study prices as A1 scope.

Grammar: [A-Z0-9]{1,16}-[A-Z0-9]{2,10}. All 936 live spot ids match; all 99 futures (ROOT-DDMMMYY-CDE) and all 1000 equity ids (64-hex) fail. The quote bound is tied to config's _CURRENCY_CODE_RE so the two grammars cannot drift apart. quote_currency_of is untouched — rails 13/18 depend on its loose parse to name "CDE" in their messages.

Two deliberate corrections

The history walk counts, it does not skip. The study said "skip-or-flag" an unparseable row in _open_exposure_by_asset. Skipping would reduce measured exposure and thereby loosen rails 4/5/6 — fail-open. It now logs a warning and still counts the row. Overcounting is the closed direction.

assets screen is exempt from --products validation. Every other caller (fetch, monitor, simulate, rules seed) now refuses an untradeable id at the keyboard. Screening must not: it is the command that answers "may keel trade this, and why not", and screen_asset reports REJECT with the reason. A usage error would make the one tool whose job is to explain an inadmissible asset the one tool that cannot be asked about one. Pinned by a test.

What was deliberately NOT built

No engine read of capabilities().asset_classes. The live path builds CoinbaseClient, which has no capabilities(); paper passes broker=None. Such a gate is dead code that reads as a defence — the same pattern already built and deleted once in #164. The field instead gets a vocabulary check and conformance assertions so it cannot rot, and the reconciliation is deferred to the broker-port migration as a load-time check.

Also not built: the id→class classifier (A1 scope, fails open), and capabilities() on CoinbaseClient (that is the migration itself, or a second source of truth).

Verification

1804 tests pass (1698 → +106), ruff check . clean.

Separately, the deployment upgrade to 0.4.0 was verified end to end: a full agent cycle ran clean against a throwaway DB copy — mode=confirm polled=225 products=['ADA-USD','BTC-USD','ETH-USD','PAXG-USD','XLM-USD'] stale=[] signals=0.

🤖 Generated with Claude Code

eaitbrahim and others added 2 commits August 5, 2026 18:14
Rail 18 checks the settlement LEG; it does not check the instrument SHAPE.
`quote_currency_of("BTC-PERP-USD")` is "USD" and `_asset` is "BTC", so a
derivative-shaped id with a legitimate final segment passes rail 1 AND rail
18 on the live allowlist. Rail 19 is the only thing that stops it, which is
asserted as a test.

- `parse_spot_product_id` in keel-core: [A-Z0-9]{1,16}-[A-Z0-9]{2,10}, total,
  never raises. The quote bound is tied to config's _CURRENCY_CODE_RE so the
  two grammars cannot disagree. `quote_currency_of` is untouched -- rails
  13/18 depend on its loose parse to name "CDE" in their messages.
- Rail 19 `spot_instrument`: both sides, every mode, not in LIVE_STATE_RAILS,
  appends a violation and never raises.
- `_asset` made total. `_open_exposure_by_asset` logs an unparseable history
  row and STILL COUNTS it -- a deliberate correction to the study's
  "skip-or-flag" wording, because skipping would reduce measured exposure and
  loosen rails 4/5/6. Overcounting is the closed direction.
- `--products` validated where the operator types it, so
  `rules seed --products XLM-28AUG26-CDE --status live` now fails at the
  keyboard instead of seeding a row the agent polls and the rails veto forever.
- `assets screen` is deliberately EXEMPT from that validation. Screening is
  the command that answers "may keel trade this, and why not"; a usage error
  would make the one tool whose job is to explain an inadmissible asset the
  one tool that cannot be asked about one. Pinned by a test.
- BrokerCapabilities.asset_classes gets a vocabulary check and conformance
  assertions, but the engine still does not read it: the live path builds
  CoinbaseClient, which has no capabilities(), and paper passes broker=None,
  so such a gate would be dead code reading as a defence -- the same pattern
  already built and deleted once here. Deferred to the broker-port migration,
  as a load-time check.

A chosen validator, not a classifier: a classifier must enumerate the shapes
keel refuses and so fails OPEN on a shape the venue has not invented yet; a
validator answers only "is this a well-formed spot id" and fails closed.

Verified: all 936 live spot ids match the grammar; all 99 futures and all
1000 equity ids fail it. The six live deployment rules are unaffected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every claim below was re-verified by execution before being changed.

1. `_open_exposure_by_asset` is now SIDE-DEPENDENT, and the absolute claim it
   carried was false. That figure is NET -- BUY adds, SELL subtracts -- so
   counting an unparseable SELL REDUCES the bucket rails 4/5/6 cap, i.e. it
   loosens them. Measured: ADA-USD BUY $900 -> {'ADA': 900}; + an
   ADA-28AUG26-CDE SELL $800 (same `_asset` bucket) -> {'ADA': 100}, and past
   the BUY total the trailing `if amt > 0` filter deletes the bucket outright.
   A futures SELL is exactly the row shape the study found passing every rail.
   Malformed BUY: counted (over-states = closed). Malformed SELL: skipped.
   Both WARN, now carrying `action=counted|skipped`. The old tests seeded BUY
   rows only, so the SELL half was unproven; two tests cover it now.

2. `assets screen` ADMITted the very instrument rail 19 exists to veto. Proven:
   with BTC attested and 2000 bars, `--products BTC-PERP-USD` printed ADMIT.
   The screen's only id-derived criterion was `quotable_in_settlement_currency`,
   which reads the LAST segment -- and BTC-PERP-USD's is USD. `screen_asset`
   gains a `spot_instrument` criterion applying `parse_spot_product_id` (rail
   19's grammar, imported not restated) to a `product_id` MarketFacts now
   carries. It flows through `_screen_product`, so `assets propose` and
   `holdings --screen` inherit it. The `--products` exemption is PRESERVED --
   the screen reports rather than refuses -- but what it reports is now true.

3. A non-uppercase allowlist entry was silently untradeable: `allowlist: [btc]`
   derived `btc-USD`, which fails rails 1 and 19 on every cycle. `_parse_allowlist`
   now shape-checks each entry against `is_spot_base_code`, the base-leg half of
   the same grammar, split out of `_SPOT_PRODUCT_ID_RE` so the two cannot
   disagree. REJECTED with the uppercase form in the message, not folded: a
   settlement code is COMPARED against uppercased output (so folding is free),
   an allowlist entry is CONCATENATED into a venue identifier. Blast radius nil
   -- every shipped config already lists uppercase tickers.

4. `rules seed` loads config unconditionally now, so four tests passed only
   because pytest runs from the repo root. All four pass `--config`; from a
   config-less cwd the branch now fails exactly the 7 tests `main` does. (The
   review named two; `tests/test_cli.py` had two more.)

5. `validate_product_ids` is split so callers can weigh the two failure kinds:
   `check_product_ids` returns typed `ProductIdProblem`s (SHAPE / SETTLEMENT).
   SHAPE stays fatal everywhere -- always a typo. SETTLEMENT stays fatal on
   `rules seed`, which writes a row the agent polls, and WARNS on `fetch` /
   `simulate`, which place no orders. Making it fatal there broke the screening
   workflow: `assets screen --products BTC-EUR` is exempt so the pair CAN be
   asked about, but the answer is dominated by "0 daily bars", and there was no
   way to fetch that history without first widening `settlement_currencies`.

6. Nits, each a claim that was wrong rather than merely thin:
   - Rail 19's comment (and the module docstring) overstated. `BTC-PERP` --
     Coinbase International's real perpetual format -- PASSES rail 19's grammar
     and is stopped by rail 18 alone, so for two-segment ids spot-only remains
     a property of `settlement_currencies`. Stated honestly, pinned by a test.
   - The feasibility doc described the `assets screen` exemption BACKWARDS.
   - `monitor` was listed as a `--products` caller in two docstrings; it has no
     such option. (The module docstring never claimed it -- that third instance
     did not exist.)
   - ASSET_CLASSES cited the venue's SPOT/FUTURE/EQUITY while defining
     spot/futures/equity; now stated as keel-side spellings, which is what makes
     the near-miss test's rejection of "future" coherent rather than contradictory.
   - `test_quote_currencies_is_non_empty` kept, with the real reason: rail 18's
     default MIRRORS the adapter declaration (neither derived from the other),
     and an empty declaration makes that agreement vacuous.

1888 pass, ruff clean.

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

Copy link
Copy Markdown
Contributor Author

Independent review applied — six findings, all confirmed

A fresh-context reviewer swept all 1,114,112 Unicode codepoints against the grammar (zero non-ASCII accepted in either leg), enumerated all 41 stored product ids across both live DBs and the pre-0.4.0 backup (all parse), and confirmed no exit can be trapped. Three findings were substantive:

1. Counting a malformed SELL row was fail-OPEN — my reasoning was wrong. _open_exposure_by_asset subtracts SELL notional, so counting an unreadable SELL loosens rails 4/5/6. Proven: ADA-USD BUY $900 → {'ADA': 900}; adding a counted ADA-28AUG26-CDE SELL $800 → {'ADA': 100}, and a large enough one deletes the bucket via the trailing if amt > 0 filter. The rule is now side-dependent — count a malformed BUY (over-states = tighter cap), skip a malformed SELL. This supersedes both the study's unconditional "skip-or-flag" and my first correction of it; neither absolute survives a SELL. Pinned by two tests, including one stating the failure as money (the concentration cap must still trip). Verified by mutation: reverting turns exactly those two red.

2. assets screen ADMITted the very instrument rail 19 exists to veto. assets screen --products BTC-PERP-USD returned ADMIT. The exemption's justification claimed screen_asset rejects malformed ids — it doesn't; its only id-derived criterion is settlement, which BTC-PERP-USD passes. The screen now has a real spot_instrument criterion reusing rail 19's grammar, so it reports REJECT with the reason (exit 0) rather than refusing the option. That keeps the exemption honest and preserves the design intent. propose and holdings --screen inherit it through the same chokepoint.

3. A lowercase allowlist entry was silently untradeable. allowlist: [btc]btc-USD → rails 1 and 18 pass, rail 19 vetoes every cycle — the same "silent unfixable rejection" load_config's quote_currency check exists to prevent. Now shape-checked at load, rejected rather than uppercased: a settlement code is compared against uppercased output, but an allowlist entry is concatenated into a venue identifier, so folding would mean the asset keel trades isn't the one the file names.

Plus: fetch/simulate now treat shape as fatal (always a typo) and settlement as a warning, so you can still fetch history for BTC-EUR to evaluate it before widening the config — rules seed keeps both fatal since it writes a row. Four tests that had silently become cwd-dependent now pass --config. And the rail comment no longer overstates: BTC-PERP passes rail 19's grammar and is stopped by rail 18 alone, so for two-segment ids spot-only remains a property of settlement_currencies — stated honestly and pinned by a test.

1888 tests pass, ruff clean. From a non-repo cwd the failure count now matches main exactly.

@eaitbrahim
eaitbrahim merged commit 6cced07 into main Aug 5, 2026
1 check passed
@eaitbrahim
eaitbrahim deleted the feat/r2-spot-instrument-rail branch August 5, 2026 22:51
eaitbrahim added a commit that referenced this pull request Aug 5, 2026
Minor: rail 19 (#167) is a new un-overridable hard rail, adds a screen
criterion, and adds a load-time failure for a non-uppercase allowlist entry.
It also tightens --products at the CLI on every command that writes or
trades.

uv.lock is relocked in the SAME commit this time. Leaving it behind is what
made the 0.4.0 release fail its self-identification check: CI's `uv sync`
rewrote a tracked file, so the artifact reported (DIRTY).

The live deployment is on 0.4.0, which has rail 18 but not rail 19; cutting
this release is what gets it there.

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