Repository navigation
keel v0.13.0
Built from c5ee7e3. Version binds to this hash:
keel --version reports keel 0.13.0+c5ee7e3d7773 [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.13.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.
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.
Other changes
fix(web): the worker was holding the door shut against the only key (#604)
What you hit
Four symptoms, one cause.
keel serve prints http://127.0.0.1:8765/?token=.... The server exchanges that token for the session cookie on that navigation — do_GET reads query["token"] and answers with Set-Cookie — and every /api/* read afterwards is gated on the cookie by _admitted.
That is the request #538's service worker was answering out of its cache:
if (request.mode === "navigate") {
event.respondWith(caches.match(SHELL, ...).then((hit) => hit || fetch(request)));
}The server never saw the token. The shell loaded from cache, every read it made was refused, and the page said "Not authorised — open the address keel printed when it started" to someone who had done exactly that.
The other three — no logo, keel not clickable, no light/dark icons — are the same cache. The brand block and the theme toggle shipped in #597, which is in v0.12.2; a browser holding the previous build's shell shows the 0.12.1 header.
The deadlock, which is the part worth naming
The update offer that would replace the worker lives in a footer main.js fills only after a successful boot read — and the boot read is what is being refused. A cached worker whose shell cannot authorise cannot be replaced by the mechanism built to replace it.
The fix
One branch: a navigation carrying the session token declines to the network.
A bare return, not respondWith(fetch(...)) — declining hands the request to the browser exactly as if no worker were installed, redirect and cookie handling included.
No cache fallback there, deliberately. An unreachable server should show the browser's own failure page; a cached shell would look like it worked and then refuse every read, which is the failure this exception exists to prevent.
The pin
Parses the navigation branch, asserts the token check precedes the cache lookup, and asserts the worker's parameter name equals the one server.py reads. Mutation-verified three ways — guard removed, guard moved after the cache lookup, parameter renamed on the server side alone. All three fail it.
4438 passed, 3 skipped.
You need one manual clear
Your browser still holds the pre-fix worker, and that worker is what would intercept the navigation that installs its replacement. Once, in the browser you use for keel serve:
DevTools → Application → Service Workers → Unregister, then reload with the token URL. Or right-click reload → Empty Cache and Hard Reload. A private window also works as a check.
After that the token exchange lands, and the 0.12.2 header — mark, clickable brand, theme toggle — appears.
docs(decisions): monetisation is not now, and affiliate links are a permanent no (#603) (#606)
Summary
- Adds
docs/decisions/0004-monetisation-not-now.md, recording keel's answer to the five monetisation options Jesse's founder evaluated publicly — donations and token-issuance rejected outright, exchange sponsorship and SaaS deferred on the sequencing argument (Jesse open-sourced 2018 → public 2020 → monetised 2021 with an earned audience; keel is six weeks old with 3 stars), a closed live-trade plugin rejected specifically becausekeel/execution/is the compliance enforcement itself, not a bolt-on feature. - States the affiliate-link conflict as a permanent no, not a "not yet": keel's headline finding is that fees are the binding constraint, and referral revenue paid per user routed to a venue is a direct stake in the same fee schedule the project's credibility depends on assessing without an incentive.
- Records four triggers that would reopen the deferred parts, framed in adoption and demonstrated demand rather than revenue — per the issue's own ask.
- Registers 0004 in
docs/decisions/README.md's index, and backfills the missing 0003 entry found in the same file.
Closes #603.
Test plan
-
uv run pytest -q—4437 passed, 3 skipped in 148.71s (0:02:28)
fix(docs): the wheels' stated floor was 3.11, the wheels' actual floor is 3.14 (#595) (#607)
The falsehood
#546 raised requires-python to >=3.14 before v0.12.0 shipped, so every published wheel since v0.12.0 has carried that floor. docs/desktop-install.md and the v0.12.0 release notes' Desktop section never got the memo — both said "the wheels declare >= 3.11", which was true two releases back and false of every release since. Someone on 3.11–3.13 who trusted that line and ran the terminal installer got refused by pip mid-install, having followed the docs exactly.
README.md and packaging/macos_app.sh's DMG note carried the same stale 3.11, and — worse — scripts/install.sh's FALLBACK_FLOOR (the constant it enforces when it can't fetch the release's own pyproject.toml) was also still "3.11", so even the installer's own fallback path enforced the wrong floor.
The fix
README.md,docs/desktop-install.md,packaging/macos_app.sh:>= 3.11→>= 3.14.scripts/install.sh:FALLBACK_FLOOR="3.11"→"3.14", comment updated to match..github/workflows/release.yml: the Desktop section of the release-notes template now says "Python 3.14+." This fixes the next release's notes — the already-published v0.12.0 GitHub Release is left alone; retro-editing a published release is its own kind of falsehood.docs/desktop-install.mdalso dropped a now-false claim that the source-checkout path "develops on a newer Python than the wheels require" — that's no longer true now that both floors are 3.14.
The new pin
tests/test_install_script.py already cross-checks README/desktop-install.md/macos_app.sh/FALLBACK_FLOOR against each other (#557) — and stayed green throughout, because all four agreed on the same wrong number. Nothing checked them against the one place that isn't hand-maintained: pyproject.toml's requires-python.
Added test_the_wheels_floor_stated_in_release_facing_docs_matches_requires_python to tests/test_python_floor.py. It parses requires-python fresh from pyproject.toml (never hardcodes "3.14") and fails if any of the five places above states a different floor.
TDD: wrote the test first, watched it fail against the stale docs:
AssertionError: README.md states the wheels floor as '3.11', but pyproject.toml's
requires-python is '>=3.14' (floor '3.14') -- this is the exact drift #595 was filed over
Then fixed the docs and watched it pass.
Mutation evidence: after the fix, reintroduced 3.11 into docs/desktop-install.md's >= 3.14 line, reran — the same assertion failed for the same reason (states the wheels floor as '3.11', but ... requires-python is '>=3.14') — then restored the file and reran green. A pin that's never been seen to fail is not a pin.
…truncated — full description in #607.
docs(spec): the web UI spec describes a shipped system, not a target (#601) (#608)
Part one of #601, and it turned out to be a different job than the issue expected.
The premise was stale
#601 asked for the unmerged local branch docs-web-ui-spec to be landed, on the reading
that its 437-line design document had never reached main even though the rewrite it
specifies had shipped. That reading is wrong. The document landed at #531, and #538
amended its § "JS file structure" in place afterwards. The local branch is behind
main, not ahead of it — git diff origin/main docs-web-ui-spec over that file is
eighteen lines, all of them main carrying the #538 amendment the branch lacks. There was
nothing to land and nothing to rebase.
The same staleness explains the issue's other expectation. #601 warns of an in-flight
keel/commands/research_console.py on that branch to reconcile with; that file is an
artefact of the branch's old base, and moved to keel/corpus.py when #541 deleted the
console layer around it. There is no conflict to reconcile.
What was genuinely left undone
The second half of the ask: checking whether the document's claims still match what
shipped. They did not.
A reader arriving at the page now reads "Today keel serve renders HTML in Python
(keel/web/render.py, 872 lines)" as a fact about the installed code, goes looking for
that file, and finds nothing. That reads as documentation rot, when what actually happened
is that the document's argument succeeded and the file was deleted at #540 — exactly as
its own § "What this deletes" table asked. Same for keel/commands/tui.py (#541) and
keel/commands/help_console.py.
So the status line now says shipped and names the seven issues that shipped it, and a new
paragraph up front tells the reader which citations below point at a state that no longer
exists. The present tense of the body is deliberately left standing. It is the tense
the argument was made in, and rewriting it into the past would turn a record of why into
a description of what — which is what the code is for. This is a historical design
record and it is not rewritten wholesale.
Four assertions are corrected where they had gone false rather than merely dated:
…truncated — full description in #608.
feat(research): keel research — one front door over the thirteen evidence modules (#601) (#609)
Closes #601.
The issue's premise was half wrong, and the correction is the design
#601 says "thirteen modules with no front door." Six of them already had one. keel trials
fronts ledger.py (record/list/verify), cscv.py+matrix.py (pbo), deflate.py,
montecarlo.py and walkforward.py; keel rules lookahead fronts bias.py. Only six were
genuinely unreachable: significance.py, cts_factors.py, independence.py,
throughput.py, tuning.py, pooled_review.py.
So the real gap was never "no code path." It was no single place that says here is the
evidence toolkit — the tools were scattered across two command groups and 30+ ad-hoc
drivers under docs/experiments/, discoverable only by reading source. keel research is
that place, and it is three things:
keel research index— every module, the question it answers, what it cannot
answer, and the command that runs it.--json, and--module NAMEfor one entry.- Six new subcommands for the six modules that had none.
- Five aliases —
pbo,deflate,monte-carlo,walk-forward,lookaheadare the
same click command objects registered a second time, never copies.keel research pbo
andkeel trials pboare byte-for-byte the same code. A front door that reimplements is
a front door that drifts the moment one copy gets a bugfix the other doesn't.
No new statistics anywhere. Every number traces to a function already in keel/research/;
the command layer assembles inputs, calls in, and prints what comes back, reusing each
module's own render_family/render_report rather than a second renderer that could drift.
keel/commands/research.py's docstring cites ADR 0003 per its standing rule 3.
A refusal is a result, not an error
#601's second bullet, applied everywhere — including to code that predates it.
keel trials deflate, pbo, monte-carlo and walk-forward each had a path where the
ledger was readable, the request well-formed, and the evidence simply could not answer it —
and all four reported that as click.ClickException: exit 1, on stderr, indistinguishable
from an operator typo. Those are now printed on stdout at exit 0.
The line drawn, and held: an operator error means the request was wrong (a rule id that
doesn't exist, a db that won't open, a broken ledger hash chain — that last is tampering, not
insufficient evidence); a refusal means the request was right and the evidence cannot
answer it. Only the second became a result. rules lookahead's exit-1 on an actual
lookahead finding stays loud on purpose — a definitive finding is the opposite of a refusal.
Observable today:
$ keel research pooled-review --db /nonexistent.db # operator error
Error: pre-registered profile db(s) not reachable read-only → exit 1, stderr
…truncated — full description in #609.
feat(execution): scale_out resizes the bracket it stands beside, and books the half it sold (#502) (#611)
Closes the remaining half of #502: scale_out's two prerequisites, and #446's exit-side sibling
in the same pass.
bracket resize
scale_out called _run_order directly — the pipeline BELOW execute — so it never reached
_clear_resting_bracket, whose own docstring says it lives where it lives "so every SELL path —
the rule exit today, scale_out or any future one — gets it by construction". It did not. The
resting native bracket commits the ENTIRE base position, so on spot the base is locked and the
partial sell is rejected; if it filled, an oversized bracket was left able to sell inventory no
longer held.
The sequence is now crash ledger → cancel → sell → book → re-place at the remainder.
The ledger goes first, before any venue call — #519's pattern, the same unbracketed: key
place_bracket and _roll_stop use, deliberately not a new one. It carries the remainder as its
qty, and that is correct in both crash windows precisely because the sweep does not read that
number: reconcile_unbracketed_positions sizes from the TRANCHE and takes only the levels from
the record. Die before the sell → the tranche still says the full size → the full position heals.
Die after the booking → the tranche says the remainder → the remainder heals.
Re-placing smaller is the genuinely new port-consumer behaviour. _roll_stop re-places the
same qty at a new price; this re-places the same price at a new qty. BracketGTC already
expressed the order.
Three refusals, all before the venue is touched: non-positive qty; qty >= held (a full exit
belongs to execute's EXIT path, which also retires position_rule: and state this function
does not own); and no recorded open_stop/open_target — without levels there is nothing to
re-place, so cancelling would leave the remainder naked with nothing in the ledger saying so.
partial trade_outcomes
Schema v13 adds realized_qty / realized_proceeds / realized_fees to positions, and
reduce_position is the positions.qty UPDATE that did not exist. qty now means what is STILL
HELD — which is how the sweep and exit_policy already read it.
…truncated — full description in #611.
feat(trade-scope): the venue trade-scope record, its backfill, and keel scope attest (#233 PR1) (#612)
Issue #233, PR1 of the accepted design. The record and the ceremony, no enforcement. Nothing in this PR can veto an order; rail 20 arrives in PR2 (feat-233-rail-20).
Why
A credential that reads fine is not evidence it can trade. ROBINHOOD_API_KEY was well-formed, every read succeeded, /accounts/ reported is_api_tradable: true, and the first live order came back 403 {"detail":"You do not have permission to perform this action."}. The key was read-only. A venue gated on "are there keys in .env" would have shown it as available and found out at the only moment that costs money.
The cost of that afternoon was never the 403. It was that nothing recorded it, so the next run could not tell a read-only key from a working venue.
Two writers, because attestation alone would have been wrong here
venue_trade_scopes holds one row per venue: unverified → attested (a human's claim) → confirmed (the venue accepted a placement) or refuted (the venue refused one on permission grounds).
Attestation alone is the mechanism that would have failed in the motivating case: the operator who minted that read-only key believed it was a trading key and would have attested --trading in good conscience. keel/commands/setup.py's OFF_VENUE doctrine already names that failure — a green check verifying nothing is worse than an honest manual step. So the venue is the second writer and it overrules the first. A confidently-wrong attestation costs exactly one refused entry: a 403 that moves no money.
may_place_live_entry() is driven by state alone, never by refuted_ts. Re-attesting over a refuted record is how an operator reports a rotated credential, and the record deliberately keeps refuted_ts so doctor can still say "you re-attested a venue that refuted a credential on <date>". Had the predicate vetoed on refuted_ts is not None, the one path that recovers trade scope would never unblock it.
No TTL, deliberately
subscription.py expires because a subscription is user-asserted with no observation channel. Trade scope has one: the venue re-confirms it on every accepted placement. There is no silent drift for a clock to catch, so a due date here would be ceremony with nothing behind it to expire.
The migration is the safety-critical part
Without a backfill, upgrading would hand the running Coinbase deployment — which trades unattended, daily — a database with no row for its venue, and PR2's rail 20 would veto its next live ENTRY on a venue that has been working all along.
So v13 reads the orders audit log: mode = 'live' AND status <> 'rejected'. executor.py writes status = 'rejected' only when the broker REFUSED the placement, and a rails veto writes no row at all (the insert happens after the guard gate), so any other status means the venue ACCEPTED it. That is proof supplied by the venue itself, which is why the backfilled row is confirmed rather than attested.
…truncated — full description in #612.
feat(web): the chart gains a cursor legend, a trade highlight, and a local export (#602) (#613)
Summary
Closes #602's "useful half": the equity curve (chart.js::equityChart, the only chart keel draws — there is no OHLC/candle data and no second pane) gains a cursor legend, a trade-hover highlight, wheel-zoom + drag-to-pan with a "Reset view" button, and a local "Save as image" export. Full reasoning is in the commit message; the short version of the judgment calls:
- "Synchronized cursor across stacked panes" doesn't apply — keel has one pane, not Jesse's multi-chart layout. Noted, not faked.
- "Highlight its orders" — keel's journal has no order-level breakdown of a closed trade. Read as highlighting the two equity points (running total just before/after the trade closed) that bound the trade's contribution to the curve — the nearest thing the data model has to an entry/exit pair.
- New wire key:
entries[].point_index(bare string,Nonefor a row the curve drew no point for) — the join between a journal row and its point on the curve, sincebuild_equity_curveskips rows with no recorded net. Mutation-verified intest_payload.py. - Arithmetic placement:
chart.js/render.jsstay in_DERIVATION_FREE(zero arithmetic, zero.valuereads) — all pointer/zoom/canvas math lives inmain.js, which already owns "interface arithmetic."chart.jsgained two placement-only exports:showTradeAt,highlightTrade. - Non-colour signal: a losing trade's highlighted segment is dashed and its end markers point down (vs. solid/up for a gain) — colour is never the only signal, matching #532's ▲/▼ precedent.
- Zoom survives the 15s poll:
rebuildIntoreplaces the whole view on every poll/tick, so the currentviewBoxis kept outside the DOM and re-applied there — same mechanism<details>open-state already uses. - Save as image, no
data:URL:_STATIC_CSPhas noimg-srcoverride, soimg-srcfalls back todefault-src 'self'and refusesdata:. Export clones the live SVG, inlines computed style, serialises to aBlob, decodes viacreateImageBitmap(no URL, no fetch), downloads via ablob:URL. - Keyboard access:
svg.curveis nowtabindex="0"; arrow keys pan,+/-zoom,0/Homeresets. Reset-view/save-image are plain<button>s (free).
Test plan
…truncated — full description in #613.
feat(guards): rail 20 vetoes a live entry on a venue nothing has proven (#233 PR2) (#614)
Stacked on #612 (PR1). Base is feat-233-trade-scope, so the diff here is PR2 alone. It retargets to main automatically once #612 merges.
PR1 built the record. Nothing read it. This is the half that makes it load-bearing.
Shape, borrowed rather than invented
Entries only — rail 17's doctrine. The reflex is to gate both sides, and it's wrong: a read-only credential can't sell either, so vetoing the exit protects nothing the venue's own refusal wouldn't, and it costs the attempt — the one thing that produces evidence. A position keel refuses to exit is worse than one the venue refuses, because only the second leaves a 403 for the record to learn from.
Fails closed on a missing record. may_place_live_entry() lives on the record; the rail never re-derives the decision from state fields. One place decides what the four states mean, so a state added later can't be silently treated as permission.
Venue-keyed the way rail 14 keys it. In LIVE_STATE_RAILS, so paper skips and reports it — a rail that vanishes in paper is one an operator forgets before the day it matters.
The refuted message is distinct and names refuted_reason. An operator who attested in good conscience and was refuted needs the venue's own words, not a generic veto — that gap between belief and evidence is the entire reason this rail exists.
doctor
trade_scope_findings beside attestation_findings. CONFIRMED is OK. ATTESTED+TRADING is OK but says "unconfirmed operator claim" out loud — that is precisely the state the 2026-08-19 Robinhood credential was in, and it read as healthy. A separate WARN fires when refuted_ts is set but the current state isn't REFUTED: someone re-attested over a refutation, legitimate when credentials were replaced and worth saying when they weren't.
Six pins, each broken to RED and restored
| mutation | observed failure |
|---|---|
| fail-closed guard neutered | AttributeError on the None record |
may_place_live_entry() ignored |
read-only and refuted both stopped vetoing |
is_buy dropped |
the SELL-doctrine test caught the exit veto |
dropped from LIVE_STATE_RAILS |
paper stopped reporting while still not vetoing — which is why that test asserts both halves separately |
venue hardcoded to coinbase |
the alpaca-bound test saw the wrong venue read |
| doctor WARN gated off | the re-attestation finding vanished |
One correction
The brief claimed check()'s "eighteen rails" docstring was already wrong. It wasn't — rails 1–12 and 16–19 is exactly eighteen. There was no pre-existing drift. It reads nineteen now because rail 20 exists, not because it was ever wrong.
Verification
4543 passed, 3 skipped · ruff clean · mypy clean (356 files)
…truncated — full description in #614.
docs(readme): the MCP server gets a front door (#617)
Closes #600.
What was stale
The issue's premise checked out against origin/main as of this PR: docs/mcp-server.md fully documents keel's read-only MCP server (eight tools in keel/mcp/tools.py), and README.md mentioned MCP zero times across its 216 lines (212 at issue-filing time; it grew by four lines for docs/research-toolkit.md in the interim -- the zero-mentions fact was unchanged).
What changed
- Opening description -- a new sentence naming keel's "fourth way to read all of this":
keel mcpover stdio, linked todocs/mcp-server.md, stating the boundary in the same breath (cannot attest, promote, arm autonomy, or place an order -- by construction, not promise). - Feature list (
## How keel works) -- a new bullet alongside Rules/rails/Screening/Confirm-vs-autonomy, naming all eight handlers (doctor,capabilities,profiles,orders,veto_log,purification,trials,reports), where they live, and the same enforced boundary (query_only+ the write-verb scan intests/mcp/test_readonly.py). - Documentation map --
docs/mcp-server.mdgets an entry like every other doc. docs/mcp-server.md-- one line back to the README (../README.md#how-keel-works) so the cross-link runs both ways and the two pages can't drift apart in only one direction.
Boundary, stated as enforced not intended
Per the issue's "What NOT to copy" section: keel's MCP must never grow the ability to author or promote a rule, attest, arm autonomy, or place an order -- that would let an assistant route around both the promotion ladder and the compliance gate. The README states this the same way docs/mcp-server.md already does: it's a query_only guard and a static write-verb scan (tests/mcp/test_readonly.py), not a policy promise.
Tests
Four new pins in tests/test_readme.py, written and run red first:
AssertionError: the README's MCP section must name the 'doctor' handler
AssertionError: the Documentation map must list docs/mcp-server.md
AssertionError: docs/mcp-server.md must link back to the README -- a one-way link is how the two drift
All four went green only after the corresponding doc edit -- mutation-verified by construction (TDD red->green, not a post-hoc deletion).
Results
uv run pytest -q: 4571 passed, 3 skipped in 153.38suv run ruff check keel tests packages: All checks passed!uv run mypy: Success: no issues found in 359 source files
fix(release): name the real per-platform SHA256SUMS files (#618)
Closes #605.
Premise checked against origin/main
Confirmed stale: .github/workflows/release.yml's Checksums step has always named its output per matrix leg -- CKSUM_FILE="SHA256SUMS-${{ runner.os }}-${{ matrix.arch }}.txt" -- deliberately, per a comment right above it ("Per-leg name, not SHA256SUMS.txt: three matrix legs uploading one shared name under --clobber would leave only the LAST leg's sums on the release"). Both docs/desktop-install.md and the release-notes template composed inside release.yml's own release job still said "SHA256SUMS.txt is attached as well" (singular). v0.12.0's actual attached files: SHA256SUMS-macOS-arm64.txt, SHA256SUMS-macOS-x86_64.txt, SHA256SUMS-Windows-x86_64.txt -- matching the issue exactly.
What changed
docs/desktop-install.md-- names the three real files, explains why there's no combined one, and tells the reader to pick the file matching their platform (with asha256sum -cexample)..github/workflows/release.yml's "Compose release notes" step -- same fix, same wording style.tests/test_desktop_packaging.py-- two new tests that compute the expected filenames from the real source: the desktop job's matrix (strategy.matrix.include, parsed with the strict YAML loader this file already uses) and the Checksums step's own naming template (extracted via regex, with an extra pin that the template's shape itself hasn't silently changed). Both docs copies are checked against that computed set, and against the absence of the literalSHA256SUMS.txt.
This is the same family of defect as #595 (one fact, five stale copies) and follows the same fix discipline per that issue: read the real source rather than hardcode the literal being checked, so a future change to the matrix or the naming template makes the test recompute its expectation instead of silently passing against stale names.
Mutation verification (each reverted after)
- Reverted
docs/desktop-install.mdto the old singular wording:AssertionError: docs/desktop-install.md must name 'SHA256SUMS-macOS-arm64.txt' -- it is one of the files the release workflow actually attaches - Reverted the release-notes echo lines the same way -- the sibling test failed identically against the "Compose release notes" step.
- Changed
CKSUM_FILE's template to use underscores instead of hyphens:(both doc-facing tests failed too, since neither doc names underscore-separated filenames)AssertionError: the checksum filename template changed to 'SHA256SUMS_${{ runner.os }}_${{ matrix.arch }}.txt' -- the docs and the release-notes wording may need to change with it, not just this test
Results
uv run pytest -q: 4569 passed, 3 skipped in 151.74suv run ruff check keel tests packages: All checks passed!uv run mypy: Success: no issues found in 359 source files
feat(broker-api): the venue falsifies what the operator attested (#233 PR3) (#619)
Closes half of the remainder of #233. PR1 (the record, migration, keel scope attest) and PR2 (rail 20, doctor) landed in #612/#614. This is PR3: the venue's half of the record. PR4 (the display) is a separate branch.
Why
The record has two writers. The operator attests; the venue falsifies. Until now only the operator wrote, so a wrong attestation stayed wrong forever — executor._run_order stood exactly where venue truth arrived and threw it away (log_exception + re-raise, recorded nowhere), and the next cycle could not tell a read-only credential from a healthy venue.
That is the 2026-08-19 incident's real cost. The 403 moved no money. The silence cost an afternoon.
The single most important property
TradeScopeDenied latches. Writing it makes rail 20 veto every live ENTRY on that venue until a human types yes at a terminal. So a transient failure classified as a refusal would take a healthy live deployment off the market and require physical presence to restore it.
A missed refusal costs one more refused order. A false one costs an outage. Every classifier here is a narrow allow-list; everything else re-raises untouched. Pinned per adapter against a 5xx, a 503 whose body carries the venue's own permission wording, a 429, a 401, a timeout, a connection reset, and an unparseable body.
Per venue
| venue | classified on | why |
|---|---|---|
| coinbase | 403 and the SDK's own Missing required scopes predicate, on preview and placement |
coinbase.rest.rest_base.handle_exception hard-codes exactly this branch ahead of every other 4xx. The status alone is not enough: this venue also answers 403 PERMISSION_DENIED for product entitlement — observed live 2026-08-05 against a futures product on a portfolio not onboarded for FCM. Preview is classified too because on this venue preview is a real call under the same scope and the executor previews first; mapping only placement would have shipped a gate that cannot fire on the venue this deployment actually trades. |
| robinhood | 403, status alone | The motivating case: 403 {"detail": "You do not have permission to perform this action."} from a live probe. One observed 403 on this venue and this is it. Narrowing further on unverified prose would fail open the day Robinhood rephrases the sentence — the failure mode with no symptom. |
| alpaca | nothing, deliberately | Its 403 is documented here and pinned by an existing test as insufficient buying power. Mapping it would latch REFUTED over an ordinary cash shortfall. 422 is a bad body, 401 a credential the venue does not recognise, the rest are the network. No permission-refusal shape has ever been observed from this venue, and #233 forbids pre-classifying from documentation. A parametrised test across six statuses holds this refusal in place. |
…truncated — full description in #619.
fix(commands): research's read-only commands stop opening the db read-write (#620)
Closes #610.
Premise checked against origin/main
Confirmed: keel research significance --from deployment, factors and independence all reach their data through _open_repo (keel/commands/_common.py), which calls migrate(conn), and migrate commits unconditionally. keel research pooled-review, in the same file, already connects every profile db mode=ro via its own _connect_ro and never migrates. The inconsistency the issue describes is real and unchanged since filing.
The two decisions the issue left open
1. What a read-only command should do against a database that needs migrating. Refuse, before any data is read. A database stamped below this binary's SCHEMA_VERSION is a shape this binary was never tested against -- reading it quietly would be answering a question with evidence of unknown provenance. The issue's own instinct (refusal as operator error, not an evidence refusal -- #601's distinction) is what I implemented: _open_repo_ro raises a ClickException naming the stored version, this binary's version, and the remedy (run a command that writes, e.g. keel migrate, or run a binary that matches its version) rather than silently reading a schema Repository was never built against.
2. How wide to take the seam. The seam (_open_repo_ro in keel/commands/_common.py) plus the three research/ commands, exactly as the issue's own inclination suggested -- nothing in insights, status, pnl, activity was touched. They share the same read-write shape and are explicitly left as a follow-up once this seam has a track record, per the issue's own "doing all of them is a bigger change than this issue needs" framing.
What changed
keel/commands/_common.py--_open_repo_ro(ctx):pooled-review's ownmode=roURI shape, promoted to a shared seam, with two refusals before any data is read: a missing file (mirrorskeel/mcp/tools.py::_open_readonly_repo's existence check --mode=roalone already refuses to create the file, but the rawsqlite3.OperationalErrornames neither the file nor the reason) and a stale schema version (decision 1, above).keel/commands/research.py--research_significance,research_factors,research_independencenow call_open_repo_roinstead of_open_repo.pooled-review's own_connect_ro/read_orders/read_ledgerare untouched -- they already had the right shape.docs/research-toolkit.md-- the "which of these open your database read-write" hazard section is rewritten to state what's now true (all four research commands that touchorders/trade_outcomesare read-only, the stale-schema refusal message, and the explicit remaining-scope boundary).tests/commands/test_research_commands.py-- three new tests (below).
Tests, mutation-verified (each restored after)
…truncated — full description in #620.
test(data): the v2 backfill idempotency pin now actually calls the step twice (#621)
Closes #615.
Premise checked against origin/main
Confirmed exactly as described: test_migration_is_idempotent (tests/data/test_migrations.py) called db.migrate(conn) three times and asserted one broker_subscriptions row. migrate()'s version gate (if current < target) means calls two and three run zero migration steps once the database is stamped at SCHEMA_VERSION -- so the assertion held regardless of what _migrate_v2_broker_subscriptions actually does with a row that already exists.
Also found, per the issue's own "audit the other repeated db.migrate(conn) calls" ask: test_migration_does_not_overwrite_an_existing_row had the identical vacuous shape -- a second db.migrate(conn) call that is always a no-op once the database is at SCHEMA_VERSION. Interestingly, this file already has the fix's own template in house: test_v14_migration_does_not_overwrite_an_existing_attestation (v14's version of the same test) is paired with test_v14_migration_step_does_not_overwrite_an_existing_attestation_when_rerun, which calls _migrate_v14_venue_trade_scopes directly a second time and says explicitly why that's the only way to reach the guard. The v2 backfill's tests never got that companion fix when #233 established the pattern for v14 -- this PR is that missing fix, for v2.
What changed
Both test_migration_is_idempotent and test_migration_does_not_overwrite_an_existing_row now call db.migrate(conn) once (the real v1→v14 upgrade path), then call db._migrate_v2_broker_subscriptions(conn) directly a second time, against state the step has already touched -- the only way to actually re-enter its early-return guard, since migrate()'s public API will never re-run a step once the stored version matches.
Mutation verification (restored after)
Deleted the guard inside _migrate_v2_broker_subscriptions (the SELECT 1 FROM broker_subscriptions WHERE venue = 'coinbase' LIMIT 1 early return) and re-ran both rewritten tests:
sqlite3.IntegrityError: UNIQUE constraint failed: broker_subscriptions.venue
Both failed (venue is the table's PRIMARY KEY, so an unguarded second INSERT collides outright rather than silently duplicating or overwriting) -- exactly the "mutation someone who disagreed with you would plausibly write" this pin needs to catch. Restored the guard; both tests pass again.
Results
uv run pytest -q: 4567 passed, 3 skipped in 152.98suv run ruff check keel tests packages: All checks passed!uv run mypy: Success: no issues found in 359 source files
Single-file change (tests/data/test_migrations.py), as the issue asked.
feat(setup): the first-run ceremony presents rail 20, and records the off-venue step honestly (#437 D4) (#622)
Closes #437. Closes the "Desktop distribution — keel for non-technical users" milestone.
What was actually left
Most of D4 had already shipped under its own number. keel/commands/setup.py opens with """What a deployment still needs before it can trade -- observed, never assumed (#437, D4).""" and already carried the step taxonomy, the read-only inspect(), the ACTIONS registry, keychain credential capture and the paper-default template; #534/#537/#540 wired the browser Setup page over it; the .app launches keel serve. Three of the issue's scope items — keychain credentials, migrations on every start, paper by default — were already true and are pinned here rather than built. The full premise check, including the citations that had gone stale, is on the issue.
Three things were genuinely missing, and one of them opened the morning this was written.
Rail 20 was invisible to the ceremony
keel scope attest and rail 20 merged under #233. setup.py's STEPS had no scope step and READ_TABLES had no venue_trade_scopes, so an operator who completed every step the Setup page listed was still vetoed on their first live entry — by a command the checklist never named. That is the exact failure the checklist exists to prevent, reintroduced by a rail landing faster than the ceremony that presents it.
The step sits between withdrawals_attested and confirm_cycle, which is the runbook's order: the attestations that release rails come before the supervised cycle that exercises them. The observation reads the row and calls VenueTradeScope.may_place_live_entry() rather than re-deriving the state machine — scope.py is explicit that there must be exactly one place to get that wrong — and the venue resolves the way rail 20 resolves it, so an alpaca deployment is not gated on a coinbase record nothing writes.
It gets no one-click action. keel scope attest --trading is one of the eight rows in keel/capabilities.py: it releases a rail-20 veto and is TTY-gated, so it stays CLI-only, exactly as withdrawals attest --enabled does.
The off-venue acknowledgement, and a contradiction settled
setup.py said two incompatible things. Its module docstring: "keel can show the checklist and record that you say you did it; it cannot verify … never reported as done here — only as acknowledged." Its ACTIONS comment: "OFF_VENUE steps stay unreachable, because there is nothing there to record that would be true." No acknowledgement record existed, so the second was in force and the issue's third acceptance criterion — live requires "the venue-side checklist explicitly acknowledged" — was unreachable.
…truncated — full description in #622.
fix(web): the capability count said eleven in six places, the registry holds eight (#616) (#623)
Why
keel/capabilities.py's CAPABILITIES inventory holds eight rows today (confirmed against origin/main at the time this branch was cut, with keel scope attest --trading newly added by #233). Six places still said eleven: keel/web/server.py (twice), keel/web/security.py, keel/web/__init__.py, keel/web/static/js/render.js, keel/web/static/js/api.js.
Every one of those sentences makes the same load-bearing safety claim — "not one of the N capability-increasing actions in keel/capabilities.py is reachable from the browser" — and it is the sentence a reviewer checks the disjointness test against. The number has drifted twice already (eleven → seven when the TUI was deleted, seven → eight at #233). keel/cli.py records that history correctly and is untouched. keel/commands/setup.py is being corrected on another branch per the issue and is untouched here. keel/capabilities.py itself is untouched (read and derived from, never edited).
I also fixed four of the same stale "eleven" mentions in tests/web/test_server.py's own docstrings — leaving them while this PR's new test asserts the count is eight would have made the test file self-contradictory.
The mechanical fix
test_no_capability_increasing_action_is_reachable_from_the_web_layer already proves disjointness by AST-scanning keel/web/ for any import or call of a gated capability function. It said nothing about whether the prose's stated count was honest. test_the_disjointness_comments_state_the_actual_capability_count is new, in the same test file (extending rather than duplicating), and:
- scans every
.py/.jssource underkeel/web/for the"<word> capability-increasing actions"pattern (tolerant of the line wrap that splitscapability-andincreasingacross a#:/*comment continuation), - converts the stated number word to an int,
- asserts it equals
len(CAPABILITIES)— never a literal written in the test.
A ninth row added to CAPABILITIES without a prose update fails this test until the prose catches up.
Mutation verification (each reverted after)
- Added a ninth row to
CAPABILITIES, prose untouched:AssertionError: __init__.py says 'eight' (8) capability-increasing actions, but keel/capabilities.py's CAPABILITIES has 9 rows -- prose is stale assert 8 == 9 - Reverted
security.py's "eight" back to "eleven":AssertionError: security.py says 'eleven' (11) capability-increasing actions, but keel/capabilities.py's CAPABILITIES has 8 rows -- prose is stale assert 11 == 8 - Deleted the new test function entirely:
tests/web/test_server.pydropped from 62 to 61 passing, andtest_no_capability_increasing_action_is_reachable_from_the_web_layerstill passed on its own — the disjointness pin and the count pin are independent assertions, not one wearing two names.
Test plan
…truncated — full description in #623.
feat(web): five venue-readiness states, derived offline, beside the declarations (#233 PR4) (#624)
Closes the remainder of #233 alongside #619 (PR3). PR1/PR2 landed in #612/#614. This is PR4: the display. Independent of #619 — either can merge first.
Why a second block and not more columns
keel brokers list has always answered one question honestly — what does the adapter declare — and ends on the line that says so:
capability display only -- no key presence is read or implied, and no secret is shown
That line is exactly why the command could never answer the question an operator actually arrives with: can a live entry be placed on this deployment, right now. Both questions are legitimate; they are not the same question, and merging them re-blurs the distinction #233 exists to draw.
So:
- the declarations block and
NO_KEY_INFERENCE_LINEare emitted unchanged and still terminate it; --jsonis untouched — still the declarations list verbatim. Turning it into a dict, or adding readiness to those rows, would break its consumers and merge the two facts. The machine surface for readiness is/api/venues;- the web keeps the same seam:
venuesandreadinessare sibling top-level keys, so_venue_payload's existing promise that a row there "is not a claim that the venue is configured or reachable" stays true.
Pinned: the line's exact text, that it terminates the declarations block, and that readiness vocabulary appears in neither that block nor --json.
The states, in the order they are asked
not_installed → not_tradeable → no_credentials → partial_credentials → malformed_credentials → record_unreadable → not_permitted → ready
Three of these were added after review, and each replaced a claim the display could not support — see "Four honesty defects" below.
Each only runs when the more specific one above it did not fire, so an operator gets the most actionable thing true of the venue rather than the most generic. Everything is derived strictly offline — no network call, ever. Nothing here gates anything: readiness reports, rail 20 decides. The trade-scope half calls VenueTradeScope.may_place_live_entry() rather than re-deriving the state machine, the same discipline rail 20 and doctor.trade_scope_findings keep.
malformed is the state the 2026-08-19 incident paid for
ROBINHOOD_API_KEY held the base64 Ed25519 public key derived from ROBINHOOD_PRIVATE_KEY rather than the identifier Robinhood issues. Both present, both well-formed 44-character base64, every request signed correctly, every one 401'd — indistinguishable from a revoked key, a stale clock, or a signing bug.
…truncated — full description in #624.
docs(experiments): all three of #523's slippage-cap options, measured on one cohort (#625)
Measurement only. Nothing under keel/ is changed — not the cap, not the admission floor, not the model. Adds one pre-registered experiment record, its driver, and its JSONL artifact.
What this is
#523 offers three options for the 50 bp slippage cap and says plainly that none is obviously right. One of them (a $5M admission floor) disqualifies live PAXG. This PR measures all three on the same cohort by the same method so the choice can be made on numbers, and recommends nothing.
#523 is not stale
Every substantive claim re-measured against origin/main (ed206c0) today. Several reproduce to four decimal places — CRO 0.3195 → 0.2428, STX 0.2932 → 0.2276, ATOM/FIL/JASMY exact. The corpus-tail value the cap's docstring reasons about measures 183.8 bp (TON, $369,944), confirming the "~184 bp" it cites.
One correction: "relative decline is perfectly monotone in the cost multiple" is exact on the issue's six assets and becomes ρ = 0.9516 with one inversion (STX/ZEC) once the cohort is widened to thirteen. Expected — relative decline depends on P&L shape as well as cost multiple — and it does not weaken the finding.
The result
| option | direction | magnitude |
|---|---|---|
| 1 — raise/remove the cap | conservative | −0.3% to −24.0% of net PF |
| 2 — participation scaling | flattering | +0.9% to +38.6% at the real $50 clip |
| 3 — $5M admission floor | neither | 0.0% on everything it keeps; deletes 15 products + live PAXG |
Capping at the corpus tail (183.8 bp) and removing the cap entirely are bit-identical on every product, because nothing cached is thinner than TON.
At the $50 clip this deployment actually fills, square-root-law impact is under 5 bp for every product in the corpus. Inverting the model shows the shipped curve implicitly prices a clip of $26k–$165k — 500× to 12,000× larger than what the box trades. Option 2 is a flattery until the spread it would replace is measured, and keel stores no book data to measure it with.
Nothing in any of the 210 cells reaches net PF 1.0 at n ≥ 100. The best is ZEC 0.8146 under the most flattering arm measured. #523's own reading holds and strengthens: the cap masks severity, not viability.
Controls
- Identical entry/exit timestamp sequences across slippage rates of 0, 5, 50, 104.74 and 183.8 bp; identical
n_tradesacross all seven arms on all 30 products. The arms differ in cost alone. - BTC/ETH frozen across
shipped/tail_cap/uncapped; every product above $5M/day is unaffected by options 1 and 3 by construction. taker_pct: 0.012separately verified against three real live fills — 1.1838% effective aggregate, ratio 1.0137×. A research claim that it overstates costs by 2× is false; nothing here rests on it.
Reproducing
PYTHONPATH=. .venv/bin/python docs/experiments/2026-08-30-slippage-cap-options.py --workers 8
…truncated — full description in #625.
refactor(rules): the rule contract lives on the rule, not in three tables in agent.py (#447) (#627)
Stage 1 of the three-PR arc from the accepted analysis on #447. The registry stays closed — this PR builds no keel.rules entry point and no plugin loading. It hardens a contract that is currently scattered.
The problem
RULE_REGISTRY was never the only hardcoded kind-table. _DECIMAL_PARAMS and _GRANULARITY_PARAMS (agent.py:131-156) mapped kind → coercion rules, and a third table was not written down at all: a literal if kind == "pullback_continuation" branch inside build_rule_from_params. With param_space() and promotion_class, that put one contract in two files and five places.
Adding a Decimal parameter to a rule meant editing a table in a module the rule's author has no reason to open, and nothing forced the edit. Forgetting it makes no noise — rules.params round-trips through json.dumps, so the value reaches the constructor as the string "1.5", and the first symptom is a Decimal/float TypeError raised deep inside the rule's own arithmetic, mid-cycle or mid-backtest.
The change
Rule.decimal_params, Rule.granularity_param and Rule.tuple_params — class attributes, beside param_space() and promotion_class. Class attributes rather than methods because build_rule_from_params must know how to coerce the kwargs before it has an instance to ask.
keel/agent.py keeps the single boundary where conversion is done and gives up deciding what to convert. coerced_param_keys still excludes tuple_params: a tuple param arrives as a JSON list, never a quoted string, and folding it in would make --params '{"ema_periods": "8"}' read as a well-typed tuple declaration.
The pins
tests/strategy/test_rule_contract.py, in three layers:
- A rule's own declaration drives the coercion — proved on a rule this repo does not ship. A test written against
TurtleBreakoutpasses identically whether the coercion reads the class or a dict naming the same string. This test was not missing before the change; it was unreachable. - Each shipped declaration matches its constructor's type annotations — the one source that cannot be forgotten, so drift is a failing test rather than a production incident.
- The live path — every shipped rule's stored row rebuilds identically through the real lossy hop, values and types.
Decimal("2") == 2.0is true, so an equality-only assertion passes against exactly the regression it guards.
Live-deployment safety
This touches the path that rebuilds live rules from the database every cycle. Layer 3 pins the round-trip end to end for all four shipped kinds. The float-instead-of-Decimal mutation below confirms the type assertion catches what equality misses.
Mutations
Each written as something a reviewer who disagreed would plausibly try, then reverted. Full observed failure text is in the commit body. Summary:
…truncated — full description in #627.
test(strategy): a rule conformance suite, curated rather than shipped (#447 stage 3) (#629)
Stage 3 of the three-PR arc from #447, stacked on #628. The registry stays closed — this ships no keel.rules entry point and no plugin-loading mechanism, and nothing in it needed one.
Why it lives in tests/, not keel/
That placement is the design decision. packages/keel-broker-api/.../conformance/suite.py ships the broker contract from an installable package because third-party adapters exist and need something to prove themselves against. #447 decided the rule registry stays curated: RULE_REGISTRY is a closed, hand-maintained dict of exactly four rules, there is no entry point, and there will not be one. A suite shipped from keel/ would imply the opposite — that some future third party is meant to subclass it and register a rule of their own.
So RuleConformanceTests lives under tests/, imported by four subclasses and nothing else: conformance the four shipped rules are held to, not a contract offered to anyone who is not already one of them.
What it asserts
tests/strategy/rule_conformance.py — a pytest mixin with two hooks, rule() and firing_candles(). Seven assertions × 4 rules = 28 tests.
detect()is deterministic across repeated calls — on the same instance, deliberately:PullbackContinuationcaches a_RunningStateacross calls (#352), and a fresh instance per call would never exercise itdetect()does not mutate the candle series it is given — the engine loops every rule over one shared read- the runtime direction pin holds (
direction == "long"plusengine._long_shaped_ok) describe()round-trips throughbuild_rule_from_params, types as well as values, for whatever each subclass configured rather than only the defaultstest_rule_contract.pyalready pins- every
param_space()spec's.kwargnames a real constructor parameter promotion_classis a namefloor_for_class()recognises — it falls back to the default floor silently for anything else, so a typo promotes against the wrong floor, forever
The anti-tautology guard
Determinism, non-mutation and the direction pin are all checked against the Setup detect() returns — and all pass vacuously if it returns None. A suite whose every rule declined on its fixture would report green while checking nothing.
So the base class takes a second hook, firing_candles(), and _fired_setup() asserts the Setup came back non-None before anything downstream can mean something. That assertion is pinned as its own named test (test_the_firing_fixture_actually_fires) rather than left as an implicit precondition. Each subclass reuses the firing series from the rule's own test module, already hand-verified gate-by-gate, rather than inventing a fifth set that could drift.
The honest limit, stated in the docstring
detect()is documented as "Pure" onRuleitself, but purity is not an assertable property from inside the same process as the code under test.
…truncated — full description in #629.
feat(strategy): a rule cannot go short, and cannot propose a short wearing a long's label (#447) (#630)
Reopens the work from #628, which GitHub auto-closed when its base branch was deleted on #627's merge. Rebased onto main; content unchanged apart from the rebase.
What this closes
Setup.direction: Literal["long"] was a promise to mypy and nothing else. RULE_REGISTRY is a plain dict any caller can write to, so a rule — a first-party one edited carelessly, or a foreign one — could construct Setup(direction="short", ...) and nothing raised.
It has never mattered, and this does not exist because it started to. Four independent layers already stand behind it. What was missing is that #447 asked for the property confirmed by test rather than assumption, and it was assumption.
The design changed under measurement
#447's analysis asked for stop < entry < target on Setup.__post_init__. It cannot live there. agent._handle_exits rebuilds a Setup for a held position whose stop is open_stop, and exit_policy.next_stop ratchets it strictly upward — max(stop, entry) at break-even, higher on the trail. A winning trailed long has stop > entry as its normal state.
Measured rather than argued: strict → 57 failures; non-strict stop <= entry <= target → still 5, including test_the_live_trail_never_widens_the_stop, which is the live exit path. Dca's stop=0 / target=entry sentinels are a second, independent reason.
So Setup.__post_init__ enforces direction == "long" only, and the price ordering became engine._long_shaped_ok — at the rule boundary, the one place a Setup is known to be a fresh proposal from rule.detect() rather than a reconstruction of something already held.
It refuses rather than raises. evaluate runs every live rule in one loop; a raise would take the whole cycle down — every well-behaved rule's entry with it — because one rule proposed nonsense. Skipping emits no Signal, so nothing reaches sizing or execution.
Mutations that changed the work
…truncated — full description in #630.
chore(git): ignore the Playwright MCP's console-log scratch directory (#631)
An agent driving a browser from the repo root leaves .playwright-mcp/ behind — 74 console logs after one research session on 2026-08-30. Untracked and uncovered by .gitignore, so the next git add -A would have swept them into a commit.
Scratch output of a session, never an artifact. Ignored rather than tidied by hand, because the hand-tidy only works if whoever runs the browser next remembers.
The directory itself is already deleted; this stops it recurring.
fix(backtest): the slippage cap is the corpus tail, not a round number (#523) (#632)
Implements option 1 of #523, as decided by the owner: raise the slippage cap to the value derived from the corpus tail. Options 2 and 3 were measured in #625 (docs/experiments/2026-08-30-slippage-cap-options.md) and rejected — 2 is flattering (+0.9% to +38.6% at the clip this deployment actually fills), 3 would disqualify live PAXG.
The change
SLIPPAGE_CAP_PCT: 0.005 → 0.01838. 183.8bp is not a chosen number — it is the module's own curve read at the thinnest measured product, SLIPPAGE_FLOOR_PCT * sqrt(anchor / 369,944.11) for TON-USD, quantised to the tenth of a basis point every caller prints at.
The old 50bp was picked so the clamp bound at exactly 100× below the anchor. That put the binding point at $5M/day while ScreenPolicy.min_median_daily_volume admits from $1M/day — so 17 of 30 cached products were capped, 16 of them simultaneously admissible, all charged an identical 50.0bp across a 4.3× spread in liquidity. Inside that band the square-root model degenerated into the flat-fee model #259/#334 shipped to remove. At 183.8bp the clamp binds at ~$370,015/day, beneath the admission floor, so no admissible product can be flattened onto one rate again.
Measured cost: 0.3%–24.0% of net profit factor across the cohort, all conservative, no verdict moved — nothing was net-positive at either rate.
What is deliberately unchanged
- The admission floor stays at $1M/day, so live PAXG's status is unchanged by this. Option 3 would have disqualified it at $1.25M median — and at $3.39M on the longer
PAXG-USDTview it is still below $5M — but a position the deployment holds must not be dropped as a side effect of a cost-model change. A test now pins the floor so this change cannot alter admissibility. SLIPPAGE_FLOOR_PCT, and the promotion gate (#335).execution.max_entry_spread_pctstays at 0.005 and becomes an independent number. Leaving the live entry gate at 50bp while the cap rises makes it stricter than the model's worst case rather than equal to it — the fail-closed direction. Every place describing it as "anchored to the cap" now says what it actually is.
What this does not fix
Inverting the square-root impact law at these rates says the shipped curve prices a $26k–$165k clip; the deployment fills $50. The 50bp cap was accidentally holding that overstatement down. This removes one unjustified number and leaves the larger one exposed — that is #626, and this PR does not address it. An honest overstatement, not a correct model.
Acceptance criteria
…truncated — full description in #632.
feat(trade-scope): evidence is bound to the credential that earned it (#633) (#635)
Closes #633.
venue_trade_scopes (#233) records what a venue proved about a credential — but the row was keyed by venue alone. keel credentials forget CDP_API_KEY then set with a different key left it reading coinbase → confirmed, and rail 20 permitted a live entry on the new key using the old key's evidence.
It failed safe eventually — a scope-less key 403s and TradeScopeDenied latches REFUTED — so the cost was one refused order, not a wrong trade. But one order later than necessary, and in between the record asserted something it could not know.
Why not a hook on credentials set/forget
That gate is one the common path walks around. credentials show resolves from three sources, and forget says so itself — keel does not edit your .env. Editing that file, or exporting a different value, changes the credential with no keel command in the loop. So evidence is bound to the credential and compared at read time; it stops mattering how the credential changed.
What is hashed, and why it is not the secret
Only the identifier. A total preimage break would recover a credential id and nothing that can sign — but that is not the reason it is right:
- rotating the secret under the same identifier is the same credential with the same scope, and must not withdraw permission
- a different identifier is a genuinely different credential
Fingerprinting the secret would call an ordinary rotation a credential change, and call a stolen-then-replaced secret under the same key id no change at all — backwards on both counts. Domain- and NUL-separated, so a coincidental string collision across venues cannot read as the same credential.
The production hazard, and why NULL permits
The live deployment's row was backfilled to confirmed by the v14 migration from the orders log. It has no fingerprint and can never be given one — the migration cannot know which credential placed a 2026-07 order. A naive "mismatch ⇒ not permitted" would veto the next live entry on a healthy deployment, which is exactly what that backfill exists to prevent.
So NULL means "recorded before fingerprinting existed" and is treated as matching. Only a recorded fingerprint that differs may withdraw permission. current_credential_fingerprint never raises and returns None for anything it does not know — callers read that as unknown, never as grounds to refuse. A fingerprinting failure must not stop a deployment.
Verified against a copy of the real live database, not a fixture
before: 12 -> new build: 15 (migrates cleanly)
row: coinbase / confirmed / credential_fingerprint = None
recorded NULL: matching fp -> True differing fp -> True None -> True
recorded "fp-AAA": "fp-AAA" -> True "fp-BBB" -> False None -> True
The grandfather clause is real and the discrimination is real.
Verification
…truncated — full description in #635.
docs: the evolution plan — Jesse's skeleton, keel's soul (#643)
Saves the evolution plan — discussed and owner-approved across the 2026-08-28→31 delivery sessions — as docs/evolution-plan.md, so it stops living only in session transcripts and every session (plus the site's engine-docs sync) can find it.
Contents: the Jesse take/refuse table, the three moats, the eight-line constitution, the phased roadmap A–F wired to the already-filed issues (#595, #601, #437, #602, #603, #370/#371, #636, #637, #638, #570–#578; site #117/#120/#135), the crypto-only tier table destined for ADR 0004, the options constitution framing for ADR 0005, the IKA integration architecture, and the standing decisions log (MCP deferral, signing Option D, the repo/board boundary).
Docs-only. No code, no behavior. Companion to #603 and #637.
ci(workflows): three workflows that read no documentation stop running on it (#644) (#645)
Closes #644.
code-quality.yml, security.yml and migrate.yml fired on every docs-only change and can produce no signal from one. Seven docs-only PRs merged on 2026-08-30/31 started all three for nothing.
security.yml is the largest item and the one the sweep first missed — it answers push and pull_request, so a docs-only PR cost two of its runs where the others cost one. Both triggers are filtered.
install-smoke.yml and installer-smoke.yml already key on paths:, so the pattern existed and had simply never been applied to these three.
⚠️ ci.yml is deliberately unchanged
keel's documentation is under test, and ci.yml runs those tests. Two of them exist because docs drifted and nobody noticed:
test_python_floorreadsREADME.md,README.ar.md,docs/desktop-install.mdand fails when they disagree with the release'srequires-python— #595/#607, where five places said 3.11 while the wheels required 3.14.test_doc_linkspins the documentedSHA256SUMS-*filenames against whatrelease.ymlactually emits — #605/#618, where the docs promised one file and the workflow shipped three.
A docs-only PR is the only kind those pins are for. Filtering docs out of ci.yml would disable them on precisely the changes they were built to catch.
A comment cannot enforce that, so tests/test_workflow_triggers.py does: the filter is required on the three and forbidden on ci.yml, in both directions. It also refuses a paths: allowlist on ci.yml — the same defect inverted, since an allowlist skips everything not named.
Why this is safe
- No branch protection on
main(GET /branches/main/protection→ 404). The usual footgun —paths-ignoreon a required check leaving a PR permanently unmergeable — does not apply. - The weekly crons backstop it.
paths-ignoregoverns onlypush/pull_request;schedulefires regardless.code-qualityruns Mondays 06:00 UTC andsecurityMondays 04:23 UTC, so even a pathological miss is re-scanned within seven days.migrate.ymlhas no cron and needs none — its push run is a no-input migration smoke test.
One distinction worth keeping
docs/** sweeps up the experiment drivers and ledgers under docs/experiments/. Correct for these three, which read none of it. Not correct for ci.yml, where docs/experiments/trials-ledger.jsonl is a hash-chained artifact the research tests reason about. Same glob, opposite verdict — recorded so it is not re-litigated.
Mutations, each observed failing and restored
| mutation | caught by |
|---|---|
the filter added to ci.yml |
test_ci_never_skips_a_documentation_change |
security.yml filtered on push but not pull_request — the miss this nearly shipped |
the per-event loop |
the ignore list drifting by one entry (LICENSE dropped) |
exact-set assertion |
ci.yml given a paths: allowlist instead |
the inverted-defect assertion |
…truncated — full description in #645.
docs: evolution plan amendments from the external review (#647)
Five surgical amendments to docs/evolution-plan.md from the 2026-08-31 external review, all cross-referenced to where the decisions were recorded: ceremony card v1 fields + idempotency_key+proposal_hash binding (Phase B), SEC XBRL companyfacts + AAOIFI 33/33/5 screening source (Phase C, recorded on #370), Fee Reality benchmark as Phase E's flagship (#646 + site #136), and the hosted-confirm signer hypothesis — native app or passkey-gated non-extractable WebCrypto with the engine as a dumb relay, settled by validation spike (Phase F). Docs-only. This PR also live-validates #644's new paths-ignore: Code Quality and Migrate should skip it.
docs: the sw-push claim corrected — the groundwork does not ship (#649)
One-line honesty fix to docs/evolution-plan.md Phase B: the plan claimed Web Push groundwork already ships; sw.js actually carries offline/manifest only — no push handler, no subscription endpoint, no RFC 8291 payload encryption, no VAPID management in the engine. Rewritten as greenfield work, gated behind the remote-exposure security pass (#648). Docs-only.
docs: the Software Architecture Record (SAR) (#650)
Creates docs/architecture.md — the Software Architecture Record. Every claim about the present state was verified against main this session (package layout, the loopback-by-design server checks, confirm.py's TTY-only path, the kraken honest stub, freshness.py, sw.js's absent push handler); every claim about the future names its issue (#648, #637, #638, #626, #370…). Companion to docs/evolution-plan.md (strategy) and the ADRs (single decisions). Docs-only — and a live test of #644's paths-ignore.
docs: the price-drift gate joins the approval envelope (#651)
From the 2026-08-31 SAR review, the one new design worth keeping — with two keelifications: (1) exits are never walled off (entries refuse + re-propose; exits degrade to the typed-phrase path, per _ask_to_place's documented principle that trapping a position behind a gate is the worse money outcome); (2) the drift tolerance is derived per-product from measured spread (#626), not a flat constant — the #523 lesson. Docs-only.
docs: PDF editions of the architecture record and the evolution plan (#652)
Adds docs/pdf/keel-architecture.pdf (6 pages) and docs/pdf/keel-evolution-plan.pdf (7 pages) — styled, vector, with title pages and metadata — for team review and external sharing. The markdown sources on main remain canonical; the PDFs are regenerable artifacts (converter + render pipeline documented in the delivery session). Docs-only.
fix(agent): a filled paper exit books its outcome and closes its tranche (#639) (#653)
Closes #639.
The caveat first: this is NOT already fixed on main
The issue's own instruction was to check before writing a line, because the six
exits ran under pre-#502 code and book_exit moved in 3d9cc6c. Driven end to
end against a throwaway database on cfdea1f — two paper run_once cycles,
bar one enters, bar two rips through the target:
cycle 2 orders: [('BUY', 'filled', '100.0500'), ('SELL', 'filled', '114.942500')]
filled SELL with actual_fill: [('filled', '114.942500')]
=== VERDICT ===
trade_outcomes rows: 0
open positions: 1 | all: [(1, 'open', None)]
REPRODUCES
Same script after this change: trade_outcomes rows: 1 | open positions: 0 | all: [(1, 'closed', 176400)].
#502 rewrote a path the paper book never reached.
Why
The agent OPENS a paper tranche — _open_tranche runs in the shared entry loop,
paper and live alike — and until now nothing ever closed one.
_paper_resolve_bars → PaperTrader.on_candle → _close writes the exit
ORDER and forgets the position it belongs to.
_handle_exits cannot cover for it, and this looks like an ordering accident
but is not: _held_position reads mode="live" orders only, so in a paper
cycle it returns qty 0 and _handle_exits returns [] before it ever looks at
a rule. The live exit path is unreachable from paper by construction — and
deliberately so, since routing paper exits through executor.execute would put
a real broker behind them. So the booking belongs where the tranche was opened.
promotion.py's pooled sample-size axis (#338) counts trade_outcomes. With
nothing writing to it, pooled n is pinned at 0 and no rule can ever be promoted.
What changed
keel/agent.py—_book_paper_exit: fetch the exit order,_close_tranches
(which isstreak.book_exit, so FIFO attribution and the outcome row stay in
exactly one place), retireposition_rule:<product>. Two things derived, never
asserted:is_dcafrom the tranche's owning rule (§12.6 exempts DCA from the
streak, never from the record), andnow_tsfrom the cycle's clock,
matching theopened_at_open_tranchewrote — a bar timestamp would also set
rail 16'sstreak_halt_untilto a cool-off that expired before it began.keel/commands/doctor.py— one new function + one line ingather_findings.
Nothing existing touched (#640/#642 own this file concurrently).
ledger.unbooked_exitis stated over the two tables that must agree, not over
this bug: an OPEN tranche whose product has a FILLED SELL dated at or after it
opened. Modes pooled — an unbooked live exit is strictly worse. WARN, not
FAIL: what is lost is evidence.
Pins and mutations
Seven mutations, each observed failing and restored from a scratch copy:
…truncated — full description in #653.
fix(status): a paper position stops crying wolf about a bracket it cannot have (#641) (#655)
Closes #641.
Every open position in a paper deployment rendered NO bracket as a WARN — nine of nine on the current book. has_bracket is bracket_order_id is not None, and paper never places a venue order, so it is False on every paper position by construction. The flag was not reporting a risk; it was reporting the mode, in the vocabulary of a risk, and it could never resolve.
Why it mattered
The cost is desensitisation. NO bracket is a serious warning in live — a real position at a venue without its protective order, the state reconcile_unbracketed_positions and the unbracketed: crash ledger exist to heal (#519, #502). An operator reading nine amber rows a day in paper is trained to ignore the one that counts.
And paper is not unprotected, which is what makes the amber wrong rather than merely noisy: PaperTrader.on_candle resolves the setup's stop and target against each candle's range via backtest.py's shared touch helpers, taking the stop when a bar touches both.
What a paper row says now
| case | rendering |
|---|---|
live, any has_bracket |
unchanged — bracketed GOOD / NO bracket WARN |
paper, initial_stop recorded |
paper stop $X — NEUTRAL |
paper, no initial_stop (pre-0.12.2 tranche, or a DCA leg) |
n/a — paper resolves stop/target on candle touch — NEUTRAL |
The third row is the #624 lesson: a display must not assert what it cannot know. A tranche with no stop recorded says so rather than inventing one.
Live is untouched, and the guard is ordered so it cannot break by accident
if position.has_bracket or mode != "paper":
return flag(...) # the original, unchangedEvery non-paper mode returns before any paper branch is reached. Pinned in both surfaces:
test_live_position_with_no_bracket_still_warnstest_live_position_with_a_bracket_still_reads_goodtest_live_position_with_no_bracket_still_renders_no_bracket(terminal)
Both renderers move together — web/payload.py and commands/status.py — so the browser and the terminal cannot disagree about what a missing bracket means.
Verification
4904 passed, 3 skipped · ruff check clean · mypy clean (372 files).
feat(web): the installed console survives the browser closing, and says the truth when it cannot (#634) (#657)
Closes #634. Files #656 for the part that is deliberately not here.
What you hit
Install the console, launch it a second time, land on "Not authorised" with an instruction to go and read a terminal — which a window with no address bar cannot follow. And on 2026-08-31, a plain browser tab autocompleting 127.0.0.1:8765/status out of history hit the same wall, so this was never only about installing.
It was two failures, and conflating them made it look unfixable
(A) The browser closed; keel serve never stopped. The token was valid the whole time. The refusal happened because the cookie carried no Max-Age and died with the browser session — a default nobody chose. A phone evicts an installed app from memory constantly, so this is the everyday case. It is also exactly what the browser-tab report was.
(B) keel serve restarted. The token is new and nothing on the device can know it. This one is a theorem, not a preference: after a restart, no client can present a credential the new run accepts unless the server persisted something across the restart. There is no third way.
All three of the issue's options aim at (B). (A) is most of the pain and needs nothing persisted anywhere.
The decision: option 3, plus the half of (A) its framing missed
The cookie gets a lifetime
security.session_cookie builds the whole Set-Cookie in one place now, Max-Age included, and the server uses it instead of formatting its own string.
This does not extend the token's life by one second, and that is the entire argument. The cookie only ever carries ServeConfig.token, which new_session_token mints per process and never writes to disk. When the process exits, every copy of that value — cookie jar, scrollback, a pasted link — stops authenticating anything. So a persistent cookie is a browser holding a value whose power is already bounded by a process it does not control. new_session_token's "a token that outlives the process it authorised is a credential" stays literally true: the token does not outlive the process; only the browser's inert copy does.
Thirty days is measured against the useful life of a serve process, not against the value's sensitivity — the sensitivity ends at process exit whatever that number says.
What it costs, said out loud
- Closing the browser used to revoke access. By accident, as a side effect of a missing attribute. It no longer does. The replacement is restarting
keel serve, which mints a new token and invalidates every outstanding cookie at once, andservenow prints it: "Stopping keel revokes it: the token is new every run and is never written to disk." - The token now sits in the browser's profile on disk rather than only in memory — bounded by the same process lifetime.
Neither makes the --host warning less true. The thing in the way is still a per-run secret that still travels in cleartext.
…truncated — full description in #657.
fix(wrappers,doctor): the deployment stops trading, or stops running, and now says so (#640, #642) (#658)
One arc: a deployment that degrades silently. #640 is a profile that stopped cycling and nothing said so; #642 is a book that stopped entering and nothing said so. Both are the same defect — keel knows, and does not say — so both are fixed by the same work: something that feeds the rails, and something that reports when they fire.
What shipped
The cycle shape, in all four wrappers — fetch → doctor → cycle → post-cycle doctor. keel-live-run.sh, paperforward-run.sh, paper-hourly-run.sh and paper-equities-run.sh all now fetch before cycling and run doctor on both sides of it. A fetch failure is reported rather than swallowed; any FAIL reaches the notification path. Neither fetch nor doctor can abort the script or change $STATUS. The three paper wrappers gained the notify()/$OSASCRIPT seam they never had.
Exit 4 is no longer indistinguishable from a quiet market. agent.DATA_NOT_READY_EXIT gets its own log line and its own notification, distinct from a generic failure and from a clean cycle with signals=0. The load-bearing "leaves the day unstamped, retries next trigger" contract is unchanged.
doctor can now see a profile that is not running. New profile.scheduled (a plist exists but launchd has no job for it — #640's exact incident) and profile.cycled (a profile stalled against its own cadence), plus profile.unreadable. Gathered from every profile a deployment declares, so the live book's daily doctor run reports on the hourly book that has gone dark. Deliberately wired into doctor_cmd rather than gather_findings, because that seam is shared with keel mcp and pinned read-only.
Finding.products — machine-readable per-product identity on data.missing / data.stale / data.gaps, emitted by --json, so a caller can name a cold series without re-parsing prose.
The launchd install step, written down for the first time. Neither runbook contained a single launchctl command. docs/operator-runbook.md gains "Installing and verifying a profile's launchd job" with the real bootstrap/list/bootout commands and the #640 incident on record; docs/go-live-runbook.md gains a "Schedule it" step.
The per-product acceptance criterion is refused, deliberately
#642 asks that "doctor's verdict gates PER PRODUCT, not per book… the engine already reasons per product, and the wrapper should not be coarser than the engine."
On this tree the engine is book-wide, on purpose. keel/agent.py:
# The whole-cycle admission bit: ANY blocked rule, on ANY product, withholds EVERY entry
# this cycle -- see the pre-pass comment above for why this cannot be decided per-rule
# or per-product.
entries_allowed = not blocked_entries…truncated — full description in #658.
feat(orders): the venue's book at submit is written down, because spread is the cost (#626) (#660)
Closes #626 — option 1 only. Options 2 (persisted realised slippage) and 3 (a depth snapshot during assets discover --probe-liquidity) are not attempted here, and everything in the issue's "Not in scope" section (SLIPPAGE_*, ScreenPolicy.min_median_daily_volume) belongs to #523, which was decided and shipped as #632.
The finding that made this cheap
The issue describes option 1 as "capturing best bid/ask at the moment the market order is submitted", implying new work. keel already fetches it, and has since #350. _run_order previews before every live placement, and the routing-time max-entry-spread gate reads best_bid/best_ask out of that preview — through _preview_book, whose other consumer is #332's entry-override warning — to decide whether to enter at all. It then threw the quote away.
So this is pure persistence of a value already in hand: no new Broker protocol method, no extra venue call, no added latency, no new failure mode on the order path. The write rides the insert_order that already precedes place_order — the row either goes in with two more columns or the order was never placed, exactly as before.
Why it matters
config.live-sandbox.yaml sets max_per_order_usd: 100 and the three real live fills were $50.00, $50.00 and $61.71. At $50 the square-root law puts market impact under 5bp for every product in the corpus while slippage_for_quote_volume charges 50bp. Essentially the whole modelled cost at this size is spread — and nothing recorded a single quote. That is why #523's merged measurement (docs/experiments/2026-08-30-slippage-cap-options.md) reports every participation arm as a lower bound on cost and declines to recommend option 2 on that basis.
What is stored, and why
The raw pair, never a derived spread. orders.submit_best_bid / orders.submit_best_ask, TEXT holding str(Decimal(...)) like every other price here. expected_fill/actual_fill are the precedent: two numbers, not one delta. A stored (ask - bid) / mid cannot be re-derived differently later, and half-spread off the mid, the half actually crossed, and relative spread are three different questions off one pair.
Four decisions
…truncated — full description in #660.
feat(orders): the orders table gets a surface -- what keel bought, sold, and whether anyone agreed to it (#659) (#661)
Closes #659.
Repository.get_orders() has existed since the beginning and nothing in either front-end called it. Verified before writing a line: the only callers are agent, executor, guards, reconcile, mcp/tools, strategy/paper and doctor — every one a writer's read, none a surface. grep -rn orders keel/web/ returns three comments and a broker capability enum, and no query. So "what did keel actually buy and sell, and at what price" was answerable only by opening SQLite by hand: status shows what is held right now, activity parses the log file.
This adds one service, one terminal command, one JSON endpoint and one browser view — all over a single report, so the two front-ends cannot disagree about a figure. The #641 arrangement.
What lands
keel/commands/orders.py |
OrdersReport, gather_orders, render_orders, keel orders |
keel/web/payload.py |
orders_payload — places values, derives none |
keel/web/api.py |
/api/orders (GET, read-only), ?scope=, ?limit= |
| client | ordersView in render.js; an eighth nav entry |
The load-bearing decisions
The read is unfiltered. gather_orders calls repo.get_orders() with no mode, no product_id, no status, and /api/orders has no ?mode=. Each deployment book holds exactly one mode — keel-live.db all live, keel.db and keel-paperhourly.db all paper — so a mode="live" filter renders empty on two of the three real books and reads as "keel has never traded". mode is carried per row instead. The pin is on the call (assert repo.calls == [((), {})]), not on a fixture that happens to hold both.
Three counts, because "empty" has two causes. total_count / scoped_count / shown_count, and empty_reason says which: "no orders in this book at all" versus "the book holds N, all of them outside this scope". An empty table that could mean either is a surface asserting something it has not established.
confirmation leads the row, ahead of the product and the price — pinned as position, not presence, in both renderers. Four words reach that column, not the two the issue described: autonomous, confirm (which means approved, since a declined confirm_fn returns before insert_order), bypass (the pre-2026-07-21 spelling, deliberately unmigrated) and paper. bypass is in AUTONOMOUS_CONFIRMATIONS, not merely in the prose — a view answering "did I approve this" correctly only for rows written after a particular Tuesday would be worse than one that did not answer.
expected_fill and actual_fill sit adjacent with the divergence between them, and the verdict is side-aware: paying more than expected is adverse on a BUY and favourable on a SELL, so a client colouring the minus sign would be wrong on half the rows. Both the number and the judgement are computed in Python; the state is passed to money(), not inferred from the sign.
…truncated — full description in #661.
chore(release): 0.13.0 — schema 12 to 16, and the deployment can finally read itself (#662)
61 commits since v0.12.2 and no release carries any of them. keel update correctly reports "already at the latest release (0.12.2) — nothing to do" while main sits four schema versions ahead. This is the bump that closes that gap; per docs/RELEASING.md the release itself is a human dispatch, and the workflow refuses to change the version so CI never writes to main.
Why a minor, not a patch
Four migrations run in one pass on a v0.12.2 deployment:
| v13 | scale_out's realized_qty / realized_proceeds / realized_fees (#502) |
| v14 | venue_trade_scopes + the backfill from the orders audit log (#233) |
| v15 | the credential fingerprint (#633) |
| v16 | submit_best_bid / submit_best_ask (#626) |
Verified against a copy of the live book, which is stamped at 12: 12 → 16 in one pass, integrity_check → ok, orders ALTERed rather than recreated, and the v14 backfill stamps coinbase as confirmed from real placement history — so rail 20 permits the next live entry rather than vetoing a healthy deployment.
What the release contains beyond the schema
- The deployment can be read. Orders finally have a surface, in both paper and live (#659). Paper exits book
trade_outcomes, so the promotion gate has an input for the first time (#639). - It stops failing silently. Wrappers fetch and run
doctor, and aFAILalerts (#642). A stalled profile is visible (#640). Paper stops warning about brackets it cannot have (#641). - The console re-authorises without a terminal (#634), and the service worker no longer swallows the token-bearing navigation (#604).
- Costs got more honest. The slippage cap is derived from the corpus tail rather than chosen (#523), and the venue's book at submit is recorded so spread stops being the unmeasured term (#626).
- Rule contract hardening, runtime rails and a rule conformance suite (#447); trade-scope evidence bound to the credential that earned it (#633).
The bump moves the whole workspace
Every workspace pyproject.toml and every exact-version sibling pin, not just the root. Bumping the root alone leaves the workspace disagreeing with itself, and test_workspace_siblings_are_pinned_to_the_exact_version caught exactly that on the first attempt:
workspace versions disagree: {'keel-trader': '0.13.0', 'keel-core': '0.12.2', ...}
uv.lock moves with them — release.yml checks it before anything else and fails the release if it was left behind (#424).
Verification
5104 passed, 3 skipped · ruff check clean · mypy clean (378 files).
After merging
Actions → Release → Run workflow, entering 0.13.0. Then on the deployment, keel update — it backs up all three databases before migrating, and never deletes those backups.