Skip to content

keel v0.14.0

Choose a tag to compare

@github-actions github-actions released this 07 Sep 14:02
· 130 commits to main since this release
7770062

Built from 7770062. Version binds to this hash:
keel --version reports keel 0.14.0+7770062a7236 [release].

Install

Download all wheels from this release into one directory, then install the
keel_trader wheel by path:

pip install --find-links . ./keel_trader-0.14.0-py3-none-any.whl
keel versions

keel versions — not keel --version — is the check: it reports every
keel distribution in the venv and exits non-zero if a sibling was left behind at
an older version, which --version cannot see. Upgrading an existing
deployment: see "Deploying a new version" in the README.

⚠️ Never install by bare name. The distribution is keel-trader; the name
keel on PyPI belongs to an unrelated project, so pip install keel fetches
someone else's package. A build reporting DIRTY or [checkout] is not this
release and must not be run against live funds.

Configure

config.yaml is attached to this release: the production config, in
auto_trade.mode: confirm — keel previews every order and waits for your
approval. Drop it beside the install (or run keel init-config --live), put
your CDP key in a git-ignored .env, then:

keel migrate     # existing database: apply schema migrations
keel init        # fresh deployment: write config + seed candidate rules

Seeded rules start as candidate and trade nothing until you promote them.

Fixes

