feat(compliance): rail 22 — the cash posture no venue will affirm (#6… - #712
Merged
Conversation
…, part 1 of 2) Stage 2 of #666. Schema v18. WHAT STAGE 1 ESTABLISHED, AND WHY IT CANNOT BE FIXED WITH ANOTHER READ. Coinbase exposes no cash-versus-margin field for spot. The 2026-09-02 probe found `margin_rate` present in every account's response schema and carrying `null` -- so a check keyed on its presence would refuse every deployment -- and portfolios `DEFAULT`/`CONSUMER` with no INTX. Every margin, borrow, leverage and liquidation field in the SDK lives in the futures or perpetuals types. The adapter check therefore REFUTES and never issues: an INTX portfolio proves derivatives are available, its absence proves nothing. That residual is the same shape as rail 17's `qabd`. Silence is not evidence of possession, and silence is not evidence of a cash account. What closes it is a human who knows their own account saying so, on the record, with the venue able to contradict them. TWO DESIGN CHOICES DIFFER FROM `VenueTradeScope`, AND BOTH FOLLOW FROM THAT: * **No `CONFIRMED` state**, and a test pins its absence. Trade scope earns one because the venue re-proves it on every accepted placement. NOTHING can ever prove a spot account is cash-only, so `CONFIRMED` would be a state nothing is entitled to write -- and an unreachable state is one a later reader eventually writes anyway. * **A TTL, where trade scope has none.** Trade scope needs no clock precisely because the venue re-confirms it continuously. This record has NO observation channel, which is `VenueSubscription`'s situation, and there a due date is the only thing between a lapsed claim and a live spend. 90 days, not rail 17's 7: an account does not silently acquire margin, and a prompt that fires weekly gets automated, which is how an attestation stops meaning anything. The due date is STORED, not derived, so changing the TTL cannot retroactively expire a claim a human made under the window in force when they made it. Rail 22 vetoes new ENTRIES and nothing else -- exits, stop rolls, cancels and DCA exits are untouched, because a rail that blocked an exit over a fact about the ACCOUNT would strand a position that wanted out. It is in `LIVE_STATE_RAILS`, so paper skips it AND reports the skip. FOUR distinct veto messages, because there is no venue read to fall back on and the message IS the interface: never-attested, expired, attested-margin (which calls for a change to the ACCOUNT, not a re-run of the command), and a DIFFERENT credential -- never described as "never attested", which is #624's exact mistake. `keel posture attest --spot-cash` is TTY-gated and declared in `keel/capabilities.py`; `--margin-enabled` is ungated, because it only ever reduces capability -- `keel autonomy`'s asymmetry. Recording margin is better than refusing to write: absent reads as "nobody has attested", which is a less useful fact and invites the same person to be asked again next week. `refute_posture` is deliberately not symmetric: a human issues a claim, only the venue withdraws one. With no record it writes nothing rather than inventing a history, and `refuted_ts` marks the FIRST contradiction of a claim rather than advancing every build. Tests written first, red before green: 42 across four files (16 core, 10 storage, 9 rail, 11 CLI, 6 refutation). Mutation-verified, 9 mutants, all killed: the entries-only gate removed; the paper skip removed; a missing record failing open; the TTL ignored; a margin attestation permitting; due-at off by one; refutation inventing a record; attest not stamping a due date; a refuted record permitting. A tenth was written and discarded as ineffective -- `del is_buy` after the branch changes nothing, and counting it would have inflated the run. The rail-count pin did its job and exposed two bugs IN ITSELF along the way: it read "the 20% rail" (rail 11's drawdown threshold) as a count claim, and `\btwenty\b` matched inside "twenty-one" because a hyphen is a word boundary, so every line it had just corrected still read as stale. Both are fixed with the reasoning inline. Refs #691 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NzuKAe2RVrPt9acVAWjRyL
Four findings from the review of #712. Two were self-inconsistencies in the work's own stated reasoning; one was an operational cliff. 1. THE ENUM CONTAINED THE TRAP ITS OWN DOCSTRING ARGUES AGAINST. `CashPostureState` had an `UNVERIFIED` member that nothing writes: `posture attest` writes `ATTESTED`, `refute_posture` writes `REFUTED`, and "nobody has attested" is NO ROW, returned as `None`. It was exactly as unreachable as the `CONFIRMED` I had argued at length for omitting, sitting one line below that argument. Absence-as-`None` and absence-as-a-row are one fact, and two spellings invite a caller to check one and miss the other. Removed; rail 22's generic `else` branch existed only to catch it, and EXPIRED is now the only remaining case -- a property of the state machine rather than luck, which the comment says. 2. NOTHING WARNED BEFORE A 90-DAY ATTESTATION EXPIRED. The rail vetoes on lapse, correctly and silently, and the live profile runs unattended daily: on day 90 entries would simply stop. Rail 17 already treats an expiring attestation as WARN-worthy before it bites, and this TTL is thirteen times longer, which makes the cliff more surprising rather than less -- nobody remembers what they attested a quarter ago. `doctor` gains `attest.cash_posture` (pulled forward from part 2, because a silent trading halt is not a documentation gap), and it joins `_ATTESTATION_FINDINGS` so it reaches the `attestation.expiring` notification. The warning window is PROPORTIONAL to the TTL -- a sixth, 15 days on 90 -- not rail 17's flat two days: two days is ~29% of a 7-day window and 2% of a 90-day one, and a daily profile that misses two cycles gets no notice at all. The event's message no longer hardcodes "rail 17"; it reads a label per finding. A MARGIN attestation FAILs with a fix naming a change to the ACCOUNT, not a re-attestation -- telling an operator to re-attest would send them to type the same true answer again. 3. `refute_posture` HAD NO CALLER, so the "INTX refutes a standing attestation" criterion was unmet and the `REFUTED` state was reachable only from a test. `record_cash_posture_refutation` now wraps the venue check: on refusal it refutes, then RE-RAISES. Recording is in addition to failing closed, never instead -- swallowing the exception would turn a hard stop into a database row. Refute-only is preserved: a clean check touches nothing, because "no INTX portfolio" is the absence of contradicting evidence, not proof of a cash posture. `_build_broker` takes an OPTIONAL `repo`, passed on the two paths that go on to trade. The read-only inspection commands have no repo and will not act on the fact; threading one through all six call sites would be churn. 26 test doubles stubbed `_build_broker` as `lambda config:` and now tolerate the kwarg. 4. `SCHEMA_VERSION - 1` in the migration test made it pass at any version. Pinned to 17, like every other version pin in tests/data/ -- literals there are the deliberate speed bump that makes a bump acknowledged. Tests written first, red before green: 15 new (9 doctor, 6 refutation seam), plus the enum pin rewritten to assert BOTH absences and say why. Mutation-verified, 6 mutants, all killed: `UNVERIFIED` restored; the warning window collapsed to two days; the WARN downgraded to OK; the finding dropped from the notification set; the refutation swallowing the refusal; a clean check refuting. Refs #691 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NzuKAe2RVrPt9acVAWjRyL
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.
…91, part 1 of 2)
Stage 2 of #666. Schema v18.
WHAT STAGE 1 ESTABLISHED, AND WHY IT CANNOT BE FIXED WITH ANOTHER READ. Coinbase exposes no cash-versus-margin field for spot. The 2026-09-02 probe found
margin_ratepresent in every account's response schema and carryingnull-- so a check keyed on its presence would refuse every deployment -- and portfoliosDEFAULT/CONSUMERwith no INTX. Every margin, borrow, leverage and liquidation field in the SDK lives in the futures or perpetuals types. The adapter check therefore REFUTES and never issues: an INTX portfolio proves derivatives are available, its absence proves nothing.That residual is the same shape as rail 17's
qabd. Silence is not evidence of possession, and silence is not evidence of a cash account. What closes it is a human who knows their own account saying so, on the record, with the venue able to contradict them.TWO DESIGN CHOICES DIFFER FROM
VenueTradeScope, AND BOTH FOLLOW FROM THAT:CONFIRMEDstate, and a test pins its absence. Trade scope earns one because the venue re-proves it on every accepted placement. NOTHING can ever prove a spot account is cash-only, soCONFIRMEDwould be a state nothing is entitled to write -- and an unreachable state is one a later reader eventually writes anyway.VenueSubscription's situation, and there a due date is the only thing between a lapsed claim and a live spend. 90 days, not rail 17's 7: an account does not silently acquire margin, and a prompt that fires weekly gets automated, which is how an attestation stops meaning anything. The due date is STORED, not derived, so changing the TTL cannot retroactively expire a claim a human made under the window in force when they made it.Rail 22 vetoes new ENTRIES and nothing else -- exits, stop rolls, cancels and DCA exits are untouched, because a rail that blocked an exit over a fact about the ACCOUNT would strand a position that wanted out. It is in
LIVE_STATE_RAILS, so paper skips it AND reports the skip.FOUR distinct veto messages, because there is no venue read to fall back on and the message IS the interface: never-attested, expired, attested-margin (which calls for a change to the ACCOUNT, not a re-run of the command), and a DIFFERENT credential -- never described as "never attested", which is #624's exact mistake.
keel posture attest --spot-cashis TTY-gated and declared inkeel/capabilities.py;--margin-enabledis ungated, because it only ever reduces capability --keel autonomy's asymmetry. Recording margin is better than refusing to write: absent reads as "nobody has attested", which is a less useful fact and invites the same person to be asked again next week.refute_postureis deliberately not symmetric: a human issues a claim, only the venue withdraws one. With no record it writes nothing rather than inventing a history, andrefuted_tsmarks the FIRST contradiction of a claim rather than advancing every build.Tests written first, red before green: 42 across four files (16 core, 10 storage, 9 rail, 11 CLI, 6 refutation).
Mutation-verified, 9 mutants, all killed: the entries-only gate removed; the paper skip removed; a missing record failing open; the TTL ignored; a margin attestation permitting; due-at off by one; refutation inventing a record; attest not stamping a due date; a refuted record permitting. A tenth was written and discarded as ineffective --
del is_buyafter the branch changes nothing, and counting it would have inflated the run.The rail-count pin did its job and exposed two bugs IN ITSELF along the way: it read "the 20% rail" (rail 11's drawdown threshold) as a count claim, and
\btwenty\bmatched inside "twenty-one" because a hyphen is a word boundary, so every line it had just corrected still read as stale. Both are fixed with the reasoning inline.Refs #691
Claude-Session: https://claude.ai/code/session_01NzuKAe2RVrPt9acVAWjRyL
What & why
Tests-first evidence
Gates (all must pass)
uv run ruff checkcleanuv run mypycleanuv run pytest -qgreenScope check
leave checked only if true, and if so: cite the source and open the discussion
BEFORE review (CONTRIBUTING.md, "Governance: rulings vs. machinery").