Skip to content

feat(compliance): rail 22 — the cash posture no venue will affirm (#6… - #712

Merged
eaitbrahim merged 2 commits into
mainfrom
feat-691-cash-posture-attestation
Sep 3, 2026
Merged

feat(compliance): rail 22 — the cash posture no venue will affirm (#6…#712
eaitbrahim merged 2 commits into
mainfrom
feat-691-cash-posture-attestation

Conversation

@eaitbrahim

Copy link
Copy Markdown
Contributor

…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_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

Claude-Session: https://claude.ai/code/session_01NzuKAe2RVrPt9acVAWjRyL

What & why

Tests-first evidence

  • Tests written first, seen failing for the right reason

Gates (all must pass)

  • uv run ruff check clean
  • uv run mypy clean
  • uv run pytest -q green

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)

eaitbrahim and others added 2 commits September 3, 2026 15:16
…, 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
@eaitbrahim
eaitbrahim merged commit f4a184a into main Sep 3, 2026
4 checks passed
@eaitbrahim
eaitbrahim deleted the feat-691-cash-posture-attestation branch September 3, 2026 20:52
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