Skip to content

keel v0.15.0

Choose a tag to compare

@github-actions github-actions released this 12 Sep 00:00
· 100 commits to main since this release
cd6f0d1

Built from cd6f0d1. Version binds to this hash:
keel --version reports keel 0.15.0+cd6f0d193bfe [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.15.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.

Other changes

feat(web): the Status and Setup pages, one section at a time (#773) (#774)

Closes #773.

Both pages had grown past the point where an operator could see anything at a glance. Status scrolled through four state cards and four tables; Setup through the deployment card and then every step of both runbook stages, each a full card with its form — about 6,000px, with the go-live steps below the fold of the fold.

Each now shows one section, chosen from a tab bar:

page sections
Status Status · Open positions · Rules · Data freshness · Subscriptions
Setup Setup · To run in paper · To go live

The control is the one scopeSwitch and statusSwitch already establish — nav.scopes / button.scopekey, aria-current carrying both the underline and the announcement, data-focus putting focus back on the pressed tab's replacement. No CSS was added.

This is not a filter, and that is why it stays in the client

statusSwitch and scopeSwitch both re-ask the server, because both narrow a collection the server capped on the way out — filtering a received page in the browser would present "the rows that happened to arrive" as the whole answer.

This control narrows nothing. /api/status sends every section of the status page in one document and always did; choosing which of them to draw is presentation, and no section is ever unreachable — every one of them has a tab, always. So there is no ?section=, and no "all" tab: the way back to any section is that section's own tab, and an "all" tab would re-create the page these two views were cut up to stop being.

The sections are a table, not five branches

STATUS_SECTIONS and SETUP_SECTIONS pair a key, a label and a builder, so the bar cannot offer a section the page has no builder for. The label is written once and reaches both the tab and the section's own heading — each builder is handed the entry it was reached through — which forecloses a tab and a heading disagreeing about which section a reader is looking at.

STAGES is folded into SETUP_SECTIONS for exactly that reason: it carried a second copy of "To run in paper".

An unwired onSection renders every section with no bar — the page exactly as it read before — rather than one section with no way to reach the others. Same guard as ordersView's onStatus, and the same reason: a control nobody wired must not cost a reader anything.

The section is kept per route.name in main.js and survives navigation, so the two pages remember their sections independently. The press forces the repaint: unforced, the rebuild is deferred while focus sits on the button that was just clicked, and a control that does nothing is indistinguishable from a broken one.

Tests

tests/web/test_view_sections.py (18 tests) pins the tables as parsed out of the JavaScript rather than restated:

  • both tables declare the sections in the page's own former reading order;
  • every section has a builder of its own — a table pointing five entries at one function would satisfy every label assertion and render one section under five tabs;
  • every key is distinct, and each of the four status headings is written by the builder its entry names;
  • no builder re-spells its label; if it writes a heading, it writes entry.label;
  • neither view dropped a payload key in the split (_view_keys against /api/status and /api/setup, pinned);
  • one sectionSwitch, one sectioned, and the two bars announce themselves differently;
  • the shell keys sections by route.name and forces the repaint.

tests/web is green: 835 passed.

Verified by hand, since the suite cannot

There is no JavaScript runtime in this repository, so what the source-text suite cannot establish was checked against a running keel serve:

  • all five status tabs and all three setup tabs render their section and only their section (paper 7 step cards, live 5);
  • focus lands back on the pressed tab after the rebuild;
  • each table keeps its heading as its aria-labelledby;
  • Setup's "To go live" and Status's "Rules" are remembered independently across navigating away and back;
  • console clean, no errors or warnings.

Page height: Status 1,900px → 619–1,074px. Setup ~6,000px → 930px on its first section.

fix(web): the section tabs had tests that passed on broken code (#775) (#776)

Closes #775. Follow-up to #774.

A post-merge review ran thirteen defects through the client. Twelve passed the suite. The one that failed did so by accident — caught by a payload-key pin, not by any test aimed at it.

The cause was uniform: the assertions were "onSection" in body and "data-focus" in body. A source-text scan passes on a declaration alone; onSection is in the signature. Naming an identifier proves the author typed it, never that it is wired to anything.

Mutation score

before after
defects rejected 1 / 13 13 / 13

Each mutation is applied to the client, the mutated bytes are read back off disk (a no-op mutation looks exactly like a killed one), the suite runs, and the file is restored in a finally. Every test below names the mutation it exists to reject and was proved red against it.

target mutation what shipped
sectioned delete the if (!onSection) block the documented "unwired renders everything" degradation, gone
sectioned const here = sections[0]; bar renders, presses recorded, the page never changes section
sectioned drop the sectionSwitch append section one is the only reachable section
sectionSwitch one shared data-focus every keyboard press dumps focus onto tab one
sectionSwitch unconditional aria-current a screen reader is told all five sections are current
stageSteps filter hardcoded to "paper" the To go live tab renders the paper checklist
stageSteps delete the blurb / the empty-stage line / hardcode the heading the paper-is-safe argument disappears
setupLive forward SETUP_SECTIONS[1] its neighbour's stage under its own tab — the mutation its docstring called unspellable
statusFreshness a second heading("h-positions", …) two elements share one id; a table announced under the wrong heading
onSectionFor sections.set("status", key) Setup's presses land in Status's bucket; Setup can never leave section one

stageSteps — the function that draws every Setup step — had no test of any kind.

The regression that shipped

market_data is a Stage.PAPER step, so Fetch market data is pressed from the To run in paper tab — and the panel reporting it was inside the Setup section. The job's state line, elapsed time, streamed transcript and job.error were all invisible to the person who started it.

jobPanel's docstring keeps a failure on screen because "the whole point of running something in the background is that nobody was watching when it broke". The split defeated that structurally. setupSummary's docstring justified the placement with reasoning that was exactly backwards; the correction is written where the claim was.

The panel is page-level state, like the <h1>. It renders above the switch now.

Two structural traps, named so they are not re-dug

_decl bounds at the next top-level declaration, not at the next \nfunction . The old bound sailed past export function and const — measured, what it called statusSubscriptions' body swallowed all of setupView and the whole SETUP_SECTIONS table. Every count taken over it was counting the wrong thing.

A test guarded by if "heading(" in body is a test that can skip itself. That guard made the label-to-heading check assert nothing for all three Setup sections, because Setup's heading is written one hop down in stageSteps — so the page whose duplicate wording motivated the whole table was the page nothing was checking. _delegate follows the hop.

Smaller

  • sectioned/sectionSwitch named their Section[] parameter table, shadowing the module's row renderer. test_every_call_resolves_to_something_the_module_has scopes parameters per function, so it would keep passing while a table(...) added inside either resolved to an array and shipped a runtime TypeError — the miss that test's own docstring records from #701.
  • Their payload parameter is payload, not data: both sit inside activityView's _view_keys region, where a data.<key> would be checked against /api/activity. The rename makes the mistake unspellable rather than merely unmade.
  • onSectionFor now names what the round trip costs — a read that fails replaces the section with stoppedView, for a document the client was holding in full, with no aria-busy while it waits.

Verified

tests/web: 843 passed. In a browser against a running keel serve: the job panel renders above the tab bar on all three Setup sections with its transcript intact, and the sections still show 7 paper steps and 5 live steps.

fix(test): the orders book fixture failed for sixty seconds of every UTC day (#777) (#778)

Closes #777. Fixes the red on main from run 34419113683.

FAILED tests/web/test_orders_view.py::test_the_scope_is_normalised_and_echoed_rather_than_refused
  assert '0' == '1'

Why it failed

?scope=today is a UTC calendar day, not a rolling window: scope_start_ts returns 00:00 UTC of the current day and gather_orders skips a row when created < start_ts. The book fixture placed its live order at now - 60.

That run's started_at was 1788998401, and 1788998400 is exactly 20706 × 86400 — one second past UTC midnight. The row landed at 23:59:01 on the previous UTC day and fell outside the scope it exists to be inside.

window frequency
before first 60s of every UTC day ~1 run in 1,440
after fixture/server clock reads straddling midnight ~1 run in 10,000,000

Green on every rerun, which is what makes it the kind of failure that gets rerun rather than read.

Not from #773/#774 — that PR touched render.js, main.js and a new test module. This fixture dates from #659.

The fix

max(now - 60, scope_start_ts("today", now)), extracted into _recent_ts so it can be tested without waiting for midnight.

Still anchored on the real clock, for the reason the fixture already gave — a fixed future instant would put every row inside every scope and make the scope assertion pass without the scope doing anything. Clamped, not replaced: the row stays a minute old on all but one run in 1440. The bound is inclusive (created < start_ts is what skips), so landing exactly on it is inside.

The pin is a real one

Parametrised at five points around the boundary. Reverted to now - 60 it goes red at 00:00:01 and 00:00:59 and stays green at 00:01:00, midday and 23:59:59 — it discriminates the flake window from the rest of the day rather than merely asserting the helper exists.

A second test holds the other half of the pairing: the 90-day-old row must stay outside today. A clamp that dragged both rows into today would make the scope assertion pass while proving the scope does nothing.

What is deliberately left open

The fixture reads the clock and the server reads it again a moment later. Reads that straddle midnight place the row in the old UTC day and judge it against the new one — the setup-to-request gap, order of 100 ms.

Closing it needs a sleep past the boundary in a fixture, or a skip guarded on the echoed scope_start_at. The second is a test that can decide not to run — the failure #775 is about — because were scope_start_at ever wrong, the guard would silently skip forever instead of going red. The residual is written into _recent_ts's docstring rather than papered over.

Verified

tests/web + tests/commands/test_orders.py: 926 passed.

fix(web): every reader answers on the clock it was handed (#779) (#780)

Closes #779. Step 1 of the two agreed items.

respond reads time.time() once per request and threads it to route.read(cfg, query, state, now_ts). Sixteen readers use it. Two took it as _now_ts and read the clock again for themselves:

read_activity   apply_scope(feed, scope, now_ts=time.time())
read_orders     gather_orders(repo, now_ts=int(time.time()), ...)

What that cost

The document reported two instants. The envelope's generated_at came from the dispatcher's reading, the scope boundary from the reader's a moment later. Across a UTC midnight those are different days, and ?scope=today answers about a day the page does not claim to be showing.

Not theoretical — #777 is the day it cost a red CI run.

And it made those two the only readers a test could not choose a clock for. #778 had to monkeypatch.setattr(web_api.time, "time", …), patching the time module process-wide, to get a deterministic reproduction. That is no longer necessary anywhere on the read surface.

No behaviour change

now_ts is computed per request at the top of respond (api.py:1205) — verified before touching it, because had it been computed at server start, wiring it up would have frozen the clock in production. This is the same instant, read once instead of twice.

read_setup, read_plans, read_rules, read_venues and read_gates keep _now_ts: they are genuinely time-free.

Tests

Each reader is called directly with a now_ts a decade from any wall clock, so a reader that ignores its argument cannot agree by coincidence — the same test at an instant inside today passes either way, which is why both are parametrised. The activity boundary is compared against scope_start_ts's own answer, not a restated literal.

test_no_reader_reads_the_clock_a_second_time keeps the rule after these two instances are gone.

A bound that lied, caught in passing. My first version of that scan bounded each function at the next def read_, which swept helper functions into the preceding reader and reported five clock reads inside read_gates — which has none. Same region-bounding error _decl carried in #775. The scan bounds at the next top-level statement, and the docstring says why.

mutation result
read_orders back to int(time.time()) KILLED
read_activity back to time.time() KILLED
dispatcher's own now_ts → a constant KILLED

The third matters: it proves the injection is real end to end rather than shadowed by a reader that would have read the right value anyway.

Verified

tests/web + tests/commands/test_activity.py + tests/commands/test_orders.py: 1112 passed. ruff, format and mypy clean.

fix(test): the clock-injection tests never exercised the seam they were about (#782) (#784)

Closes #782. Follow-up to #780.

#780's product change was correct — traced and confirmed: float→int loses nothing, now_ts is per-request on every path including SSE, the rename is complete. This PR touches no product code. Its tests were the problem.

mutations killed
before 2 / 11
after 11 / 11

The seam with no test

respond reads the clock once and threads it to route.read(...). That sentence is the whole of #780 — and all three of its tests called the readers directly. Nothing in the module called respond; generated_at appeared only in docstrings, never in an assertion.

mutation before
a day-old clock threaded to every reader 204 tests pass
now_ts hoisted to a module constant, read once at import 204 tests pass
the value floored to UTC midnight 204 tests pass

The second would have a long-running keel serve answering every request on its boot instant. test_respond_hands_the_reader_the_same_instant_it_stamped goes through the dispatcher and asserts both halves, because they fail to different mutations: as_of catches a value altered before stamping, scoped_count one altered before being handed down.

A test that passed having scanned nothing

test_no_reader_reads_the_clock_a_second_time built offenders from a loop and asserted it empty — with nothing pinning that the scan found anything. It walks 43 top-level definitions and classifies 18 readers, and passed identically when narrowed to classify zero. The population is now cross-checked against API_ROUTES, the list the server actually dispatches to, so it cannot drift into a number nobody maintains.

The scan is an ast walk now, which fixes four things at once:

  • a third offender written int(datetime.now(tz=UTC).timestamp()) escaped — both names are already imported in api.py, so it is a one-line in-idiom defect;
  • so did one hidden a call deep in a module helper — the shape the two original offenders take after any extract-method;
  • it false-positived on a module constant like _SERVER_STARTED_AT = int(time.time()), blaming whichever reader preceded it;
  • and its docstring said "bounded at the next top-level statement" while the code bounded at the next def/class.

A fixture that never reached the reader

The activity test pointed tmp_path at a config and asserted scope_start_at. But resolve_log_path's default is relative and resolves against the process CWD — verified, it resolves to <checkout>/logs/keel.log. So the config was decoration, the feed was empty, and the only assertion left was on a pure function of now_ts that no feed can influence. Making apply_scope filter on the wall clock while still echoing the injected boundary passed everything.

It owns its log now — a cycle either side of the boundary, an absolute logging.file — and asserts the cycle that survived, not the label.

Two smaller

The orders fixture gains a third row, one second before the boundary. The scope is a lower bound with no upper bound, so a clock shifted backwards only widens the window; with rows only at the boundary and two days out, now_ts - 86_400 changed nothing and escaped.

orders.scope_start_ts is pinned against a literal midnight. + 1 on its return passed the whole suite because _book_at places its boundary row by calling the same function — row and boundary moved together and the mutation cancelled itself out. A test that computes its expectation from the code under test cannot see that code move.

Verified

tests/web + tests/commands/test_activity.py + tests/commands/test_orders.py: 1115 passed. ruff check and mypy (458 files) clean.

No ruff format run this time — see #783, which is about the repo being 40% unformatted with no CI gate. The file happens to be format-clean anyway.

chore: one-shot ruff format across the tree, and gate it (#783) (#785)

Closes #783. Mechanical. No behaviour change, with one syntax change called out below.

ruff format was configured and clearly intended — ruff.toml sets its line-length and target-version and carries a careful note about a hazard someone reasoned through — but nothing ran it, and 197 of 496 files had drifted.

The cost wasn't aesthetic: a contributor who ran the formatter on a file they touched reformatted regions they hadn't, and those hunks landed in PRs whose bodies said "no behaviour change". #780 shipped six of them and a reviewer had to read each to establish it meant nothing.

197 files reformatted, 299 left unchanged
+3427 / -1960
directory files
tests/ 112
keel/ 55
docs/ 17
packages/ 10
scripts/ 3

The gate

ruff format --check . in both ci.yml and release.yml. Both, because release.yml is dispatched against whatever main is at the time, which need not be the commit CI went green on — the same argument that file already makes for re-running mypy there.

Whole tree, where ruff check in the same workflows is scoped to keel tests packages. Lint has a debt in docs/ and scripts/; formatting no longer has one anywhere, and scoping the check would re-open the drift this pass closed. It's its own step, so a formatting failure reads as one instead of being masked by a red lint on the same run.

⚠️ One change is not cosmetic

The pass rewrote 38 occurrences of except (OSError, OverflowError, ValueError): into PEP 758's unparenthesised except OSError, OverflowError, ValueError:.

That is exactly what ruff.toml's existing note predicted, and why that note is there: under the old >=3.11 floor the same rewrite emitted a SyntaxError on three interpreters the metadata invited. The floor is >=3.14 now — verified across all eight distributions, and pinned by tests/test_python_floor.py (test_every_distribution_declares_the_same_floor, test_ci_runs_the_suite_on_the_floor_so_it_stays_true). CI runs 3.14 only.

If you'd rather not carry that syntax, say so and I'll add ruff format's skip for it — but the floor makes it safe and the note in ruff.toml already anticipated this day.

Verified

The real risk in a mechanical reformat of this repo is its many source-scanning tests — inspect.getsource assertions, _code_only walks, exact-string pins. None broke:

  • full suite: 6477 passed, 3 skipped
  • ruff check keel tests packages clean
  • mypy clean (458 files)

And the gate is checked rather than assumed: both workflows still parse as YAML, both carry the Format step, and ruff format --check . against a single deliberately misformatted line exits 1.

Reviewing this

Not with git diff --ignore-all-space — that was in the first draft of this body, it was never run, and it is wrong twice: it leaves 199 files and 3308 insertions (no useful reduction), and it hides the one change below that is not whitespace.

Instead, the reformat is proved neutral rather than eyeballed. All 197 files were AST-compared against main:

  • 186 AST-identical, 0 unparseable
  • the 38 except rewrites parse to a byte-identical AST — except (A, B): and except A, B: are the same tree, so that half is neutral by construction
  • 11 files differ, all the same way: the formatter inserted a leading space into docstrings that begin with a quote (""""HALTED" → """ "HALTED"). That is a string value change, not whitespace.

Two of the 11 are in keel/ (_rail11_status, _render_tier_section) — both private helpers. No Click command's help text starts with a quote (checked by AST across keel/), so no --help output moved.

Review follow-up

Five findings, all fixed in 0bcfd4f: the gate is now pinned by three tests (deleting it from either workflow, scoping it, or moving it after Stamp build info all go red), the count is corrected to 38, and CONTRIBUTING.md / the PR template / test_the_gates_a_pr_must_pass_are_stated all carry the new command.

feat(web): the Tier 1 gate's security primitives, with no action wired (#781) (#786)

Stage 1 of #781. Builds the gate, wires nothing to a rail.

That ordering is deliberate: at no point should a half-built surface exist that can reach disengage_kill_switch. The invariant in keel/web/__init__.py — "not one of the nine capability-increasing actions is reachable from this package" — is still true after this PR, and its AST scan passes untouched.

⚠️ A correction to the brief, found before building

The specified guard was "refuse unless the request originated from 127.0.0.1". That is necessary and not sufficient.

A Cloudflare Tunnel runs cloudflared on this machine and connects to keel over loopback. So a request that began on the public internet arrives with client_address of 127.0.0.1. A guard reading only the peer answers truthfully and uselessly — and would let whoever can reach the tunnel release a rail, which is the exact population #781 exists to keep out until #648 and #656 land.

gated_action_permitted therefore requires both:

condition why
the peer is loopback the socket's remote address is the one thing in a request an attacker cannot choose
external_hosts is empty this is how the module already spells "remote posture"

The second isn't invented for this PR: session_expired_at turns a never-expiring session into a 30-day one on exactly that field, reasoning that a remote origin "changes the population to anyone who can reach the tunnel". Same signal, same reason, one decision further along.

What's here

is_loopback_peer reads the peer address, never a header. Host: and X-Forwarded-For: are claims the client writes, and this check exists for the case where the claim is a lie. (HostPolicy.permits reads the header and defends a different thing — DNS rebinding.)

It accepts the whole 127.0.0.0/8 block rather than one address — systemd-resolved answers on 127.0.0.53, and == "127.0.0.1" would refuse a local operator with a false refusal that reads as a broken button — plus ::1 and the IPv4-mapped ::ffff:127.0.0.1 that a dual-stack bind presents for a v4 client. It fails closed on anything it cannot parse.

The parameter is object, not Any, so mypy enforces the narrowing rather than waving it through: what arrives is BaseHTTPRequestHandler.client_address, which typeshed declares a 2-tuple and which is a 4-tuple on IPv6.

gates_token derives the write token for a gated action exactly as csrf_token derives the setup one, under a different label. That difference is the property: the setup token is written into /api/setup's document, so anything that can read that page holds it — and one value covering both surfaces would make "can create a deployment" and "can release a halt" the same permission. Same secret, two derivations, neither usable as the other, each revocable alone.

This is the "separate token" call from the plan, done the way this codebase already does domain separation rather than by adding a second secret to manage.

Also

tests/web/test_server.py said "eight capability-increasing actions"; there are nine. The three keel/web/ sources say nine correctly and are pinned by test_the_disjointness_comments_state_the_actual_capability_count — which scans keel/web/ only, so it could not see the test's own prose. Fixed here, since this is the machinery being extended.

Verified

tests/web + tests/test_capabilities.py: 900 passed. ruff, ruff format --check and mypy (458 files) clean. 13 new tests, each parametrised over the cases that matter — the loopback block, the IPv6 forms, the malformed peers, and the tunnel.

Stage 2

Wires the three brake-releases (resume, resume-entries, reset-hwm) behind this gate plus the typed-phrase modal, and the ungated one-click Halt. That stage is where keel/capabilities.py gains its second Gate and where the __init__.py invariant gets rewritten — deliberately, in the PR that earns it.

feat(web): the browser gate and its closed action table, still wired to nothing (#781) (#787)

Stage 2a of #781, on top of the merged #786 primitives.

keel/capabilities.py has said since it was written that "#436's browser gate would be a second Gate here and a second value in Capability.gate — which is the point of writing the vocabulary down before there are two." This is that second kind of evidence, as its one implementation, symmetrical to _require_interactive_confirmation being the one implementation of the first.

Nothing routes to it yet. The invariant in keel/web/__init__.py — not one of the nine capability-increasing actions is reachable from this package — is still true, and its AST scan still passes: TIER1_ACTIONS names resume, resume_entries and reset_hwm as strings, never imports or calls them. The dispatch that will call them belongs in server.py, so this module stays a declaration.

The table is closed, and the closure is the property

key releases phrase
resume the kill-switch halt that stopped all trading RESUME TRADING
resume-entries rail 16's consecutive-loss halt on new entries CLEAR STREAK HALT
reset-hwm rail 11's drawdown veto, by re-seeding the high-water mark RESET HIGH WATER MARK

The other six are refused here as firmly as they are refused to cron — and the tests name autonomy-on, record-flow, withdrawals-attest and update individually, asserting each is absent. Admitting one means deleting a test that states why it was out, rather than adding a line nobody reviews.

Why these three is written where the table is. All three release a brake a rail applied — deliberately not described as risk-reducing, since they increase what keel can do. What makes them Tier 1 is that the capability is bounded and instantly reversible: the rail that fired can fire again, and keel kill undoes all three with no ceremony, because stopping must never be slower than starting. The other six grant standing capability or move a baseline — record-flow rebases the drawdown high-water mark, which capabilities.py calls "the one direction a circuit breaker must not fail in".

The phrase

Exactly, meant literally: not case-folded, not stripped, not prefix-matched. The operator reproduces one specific sentence, and every loosening turns a deliberate act into a plausible typo. Pinned: resume trading, a trailing space, and a truncation all fail.

Never the action's own key, and no two share one — a phrase derivable from the URL is evidence of nothing, and a shared phrase would prove a human but not intent. compare_digest because it costs one call and removes the timing question (belt-and-braces; the modal prints the phrase).

phrase_matches fails closed on the key as well: an action absent from the table has no phrase, and "no phrase" must never read as "no phrase required" — which is what a lookup with an empty default would quietly mean.

One of the repo's own pins caught me

The module's first title line read "Tier 1 capability-increasing actions", which test_the_disjointness_comments_state_the_actual_capability_count correctly read as a disjointness claim naming N=1 against nine. It scans every keel/web/ source for that sentence shape because the number "has drifted twice already". Reworded to "three of the nine capability-increasing actions" — which states the truth and satisfies the pin for the right reason.

Verified

tests/web + tests/test_capabilities.py: 925 passed. ruff, ruff format --check and mypy (460 files) clean. 12 new tests.

What's next

Stage 2b routes to this — the POST /api/gates/* gauntlet, the three call sites, the second Gate in keel/capabilities.py with three surface="web" rows carrying mirrors to the CLI originals, and the rewrite of both the __init__.py invariant and test_the_only_gate_today_is_the_tty_one. That test's own docstring says it "is where that change announces itself — so adding one cannot be a quiet edit", which is why it gets its own PR rather than riding along here.

Stage 3 draws the modal and the ungated one-click Halt.

fix(web): the safety invariant scanned command names, not effects (#788) (#789)

Closes #788. Standalone, off main. No production code changes — this is the guard, not the thing guarded. Nothing in #781 depends on it landing a particular way, but stage 2b should not be built on top of it as it was.

The gap

keel/web/__init__.py states the guarantee the whole package rests on:

"Not one of the nine capability-increasing actions is reachable from this package… The server cannot arm, release or spend."

It did not assert that. The scan forbade this package from naming any Capability.function — and those are the CLI commands, Click callbacks taking a ctx that no web module would ever call. What releases a rail is the operation underneath:

gated action scanned does the work — not scanned
keel resume keel.cli.resume trading.disengage_kill_switch
keel resume-entries keel.cli.resume_entries trading.clear_consecutive_loss_halt
keel reset-hwm keel.cli.reset_hwm trading.reset_high_water_mark

Planting an ungated disengage_kill_switch(repo) into a web module passed the entire safety suite. The guarantee held because nobody had written the line, not because the test would have caught it.

What is asserted now

The effects, derived. For each of the nine, the functions its gated command actually calls, read out of the source — never a hand-written map. keel/capabilities.py exists because "a hand-written list would go stale", and a table saying "keel resume really means disengage_kill_switch" would be that same list one level down. The derivation asserts it finds ≥6 operations, so it cannot go quietly empty.

And no database write at all. Four of the nine work through a generic repo.set_state(...) that no derivation can tell from any other write. So the third scan states the posture directly: this package calls none of the repository's 28 writers. Measured — keel/web/ calls five Repository methods and all five are reads. That is also the more honest sentence: keel serve reads, and everything it can change goes through keel.commands.setup.ACTIONS, outside this package.

The original name scan is kept. It's cheap, it still catches the obvious spelling, and deleting a check because a better one arrived is how the next gap opens.

Measured against the old one

Five real effects planted in keel/web/staticfiles.py:

planted old scan new
ungated disengage_kill_switch — the #788 proof ❌ missed ✅ caught
ungated clear_consecutive_loss_halt ❌ missed ✅ caught
ungated record_declared_flow ✅ caught ✅ caught
a raw repo.set_state(…) write ❌ missed ✅ caught
apply_scope_attest ❌ missed ✅ caught

test_the_effect_scan_can_fail proves the derivation and the scan can match — the same shape as the existing test_the_scan_for_capability_increasing_actions_can_fail, which proved the old scan could match a name, which is exactly the thing that turned out not to be enough.

keel/web/__init__.py now says what is checked rather than what was hoped.

Verified

tests/web + tests/test_capabilities.py: 916 passed. ruff, ruff format --check and mypy (458 files) clean.

After this

Stage 2b lands on a scanner that can see the three operations it routes to, so its assertion becomes the precise one: those three reachable only through the gated handler, the other six unreachable anywhere in keel/web/.

fix(web): drop reset-hwm to Tier 2, and bind every phrase to its own action (#790) (#791)

Closes #790. Eleven findings on the gate table merged in #787. Mutation score 11/20 → 17/17.

The Tier boundary argued against itself

reset_high_water_mark is four writes — it clears the mark, zeroes both drawdown scalars, and empties equity_history, the rolling window the weekly peak is computed from. record_declared_flow shifts the mark by a signed amount the operator states.

So Tier 1 admitted the action that erases the drawdown evidence while Tier 2 refused the one that adjusts it, on the stated grounds that the adjusting one "can, with the wrong sign, mask a real trading drawdown".

Dropped to Tier 2. The alternative — keeping it and rewording the criterion — would have been fitting the rule to the answer. Tier 1 is resume and resume-entries.

Two related corrections went in with it:

  • resume doesn't release a brake a rail applied. guards.check reads get_state("kill_switch", default=True) — unset means engaged — so on a never-resumed deployment it moves a fail-closed default to permissive. It stays, because keel kill re-engages in one gesture, and that is what bounds it. The entry now says so.
  • Stop must ship no later than release. The browser has no halt today. Stage 2b ships the ungated one-click halt in the same change as the first release route, not after it. Written into the module rather than left as a plan.

A phrase that released every action

Every test read the expected phrase back out of the table, so the suite was self-consistent under any content. A phrase_matches ignoring action_key and testing against every row passed all twelve — making any Tier 1 phrase a master key, which is the closure this module calls its safety property, defeated. Nothing pinned the literals either: RESUME TRADING → RESUME NOW shipped green, as did swapping two actions' phrases.

Bound now, and asserted as a cross product: every phrase against every other action. Literals pinned. And no two phrases may share a first word — the near miss is an operator pattern-matching under stress, for whom a future RESUME ENTRIES beside RESUME TRADING would be genuinely confusable.

The same bug I had fixed one PR earlier

secrets.compare_digest raises TypeError on a non-ASCII str and on a non-str. I found and fixed exactly this in tokens_match during #786's review, then wrote the same pattern here without the guard. The phrase comes off an operator's keyboard through a JSON body, so a smart quote or a non-breaking space from a phone delivered the correct verdict — refuse — as a 500.

The rest

function named a click.Command. getattr(keel.cli, "resume")() would enter Click standalone mode, re-parse the server's sys.argv, run the TTY gate with no terminal, and sys.exit inside a request thread. It's operation now — the state service, module-qualified, pinned by literal and checked to resolve to something callable. Two actions may not reach one operation, which would release the kill-switch when the operator asked to clear a streak halt.

TIER1_ACTIONS is a MappingProxyType — a plain dict let any importer add autonomy-on in one line.

action/detail are imported from keel.commands.trading, not paraphrased. That module holds the wording so "neither re-words them", and the drift was already visible: the CLI printed "Any real, unrecovered drawdown stops being visible to the rail" where this said "re-seeding the high-water mark". A test asserts both strings are the module's own.

All seven non-Tier-1 actions are named individually — scope-attest and posture-attest were missing, so either could have been added for free. And the module no longer describes wiring that doesn't exist in the present tense.

Two tests that were theatre

The timing test grepped the source for compare_digest; a mutant replaced the call and left the word in a trailing comment. == and compare_digest return identical values for every input, so no black-box test can tell them apart — it reads the AST now and requires the returned value to be that call.

Writing it turned up the same class of bug in my own test: ast.walk is breadth-first, so returns[-1] is not the last return in the source. It passed by luck and failed the moment an import edit shifted the lines. Sorted by line number now, with every earlier return asserted to be a refusal constant.

Six of the twelve original tests were vacuous on an empty table. Every loop and count now asserts its own population first.

Verified

17/17 mutations killed. tests/web + tests/test_capabilities.py: 940 passed. ruff, ruff format --check and mypy (460 files) clean. Still nothing routes here — keel/web/__init__.py's invariant is unchanged.

feat(web): the browser can release two halts, and stop from anywhere (#781) (#792)

Stage 2b of #781. POST /api/gates/* routes to the gate table, and the browser stops being read-only for the first time.

The gauntlet, and the order is the contract

A release must survive, in order:

_admitted Host policy, session cookie, expiry
_sec_fetch_site_ok
X-Keel-Client a header a plain HTML form cannot set
gated_action_permitted loopback peer and loopback bind and no declared remote origin
gates_token in a header of its own, derived apart from the setup token
the typed phrase exact, checked last

Nothing opens a database until the phrase matches. The test that pins it points db_path at a path that does not exist and asserts a wrong phrase still returns rather than raising — the ordering asserted, not described. And no refusal echoes the phrase: a 403 quoting it would put a credential-shaped string into logs and into whatever an operator pastes while asking for help.

Stopping is never gated on locality

/api/gates/halt takes no phrase and no locality check. It needs the session and the gate token, because it is a write; nothing else, because an operator whose keel is behind a tunnel must be able to stop it from the tunnel. A stop refused for being remote is the one failure this surface must not have.

It ships in this change, not after it — a release route landing first would mean starting was available from a phone and stopping was not.

The second gate announces itself

keel/capabilities.py gains BROWSER beside TTY, and two web rows carrying mirrors to their CLI originals — the shape mirrors was kept for, "the moment a second front-end gates anything again".

test_the_only_gate_today_is_the_tty_one said it "is where that change announces itself — so adding one cannot be a quiet edit". It is test_the_gate_vocabulary_is_stated_and_bounded now: the assertion moved rather than being deleted, which is the point of having written it that way. Two new tests name which actions the second gate covers and require every browser row to mirror a TTY row.

Three invariant scans are scoped to TTY rows, because a web row's function is the browser gate's own implementation and necessarily lives in keel/web/ — forbidding it there would forbid the gate from existing. What the browser reaches is Tier1Action.operation, pinned from both sides by test_the_only_permitted_effects_are_the_ones_the_gate_table_names.

⚠️ One guard was invisible to its own tests

Deleting gated_action_permitted from the route passed the entire suite. Every routing test ran against a loopback server, so the check always passed and removing it changed nothing observable — the single most important guard in the feature, untested at the level it runs.

Fixed by testing the posture that makes it bite: a deployment with --external-host set refuses a release and says the action remains available at the terminal; the halt on that same deployment succeeds, pinning the asymmetry rather than leaving it to a docstring.

mutation before after
loopback check removed ❌ escaped ✅ killed
halt branch removed (halt 404s) ❌ escaped ✅ killed
gate token check removed ✅ ✅
gate token swapped for the setup token ✅ ✅
unknown key not refused ✅ ✅
phrase not checked ✅ ✅
database opened before the phrase ✅ ✅
refusal echoes the phrase ✅ ✅

8/8, from 5/8.

Verified

Full suite: 6557 passed, 3 skipped. ruff, ruff format --check and mypy (460 files) clean.

The MCP capabilities tool asserted a single gate; it now reports what the inventory declares, derived rather than spelled.

Reviewing this

This is the PR where a rail opens to a browser, so it deserves the same adversarial pass the four before it got. The places I would look hardest: whether _gated_action's early returns can be reordered without a test noticing, whether the halt's exemption from the locality check is exploitable on a tunnelled deployment beyond the DoS-in-the-safe-direction it is meant to be, and whether the TTY-scoping of the three scans widened anything I have not noticed.

Stage 3 draws the modal and the halt button.

feat(attestations): one model, three surfaces, and the four numbers that disagreed (#793) (#794)

Closes #793.

Live stopped placing orders on 2026-08-25 and nobody noticed for 17 days. The exposure caps were the primary cause, but rail 17 expired on 09-08 and stacked on top — and nothing anywhere said so until someone went looking in the veto log.

The model

keel/attestations.py answers, for every attestation keel has: what is it, when was it made, when does it expire, how long is left. It reads each rail's own constant and restates none of them — rail 17 derives its expiry from executor.WITHDRAWAL_ATTESTATION_TTL_SEC at call time; rail 22 uses the due date stored at attest time, which has no default precisely so a reader cannot cover for a writer that forgot one.

doctor.TTL_SEC = 7 * 86_400 is deleted. Its comment read "Rail 17's TTL is the executor's constant; doctor only READS it" and it did not — it redeclared the number. Its ttl_sec= parameter goes with it; both callers used the default.

Three things the issue got wrong

Found by reading the code rather than the issue, and recorded on it:

1. Doctor already had a pre-expiry state. It has warned on rail 17 since it was written. What it did not have was agreement: doctor warned at _days(remaining) <= 2, which truncation makes anything under 72 hours, while the banner's window is 48. Between the two an operator saw a doctor WARNING and a cockpit calling the same attestation healthy — the exact failure this issue exists to prevent, already shipped.

2. The warning window cannot be one global number. 48 hours is right for rail 17's 7-day TTL and is no notice at all on rail 22's 90-day one. Doctor knew that too — it warns rail 22 at ATTESTATION_TTL_SEC // 6 and its comment reasons about being "proportional to" a fixed TTL. The window is now a field on the attestation, and rail 22's is read from that constant rather than becoming a second opinion about it.

3. Rail 17 reads two keys, and doctor read one. The rail vetoes on enabled is False with its own sentence ("withdrawals are suspended/restricted for this account"). attestation_findings, handed the timestamp alone, reported ok · withdrawal capability attested · 6 day(s) remain for an account whose broker had frozen withdrawals. keel status never had this bug — it resolves through the executor's own _withdrawals_enabled. The model gains REFUSED, which is not an expiry and not an absence, and all three surfaces read it.

The three surfaces

keel doctor reads the model. Every number in its rail-17 sentence now moves when the executor's TTL does.

The cockpit grows a sticky banner, carried by the envelope rather than by one endpoint's data. Rail 17's expiry was on screen for three days — in a card, below the fold, on one page of eleven. null (nothing was read) stays distinct from [] (someone looked and found all clear). The banner names the remedy, because rails 17 and 22 are attested from a TTY and #781 deliberately left them outside the browser gate.

attestation.expiring fires once per attestation per window. It fired on every cycle, which on this deployment means two rails, hourly, indefinitely; an alert that repeats until it is fixed stops being read on about the third day. A window is the state paired with the expiry, so the halt alerts again after the warning did, a renewal that lapses alerts again, and a rail never attested at all — rail 22, today — alerts once rather than forever.

That last one makes notify_after_cycle write, which its docstring said it never did. The departure is deliberate and documented: one state key that no rail, report or decision reads, and tests/test_notifications.py pins the write list as exactly that key.

Verification

Mutation, not coverage: 14 defects across the model, doctor, the payload, the envelope, the client and the ledger, each proven to reach disk before the suite ran.

Three survived the first pass and are worth naming:

survivor why the test missed it
ledger replaces instead of merging invisible until a third cycle reads the evicted entry — the test stopped at two
doctor keeps its own 7 * 86_400 the state came from the model, so the verdict was right; every number in the sentence was still wrong ("expired 0 day(s) ago" on a 3-day TTL)
a tick claiming all clear the assertion existed — in a file my harness did not run

All three are now killed by tests that name the mutation they exist to reject. 6617 pass, mypy clean, ruff format clean.

Still open on #793

Stage 3 of #781 (the modal and the halt button) is unrelated and untouched. The Status page's own withdrawal_attestation card is unchanged — it is now the least authoritative of the four surfaces, and folding it into the model is a follow-up rather than part of this.

chore(release): 0.15.0 -- the browser can release two halts, and attestations warn before they bite (#795)

Version bump only — eight pyproject.toml files, their == pins, and uv.lock. No code changes.

Minor, not patch. Two features have landed since v0.14.1:

  • #792 — the browser gets a real gate. Two Tier 1 halts (resume trading, clear the streak halt) can be released from the cockpit behind a typed phrase, loopback-only, and the kill switch can be engaged from anywhere. The safety invariant was rewritten in #789 to scan derived effects rather than command names, after the name scan passed on a dispatch it could not see.
  • #794 — one model of every attestation, read by keel doctor, the cockpit banner and the attestation.expiring webhook. It also closed four places where a surface disagreed with the rail it reported on, including keel doctor reporting ok for an account whose broker had frozen withdrawals.

Also in the range: #785 (one-shot ruff format across the tree, now gated in CI), #780/#784 (every reader answers on the clock it was handed), #789/#791.

Release preconditions, checked locally

gate result
uv lock --check clean — the lockfile moved with the bump (#424)
keel/templates/config.live.yaml mode: confirm
full suite 6633 passed, 3 skipped
mypy clean, 464 files
ruff format --check clean

After merge: Actions → Release → Run workflow, entering 0.15.0.