test(scope,executor): two tests took their precondition from the machine (#742) (#743)

Closes #742.

Two tests passed in CI and failed for any contributor with a configured deployment — the people most likely to run the suite locally.

FAILED tests/commands/test_scope.py::test_attest_writes_none_when_no_current_credential_resolves
FAILED tests/execution/test_executor.py::test_confirming_replaces_a_stale_fingerprint_rather_than_carrying_it_forward

The mechanism is sharper than "the environment leaks in"

CI's empty credential store was silently supplying a precondition one of the tests needed.

current_credential_fingerprint is imported independently at three sites — commands/scope.py, execution/guards.py (rails 20 and 22), and execution/executor.py. The executor test patches only its own. With a credential configured:

guards' copy resolves a genuine fingerprint → it disagrees with the row's "stale000…" → rail 20 vetoes the entry → execute never reaches the confirm step → the fingerprint stays stale and the assertion fails.

On CI the unpatched call returns None — "no evidence to disagree with" — no veto fires, and it passes. The None that made the test work came from the machine, not from the test.

The fix

Each test states its own assumption, in the pattern its own file already uses for every sibling test:

  • test_scope.py patches keel.commands.scope.current_credential_fingerprint → None, exactly as the three tests above it already patch that same target. What's under test is what the command writes when nothing resolves; the "nothing resolves" half belongs in the fixture.
  • test_executor.py patches both sites, deliberately to different values: guards → None so rail 20 has nothing to disagree with and the entry reaches the confirm step; executor → "fresh…" so the overwrite under test is observable. The asymmetry is written into the test, because a reader would otherwise "fix" it.

A suite-wide fixture was tried first, and is the wrong shape

Recorded because the idea is tempting and the reasons it fails aren't obvious. Three attempts, three collisions:

attempt broke why
stub _from_keychain 1 made test_the_broken_keychain_test_is_not_vacuous vacuous — it calls that function directly to prove it reaches keyring.get_password
pin default_env_path 5 test_paper_equities_profile.py legitimately chdirs and writes its own .env for load_alpaca_secrets, which doesn't use read_secret at all
wrap read_secret for identifier names 12 test_credential_identity.py deliberately exercises real .env/keychain resolution using exactly those names

…truncated — full description in #743.

Research & validation

docs(experiments): the 08-13 restatement restated on v0.13.3 (#737, #738) (#739)

Closes #737. Closes #738.

The verdict

0 of 240 configurations clear n ≥ 100 ∧ profit_factor > 1.0 at the taker rate this account actually pays, priced per product. Zero at the maker rate. 960 trials — five shipped signal families × 24 assets × 3 fees × 2 slippage regimes, plus the transfer check and the frequency axis. No argmax in Arm A, zero free parameters.

The 08-13 null holds across a population 2.7× larger, under a strictly more expensive cost model, on a corpus 23 days longer.

The finding, which is not that number

The engine never moved the 08-13 numbers. 565 new candles did.

The deployment DB had grown since 08-13, so a naive re-run would have absorbed that and reported it as an engine effect. A truncated-corpus control separates them:

cell 08-13 printed today's engine, 08-13 corpus today's engine, full corpus bars added
turtle XRP-USD 157 / 1.223 157 / 1.223 — identical 157 / 1.223 +0
turtle PAXG-USDT 238 / 1.145 238 / 1.145 — identical 238 / 1.145 +0
turtle FET-USD 269 / 1.206 269 / 1.206 — identical 272 / 1.207 +565
turtle CRV-USD 260 / 1.017 260 / 1.018 267 / 1.023 +565
turtle ZEC-USD 268 / 1.442 271 / 1.411 274 / 1.700 +565
pullback ZEC-USD 170 / 1.044 171 / 1.025 174 / 1.695 +565

Plus a fourth identity from Arm A: rsi_meanrev at its shipped oversold=20 reproduces §4's anchor to four decimals — median gross 1.1251 at median n=42.

#442's gap-through-stop exit fill, its ratchet policy, and #523's re-derived cap are all inert for these rules. Recorded because the honest expectation was the opposite, and because a conservative-only correction that turns out to be a no-op is worth knowing before the next one is deferred on the same reasoning.

The deletion test, run in reverse on real data

§2 of 08-13 argued ZEC's edge was tail-carried, evidencing it by deleting three trades. Adding three moved pullback_continuation on ZEC-USD from gross 1.025 → 1.695. A profit factor a fortnight of ordinary data moves by +0.67 on 174 trades is measuring three trades, not an edge.

And per-product pricing deletes the episode anyway. ZEC is the fourth-thinnest name in the universe — 107.3bp, 21.5× the floor, on $1.08M/day. Priced at its own liquidity its gross falls to 0.889: a loss before any fee.

Corrections of record against 08-13

None of these rewrite it — it keeps its numbers and gains a forward pointer (#247).

…truncated — full description in #739.

Other changes

docs(experiments): every flat-priced record points at the cost correction (#690)

Recommended by #335's own restatement, not done at the time.

Sixteen records report profit factors priced at the flat 5bp slippage floor. One document — the restatement itself — knows that no asset in keel's universe reaches that floor (1.1× to 36.8×, median ~10×).

Why a pointer and not a re-run

The correction is conservative-only: real cost is higher, so a corrected profit factor can only fall, and every one of the 120 measured deltas was negative or zero. Re-running thirteen documents to move numbers that were already null spends real compute to reach the same conclusion.

What changes isn't any document's verdict — it's what a reader may conclude by comparing across documents. A 0.9 in one and a 1.06 in another aren't on the same scale when both were priced at a rate neither asset trades at, and nothing on the page said so.

Nothing is rewritten

docs/experiments is an append-only account of what was run; editing a measured number would falsify it. Every change here is an addition — git diff origin/main -- docs/experiments/ contains no removed line that isn't part of a note being reflowed.

The index needed its own note, because the blanket one was false there

Applied uniformly, the note told docs/experiments/README.md that "the figures below are priced at the flat floor" — and the index has no figures. It now says what's true of it: that every record dated before 2026-09-01 is flat-priced.

A note that misdescribes the page it sits on is worse than no note. I caught this by reading the rendered output rather than trusting that a uniform edit was uniformly correct.

Test first

The third assertion is the one worth having: a bare link would read as a retraction, which is a worse error than the one being corrected. The pin requires the word "verdict" within 600 characters of the link, and a mutation replacing the qualification with "These numbers are wrong." fails it.

Tests: tests/test_experiment_cost_pointers.py — 3 tests.
3 mutants, 3 killed: a record losing its pointer; a pointer that reads as a retraction; and the discovery regex matching nothing — that last one because every assertion here passes vacuously if the scan finds no records.

Full suite 5258 passed / 3 skipped; ruff clean.

feat(coinbase): refuse a derivative-capable account, and say what that does not prove (#666) (#692)

Closes #666 (Stage 1). Stage 2 — the operator attestation — is filed as #691.

Written against what a probe of the live account actually returned, not against a guess.

The probe

get_transaction_summary → margin_rate present: True, value: null
get_portfolios          → DEFAULT 'Default', CONSUMER 'My portfolio'

margin_rate is present-and-null. It's in the response schema for every account, so its presence signals nothing — a check keyed on presence would refuse every deployment, which is the exact failure this issue was filed to avoid.

A non-null value is plausibly a signal, and could not be verified — there's no margin-enabled account to compare against, so that refusal branch would ship untested. A compliance gate whose refusal path has never fired against a true positive is the gate that eventually fires on the compliant case and gets disabled in anger. It's surfaced as a warning on get_fee_summary, which already fetches that response, so visibility costs no extra request.

What is unambiguous

The portfolio list. DEFAULT and CONSUMER are the spot portfolios; INTX is the international/perpetuals one, and its presence is derivative capability on the account. verify_cash_account refuses on it, called from _build_broker beside Alpaca's — one get_portfolios read per build.

Passing means "no contradiction found", never proof

The docstring says so, and a test asserts it does, because a reader taking a pass as evidence has the guarantee backwards. Coinbase exposes no cash-versus-margin field for spot — every margin, borrow, leverage and liquidation field in its SDK lives in the futures, perpetuals or derivative-order types. Nothing here can issue a cash posture. #691 carries the attestation that can, on rail 17's pattern.

⚠️ It passes on an unreadable response — the opposite of Alpaca

A property of the venues, not an inconsistency. Alpaca fails closed because multiplier is the classification: a readable answer is definitive, so silence is a distinct third state worth refusing on.

Here the check can only refute, so silence proves nothing a readable response would also have failed to prove — and failing closed would refuse a compliant deployment on a network blip while establishing nothing. A mutation making it fail closed is one of the six killed.

The pin that fired

docs/fiqh-basis.md's open question is rewritten, and test_the_venue_boundary_premise_is_stated_not_hidden failed when this landed — which is what #672 wrote it for. It's now been re-pointed twice, and its docstring records both moves: first from a proxy that stopped tracking what it stood for, now from a premise that genuinely changed.

Verification

…truncated — full description in #692.

fix(doctor,docs): the documented path out of a halt must actually end… (#695)

… the halt (#693, #694)

Two defects, both found by following keel's own instructions during the paper-equities bootstrap, and both of which cost real operator time.

#693 -- doctor's rail.kill_switch finding offered keel autonomy on as the fix. That command cannot clear a kill switch; its own docstring says so. They are separate gates on purpose: autonomy decides who gets ASKED, the kill switch decides whether the agent runs at all. An operator who followed the fix line ended up in the worst of the two states -- a halted agent authorised to trade unattended. The fix line now reads keel resume.

The old assertion in test_doctor.py pinned keel autonomy on, so the defect had a test defending it. It is restated here, with a comment saying what it got wrong.

#694 -- the runbook's paper-equities and paper-hourly bootstraps ran migrate, seeded rules and fetched candles, then stopped. migrate creates schema and never seeds, so a fresh database has no kill_switch row and get_state("kill_switch", default=True) fails closed. Both profiles logged skipped: kill_switch every cycle until someone noticed. Each bootstrap now names the arming step immediately after its own fetch block, deliberately OUTSIDE the copy-pasteable fence -- same gate as keel autonomy on and the rail-17 release: a human at a terminal, never a scheduled job.

Tests (written first, red before green):

tests/commands/test_doctor_fix_lines.py -- 2 tests: every fix line names a
real command, and the kill-switch fix names keel resume specifically
tests/test_bootstrap_arms_the_profile.py -- 3 tests: both bootstrap
sections exist, each names the arming step, and the step is NOT inside a
copy-pasteable block

Mutation-verified, four mutants, all killed: fix line reverted to autonomy on; fix line naming a nonexistent command; the equities arming step deleted; the arming step folded into the fence.

Closes #693
Closes #694

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)

feat(research): measure what a round trip costs on a commission-free … (#697)

…venue (#371)

Alpaca Phase C's cost-fidelity half. The PRD makes this a precondition -- "no strategy claim is believed before it" (§6.3, O4) -- and the ordering earned its keep: config.paper-equities.yaml ships taker_pct: 0.0, honestly labelled as awaiting this work, and priced literally that models a FREE venue.

Result: a round trip costs ~2.2bp on Alpaca equities and ~306bp on Coinbase spot, a factor of 141. keel's crypto model is validated in passing (332bp charged vs 306bp measured, conservative by 8%, from an estimator with no knowledge of Coinbase's fee schedule). Its equities pricing is 7.5x too high.

Two findings mattered more than the headline.

THE AGGREGATION CHOICE IS WORTH 22x, AND THE OBVIOUS ONE IS WRONG. Corwin-Schultz two-day estimates go negative constantly. Flooring each pair and averaging the survivors keeps the positive half of symmetric noise and discards the negative half, so the mean converges on E[max(X,0)] > 0 -- it reports a spread that is not there, and a bigger one the more volatile the series is. Worst, therefore, exactly on the asset class this exists to price. The paper's own procedure averages within a month and floors the MONTHLY mean; negatives then cancel inside the block. Same data, same estimator: MSFT 41.8bp -> 1.9bp. The first run of this measurement reported 43.6bp and a 4.1x ratio, and what exposed it was the implausibility of a mega-cap quoting 41.8bp, not a test. Both aggregations stay reachable, because the comparison between them IS the finding.

KEEL READS MSFT AS A THIN ASSET. Cached median daily quote volume is $186M -- about 2% of MSFT's real consolidated volume, which is approximately IEX's market share, and the equities profile runs on the IEX feed. Every ticker sits below the model's $500M anchor, so every one is priced as thin. The candles are fine; the statistic is being asked a question the feed cannot answer. Bounded for cost modelling, unbounded for liquidity screens and admission floors. Filed as #696.

Tests (written first, red before green):

tests/research/test_spread.py -- 28 tests. The load-bearing one is
test_flooring_each_pair_biases_a_quiet_series_up: a seeded random walk
with NO spread at all, where the naive aggregation must read >5x the
blocked one. test_the_overnight_gap_adjustment_is_applied pins §I.B --
equities gap nightly and crypto does not, so an unadjusted estimator would
inflate the equities side of exactly this comparison, in the direction that
flatters keel's existing prior.

Mutation-verified, 12 mutants. One survived -- averaging over positive blocks only rather than all blocks -- because no test had a series with both positive and negative BLOCKS; test_a_floored_block_still_counts_toward_the_average was added and kills it. One further mutant proved equivalent (sum(max(m,0)) and sum(m for m if m>0) over the same denominator) and is recorded as such rather than counted as a survivor.

…truncated — full description in #697.

feat(research): the equities DCA benchmark, read per ticker (#371) (#699)

Alpaca Phase C's second half, and the last of #371. It follows the cost-fidelity document in that order deliberately -- the PRD makes that a precondition, and a DCA sleeve priced wrong is exactly the claim it was protecting against.

THE BENCHMARK IS THE MEDIAN TICKER AT +70.62%, NOT THE POOLED SLEEVE AT +145.10%. The run's pre-declared refutation condition -- "a sleeve whose terminal value is dominated by one ticker, making 'beat DCA' a statement about NVDA rather than about equities" -- fired. NVDA returned +449.35% and is 44.8% of the terminal sleeve. Without it the remaining four returned +69.03%, within 1.6pp of the median, which is why the median is the honest summary. Quoting the pooled figure would set a bar one exceptional name built and then credit or blame a strategy for clearing it.

THE COST ARMS BARELY DIFFER, AND THAT IS THE USEFUL PART. keel's 7.5x equities mispricing is worth 0.18 percentage points over five years here; even the fully counterfactual crypto_regime arm (Coinbase's 1.2%/leg on equity bars) costs 3.08pp. A DCA sleeve pays its spread 250 times, one way, on a position it never closes. This is the crypto finding reached from the other side and it is easy to get backwards: cost is levied on the SEARCH, not on the edge. Cheap execution does not make a strategy good; expensive execution makes a mediocre one impossible.

sim/portfolio_sim is deliberately NOT used, and the record says why: it iterates ONE_HOUR bars (_window_bars is history_days * 24, mirroring the live agent's hourly account pass) and the equities profile is ONE_DAY only by configuration. Handing it daily bars labelled as hourly would produce numbers that look right and mean nothing. The accumulation loop is in the driver and calls the SHIPPED Dca rule, filling at the next bar's open plus one-way slippage -- keel's own market-order convention since #258, with the deciding bar always strictly before the filling bar.

Tests (written first, red before green):

tests/test_dca_benchmark_is_read_per_ticker.py -- 6 tests. They pin the PROSE,
because that is where the damage would be done: the headline must name the
median rather than the pooled figure, the concentration must be quantified
BEFORE the first heading, the ex-NVDA row must survive, all three declared
cost arms must still be reported, and the record must refuse to read as a
recommendation. test_one_ticker_really_does_dominate_the_sleeve guards the
premise -- if a re-run ever balances the sleeve, these fail loudly rather
than enforce a caveat that no longer applies.

Mutation-verified, 5 mutants, all killed. One initially survived: gutting the concentration sentence while the table's "44.8%" column kept the pin green. The pin now requires the figure in the opening, since a reader who takes the headline and stops is exactly who the caveat is for.

Closes #371

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

…truncated — full description in #699.

docs(evolution-plan): appendix 8 — the honest cockpit, from the Alpaca audit (#709)

Appends section 8 to docs/evolution-plan.md: the Alpaca dashboard audit's take/refuse verdict, the beyond-parity ideas (cancel asymmetry #707, per-order honesty receipts #700, the equity series #698, the Research Hub #708), sprint sequencing for milestone 28, and the standing guardrails (loopback-safe, nothing waits on #648, ceremony card stays Phase B, Fields-only wire, zero client-side math).

Markdown is canonical; the PDF edition regenerates on the next docs pass.

feat(data): record which feed a cached series came from (#696, part 1… (#710)

… of 2)

Schema v17. The provenance half of #696, landing BEFORE the gate that reads it -- a gate built on a feed inferred at read time would encode the bug it exists to fix.

THE BUG. keel's liquidity statistic is median(volume * close) over cached candles, and every threshold keyed on it was calibrated against venue-reported volume. That is coherent on a crypto exchange: Coinbase's own volume IS the scale the floor was chosen against. It is not coherent on Alpaca's IEX feed, which reports one US equity exchange's own executions. The cost-fidelity run measured MSFT -- one of the most liquid securities in the world -- cached at $186M/day against a model anchored at $500M, and priced it as a thin asset. Nothing recorded which feed produced those bars, so the answer was whatever broker.data_feed happened to be loaded when someone later read the series.

WHAT LANDS HERE.

candle_series_feed, keyed on (product_id, granularity, feed) -- NOT a mutable column on candles. A series fetched under both feeds records BOTH rows, because that is what happened; a single overwritable column would let the most recent fetch erase the fact that most of the bars came from somewhere narrower, and a mixed series is exactly the case a reader most needs warning about.

NO BACKFILL, and that is the point rather than a shortcut. Seeding rows from the current broker.data_feed would manufacture precisely the claim the table exists to make checkable, for years of bars that may have come from elsewhere. An empty table means "unrecorded", which is TRUE of every pre-existing series and is a different statement from "consolidated" -- reports_consolidated_volume returns None for the first and False/True for the second, and callers must not conflate the absence of evidence with evidence.

keel/data/feed_scope.py carries the design, whose core is an ASYMMETRY: a single-venue feed's volume is a LOWER BOUND on consolidated volume, never an upper one. Volume at or above the floor on a partial feed is therefore a CONCLUSIVE pass -- if a name traded that much on one venue alone it necessarily traded at least that much in total -- while volume below the floor licenses no claim either way and must be refused as unmeasured rather than reported as thin. That is what lets this be honest without encoding any venue's market share: the bound holds for any share below 100%, so nothing here has a percentage in it that market structure could invalidate. test_the_bound_needs_no_market_share pins that no figure leaks into executable code, because the one thing that must never happen is someone scaling a volume statistic by an assumed share.

An unrecognised feed id reads as PARTIAL, which costs a conclusive pass on a consolidated feed nobody declared and never grants one on a narrow feed nobody declared.

…truncated — full description in #710.

feat(compliance): the asymmetric lower-bound liquidity gate (#696, pa… (#711)

…rt 2 of 2)

Part 1 recorded WHICH feed a series came from. This is what reads it.

THE GATE. Venue volume is a LOWER BOUND on consolidated volume, never an upper one, so the two directions are not equally informative and the gate treats them differently:

  • at or above the floor, on ANY feed -> CONCLUSIVE PASS. A name that traded that much on one venue alone necessarily traded at least that much in total. A gate that refused here would ban MSFT and AAPL for thinness they do not have, and would turn a data-vendor pricing tier into a prerequisite for running the engine at all.
  • below the floor, on a partial feed -> NOT A VERDICT. Equally consistent with a thin asset and with a liquid one that barely trades HERE. Refused as liquidity_unmeasured, naming the feed and both dollar figures, and saying consolidated volume is UNMEASURED rather than low. A test asserts the line never contains "illiquid" or "too thin": the refusal must not assert the half it cannot see.
  • below the floor, on a consolidated feed -> unchanged. The crypto path is untouched, because Coinbase's own volume IS the scale the floor was calibrated against.
  • below the floor, scope UNRECORDED -> unchanged verdict, annotated. Every series cached before v17 is unrecorded, and treating unrecorded as partial would refuse the entire existing universe over metadata nobody wrote down. It self-heals as series are re-fetched.

market_facts reads ONE_DAY provenance specifically -- that is the granularity the statistic medians, so an hourly series on another feed says nothing about it.

doctor gains data.feed_scope, WARN and never FAIL: a free single-venue tier is a legitimate configuration, and the bound admits its liquid names honestly. What is not legitimate is not knowing, and the screen only speaks for a candidate somebody is actively screening while slippage_for_quote_volume prices off the same number every cycle in silence. Partial and unrecorded series are reported SEPARATELY: one should be re-fetched under a different feed, the other may already be consolidated.

The cost-fidelity record is amended rather than rewritten (docs/experiments is append-only). The amendment retracts two claims that were asserted rather than measured -- "roughly 2% ... approximately IEX's share" (IEX publishes ~3.8% for Q2 2026, so the figure was simply wrong) and the "~50x" understatement -- notes that no number in the document depended on either, and records that what shipped is better than what that section proposed.

Tests (written first, red before green): 27 across three files.
tests/compliance/test_liquidity_lower_bound.py -- 11
tests/compliance/test_market_facts_carries_feed_scope.py -- 6
tests/commands/test_doctor_feed_scope.py -- 8 (+2 existing)

…truncated — full description in #711.

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

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

…truncated — full description in #712.

docs(fiqh): the cash posture residual is recorded, not merely acknowl… (#713)

…edged (#691, part 2 of 2)

Closes #691. The last two acceptance boxes: keel brokers list and the docs/fiqh-basis.md open question.

THE PIN DID NOT FAIL WHEN IT SHOULD HAVE, AND THAT IS THE FINDING. test_the_unprovable_half_of_the_cash_posture_is_stated_not_hidden promised in its own docstring that "closing that needs a human attestation, not another adapter read -- and if one is ever built, this fails again". #691 built one. The pin passed, and the issue's own acceptance criterion predicted a failure that never came.

Nothing in the pin observed the attestation, so it repeated its FIRST failure mode exactly: that form pinned _sell_base_size's docstring as a proxy and held while the paragraph it guarded went false. A promise in a docstring is not a pin, and prose that matches is not prose that is true.

So this re-pointing reaches for the MECHANISM rather than for sentences. Four assertions that each go false if something real is removed:

  • CashPostureState must carry no CONFIRMED -- adding one would mean keel believes something can AFFIRM a spot cash account, which is the claim the doc spends a section refuting.
  • ATTESTATION_TTL_SEC > 0 -- without expiry, "the operator states it and keel records it" degrades to "keel remembers what someone said once".
  • cash_posture must appear in guards.py -- with no rail reading the record, "an unattested posture vetoes live entries" is a sentence about nothing.
  • the stale "a human attestation this repository has not yet built" must be GONE -- the specific prose regression, caught by string rather than hoped about.

THE DOC. Two passages were wrong on their facts, not merely dated: one said keel "has no cash-account posture check on Coinbase", which #666 added, and the other said the attestation was unbuilt. Both are corrected, and the section now states the three load-bearing properties (no confirmable state, a 90-day expiry, a claim bound to a credential) followed by the sentence to keep if only one survives: keel still cannot verify this, and neither can the venue. What changed is that the trust is now dated, revocable and written down, rather than assumed.

THE BLOCK. keel brokers list gains a THIRD block after declarations and readiness, which follows this codebase's own argument rather than a preference: venue_readiness renders separately because merging two questions "would re-blur exactly the distinction #233 exists to draw". Posture is a third question -- readiness asks whether this CREDENTIAL may trade, posture asks whether this ACCOUNT can borrow. It ends on its own honesty line, and a test forbids the words "verified", "confirmed", "checked" and "proven" anywhere in it: every other readiness surface reports something it checked, and this one cannot.

Read-only and best-effort, _readiness_rows' discipline -- a display command must not create a database, and a pre-v18 file renders an empty block rather than failing.

…truncated — full description in #713.

Feat 698 equity points (#714)

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)

feat(web): status tabs and the rule that placed each order (#700) (#716)

Most of #700 was already shipped. The issue's "Context (verified)" says there is no /api/orders endpoint and no Orders view; both have existed since #659, along with expected-vs-actual fill, the divergence and whether it went against keel, fee with a charged/modelled flag, the placement record, venue_order_id, and #626's submit-time book. What was actually missing was two things, and this is those two.

THE STATUS FILTER. gather_orders(status=), applied in the same pass as the scope so get_orders() is still called with no arguments at all -- the load-bearing pin in that function is unchanged, and its reason was always about MODE rather than about filtering in general. Server-side because a client filtering the page it received under ?limit= would be filtering fifty rows and presenting the result as every canceled order: the same argument that already puts ?sort= here.

An unknown status is applied, not refused. ?sort='s columns are a closed set this code declares, so an unknown one is a client bug worth a 400; a status is written by whatever placed the order, and _ORDER_STATUS_STATES already carries cancelled beside canceled because spellings differ. A refusal would reject a status some book genuinely holds. An unmatched filter comes back empty saying which emptiness it is, which shows a typo as plainly as a 400 and cannot be wrong about a real word.

Three new report fields, each because a number already on the page would otherwise be quietly wrong:

  • statuses -- every status in the WHOLE book, which is what a tab bar is built from. Scoped, the Canceled tab would disappear on a quiet day and return tomorrow, and a control that comes and goes is worse than one that is sometimes empty. Same reasoning as modes.
  • filtered_count -- scoped_count keeps its meaning (rows in the WINDOW), so a filtered view needs its own denominator. Without it "2 of 3 filled" counts rows from other tabs: every number true, the sentence false.
  • empty_reason: "status" -- a fourth way to render zero rows, and a fourth sentence. Reusing the empty-book wording would tell a reader keel has never traded on a deployment whose book is full. Checked widest-cause-first, so an empty book is never blamed on the open tab.

The note is a FIXED string that does not echo the requested status. ?status= is caller-controlled text; it crosses in status, which the client places as a value.

THE RULE THAT PLACED IT. rule_id alone is a foreign key -- a number a reader cannot act on. The name is rules.kind, the same string build_rule_track_record calls rule_name, so the Orders table and the track-record table cannot name one rule two ways. Resolved from ONE read of the rules table, passed down as a map: a per-row lookup would be one query per order against a book capped at MAX_ORDERS_LIMIT, and would be invisible on any fixture small enough to read, so there is a pin on the read count.

…truncated — full description in #716.

feat(web): the Positions view -- marked at the rails' own price (#701… (#717)

…, items 1-4)

keel status showed a tranche's id, product, rule, qty, entry price and bracket, and web/payload.py::_position_payload explained in its own docstring why it showed no P&L: "OpenPositionStatus carries neither, so emitting one would mean this layer multiplied qty by entry_price... the fix, if it is wanted, is upstream." This is that fix, upstream, plus the endpoint and view that read it.

THE MARK IS THE RAILS' MARK, AND THAT IS THE WHOLE POINT. agent._mark_to_market_parts values a holding at repo.get_candles(product, finest)[-1].close; gather_positions makes the same read through the same _finest_granularity. A positions page quoting a different current price would be a second answer to "what is this worth", and since the first answer moved rail 11's drawdown scalars, the page would be the wrong one.

That is asserted across the module boundary rather than claimed. The two readers do NOT share a source for QUANTITY: the agent counts inventory from the filled-orders log -- never from positions, deliberately, after the phantom-drawdown bug that taught it -- and this report reads the tranche ledger. They share only the mark. So the reconciliation test is what says the two ledgers agree about what is held, and it fails the day they drift.

WHAT IS ABSENT STAYS ABSENT. No cached candle means no mark, and no mark means no market value, no unrealized and no stop distance -- never zero. A zero market value renders a held position as a total loss, which is the most alarming thing this page could say and it would be saying it about missing data. initial_stop NULL is "not on this row" (a DCA leg, a pre-v12 tranche), so its distance is absent too: measured against a substituted zero it would read as a position comfortably clear of a stop it does not have.

Signed where the sign means something, and nowhere else. unrealized and stop_distance carry verdicts; a market value does not, because an account is not good for being worth something and a glyph on every balance hides the one figure that matters. A negative stop distance means the tranche is trading THROUGH its protection -- keel is cash-spot and long-only, so a stop always sits below the mark and the sign has one meaning. stop_distance_pct crosses as a raw fraction with no %, the posture ratio already documents for the drawdown scalars.

THE FRESHNESS CHIP IS THE ENTRY GATE'S VERDICT, not a data age. missing/behind/ unconfirmed are entry_bar_ready's own words for why the agent would refuse to open here. freshness.assess tolerates the normal forming-bar lag; entry_bar_ready refuses a one-bar-late finer series because that lag is exactly what produces a duplicate real-money order. Showing the softer number would tell a reader the feed is fine while the engine's own gate is refusing it -- and this chip is the most common answer to "why has nothing happened", which is why it sits beside the money rather than under a disclosure.

…truncated — full description in #717.

feat(web): the Balances view, from what a cycle recorded (#702) (#720)

#702 specified "a live adapter read". That was refused, deliberately and on the record.

keel serve is a loopback reader over SQLite with no credentials, no broker handle and no outbound network -- gather_status says "no broker, no network", and even the Venues screen reads adapter declarations with "no broker handle, no network, no config, no credentials". A balances page is the obvious place to break that line, and breaking it would put venue credentials into the one process a browser can reach and hand an operator's rate limit to every tab left open on a view that re-polls every 15 seconds (main.js's POLL_MS). Hundreds of REST calls an hour, during trading windows, for a number nobody asked to refresh.

What replaced it is better rather than merely safer. Cash comes from equity_points (#698): the figure the agent READ AND SIZED AGAINST when it last evaluated the rails, stamped with when it read it. A fresher number the engine never saw explains nothing about why it did what it did.

READ THROUGH THE MODE, ALWAYS. equity_points holds paper and live rows in one database, so the newest row FOR THE MODE IN FORCE is the answer -- the newest row of any mode would show a $10,000 synthetic balance on a live deployment holding $250. paper_cash_usdc is withheld entirely in live mode even though the key survives a paper->live flip: a synthetic balance beside real money is the most confusing thing this page could show. An unstamped mode, before the first cycle, yields no cash rather than a guess about which account to read.

WHAT IS NOT RECORDED IS NAMED. #702 was written around settled-versus-total cash, and that distinction is not in the database: equity_points.cash comes from _fetch_available_quote, which reads Balance.available and stops, summed across every currency in play -- so the Alpaca adapter's T+1 clamp reaches storage with the distinction already flattened out of it. The tiles render as absent with UNRECORDED IN CYCLE SNAPSHOT beside them, rather than showing the available figure under a "settled" label that would answer the question this page exists to ask honestly. #719 records the pair; the report and payload were shaped so that lands as a value change, not a shape change.

A PARTIAL SUM IS THE DANGEROUS SHAPE. An asset whose tranches are not all priced reports its QUANTITY and no value. Summing over the priced subset would look like a total and be a smaller one -- and a holding rendered as worth less than it is, on the page an operator checks to see what they have, is the failure worth engineering against. What is held is a fact; what it is worth was not observed.

…truncated — full description in #720.

feat(web): one chronology over four stores, with an injection-safe au… (#722)

…dit export (#703)

Four stores record what keel has done and none of them knew about the others: the engine's JSONL log, the orders book, the transactions ledger, and the attestation tables. Merging them is only an improvement if the merge keeps them APART -- otherwise it is four tables with their labels removed.

So provenance is a first-class field from a closed vocabulary, decided once in the service and never inferred from a row's shape by a renderer. A live fill is venue-reported. A paper fill is simulated -- the paper trader wrote it with no venue involved, and one word covering both would put synthetic and real evidence in one bucket, which is the one thing four separate tables at least never did. A transactions row is imported-ledger: the venue produced the CSV, but nothing verified the line on the way in, and that is not the same claim as a venue report. An attestation is human-attested, and the name of whoever swore to it is in the summary, because "who signed this" is the first question an auditor asks of one. A cycle is engine-log: the agent's own account, which nothing outside the process confirmed and nobody signed.

simulated is the only provenance styled WARN. Not because synthetic evidence is worse -- an imported ledger line is not worse evidence than a venue report, it is different -- but because a paper fill sitting in a chronology beside real ones is the single row a reader must not skim past.

THE EXPORT IS A SECURITY SURFACE, AND WAS TREATED AS ONE FROM THE FIRST LINE. This file is meant to be opened in Excel or Google Sheets by an auditor or a tax preparer, and both EXECUTE a cell whose text begins =, +, -, @, tab or carriage return. Several columns carry text keel did not write: a transaction's notes and coinbase_id come out of a venue's own CSV export, an attestation's source and attested_by were typed by a person. csv_safe applies OWASP's defence -- prefix with a single quote so the cell is inert text -- to EVERY cell rather than to a list of the risky ones, because a maintained list of which columns are attacker-influenced is exactly the thing that rots.

Writing the end-to-end test for that found my own example was wrong: a hostile notes value is diluted by accident, because summary prefixes it with "deposit USD -- " and the cell no longer starts with a trigger. The real path is a field that lands at the START of a cell by itself, so the test drives a hostile coinbase_id into reference. Relying on the accidental prefix is exactly what applying the defence everywhere avoids.

…truncated — full description in #722.

fix(web): the CSV export sent a body on HEAD, dropped connections, an… (#723)

…d truncated itself (#703)

Review findings on #703, which merged before these landed. Three of them are on the export route, and all three are the kind static analysis cannot see.

A HEAD RETURNED THE WHOLE FILE. _send, _send_json and _serve_static all end their write with if self.command != "HEAD". _send_csv did not, and do_HEAD delegates to do_GET -- so HEAD /api/timeline/export.csv answered with the header block plus 468 bytes of CSV. On a keep-alive connection those bytes are framed as the NEXT response: a same-origin response desync, not merely a wasted transfer, and a violation of RFC 9110 §9.3.2.

The first test written for this passed against the bug. http.client knows a HEAD carries no body and never reads one, so it reports an empty body whether or not the server sent bytes -- the test now reads a raw socket, and says why in its docstring.

THE ROUTE DROPPED CONNECTIONS. respond is what normally guarantees this server never answers a GET by raising: ApiRefusal becomes a 400, anything else a 500 envelope. The CSV branch does not go through it, so ?limit=abc propagated out of the handler -- the client saw a reset connection with no response, and a traceback of absolute source paths reached the stderr log_message is overridden to keep quiet. A first-run machine with no config.yaml did the same, where /api/timeline answers 200 with engine: "stopped". Wrapped now, answering the JSON envelope: the failure is not a file, and a browser handed a truncated download learns nothing.

THE EXPORT WAS SILENTLY TRUNCATED. It inherited the paged route's ?limit= -- default 200, ceiling 2000 -- and the file said nothing about it. A 5,000-event deployment exported its most recent 200 rows, to be handed to an auditor or a tax preparer as a complete record. The cap exists because the console POLLS the JSON route every 15 seconds; an export is a deliberate download, requested once. export_rows reads the whole scope, and the scope -- the operator's own choice of today/7d/all -- is what bounds it.

A SORT COLUMN THAT ORDERED NOTHING. sortable=("ts", ...) while the payload emits at, so ?sort=ts was accepted, echoed back as applied, and left every row where it was -- the exact failure refusing an unknown column exists to prevent. The pin for this rot already existed at test_api.py:526 and covered two routes; /api/timeline was simply not in it, and is now.

…truncated — full description in #723.

feat(web): publish the research record, rejected trials included (#708) (#724)

View 1 of the Research Hub: the /research route, the /api/research/trials endpoint, and the trials-ledger table the rest of the hub hangs off.

keel's trials ledger has been append-only and hash-chained since spec §4. What was missing was the reading of it — the record lived in a file in the repo and reached nobody. This puts it on a screen, in ledger order, with the REJECTED trials in the same table as the selected ones. That is the point of the view: the rejected rows are what make the selected one evidence rather than a claim, and no broker, platform or competitor publishes theirs.

THE STRATHERN RAIL, HELD IN THREE PLACES

  • the service — gather_trials returns rows in ledger order and offers no ordering of any kind;
  • the endpoint — /api/research/trials declares collection="" and sortable=(). Not "no performance columns": NONE. _sort_request refuses a column a route does not declare, so every ?sort= is a 400 and ledger order is the only order this endpoint has. Declaring the harmless ones (at, rule) was the tempting middle road and is refused — a sortable collection invites the next column, and the next column is the one that ranks;
  • the view — researchView(data) takes neither sort nor onSort, unlike every other table view in render.js, and not one column declares a key.

An AST scan over research_record.py mirrors the one test_research_front_door.py runs over the CLI group.

THE CHAIN BADGE HAS THREE STATES, NOT TWO

chain_intact=False means "a row was edited" and also "there was no file to read", and those are opposite facts. Styling the second as a break reports a tamper that never happened; styling it as verified asserts a check that never ran. So the badge is GOOD only after verify_chain returned no errors over rows that were actually read, and a missing ledger reads UNKNOWN. A tampered row turns it BAD and chain_errors names which rows broke — a red light with nothing to look at is not evidence.

ALSO IN HERE: A RED TEST ON MAIN

test_every_declared_sort_column_is_a_column_the_rows_actually_have has been failing since #703 added /api/timeline to it. The seeding writes positions and rules; the timeline reads orders, transactions, attestations and the log, and reads positions not at all — so the pin asserted on rows nothing had written. _seed_orders fixes it, and the pin now genuinely checks at, kind, provenance, source and product_id against real timeline rows.

Notes on two decisions a reader will ask about:

…truncated — full description in #724.

feat(web): publish what a fill is assumed to cost, per product (#708) (#725)

The Slippage Universe, as a second section of /research. Every experiment document in this repository prices its fills at 5bp — the FLOOR of slippage_for_quote_volume, reached only at the model's $500M/day anchor. Priced from each asset's own cached liquidity, almost nothing reaches it. #686 established that and it has existed since as a sentence in a markdown file; this puts the per-product table on a screen, derived rather than restated, so it cannot go stale the way a hard-coded "0 of 24" would the first time the allowlist changed.

SEQUENCE CHANGED: THIS IS VIEW 4, LANDING WHERE VIEW 2 WAS PLANNED

The Evidence Matrix is blocked and the reason is recorded on #708. Its scope note says the grid is "assembled from ledger rows … a thin assembly layer, not a new store". It is not: the ledger's 93 rows are aggregate findings with hand-written per-experiment summary keys (cells_above_pf_1, pf_zero_fee_turtle_btc), there is no (rule, asset) -> (pf, n_trades) schema to assemble, and no regime field exists at all — "regime" appears twice in the file, both times inside English prose. #686's 240 per-cell rows were written to docs/experiments/_out/, which is untracked and absent. Building that view needs a tracked per-cell artifact first; assembling it from what is there would mean inventing cells.

EVERY NUMBER ON THIS PAGE IS AN ASSUMPTION, AND THE PAYLOAD SAYS SO

#708's scope calls these figures "per-product measured bp". They are not measured. slippage_for_quote_volume opens by saying so — keel stores no book snapshots and no realised spreads, so liquidity is proxied by the one statistic it does compute — and sim/report.py carries the same correction in bold on its own table. So basis crosses as a field rather than living in a comment nobody ships, and the floor, cap and anchor travel with the rows for the reason that file gives: "a number whose assumptions cannot be recovered is not evidence." Everything needed to recompute any row is on the page.

TWO FLAGS THAT BOTH WARN, BECAUSE BOTH UNDERSTATE

A fallback row got the model's CHEAPEST rate for having no cached history; a capped row hit the thin-end bound before its own liquidity was allowed to decide. Neither is an error and both are flattering, which is exactly what a reader skimming a cost table for big numbers would miss.

at_floor_count excludes fallback rows, and that exclusion is the honesty of the headline: a fallback row IS priced at the floor numerically, so counting it would turn missing data into the best case. priced_count is the denominator it is honestly out of.

BORROWED DEFINITIONS, NO FIFTH ONE

…truncated — full description in #725.

feat(web): publish the gauntlet as it was run, not as recomputed (#708) (#727)

View 3 of the Research Hub, re-scoped from "compute the gauntlet" to "show the gauntlet as it was run" — because every computed version was measured as impossible or unaffordable first.

The exhibit

Six of the ledger's 93 trials went through the gauntlet. Three produced a PBO with the seed that makes it reproducible; three record that it could not run at all. None passed the gate.

trial PBO ran gate seed
476-optuna-turtle_breakout 0.7 yes no 476
476-optuna-rsi_meanrev 0.571… yes no 476
476-optuna-pullback_continuation 0.842… yes no 476
cusum-event-first-measurement-2026-09-01 — no no —
triple-barrier-first-measurement-2026-09 — no no —
per-product-slippage-restatement-2026-09 — no no —

Verified against the real ledger before the view was written: total=93 recorded=6 available=3 not_run=87 passed=0.

Why this view computes nothing

The web UI is a viewer of recorded facts, not a batch computation engine. All four components #708 scopes for this view are computation, and each was measured:

  • DSR is impossible, not merely expensive. keel trials deflate takes --sharpe as a required operator input, because the ledger stores no observed annualised Sharpe per trial. Computing it here would mean synthesising the input.
  • PBO raises over the ledger as a whole — build_matrix: columns are not synchronous: found lengths [1819, 1828]. It is defined only within a session whose trials share a bar count, and a page cannot pick that scope without inventing an operator's decision. Where it runs: 11.9 s, 12.9 s, 14.3 s for the three eligible sessions, against a 15 s poll.
  • Monte Carlo and the DCA benchmark both run a backtest.

Timings and constraints are recorded on #708; #726 is the engine issue for persisting the distributions the CLI currently prints and discards.

Three states, not two

Ran (pbo_available: 1), attempted-and-refused (pbo_available: 0 — every column series-missing, or the grid too thin for CSCV), and never run. The third is a count, not 87 empty rows: a table of blanks would present the gauntlet as having covered the whole record. recorded_count is likewise the honest denominator for gate_passed_count.

Two styling calls worth arguing with

A failed gate is neutral, never bad. Every recorded run failed, and that is the system working as designed — a table of red rows would read as a list of things that went wrong rather than as evidence of a discipline being applied.

…truncated — full description in #727.

feat(data): schema v20 — provenance, attestation windows, balances, audit chain (#728)

The schema half of the batched engine sprint for #715, #718, #719 and #721. One version bump, no writers.

Landing the DDL on its own keeps the database atomically v19 or v20 rather than half-migrated across a PR series, and lets the no-backfill posture be reviewed by itself.

What it adds

issue added
#715 orders.quote_provenance, orders.client_order_id
#718 attest_due_ts on asset_attestations and instrument_attestations
#719 cycle_balances — one row per (cycle, currency), available/total as TEXT Decimals
#721 audit_events — the append-only hash chain settled in this comment

cycle_balances is per CURRENCY and not one scalar pair, because folding currencies into a single figure adds them 1:1 — the no-FX bound agent._mark_to_market_parts already states it will not cross.

No backfill, and it is tested

Every new column is NULL on every row written before v20, and NULL means "not recorded" — never zero, never a provenance inferred after the fact from limit_price or the submit book. A test seeds a real v19 orders row and an attestation row, migrates, and asserts NULL across the board. A mutant that adds UPDATE orders SET client_order_id = 'backfilled' fails it.

⚠️ audit_events.ts is INTEGER, deliberately against the issue text

#721's comment specifies ts TEXT. That is wrong for this schema, and the DDL says so where a reader will meet it: every other timestamp here is INTEGER epoch seconds, and equity_points.ts already documents why — TEXT orders an epoch lexically, so "9999999999" sorts before "10000000000" and a ts >= ? range read (exactly how an audit trail gets queried) silently returns the wrong rows once the digit count changes.

Following the issue literally would have walked straight into the one bug that comment exists to prevent.

Collateral, found by the suite

Repository.get_instrument_attestation is SELECT * → dict(row), so a new nullable column mechanically changes its return shape. The round-trip test asserted exact dict equality and broke — not because behaviour changed, but because a passthrough getter now honestly reflects the column.

Verification

  • 5,899 passed / 3 skipped; mypy clean on 430 files; ruff clean; scripts/migration_smoke.py reaches schema 20 from both a fresh DB and one downgraded to v1.
  • Eight mutants killed: version not bumped, migration unregistered, either ALTER dropped, either new table dropped, the per-column idempotence guard removed, and a backfill sneaking in.

Schema only — nothing in this release writes or reads any of it. Four follow-up PRs add the writers; commands/balances.py and commands/timeline.py keep reporting their fields as unrecorded until then.

feat(engine): record where an order's price came from, and the id we sent (#715) (#729)

Writers for the two orders columns that landed empty in schema v20 (#728). Closes #715.

Quote provenance, from the same preview the gate reads

keel_core.quote_provenance holds four tokens and a pure provenance_of(preview). confirm.py's four operator-facing banners are now derived from those same tokens rather than written twice, so the sentence an operator reads before approving and the value the database records cannot drift apart. No banner's wording changed — the existing 20 gate tests pass verbatim.

executor._order_row writes it beside submit_best_bid/submit_best_ask, from the Preview it already holds. It is a property of the preview, not of the confirmation gate, so it is recorded in autonomous mode too.

Paper orders record NULL: they fill synthetically with no venue preview at all, and a fabricated provenance sharing a column with a real one would poison the measurement the column exists for — the same posture the submit-book columns already take.

The harder half: nothing could record a client order id

resolve_client_order_id mints a uuid4 per placement attempt inside the adapter, and PlaceResult carried only success/broker_order_id/reason — so the id actually sent to the venue was discarded at the boundary. Left alone, the column would have been NULL forever and read as an honest absence rather than an unbuilt feature.

PlaceResult now reports it; three adapters populate it on every return path including rejections and pre-flight refusals; the executor writes it through on placement. keel-broker-kraken is an unimplemented stub, so there was nothing to wire.

Recording only. No idempotency_key is now passed into place_order. A per-attempt id is a deliberate default, and changing it would change venue-side deduplication — a behaviour change wearing a recording change's clothes.

Live-path safety

The executor diff is 14 added lines and none removed; no control-flow condition on the order path was touched. Confirmed by inspection of the diff, not just by the suite.

Where the code contradicted the plan

The token vocabulary follows the code rather than the issue. confirm.py distinguishes exactly four preview states — native, synthetic, unpriced, unreadable — and Preview.errors is not a fifth: it is an orthogonal alarm block that can accompany any of the four (a clean venue quote can still carry errors). So there is no VENUE_ERROR token.

Also un-stales the v20 DDL comments, which said "nothing writes it or reads it back" about both columns — true when written one PR ago, false now.

Verification

  • 5,934 passed / 3 skipped; mypy clean on 432 files; ruff clean across keel/, tests/ and packages/.
  • Five mutants killed: provenance not written, client id not written, either column dropped from the _ORDER_COLUMNS INSERT tuple, and PlaceResult no longer reporting the id.

…truncated — full description in #729.

feat(engine): record and report when an attestation's window closes (#718) (#730)

Writers and a reader for the two attest_due_ts columns that landed empty in schema v20 (#728). Closes #718.

The ON CONFLICT trap, tested rather than trusted

Both upserts are INSERT … ON CONFLICT DO UPDATE SET. Adding the column to the insert list but not the update clause would mean a re-attestation silently carries the previous window forward — a compliance window that never expires, which is worse than having none.

Both clauses set it, and the test that proves it re-attests without a window and asserts the old one is cleared rather than inherited. Mutating either clause fails that test by name.

Recorded, and then actually read

keel doctor gains asset and instrument window findings. A column nothing reads is a column nobody benefits from — the mirror of #715, where a column had no writer.

A NULL window is not an expired one. Every row written before v20 has NULL, and NULL means no expiry was recorded — never "expired", never "valid forever". It is excluded from both the passed and approaching groups, and the mutation that folds NULL into a finding fails a named test.

Note the deliberate asymmetry with cash_posture_findings beside it, which FAILS on a NULL due date: rail 22 requires an expiry for a cash-posture claim, so absent is a refusal there. Asset and instrument attestations have no such rail and inherit a table full of legitimate NULLs, so absent is benign.

⛔ Report only — no veto

compliance/screen.py is untouched. An expired window does not refuse a screen or block an entry. Findings are WARN, never FAIL, and say so in their own detail text.

Whether a stale window should ever veto is a decision for you, not for a recording issue. For the record, the implementer's view is that it should not: sector and backing facts don't go stale the way a credential or cash-posture claim does, so a blanket TTL would refuse well-known assets on a clerical technicality.

Smaller things

  • --attest-due on both CLI commands takes a unix timestamp or YYYY-MM-DD, reusing insights._parse_ts — the convention keel insights journal's --since/--until already established, rather than inventing one.
  • The first-run wizard passes nothing and records NULL. No default window is invented on an operator's behalf.
  • Un-stales three db.py comments that said nothing writes or reads these columns — true one PR ago.
  • upsert_asset_attestation had no round-trip test at all before this (only the instrument path did); that gap is now filled.

Verification

  • 5,960 passed / 3 skipped; mypy clean on 433 files; ruff clean across keel/, tests/, packages/.
  • Six mutants killed: either ON CONFLICT clause dropping the field, the CLI ceasing to pass it, NULL windows becoming findings, and passed or approaching windows ceasing to report.

Two pre-existing gaps found, deliberately not fixed here

…truncated — full description in #730.

feat(engine): persist the per-currency balance pair a cycle observed (#719) (#733)

Writers and a reader for cycle_balances, the table that landed empty in schema v20 (#728). Closes #719, and with it the last of the three recording gaps #702's Balances view was split around.

Rail 13's input was not refactored, and that cost a duplicate

_fetch_available_quote reads a Balance and discards Balance.total. The obvious move is to widen it. It is also rail 13's input, called before guards.check, and its every-failure-is-None contract decides whether an order is placed — so a sibling _fetch_quote_balance_pair was added and the original left untouched. Its 149 existing tests pass unedited.

Duplication is the right trade there, and it has a price: two functions answering one question can disagree, and if they do, the balance rail 13 gates on and the balance a cycle records quietly stop being the same number — each self-consistent, the divergence invisible from either side.

So they are pinned against each other across all eight cases: case-folding, an empty currency, a raising broker, a missing account, a None broker. Making one match currencies case-sensitively fails that pin.

The codebase already names this failure — median_daily_quote_volume's docstring refuses a second copy of one statistic, because a sweep proposing an asset the screen then rejects is what drift looks like from outside.

One mode read, two tables

Rows are written inside equity._append_equity_point, off the same equity_state_mode lookup that stamps the equity point — not from a second call in agent.run_once, which would have read the mode twice and let one cycle answer "which mode" two different ways.

The scalar-vs-per-currency question

The record is per currency; BalancesReport.settled_cash/total_cash are scalars. They report the settlement currency's row only, never a sum: _mark_to_market_parts states an explicit no-FX bound, and adding USD to EUR 1:1 would break it silently. Justified in both the module and field docstrings.

A paper deployment never reads a live cycle's balances

Found by mutation: hard-coding mode="live" in the read passed the entire suite, so nothing exercised any mode but live.

A deployment that traded live and is now on paper still has live rows on disk. An unscoped read would put a real venue's settled and total cash on a paper account's page — on the one screen whose whole job is saying what is actually there. Now pinned.

Verification

…truncated — full description in #733.

feat(engine): an append-only hash chain for the book, and a column that stops saying NOT RECORDED (#721) (#734)

Closes #721.

#703's activity export shipped with a row_hash column whose every cell read NOT RECORDED. This is the record that column was built to read.

The chain is over events, not over the book

orders rows are mutated — update_order writes status, fills and fees as a venue reports them, sometimes days later via execution.reconcile. A hash chained over the order row would break on every legitimate fill, and a chain that cries wolf on ordinary operation is one an operator learns to ignore. The issue named both options and said the append-only event table is the one that actually delivers tamper-evidence for a mutable book.

keel/data/audit.py chains immutable statements about the book. Each write to orders, transactions and both attestation tables appends one event in the same transaction as the row it describes. Re-importing a transaction appends rather than rewrites — two events for one coinbase_id is the record that a line arrived twice with different content.

One chain, not one per table: a per-table chain would let an event be moved between streams undetectably.

One canonical form, and a proof it did not move

The issue's hardest constraint. keel_core.hashchain now holds the form and the walk; research/ledger.py and data/audit.py both import it, and each keeps deciding what its own rows commit to.

The move is pinned by the one test in this repo that asserts against hashes computed by a previous version of the code: the 93 git-tracked rows in docs/experiments/trials-ledger.jsonl still verify byte-for-byte. Dropping separators from the canonicaliser fails it.

Three readings, and the middle one is the point

status meaning
chained an event exists and the chain vouches for it
not chained no event was written — rows predating this, and every engine-log row (the log is a file, not a chained store). An honest gap, deliberately not a break
chain broken the event falls at or after the first break. The hash is shown and is not evidence

A chain proves a sequence, so past a break the sequence is unproven whatever the single row's own hash still says. Showing those as chained would present unverified values as evidence, which is the one thing the column exists to prevent.

chain_status sits beside row_hash, never inferred from it — in the payload, in the table, and in the CSV.

Green requires something to have been checked

intact is no errors and events exist. An empty chain reports no breaks because it has nothing in it to break, so keel doctor says "audit chain empty — unverified". That is _chain_payload's four-state lesson from #708, on the trading side, where the store cannot be absent so there are three.

Refused

…truncated — full description in #734.

feat(web): session identity on every view, and a paper banner with nothing to click (#704) (#735)

Closes #704.

Take the spine, refuse what rides on it. Making paper-vs-live visible on every page rather than buried in settings is the one organizing idea worth borrowing from a retail broker console. What their version attaches — an "Open Live Account" button on the paper banner — is a growth funnel wrapped around real money.

The chip: profile · mode · equity state

It surrounds the existing mode badge rather than replacing it, so the badge keeps owning the mode word and its db/config tooltip, and each fact stays written by the one function that knows it.

profile names the database, and the stem is as far as it goes. ADR 0002 settles what a profile is — "the database is already one-per-profile", which is also why equity_points has a mode column and no profile one — so there is nothing stored to read. keel.db and keel-live.db are the operator's own names; a mapping from those to "paper"/"live" would be this console guessing which is which from a filename, on the one surface built to stop paper and live being confused.

equity_state is a Field, because it has an UNKNOWN reading that matters: equity_state_mode is written on the first mode flip, so a deployment that has never run has none — and "which account drove the drawdown scalars" being unanswered is a different fact from either answer. It reads not recorded, not an em-dash: label(None) is absent(), whose display is right in a table cell and wrong in a chip. keel · confirm · — drops the very half the banner asks the operator to verify.

One database read on an endpoint that must survive having no database

equity_state_mode lives in agent_state, and /api/config is the only endpoint every view reads — which is what makes the chip present everywhere. That route is needs_database=False because the client boots from it alone, and it still is.

The read checks the file exists before connecting, which is not a micro-optimisation: sqlite3.connect creates what it cannot find, so an unguarded read would have a read-only view bring a deployment into existence merely by being polled every 15 seconds — and every page would then report a healthy empty install rather than offering to set one up. server.ensure_schema carries the same guard, found the same way. Any failure degrades to unknown, so a first run still boots the shell.

The banner: a statement, not an alert, and nothing to click

mode banner
paper PAPER — no real money is involved (fixed wording, asserted verbatim)
confirm the mode and the equity state — the pairing the operator is about to check against the venue's own UI
unreadable nothing. A banner is a claim about whether real money is involved, and there is no safe default for that claim

…truncated — full description in #735.

feat(engine+web): wire the discretionary journal — CLI-only in, self-reported everywhere out (#705) (#736)

Closes #705.

The journal table has been in the schema from the beginning with no repository method and no caller anywhere in the code. Dead schema is worse than no schema: a reader assumes a declared table is a used one.

The one record here that nothing can check

Everything else keel keeps is a machine's observation or a claim about the world — an order is what a venue reported, a transaction is a line out of a venue's own export, an asset attestation says PAXG is backed by allocated gold and a prospectus could contradict it.

"I felt rushed and broke my own rule" has no external referent at all. That isn't a defect — self-assessment is the only way this information exists, and no venue can produce it — but it means the record's value depends entirely on staying visibly separate from the ones that can be checked.

So self-reported is a sixth provenance in the timeline's closed vocabulary, not a reuse of human-attested. It carries WARN for the same reason simulated does: both mark a row that reads like evidence and is not.

The CLI is the only way in, and add takes no options

Not merely "it prompts by default" — a --emotion 3 would make the entry scriptable and leave the TTY gate guarding a ceremony that no longer needed a human to supply anything. A test asserts keel/web/api.py never reaches the writer.

_is_interactive, not _require_interactive_confirmation. The heavier gate demands a typed yes and exists for dangerous actions — its own docstring warns against ceremony without a matching threat model. Writing a sentence about your own trading isn't dangerous; it's unverifiable, which a confirmation prompt does nothing about. What is load-bearing is the terminal: off a TTY there is no human.

And there is no edit — no update method, pinned by name, because a journal you can go back and change records what you wish you had thought.

Blank is "did not say"

Every field but the timestamp may be skipped, including all of them. rules_followed=False is the operator confessing they broke their own rules, and bool(None) would print that confession over a question they skipped — three values, three words, on every surface. An emotion score off the 1–5 scale and an impact that isn't a number are refused, not coerced: a column holding "9", "very bad" and "3" has three vocabularies and no series.

Nothing an operator says about themselves comes back green

Every other green on this page is a fact. "I followed my rules" is the operator's opinion of the operator, and reflecting it back as a pass would be flattering them with their own words. NEUTRAL is the strongest honest reading, and a swept test asserts no state in this section is ever good — including a self-reported profit, where money()'s default would otherwise have graded the sign of a number the operator chose.

…truncated — full description in #736.

feat(web): the Plans page, inverted — a transparency artifact with nothing to click (#706) (#740)

Closes #706.

A retail broker's Plans & Features page is a tier matrix with "Current plan" and "Upgrade" badges. It is radically clear about pricing and honest as marketing — every row exists to move a reader one row down. Jesse paywalls even paper trading.

keel's constitution runs the other way: two of its eight numbered lines are "Paper is free and unlimited, forever" and "The free engine is never a demo." So this page takes the clarity and refuses the gating.

Traceability is the acceptance criterion, so it is mechanical

"Every claim on the page traces to the evolution plan or the constitution — no aspirational copy." A page of marketing sentences that merely sounded like the project's documents would pass a human reviewer and rot within a month.

So every claim carries the repository-relative path it was quoted from, and the tests open that file and look for the sentence. Verbatim, never paraphrased — a paraphrase is a new claim wearing the authority of an old one.

The check runs in both directions, and the second is why it earns its place. A claim nobody can trace fails the build; so does a claim the project has since edited out of its own documents — a promise page still making a promise nobody kept. Demonstrated by mutation: adding a ninth line to §4 of the evolution plan fails the build until the page carries it, because the count comes out of the document rather than a literal.

The whole constitution, not a selection. A page that quoted seven of eight would be choosing which of the project's own commitments a reader sees, on the page whose entire subject is what the project promises.

Nothing is for sale, and the page says so from the table

ADR 0004's answer is "not now" and none of its four triggers has fired, so no tier is shipped. That's a field, not a sentence, because "this does not exist yet" is the most important thing the table says and a sentence can be edited past without anything failing. The headline is derived from the field, so the two can't disagree — and the direction they'd disagree in is a shipped product under a headline saying there's nothing to buy.

available_now is separate and true of exactly one row: the free tier is what the reader is already running. Four rows presented as four choices would be a paywall matrix with the prices greyed out.

The refusals are structural, not editorial

…truncated — full description in #740.

feat(cli+web): the cancel asymmetry — entries ask once, protection is typed, and the console never cancels (#707) (#741)

Closes #707.

Cancelling an open entry is refusing risk — frictionless by constitution. Cancelling an open exit or protective bracket is removing protection — never one click. No venue draws this distinction; it falls out of keel's own rails.

The scope change, and why

The issue asked for POST /api/orders/cancel. Building it would have required keel serve to hold a trading credential — and that is the boundary this project does not cross. The console holds no venue credential and no broker handle, which is what keeps the worst case of a bug in the HTTP layer at "reads a local SQLite file" rather than "exfiltrates live trading keys." Unlocking the keychain and signing a request to a venue happens inside a terminal invocation the operator started, never from an ambient loopback daemon.

So there is no cancel route — not a guarded one, none. A guard is a thing that can be got wrong; an absent endpoint cannot be. The console classifies (a read) and hands over the exact command.

The terminal

keel orders is now a group — invoke_without_command=True keeps keel orders --scope 7d answering exactly as it did — plus keel orders list and keel orders cancel <id>.

Two things the code said that the issue didn't:

A protective leg is classified by what depends on it, not by its side. positions.bracket_order_id is the link, and that check runs before the side test. A row wearing the BUY side while a live tranche points at it for protection would pass a side-only rule and be cancelled on a y.

The partial fill inverts the bracket rule. executor.execute places the bracket as soon as the entry is placed, not once it fills — so a resting entry can already have a protective leg. Cancel a zero-filled entry and that leg commits inventory never acquired: an orphan, cleared with the entry by the #519 protocol. Cancel a partially filled entry and the operator holds real inventory that the bracket protects; clearing it would strip a stop from a live tranche — the exit-side hazard reappearing inside an entry-side action. The remainder is cancelled; the protection stays.

And when the orphan cannot be cleared, the command fails loudly rather than reporting the cancel it managed: an entry gone with a sell still working over inventory that does not exist is a state the operator has to be told about, not discover from a fill.

The typed phrase names the order, so a phrase copied from one prompt cannot answer another — otherwise the friction is a ritual rather than a check on which protection is being removed.

The console

Every row carries the classification and the command. Clicking Cancel opens a <dialog> naming the order, saying what cancelling means, and offering the command to copy. An order that can't be cancelled gets the reason instead of a button — a command that would be refused is worse than none.

…truncated — full description in #741.

feat(web): close #701 and #702 — the mark reconciliation pinned, the attestation chip, and a staleness verdict (#744)

Closes #701. Closes #702.

The three gaps the acceptance audit found. Each was a criterion nothing in the suite could fail on.

#701 — the reconciliation was documented and unpinned

commands/positions.py has claimed since it shipped that "THE MARK IS THE RAILS' MARK, AND THAT IS THE POINT", and nothing asserted it. Both real paths now run against one seeded book and the numbers are compared.

Writing it found why it needed writing: the two sides read different tables. agent._held_position derives the holding from filled live orders — the audit log, the same source guards.py uses — while the page reads the positions table's tranches. A real cycle writes both. My first cut seeded only the tranche, so the rails saw an empty order log and reported unrealized as zero — a true statement about nothing.

A coarser series is seeded alongside the finest and holds a different close, so the test is about the granularity choice rather than about there being one candle in the database. The absent case is pinned too, because that's where two implementations of one idea usually part company.

#701 — the attestation chip, on #718's window

The page had a freshness chip and no attestation chip; the issue asked for both. #718 recorded attest_due_ts and keel doctor reads it — the page didn't.

Four states, not a boolean, because the middle two are different facts: unattested (which screen_asset already rejects, so a quiet blank would say the opposite of the gate governing the holding), expired, due, and attested — which covers both "in date" and "no window recorded", a legitimate state #718 made deliberately.

The threshold is imported from doctor, not restated: a page warning at one horizon beside a keel doctor warning at another would have an operator believing whichever they read last. Every state is WARN or UNKNOWN, never BAD — doctor's own choice for this column, because an expired window vetoes nothing and a red chip would say otherwise.

Beside the entry gate, never merged with it: one is a claim about the world, the other about data, they disagree in both directions, and a flagged row must say which.

#702 — a staleness verdict that doesn't cry wolf on this deployment

Every stamp crossed as moment(...), unconditionally NEUTRAL. A reading from last week rendered exactly like one from a minute ago — the page told you when and left you to do the subtraction, which Rule 2 says is Python's job.

…truncated — full description in #744.

fix(engine): close #731 and #732 — pin two ON CONFLICT windows, and deliver the finding nobody could receive (#745)

Closes #731. Closes #732.

Both were found while implementing #718 and left alone to keep that PR to its scope. Both sit under rail 22.

#731 — two upsert clauses, correct and untested

broker_subscriptions and venue_cash_postures both carry attest_due_ts in an INSERT … ON CONFLICT DO UPDATE SET, both set it, and deleting either line left the whole suite green — the same hole #718 closed for the asset and instrument tables and left open here.

venue_cash_postures.attest_due_ts is rail 22's input. A re-attestation carrying the old window forward would keep an expired posture reading as current, and the rail would stop vetoing when it should — the failure direction that costs money rather than opportunity.

Mutating every column of both clauses found two more the issue didn't name:

  • attested_posture is the column rail 22 actually reads. An operator who moves off margin and re-attests, whose re-attestation silently kept MARGIN_ENABLED, would go on being vetoed by a record saying the opposite of what they stated.
  • The refutation columns had the hole the method's own docstring already states for credential_fingerprint — "a writer that could not clear it would let a stale fingerprint outlive the record it described" — so a posture the venue refuted and the operator then fixed would read as refuted forever.

Every column in both clauses is now pinned.

#732 — a registered finding nothing could deliver

attest.cash_posture was in _ATTESTATION_FINDINGS, and notify_after_cycle never called cash_posture_findings. The registration was real; the delivery path was not.

It fires when the account is attested margin-enabled, when the posture attestation has expired, or when it was attested with no due date at all — three states where rail 22 has stopped letting the agent enter positions. The symptom otherwise is silence: an agent that looks healthy and never trades again.

The break was the same bug one layer down. events_from_state emitted one attestation event per cycle, commented "the finding list carries one rail-17 verdict" — true when the registry held rail 17 alone. With rail 22 in it, that break makes a cash-posture problem invisible whenever a withdrawals problem also exists. One event per finding name now.

The pin closes the class. A test comparing the registry against a list of gatherer names would be a third list to drift. Instead it drives the real notify_after_cycle against a book where every registered finding is unhealthy at once and asserts each one arrives — so a registration with no gatherer fails there rather than in production quiet.

One behaviour change worth naming

…truncated — full description in #745.

feat(research): persist what the gauntlet computed instead of printing it (#726) (#746)

Closes #726. Unblocks #708's Evidence Matrix.

keel computed evidence and kept the prose. trials pbo printed ten figures and wrote none. trials deflate printed a DSR whose inputs the ledger couldn't supply, so the number could never be recomputed or checked. trials monte-carlo stored a distribution's ends and not its shape.

The constraint that shaped the design

Found before writing anything: _decode_summary maps a summary value to None, an int, or Decimal(value). A list raises ValueError; a dict raises TypeError. And it raises on read, inside read_trials — which every later verify_chain, trials list, trials pbo and web page goes through. One nested value would make an append-only, git-tracked file unreadable forever, with no way to take it back.

Not hypothetical: _decode_summary's own comment records a null value doing exactly this once already.

So the guard moved to the write, where it's a refusal rather than a catastrophe — and every artifact is stored as flat scalar keys (final_p05, not a nested ladder). The shape a reader can survive is the shape a writer may use.

What is now recorded

CSCV — every field of PBOResult: pbo, both degradation coefficients, prob_loss, both dominance flags, the column/block/combination counts, rows used and dropped, columns refused. Ten figures computed, one reached the ledger.

DSR — the inputs as well as the outputs, and the inputs are the point. --sharpe is a required operator input because the ledger stores no per-trial Sharpe, so DSR wasn't merely expensive to recompute — it was impossible without synthesising a number the operator had supplied. A run with no --trial-sharpe-variance still refuses to compute a DSR and now also records nothing: a stored figure nobody ran is worse than an honest gap.

Monte Carlo — a seven-point quantile ladder for finals and drawdowns. Quantiles rather than the raw array (thousands of Decimals per row in a git-tracked file), and nearest-rank rather than interpolated: every value here is an equity the model actually produced, and an interpolated quantile is a number no path reached.

Two new diagnostic kinds, and why neither feeds back

cscv and deflated_sharpe are measurements about a set of trials rather than trials themselves, so both are always series_missing — and matrix.build_matrix refuses series_missing rows, so a recorded PBO can never become a column in the next PBO over the same file.

The test for that passed for the wrong reason first: it built the matrix over session s1 while the recorded row landed in session all, so the session filter excluded it and series_missing did no work at all. It now records into the same session and fails against a mutant that gives the row a real series.

Refusals

…truncated — full description in #746.

feat(web): the Evidence Matrix, read rather than computed (#708 view 2) (#747)

Closes #708. Closes the honest cockpit milestone.

The last of #708's four views, and the one that waited for an engine record instead of being built on a guess.

Why it is a read

Building the matrix on request costs 11.9 / 12.9 / 14.3 seconds per session on the real ledger — ~39 s of CPU for three sessions, on a page the console re-polls every 15 s. And over the ledger as a whole it doesn't run at all: build_matrix requires synchronous columns, so a PBO is only ever defined within a session whose trials share a bar count, and a page can't pick that scope without inventing an operator's decision.

So #726 made trials pbo record every field of its PBOResult, and this reads them. The console displays results an operator ran and never runs one on their behalf — pinned by a test that makes build_matrix raise if it's called.

An unrun matrix is not an empty one

Three states, which is why the report carries candidate_sessions at all: no ledger · a ledger with columns and no recorded run · a ledger with runs. The middle one is the state this ships in, and the only one that can be acted on.

The guidance names a session that actually has columns. keel trials pbo --session all is the obvious thing to print and would filter to a session literally called "all", find nothing, and refuse — teaching an operator that the page doesn't know what it's talking about. So the report counts the trials build_matrix would accept and names one of those. On the tracked ledger that's pbo-grid-entry-lookback-2026-07-20, one of three genuine candidates.

It deliberately doesn't check synchronicity, which would mean reading every series — most of the cost this module exists to avoid. Suggesting a session that might turn out ragged, and getting a clear refusal from the command, is a much smaller harm than a page that costs twelve seconds to render.

PBO carries no judgement

It's the one figure a reader will want graded, and grading it is what the rail refuses: a high PBO beside a flat, positive OOS scatter is the good outcome — a broad plateau of near-identical configurations produces high PBO by construction. trials pbo's own closing sentence says to read it alongside the degradation slope, never alone, and both cross plainly so a reader can.

The dominance flags do carry a state, because they're already verdicts — and they're three-valued: False says the distribution did not dominate, which is not what an absent field says.

⛔ No sortable column on the route and no sort key in the view. A matrix ordered by PBO is a leaderboard of overfitting scores, and cscv.py forbids PBO as a ranking key in its own source.

Two tests that weren't testing anything

…truncated — full description in #747.

docs(web): script the half of #648's secure-context check a shell can do (#748)

Partial for #648 — does not close it, and the reason is the point.

Four of #648's five items already shipped in #678 and #680. I mutation-tested all six defences rather than trusting they were covered — skipping the Host check, admitting a non-loopback name on a loopback bind, dropping the port check, accepting a wildcard external host, making the remote session immortal, removing the wildcard-bind guard — all six killed.

The fifth item, secure-context re-verification, needs a real deployed origin. This turns that from an open research question into a two-minute run.

Two parts, and the split is the honest shape of the requirement

Part 1 — what HTTP can answer. The shell served over the external origin; the token exchanged for a SameSite=Strict cookie there; manifest.webmanifest and sw.js actually served; nosniff present; and the one that matters most — a spoofed Host: still refused. --external-host teaches the server one name, and a deployment where Host: evil.example is answered is one where the allowlist has been widened until it stopped being one. Non-zero exit on any failure.

It refuses a plain http:// origin outright rather than warning: every check is about what HTTPS provides, and against http:// it would report a pass for a page that is not a secure context.

Part 2 — what only a browser can answer. window.isSecureContext, whether the worker registered and with what scope, whether the cache is genuinely per-origin, whether the manifest installs, and how the installed console behaves when the tunnel dies. Printed as a checklist and not covered by the exit code — a script claiming isSecureContext from curl would be reporting something it never looked at.

A test asserts part 1 makes no such claim, scoped to the executable region so the header comment may still name the property in order to say it cannot check it.

Why this doesn't close the issue

docs/remote-access.md keeps its ⛔ section and now points at the script. #648 closes when part 2 has been done on a device and recorded on the issue. Part 1 passing is not sufficient, and no amount of scripting makes it so.

The script joins the shellcheck suite, because a shell bug in it is a bug in the one check standing between an operator and an exposed financial surface.

Gates

6,297 passed / 3 skipped; ruff clean (the one E501 is pre-existing); shellcheck clean.

docs(readme): the fee-reality benchmark as a terminal capture, generated from a real run (#646) (#749)

Closes #646 — the last deliverable. The README hook, the priced-twice table, the reproducible figures and scripts/render_fee_reality.py all shipped earlier; what was missing was the terminal capture.

One constraint decided the shape

"Generation tooling committed … so the GIF regenerates when the measurement does."

A hand-recorded screencast can't satisfy that. It's a binary blob whose numbers freeze the moment somebody hits record, and the first time the ledger moves it becomes a picture of a measurement that is no longer true — a marketing asset wearing a measurement's clothes, which is the exact thing this README argues against.

What is real, and what is not — said in the caption

render_fee_reality_cast.py runs render_fee_reality.py as a subprocess and captures its actual stdout. That command really reads docs/experiments/trials-ledger.jsonl, really parses the recorded fee curve, really prints those figures. No number is retyped and there's no fixture — a test asserts the committed asset carries the same cells the renderer emits today, and swapping the capture for a fixture kills it.

The pacing is composed. Nobody types at a uniform 55 ms per character. The README caption says so in a sentence — "The pacing is composed; the output is not" — because a capture presented as an unedited recording would be a small lie in service of a page about not telling them.

Two formats, one recording

file why
docs/assets/fee-reality.cast asciinema v2 — plain JSON lines a reviewer can read; any asciinema tool plays it
docs/assets/fee-reality.svg what the README embeds: renders inline on GitHub, no player, no CDN, no JavaScript — and it's text, so a regenerated capture arrives in review as a legible diff

The SVG is hand-built rather than shelled out to agg or svg-term-cli, for the same reason the asset is generated at all: a capture produced by a toolchain this repo doesn't have can't be regenerated by someone who clones it.

Deterministic, so its diff means something

No timestamp in the cast header — that's where asciinema puts the wall clock, and with one every regeneration would be a diff even when the measurement hadn't moved. Verified byte-identical across runs.

#646's other constraints

  • ✅ Every number reproducible from a recorded run — a test compares the capture against the renderer's live output
  • ✅ No competitor naming — swept for five product names across both assets
  • ✅ Dated caption with the per-venue fee basis in frame — Coinbase taker 1.2% · rendered from the trials ledger
  • ✅ Generation tooling committed, with a check mode wired into the suite

Gates

…truncated — full description in #749.

chore(release): 0.14.0 — the honest cockpit, and a book that can prove it was not edited (#750)

Version bump only. Thirty-seven PRs since 0.13.3.

⚠️ This release carries four schema migrations

v0.13.3 shipped SCHEMA_VERSION = 16. This release is 20, so an upgraded deployment applies four steps, not one:

adds
v17 candle_series_feed (#696)
v18 venue_cash_postures (#666)
v19 equity_points (#698)
v20 orders.quote_provenance, orders.client_order_id, attest_due_ts on both attestation tables, cycle_balances, audit_events (#721)

keel serve migrates for you, once, at bind time (server.ensure_schema) — a missing database is left alone as a first run. What it deliberately does not do is migrate per request: the pages auto-reload every 15 seconds, and a view calling itself read-only must not take a schema write lock on a database the agent may be mid-cycle on, four times a minute.

keel mcp does not migrate at all (keel/mcp/tools.py), and against a database still at v16–v17 its doctor tool raises sqlite3.OperationalError: no such table: venue_cash_postures rather than reporting the gap. Only the audit chain carries a table_present guard; the cash-posture and equity readers do not. Tracked in #751 — run keel migrate before pointing an MCP client at an upgraded deployment.

The honest cockpit (#700–#708)

Positions, Balances, Activity, Orders, the Research Hub's four views, session identity on every page, and an inverted Plans page.

The pattern underneath all of it: a view was built against a record the engine didn't keep, and the fix was always in the engine. #703's audit export said NOT RECORDED in every hash cell until #721 chained the book. #708's Evidence Matrix couldn't exist until #726 stopped throwing away what trials pbo computed.

What the engine now writes down

  • An append-only, hash-chained record of every write to orders, transactions and the attestation tables (#721) — the activity export carries real hashes and a per-row verdict on whether the chain vouches for them
  • Quote provenance and client order id behind each placement (#715)
  • When an attestation's window closes (#718)
  • The per-currency balance pair a cycle observed (#719)
  • The full CSCV, DSR and Monte Carlo artifacts a gauntlet run produces (#726)

Two new operator surfaces

keel journal — the discretionary journal, wired at last after sitting in the schema unused since the beginning. CLI-only by constitution: no web form, and add takes no value options, so an entry can't be scripted past the terminal gate.

keel orders cancel — the cancel asymmetry. Cancelling an entry refuses risk and asks once; cancelling an exit or protective bracket removes protection and takes a typed phrase naming the order. The console classifies and hands over the command; it never cancels, because keel serve holds no venue credential and this release doesn't change that.

One behaviour change to know about

…truncated — full description in #750.

fix(mcp): a reader that does not migrate must report the gap, not raise on it (#751) (#752)

Closes #751.

keel mcp's doctor tool raised sqlite3.OperationalError: no such table: venue_cash_postures against a database nobody had migrated — out of the handler, taking every other finding with it, on the first tool a client typically calls.

The rule was already written next door

audit_chain_findings states it in its own docstring: a reader that does not migrate meets an un-upgraded schema as an ordinary deployment state, not an error. It backs that with a table_present check and an OK finding naming keel migrate. Three migrations later, venue_cash_postures (v18) and equity_points (v19) had no such guard.

The check moves from audit.py to db.table_present, where the schema is defined and every reader can reach it, plus Repository.table_present for the reason audit_chain is exposed there: what this record contains is a question about the record.

Checked, not caught. OperationalError covers "no such table" and "database disk image is malformed" under one clause, and that distinction is the difference between keel migrate and stop trading.

An absent table is not an unattested posture

A bare None would have been worse than the crash. cash_posture_findings(None) is FAIL "cash posture never attested — rail 22 vetoes every live ENTRY", which is false about a database that has no posture table: nothing has lapsed, the schema simply predates the rail. It would have sent an operator to fix a rail that is not the problem.

cash_posture_schema_finding says the true thing instead, and OK rather than WARN — the engine that enforces rail 22 migrates on the way in, so a live cycle can never be running against this schema.

Why the existing test could not see it

test_a_database_without_the_table_is_reported_not_crashed_on asserts over a fabricated _chain(table_present=False). A hand-built state object can only exercise the reader that already has the flag, so the readers that lacked one stayed invisible to it.

The new tests open a real database wound back to what 0.13.3 shipped, and the MCP one sweeps every tool rather than doctor alone — a tool added later inherits it, a table added later joins one tuple. Verified by mutation: forcing table_present to True fails all three.

And the docstring that caused it

audit_chain_findings said "both the web server and keel mcp open a repo WITHOUT migrating". Only the per-request open skips it; web/server.serve calls ensure_schema once at bind, so a served database is never behind. That imprecision is what left the sibling readers looking already covered — and it had propagated into v0.14.0's release notes before this was traced. Both are corrected.

Checks

6,308 passed / 3 skipped (6,305 + the three added here), ruff and mypy clean.

Not a blocker for v0.14.0

…truncated — full description in #752.