Skip to content

keel v0.12.0

Choose a tag to compare

@github-actions github-actions released this 28 Aug 06:22
· 360 commits to main since this release
cee9e5a

Built from cee9e5a. Version binds to this hash:
keel --version reports keel 0.12.0+cee9e5ad3790 [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.12.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.

Desktop app (macOS / Windows)

⚠️ These builds are not code-signed, so your computer will warn you.
Apple's certificate is $99/yr and Azure Trusted Signing is ~$120/yr, and
keel cannot currently afford either. There is no cheaper tier and no free
open-source option on either platform. (Windows is worse value than it looks:
since 2024 even an EV certificate no longer grants an instant SmartScreen
pass — reputation is earned from download volume over time.)

Prefer no warning at all? Install from the wheels above instead — nothing
is downloaded as an application, so nothing objects. Needs a terminal and
Python 3.11+.

Full explanation, including how to check what you downloaded:
https://github.com/CodeGateSoftware/keel/blob/main/docs/desktop-install.md

macOS — the first open is refused. Open System Settings → Privacy &
Security
, scroll to the message about keel, and choose Open Anyway.
You will only do this once.

Windows — SmartScreen shows "Windows protected your PC". Choose More
info → Run anyway
.

Verify what you downloaded before you run it. Every artifact carries a
GitHub build attestation binding it to this repository, this workflow and this
commit — which is the question a code-signing certificate answers too:

gh attestation verify <file> --repo CodeGateSoftware/keel

SHA256SUMS.txt is attached as well. If either check fails, do not run it.

The app opens keel in your browser; there is no terminal involved. Your
config, database and credentials live in your user Application Support /
LocalAppData folder and are never touched by an update.

Other changes

feat(update): D6 — packaged installs update per-release; refusals name the desktop path (#439) (#510)

Closes #439

The decision (Option A — per-release installer)

keel update hard-refuses a bundled layout, twice over (deployment_layout_refusal wants
<launch>/.venv/.../site-packages/keel; _uv_install wants uv on PATH) — and #439 asked
whether to make it bundle-aware or scope the desktop product to per-release installs.

Decided: A. The installer is the update path. Recorded in
docs/decisions/0001-desktop-update-path.md (Context / Options / Decision / Consequences,
plus the docs/decisions/ README establishing the pattern). The issue's own reasoning, which
is the record's reasoning: B buys convenience and costs an update channel that must itself be
secured; for a tool that moves real money, a user deliberately downloading a signed installer
is the better trust posture; and the os.execv relaunch is poor for frozen GUI binaries.

Standing rules: the uv-venv keel update path is unchanged and remains the answer for
terminal deployments; desktop bundles NEVER self-update.

What changed

  • plan_update short-circuits a packaged install to its ONE refusal
    (packaged_update_refusal). Previously it appended the packaged reason but kept piling on
    venv vocabulary ("outside the launch folder", "no distributions installed") — each true and
    useless to someone whose update path is a download.
  • packaged_check_lines (pure) — the read-only report for a bundled layout: the same
    first line as the venv report (current: X latest: Y), then exactly one verdict line in
    desktop vocabulary: "a newer release exists: vX.Y.Z — a packaged install updates by
    re-downloading the installer; get it from (see docs/desktop-install.md,
    'How updates arrive')" / "you are at the latest release — nothing to do" / an honest
    "cannot compare" on a junk tag. Reuses is_newer_version; no new comparison logic.
  • CLI routing: on a packaged install, keel update and keel update --check are the same
    read-only report — no plan, no TTY gate (nothing mutates, so there is nothing to confirm) —
    and a network failure is a calm "could not check ... the latest installer is always at
    " with exit 0 (an unavailable check is not an error state).
  • Refusal wording: deployment_layout_refusal's outside-launch branch and the uv-missing
    error in _uv_install now name the desktop path (docs/desktop-install.md) instead of
    assuming a venv operator; packaged_update_refusal links the "How updates arrive" section.
  • Docs: desktop-install.md gains "How updates arrive" (updating looks exactly like
    installing; config/database/credentials are never touched); the operator runbook's
    self-update section names its scope (venv deployments) and points desktop users at the
    installer path.

What deliberately did NOT change

…truncated — full description in #510.

feat(installer): the curl|bash no-warning path, auditable line by line (#479) (#511)

Closes #479

What

curl | bash installer that bootstraps the release wheels — the no-warning path. scripts/install.sh (~215 lines, every step says what and why before it runs), 16 property-pinning tests over its text, a two-OS CI smoke that runs it for real, and pointers from docs/desktop-install.md (which now leads its no-warning section with the installer) and the README quickstart.

Explicitly not downloading an app binary: the OS-warning path stays the .dmg/.zip walkthrough; signing stays #438.

curl -fsSL https://raw.githubusercontent.com/CodeGateSoftware/keel/main/scripts/install.sh | bash

Design decisions

  • Venv location: ~/.keel/.venv, config beside it. One per-user folder you can look inside — keel's deployment model (keel_core/paths.py). The venv is named .venv deliberately: that is the exact layout keel update's deployment_layout_refusal recognises, so the updater serves what the installer built. Verified locally: on a fresh install, keel update from ~/.keel answers already at the latest release (0.11.0). No sudo, nothing written outside $HOME.
  • uv if it works, pip otherwise. Discovery probes uv --version, not just command -v — a broken shim (asdf + changed HOME failed exactly this way on my machine) is honestly treated as absent and the pip path (python -m venv + ensurepip bootstrap + pip install --no-input) takes over. Both branches install by exact wheel path with --find-links, mirroring keel update's _uv_install form — never by name from an index (PyPI's keel is an unrelated project).
  • Checksums, honestly. The release publishes SHA256SUMS for the desktop artifacts only; there are no published wheel checksums, and the script says so in its output rather than pretending to verify — it records each wheel's sha256 as computed locally instead, leaving an auditable record of what was installed.
  • Exactly the five production wheels (keel_core, keel_broker_api, keel_broker_coinbase, keel_broker_alpaca, keel_trader), selected by exact <prefix>-<version>- name — the test pins the script's allowlist equal to PRODUCTION_WHEEL_PREFIXES from keel/commands/update.py, so the two selectors cannot drift. The names of the excluded venue wheels never appear in the script at all.
  • keel versions before success. The verify runs from ~/.keel (so state resolves against the deployment) and its failure fails the script; the success banner is unreachable otherwise. The next-steps output composes with #439 option A: re-run the installer, or keel update for this venv layout; desktop bundles update by re-downloading.
  • Never clobbers the user. An existing config.yaml is kept and said so; keel*.db is acknowledged and untouched; re-running upgrades the venv in place.

Real local run (this Mac, isolated HOME)

…truncated — full description in #511.

D5 delta (#438): Inno Setup installer, gated signing + notarisation, protected signing environment (#512)

What already existed (the reconciliation)

The v0.11 desktop job already freezes --onedir via packaging/keel.spec, wraps macOS into a .dmg (packaging/macos_app.sh, browser-opening launcher, READ ME FIRST inside the image), zips Windows, and attaches per-OS SHA256SUMS + build attestations — all unsigned, by the decision recorded in #438's comments. This PR is the delta to full #438, exactly the shape the issue's own "If this is revisited" comment called for: "signing steps drop into the desktop job behind a protected environment. Reversing this is adding steps, not undoing work."

The delta, in four commits

  1. fix(release) — the wheel-floor comment tells the truth. The verify step claimed Requires-Python: >=3.14.4 while every pyproject.toml says >=3.11. Now derived from the manifest in the test, so it cannot drift. 3.14.4 is the build interpreter pin (.python-version), not a floor the wheel enforces — the comment says so now.

  2. ci(release) — the Windows Inno Setup installer. packaging/keel.iss: PrivilegesRequired=lowest, DefaultDirName={localappdata}\Programs\keel — per-user, no admin prompt. It owns the program directory only; the deployment (config.yaml, keel*.db, .env, logs/) is never written and survives uninstall (no [UninstallDelete]). The zip stays beside the setup.exe (the no-install route docs/desktop-install.md documents); both are attached, checksumed, and attested (one space-separated multi-subject subject-path). The version-decision UX #438 specifies (version from on-disk metadata, confirm same-version, downgrade warning because db.py has no down-migrations) is documented in the .iss header as deliberately not here yet — the issue itself deferred it ("Specified here rather than built").

…truncated — full description in #512.

feat(broker-api): a bracket is one order kind, because two legs race (#502 stage 1) (#521)

Stage 1 of the #502 design. Port vocabulary only — zero live-path change, and deliberately no live caller.

The executor still builds its raw trigger_bracket_gtc dict against the pre-port CoinbaseClient; moving it is stage 2's job. Grep confirms zero references to BracketGTC under keel/.

What it adds

BracketGTC joins the OrderSpec sum type. An exit bracket that closes a held position — not an entry-plus-exits parent order, because keel enters with market IOC and protects afterwards, so encoding an entry would represent a shape no keel path produces.

__post_init__ refuses non-positive numerics and a stop that doesn't sit on the losing side of the target. Equal legs are refused as firmly as inverted ones — an equal-leg "bracket" is a stop and a target racing at the same price, which is not a shape a rule can mean.

No stop_direction field. Derivable from side exactly as StopLimitGTC derives it; a field would let a caller build a SELL bracket that triggers upward — representable nonsense, which is what the sum type exists to prevent.

Names are keel's, not Coinbase's. take_profit_price, not limit_price — so a second venue's translation doesn't start from Coinbase's vocabulary, and so it can't be confused with LimitGTC.limit_price.

The Coinbase translation is byte-identical to what executor._bracket_order_configuration emits today, pinned by a parity test so the two can't drift while both exist. The test imports both; production code doesn't.

Deviation from the plan, worth knowing because it recurs

The design assumed "other adapters refuse structurally, add no code." Half true. Alpaca and Robinhood match OrderSpec exhaustively with assert_never, so widening the union broke both under mypy — each needed an explicit refusal case.

That's the better outcome (it mirrors the defence-in-depth Robinhood's translator already applies), but every future OrderSpec kind will hit the same wall, including stage 2's.

Two venue facts established while here

  • Alpaca does support order_class=bracket — equities-only, and this adapter declares asset_classes={"equity"}. So its absence means "not written yet", not "impossible".
  • Robinhood and Kraken are genuine venue limitations — Kraken's close[...] is an OTO, not an OCO.

Latent gap found, not fixed here

Kraken has no _reject_unsupported — every method raises NotImplementedError rather than UnsupportedOrder, so it would fail test_every_undeclared_order_kind_is_refused if it had a conformance subclass. It doesn't have one, so nothing fails today. Flagging rather than widening this PR's scope.

Verification

  • pytest -q — 4,550 passed, 3 skipped
  • ruff check keel tests packages — clean
  • mypy — clean, 353 source files

All three re-run independently after the implementing agent reported, not taken on trust.

Not in this PR

…truncated — full description in #521.

fix(executor): write the crash ledger before cancelling, so a dead roll is loud (#519) (#522)

Closes #519.

The hole

_roll_stop cancelled the resting bracket and then placed the replacement without ever writing an unbracketed: record — place_bracket writes one only when a placement is refused.

So a process dying between the cancel and the place left no resting bracket and no intent. On the next cycle reconcile_unbracketed_positions hit if not intent: continue — the branch that exists for DCA, whose docstring explains that escalating it "would fire a CRITICAL for every DCA tranche on every cycle, which trains the alert to be ignored."

The position stayed naked with no CRITICAL, indistinguishable from a tranche that carries no stop by design. That's the worst shape this failure could take: not merely unprotected, but unprotected and invisible.

The fix

Write the record before the venue is touched; clear it only once the replacement rests. Every death in that window now lands in a state the existing sweep already converges — next cycle it finds no bracket, finds these levels, re-places from them.

Deliberately the same unbracketed: key place_bracket uses, not a new roll_intent: one. The sweep, its ledger-sized qty, its escalation and its clear-on-success semantics are written and tested once; a second key would mean a second healer to keep correct. A record left by an aborted roll is harmless — the sweep skips any product whose bracket still rests.

The CRITICAL stays, and the record is retained alongside it. Automatic recovery next cycle is not a reason to downgrade an alert about a position unprotected right now: this deployment cycles once per UTC day, so "next cycle" can be a day away.

Tests

Three, pinning the contract at the crash point rather than around it:

  • test_a_roll_writes_its_crash_ledger_before_touching_the_venue — spies on _cancel_at_exchange (the moment the old bracket stops resting) and asserts the record already exists with the right levels.
  • test_a_successful_roll_clears_its_crash_ledger — left standing, the sweep would re-place a bracket that already rests.
  • test_a_failed_roll_RETAINS_its_crash_ledger_for_the_sweep — the naked case: record survives and the CRITICAL still fires.

Verification

  • pytest -q — 4,553 passed, 3 skipped
  • ruff check keel tests packages — clean
  • mypy — clean, 353 source files

How it was found

The #502 design pass, not an incident. _roll_stop has no automated caller today, so the window is entered only by a manual roll — which is why this has never bitten. #502's proposed per-cycle stop management would enter it on every ratchet, which is what turned a latent hole into a blocker.

Worth fixing on its own merits regardless of whether that step ever ships.

feat(db): record the stop a tranche was sized against (#520, #502 stage 2) (#527)

Closes #520. First piece of #502 stage 2 — the named prerequisite for the live break-even arm.

The gap

exit_policy.next_stop's break-even arm computes its threshold from the trade's original per-unit risk:

entry + be_roll_rr × (entry − initial_stop)

Live state carried entry_fill (the ledger) and open_stop:<product_id> (the current, already-ratcheted stop) — and nothing else. The number the threshold is most sensitive to was simply absent.

Why substituting the current stop is wrong, not merely imprecise

The current stop rises on every ratchet, shrinking (entry − stop). So the threshold creeps toward entry and the arm fires earlier each time, drifting further from the measured policy the longer a trade runs.

Live and sim would then encode two different break-even rules while appearing to share exit_policy's functions — the exact failure that sharing them was meant to prevent.

What ships

  • v12 migration adding positions.initial_stop, idempotent by the v8/v11 PRAGMA table_info guard (a database stamped at v11 got positions from v4's DDL, and CREATE TABLE IF NOT EXISTS never adds a column).
  • Written at tranche open by agent._open_tranche from the entry setup's stop, and never rewritten — that separation from the ratcheting open_stop: is the fix.
  • Repository.open_position takes it; _position_row_to_dict decodes it nullable.

The nullable handling is load-bearing

None means UNKNOWN, and readers must switch the break-even arm off for that tranche rather than guess.

Zero would be a real number — a stop 100% below entry — and a catastrophic one to compute a threshold from. So _position_row_to_dict is careful not to let _text_to_dec invent one, and there's a test named for it.

The trailing arm is unaffected either way: it needs no original risk, so a tranche without one keeps trailing and simply never break-even-rolls.

No backfill, deliberately. The honest value for every pre-v12 tranche is NULL. Inventing one would fabricate the single input the policy is most sensitive to.

DCA passes None, and that is legitimate rather than degraded — it carries no stop by design, and requiring one would refuse a real tranche.

Tests

Six, in tests/data/test_initial_stop.py:

  • round-trips as a Decimal;
  • unrecorded reads as None, not zero (the load-bearing one);
  • DCA-style open with no stop is accepted, not rejected;
  • the column exists on a fresh migrate;
  • the migration is idempotent and does not backfill;
  • initial_stop is not rewritten when the live stop ratchets.

The three schema-version tripwires were bumped 11 → 12 consciously, which is what they exist to force.

Verification

  • pytest -q — 4,559 passed, 3 skipped
  • ruff check keel tests packages — clean
  • mypy — clean, 354 source files

Not in this PR

…truncated — full description in #527.

docs(prd): what keel can learn from Jesse, which is not its strategies (#530)

PRD from comparing keel's Rule ABC against the Jesse framework's Strategy API and its published example strategies.

Milestone: Strategy-API expressiveness (Jesse comparison). Issues: #528, #529.

The conclusion is deliberately narrow

keel is cost-bound, not signal-bound — round-trip friction ~2.5%, no shipped rule family net-positive, turtle_breakout negative on all 24 assets measured. So porting DUAL_THRUST, KDJ, Bollinger, SMACrossover and the rest is the known dead end: a rule negative on 24 assets is not fixed by a 25th signal.

The lesson is the API, not the strategies.

Four of the gaps are already filed — that is the finding

Jesse can express: a conditional entry at a chosen price, a per-bar update_position() hook, pyramiding, partial exits, fill-event hooks, per-strategy state, and a declared hyperparameter space.

Four of those are already keel's open issues — #333 (conditional entry), #447 (pluggable rules), #502 (stop management, partial exits). An external framework independently re-deriving the same gaps is corroboration of the roadmap, not an addition to it, and the PRD explicitly declines to re-file them.

Two things are genuinely new

#528 — declare each rule's parameter space. keel already consumes n_trials in research/deflate.py::expected_max_sharpe, reasons per-choice in source comments about whether something "increments the trials budget (§73.12)", and keeps a ledger with explicit provenance. But the number itself is hand-recorded. Jesse's hyperparameters() makes it a property of the strategy. This is the one place Jesse's design is straightforwardly better for a discipline keel cares about more than Jesse does.

Explicit non-goal: no optimiser. Running one against a cost-bound engine manufactures exactly what the deflated-Sharpe machinery exists to detect.

#529 — document the fill-model hazard. Jesse strategies name their entry price and assume the fill (self.buy = qty, entry). keel priced that assumption when #258 corrected the fill model and pullback_continuation's gross PF fell 0.92 → 0.77 with double the trade count. Any foreign strategy ported naively inherits an optimism this project has already measured — and compounds it with the cost regime.

Method note

jesse.trade/strategies is Cloudflare-protected and could not be read; the analysis is from jesse-ai/example-strategies and the API surface those strategies exercise. Reading the source is the better record anyway: what matters is what the framework lets a strategy say, not what its strategies claim to earn.

What it records in the other direction

…truncated — full description in #530.

docs(design): the web UI rewrite — zero-dependency, view-source, on-device (#531)

Design spec for the UI rewrite, following youperiod.app's technical philosophy.

docs/superpowers/specs/2026-08-23-web-ui-rewrite-design.md. No code changes.

What it proposes

Delete render.py's 872 lines of server-side HTML. The local process serves JSON plus static files; the client is plain ES modules with no framework, no bundler, no transpile, no minification, no source maps, and zero third-party JavaScript.

Zero dependencies is achievable rather than aspirational for two reasons: keel's cryptography lives in Python and the OS keychain, never in the browser — so youperiod's three dependencies have no analogue here — and money crosses the wire as pre-formatted strings, so the client never computes and never needs a decimal library.

Three findings that shaped it

Installing the app from keeltrading.com cannot work. An origin problem, not a preference. keel's data is a local SQLite file; an HTTPS page fetching http://127.0.0.1:8765 is exactly the path Chrome's Private Network Access is tightening and Safari blocks. The exits are a tunnel or a hosted service, both excluded by "no server reach." So the PWA is served by keel itself — where http://127.0.0.1 is already a secure context, and manifest, service worker and install all work today.

Consequence recorded plainly: iPhone and Android are out of scope, excluded by the constraint rather than deferred by choice.

docs/ is the source, not a duplicate. engine-docs.manifest.json pins CodeGateSoftware/keel@main and fetch-engine-docs.mjs calls itself "the only writer of src/content/engine-docs/", exiting non-zero if a pinned document disappears. Stripping docs/ from keel would fail the website build.

What ships instead fixes a bug that is already in every release: the wheel carries artifacts = ["keel/templates/*.yaml"] and no docs/, so /glossary renders an empty state in every installed deployment — as help_console.py:138-146 says in its own docstring. The app code drops doc prose and deep-links to keeltrading.com/{lang}/docs/{slug}, which yields en/fr/ar for free.

--good and --bad are the same brightness. Light mode: #1f5f4f L=0.0904, #96322a L=0.0893 — a 1.01:1 ratio. Profit and loss are separated by hue alone (render.py:82 — pure colour classes). WCAG 1.4.1 failure in an application whose central signal is gain-versus-loss; roughly one in twelve men cannot distinguish them, and greyscale or direct sunlight collapse them entirely. Form inputs also have no visible boundary (render.py:96-97, --line at 1.27:1 against the page background), failing 1.4.11 — which lands on D4's wizard, almost entirely forms.

…truncated — full description in #531.

fix(web): profit and loss were the same brightness, and inputs had no visible border (#543)

Closes #532.

The finding

--good and --bad were photometrically identical in light mode:

good #1f5f4f  relative luminance 0.0904
bad  #96322a  relative luminance 0.0893
                        delta 0.0011  →  1.01:1

Profit and loss were separated by hue alone — render.py:82 carried nothing else. That fails WCAG 1.4.1 Use of Color in an application whose central signal is gain versus loss. Roughly one in twelve men has red-green colour deficiency; greyscale, e-ink and direct sunlight collapse the distinction for everyone else.

--accent was also byte-identical to --good in both themes, so a link and a gain rendered alike.

The rule this change encodes

Move the colour that has room to move away from the background. Toward the background loses contrast; away gains it. When two colours need luminance separation, moving the one with headroom gains separation and contrast instead of trading one for the other.

  • Light background, both colours dark → darken bad.
  • Dark background, both colours light → lighten good.

A first draft did the opposite in both themes and cost two AAA grades to buy separation. That alternative is now recorded in the source comment as rejected, with the reason.

Result — zero grade drops, four tokens improved

Light

token before after
bad 7.22 AAA 13.92 AAA
accent 7.17 AAA 7.70 AAA
good 7.17 AAA 7.17 AAA (unchanged)

Dark

token before after
good 8.39 AAA 10.39 AAA
accent on card 7.83 AAA 7.68 AAA
bad 6.24 AA 6.24 AA (unchanged)

good/bad luminance delta: 0.0011 → 0.0681 light, 0.1234 → 0.2382 dark.

fg, muted and warn are untouched on every surface. Every entry that was AAA before is still AAA.

Form inputs

.field input used background: var(--bg) — the page background — so a --line border at 1.27:1 was the only thing marking the control. Fails WCAG 1.4.11 (3:1 for component boundaries).

Raising --line globally was rejected and the rejection is recorded: --line also draws table rules, the footer border and card edges, which 1.4.11 explicitly exempts as decorative. A new --control-line is scoped to interactive boundaries only — 3.72:1 light, 3.54:1 dark. --line is unchanged, and a test pins that it stays unchanged.

Tests

tests/web/test_palette_contrast.py, 8 tests, pure Python, no new dependency — the WCAG formulas are about twenty lines.

Two properties make it a real guard rather than a restatement:

  • It regexes the tokens out of render._STYLE rather than holding a copy, so a reverted or fat-fingered value fails for real.
  • It pins the grade (AAA/AA), not just the ratio. A ratio floor could not have caught the first draft's mistake: 4.89:1 and 7.17:1 both clear 4.5:1, and only one is AAA.

…truncated — full description in #543.

feat(web): the serialisation contract — money as strings, values presentation-ready (#544)

Closes #533. Prerequisite for the rest of milestone 22 — seven downstream issues inherit this shape.

Ships keel/web/payload.py and its tests. No endpoints — those are #534.

The three rules

Money crosses the wire as strings, never as JSON numbers. JSON.parse yields IEEE-754 doubles, and keel is Decimal-only precisely because binary floats corrupt money.

Values arrive presentation-ready. The client places them; it never derives them. Every figure a user sees was computed by the Python that holds the rails — which is what makes the invariant checkable rather than merely likely, and why the client needs no decimal library at all.

Semantic state is a field, not an inference.

"pnl": { "value": "-12.34", "display": "▼ −$12.34", "state": "bad" }

A client must never decide "this is bad" from a minus sign — that is arithmetic by another route, and it relocates a judgement into the browser.

The guard is stronger than briefed

I asked for "no monetary field serialises as a number". What shipped is no JSON number anywhere — a recursive walk over the round-tripped payload, with bool excluded first since bool subclasses int.

That needs no maintained list of money fields, so it cannot rot as fields are added. It also makes the reserved sort field coherent: it would be the single, named exception.

There is a positive control (test_the_number_walker_is_proven_false_capable) so the walker cannot silently stop walking.

Four places it declined to compute

This is the invariant doing its job, and worth reading before review:

  1. Notional on an open position — OpenPositionStatus carries qty and entry_price, and my own spec example shows a notional. It is not emitted. A test pins the absence and names gather_status as where the fix belongs.
  2. Drawdown — drawdown_total_pct is named for a percentage but holds a fraction (guards.py:583 compares it against max_total_dd_pct: 0.20). It crosses unchanged, with no % in the display. This surfaced #542: render.py's pct() appends % to that same fraction, so the current browser shows a 20% drawdown ceiling as 0.20%. This PR does not inherit that bug and pins that it does not.
  3. A unit on qty — the base asset is not on the report, and deriving it means parsing product_id, whose audited decoder is in guards.py and out of this layer's reach.
  4. Reusing render._tone_for_rail — it falls through to "good", so "unknown" (which _rail11_status returns deliberately, because "ok" would be a lie) would style as passing.

Thinness pinning

test_console_thinness.py was extended, not forked — the module was already in its scanned set, so Rules 1–5 applied on arrival. Added Rule 6, serialiser-scoped: no .normalize(), no round(), no unscoped float(), no custom JSON encoder.

…truncated — full description in #544.

feat(web): serve static assets, with the headers that make the UI's reach checkable (#545)

Closes #535.

Adds static file serving plus the security headers the browser client will rely on. No existing security layer is modified — keel/web/security.py has a 0-line diff, and no existing test body was edited.

Headers, and where they go

New /static/* route:

header scope why
Content-Security-Policy: default-src 'self'; connect-src 'self' text/html only Makes the UI provably incapable of sending positions, equity or trade history anywhere but the local process. Browser-enforced, verifiable in seconds. Per spec, CSP is invalid and discouraged on non-HTML content types.
X-Content-Type-Options: nosniff every static response Content-type sniffing.
Referrer-Policy: no-referrer every static response The app will link out to keeltrading.com, and the session token rides in the URL until the cookie exchange.
Strict-Transport-Security never sent The origin is loopback HTTP by design. A standing comment records why, so nobody re-adds it.

The existing rendered pages keep their original _SECURITY_HEADERS (default-src 'none', X-Frame-Options: DENY) untouched — correct for zero-script pages.

A third CSRF layer

security.py already documents five layers, including a SameSite=Strict HttpOnly cookie and an HMAC-derived CSRF token described in-source as "the layer that does not depend on the browser being current." This adds one more, because SameSite has had parser bypasses.

Every POST now requires X-Keel-Client: 1, checked before the body is parsed. A custom header forces a CORS preflight a hostile origin cannot satisfy — and the specific attack it closes is the HTML form POST, which is not preflighted and therefore slips past checks that assume all cross-origin requests are.

Sec-Fetch-Site is refused when present and wrong (page JavaScript cannot forge it), and accepted when absent (older browsers).

Directory traversal

resolve_static_asset() resolves and checks containment via .relative_to() — not string matching on "..".

Tested over the real wire and at unit level: /static/../secret.txt, ../../, %2e%2e, double-encoded %252e%252e (must stay inert, not decode twice), a null byte, and /static//etc/passwd — the last being the footgun the module docstring names, since Path("/root") / "/etc/passwd" discards the root entirely.

A measured correction to the packaging story

The issue asserted the wheel omits docs/ because artifacts lists only the YAML template. That was wrong, and I confirmed the correction independently.

…truncated — full description in #545.

chore: raise the Python floor to 3.14, and record why it moved back up (#546)

Support only Python 3.14 and above.

This reverses #283, which was a measured decision, so the change is careful to record what shifted rather than just flip a number. The whole argument lives in tests/test_python_floor.py's docstring, which is the file that exists to keep this honest.

The measurement from #283 still stands

3.13, 3.12 and 3.11 all pass identically to 3.14. Nothing in this code needs 3.14. assert_never (3.11+) was the one binding feature, and 3.10 still fails collection on it.

So this is a policy floor, not a measured one, and the docstring says so in those words. The two must not be confused by whoever revisits this.

What changed around the measurement

  • Users stopped supplying the interpreter. #283 predates the signed desktop bundle (D5) and scripts/install.sh, both of which bootstrap their own environment. The floor now reaches contributors and packagers rather than everyone who installs — a far smaller group, and one that can install a Python.
  • 3.14 was days old when #283 was decided. Distributions package it now.

What it unblocks — both verified, not assumed

ruff can target py314. It could not before, and ruff.toml recorded exactly why: under py314 against a >=3.11 floor, ruff format rewrote except (A, B, C): into PEP 758's unparenthesised form — a SyntaxError on 3.11–3.13, which the metadata then invited. Raising the floor removes every interpreter that cannot parse it, so the hazard is gone rather than tolerated. ruff check is clean at py314 with no new UP violations.

The numpy.* mypy override is deleted. numpy's bundled stubs use PEP 695 type statements that a python_version = "3.11" run cannot parse, so the workspace silenced the transitive crawl — follow_imports = "skip" plus follow_imports_for_stubs — rather than raise the floor every package pins. mypy now passes clean without it, 359 source files.

CI collapses to one leg

Two legs existed to test the two ends of a span that no longer exists. It stays a matrix rather than a scalar so a second supported version is one list entry, and so the required status context stays test either way.

Three literals became derivations

The installer gate (tests/test_install_script.py), the CI leg, and the docs check all now derive the floor from requires-python instead of repeating it. Raising or lowering it again is one edit plus the docstring.

The docs assertion was tightened because it false-passed. "3.14" in readme matched "any Python 3.11+ (the repo develops on 3.14)" — the development pin satisfied the test while the stated floor stayed wrong. It now requires the 3.14+ form. The Arabic mirror is checked for the version alone, because "3.14 فما فوق" is better Arabic than transliterating the English token.

One test deleted with no successor

…truncated — full description in #546.

feat(web): the JSON API over the existing reports, with server-side sort (#547)

Closes #534. Unblocks #536–#539.

Reads only. No new write surface, and keel/web/security.py, tests/commands/test_console_thinness.py and tests/web/test_server.py all have a 0-line diff — verified against the branch, not asserted.

The envelope

Every route answers with the same key set: {as_of, engine, data, sort}. Failures are {as_of, data: null, error: {status, title, detail}}, where status is a string ("404", not 404) — the no-JSON-numbers rule reaches the error document too, so a client never has to switch parsing mode.

route covers sortable
/api/config — —
/api/status / open_positions
/api/setup /setup — (runbook order is the information)
/api/activity /activity cycles
/api/insights /insights rules
/api/journal /insights entries
/api/rules /rules rules
/api/venues /venues venues
/api/gates /gates —

/glossary deliberately has no counterpart — it becomes an outbound link in #539. test_the_api_routes_cover_every_html_route_that_reads compares both route tables mechanically, so #540 cannot delete an HTML route the API never replaced.

Sorting

?sort=<column>&dir=asc|desc. An unknown column or direction is a 400 naming the valid ones — silently ignoring is how a dead sort control ships. sort.columns is echoed even when nothing was sorted, so #537 reads the column list off the response rather than hardcoding it.

Ordering lives in payload.order_rows, beside the _plain that wrote the strings, and is Decimal per column — never per row, which would mix Decimal and str in one comparison. Absent values sort last in both directions.

The float test asserts its own premise first. Two ERC-20 quantities one wei apart — legal at an 18-decimal base increment — are distinct as Decimal and equal as float. test_float_and_decimal_orderings_of_the_same_column_genuinely_differ states that disagreement before the endpoint test runs, so the endpoint test cannot be green for the trivial reason that sorting sorts. Mutating order_rows to float produces [0.099, ...002, ...001] where Decimal gives [0.099, ...001, ...002].

Engine stopped is a state, not an error

HTTP 200, engine.state: "warn", data: null, as_of present.

Two deliberate calls recorded in the source: a non-2xx status is what #536/#538 will read as unreachable, and a first-run user with no deployment is not in an outage. And data: null rather than {} — a view handed an empty object renders zeros, which for a trading dashboard is worse than rendering nothing.

/api/config, /api/venues and /api/gates still carry data when stopped: they describe the binary, not a deployment.

The routing layer formats nothing, enforced by AST

…truncated — full description in #547.

feat(web): the client shell — zero dependencies, no build step, served as authored (#549)

Closes #536. Unblocks #537, #538, #539.

The first JavaScript this project ships: 1,239 lines across four modules, zero third-party code, no bundler, no transpile, no minification, no source maps. What the browser runs is byte-for-byte what a reader sees in devtools.

file role lines
static/js/main.js entry point, History API router, poll 312
static/js/api.js the single fetch wrapper 231
static/js/render.js DOM from payloads; no arithmetic 587
static/js/format.js Intl.DateTimeFormat, dates only 109
static/css/keel.css palette + responsive layout 318
static/index.html shell, landmarks, the one live region 103
tests/web/test_client_assets.py 44 tests 708

Routing

pushState on click, popstate for back/forward, location.pathname the only source of truth.

Deep links and reloads work because resolve_client_route serves the shell for a closed list of seven names, not a wildcard. A wildcard turns a missing .js into a 200 of HTML that the browser then refuses under nosniff — surfacing as a MIME error instead of a 404. Hash routing was rejected: a # in every URL an operator copies.

"keel isn't running"

api.js normalises four outcomes into one shape, so render.js has no shape branches. HTTP 200 with data: null is never treated as an error — api.py's docstring warns about exactly that.

A stopped engine keeps the server's warn; a refusal or unreachable server gets a client-minted bad. Same closed vocabulary, different judgement, which is what state is for. stoppedView is reached only via data === null, so zeros cannot render.

Proving render.js has no arithmetic — and why the first attempt wasn't enough

A four-state lexer strips comments and strings, then rejects + - * / %, ++, --, Number, parseInt, Math, toFixed, NumberFormat. It is small enough to be obviously correct only because render.js bans template and regex literals — which incidentally makes building an HTML string impossible, so the client has no innerHTML at all.

Mutation testing showed the scan was insufficient. This passed cleanly:

return v.value < 0 ? "bad" : "good"

No operator, no numeric identifier — and exactly the forbidden thing, since state is a field precisely so the client never judges a value. Two further rules close it (render.js never reads Field.value; no relational comparison), and the test carries the mutation that motivated them. Seven mutations verified to fail the suite.

Accessibility

One aria-live/aria-atomic region, on the engine banner — and deliberately not on the data. A 15-second poll wrapped in a live region would re-announce the whole dashboard twice a minute.

…truncated — full description in #549.

feat(web): the remaining six views, an SVG curve nobody has to see, and a stream that says when it stops (#537) (#550)

Closes #537.

The bulk of the client port: /setup, /activity, /insights, /rules, /venues and /gates
as client views over #534's JSON API, plus the equity curve and live updates over EventSource.
With this, the browser client reaches parity on information with the rendered pages — which is
the precondition #540 (delete render.py's HTML) and #541 (delete the curses TUI) both wait on.

Parity is on the information, and the divergences are written down

Every view is judged against both the rendered pages and tests/commands/test_tui.py. Where
those disagree with each other the divergence goes into the view's docstring rather than being
reproduced — porting a bug forward because the old surface had it is not parity.

Porting found two more instances of #548's pattern (a lookup that misses, defaulted, nothing
raised), both of which the client does not inherit:

  • render.py::_STEP_KIND_NOTE has three entries against StepKind's four. The missing one is
    operator_input — the kind of the real credentials step — so the rendered /setup prints an
    empty note on the step where "what may a wizard do for you" matters most.
    payload._STEP_KIND_NOTES carries all four.
  • render.py::pct appends % to four values that are fractions (#542). payload.ratio's
    docstring names the trap; the client shows the value at the scale the CLI does.

The chart: coordinates are computed in Python

build_equity_curve returns plot coordinates, not just figures. Where a vertical axis starts is
what makes a $3 wobble look like a collapse; keel does not let a front-end decide whether a number
is bad, and the axis it is drawn against is the same delegation wearing different clothes. So the
baseline is always zero, in Python, where a test can read it.

That is checkable rather than asserted: chart.js needs no arithmetic, so the lexer scan that
guards render.js now runs over it too — proven by mutation. The curve is role="img" named by
its own <figcaption>, with the journal table beside it for the figures.

/setup keeps its gate, and its action set is unchanged

keel.commands.setup.ACTIONS is a 0-line diff, keel/web/security.py is a 0-line diff, and
nothing under /api/* answers a POST. The client lists every action keel offers, with its inputs;
the button stays on the page that holds the write token, because /api/setup ships no CSRF token
on purpose and inventing a way to hand the client one would widen the write surface. A client that
hides a button is not a gate, so nothing is hidden — only the button is elsewhere. Where a browser
client gets a write token belongs to the milestone (#540), not to this commit.

Verification

…truncated — full description in #550.

feat(web): installable, with a worker that structurally cannot cache a balance (#538) (#551)

Closes #538. Stacked on #550 (feat-537-remaining-views) — that PR was still open when this
branched, so review it after #550 lands; I'll rebase onto main then.

Manifest, icons and a service worker, so keel serve installs as a desktop app and opens to the
shell — and its own honest banner — when the engine is not running.

The API rule is enforced by scope, not by care

sw.js ships at the static root, so its registration scope is /static/ and /api/* is
outside it. A worker's fetch handler is never invoked for a request outside its own scope — not
skipped, not returned early from: the browser does not consult it. No edit to that file can cache
an API response, because no edit to that file can see one. Two further layers back it up: the only
cache write in the module is addAll(PRECACHE) (no runtime put, pinned by a test), and an
explicit /api/ guard in fetch — dead code today, pinned by a test that says so, because #540
widens the scope to / and it stops being redundant on the day nobody is thinking about it.

This is where the implementation departs from the spec's file list, which puts sw among the
modules in js/. js/sw.js is scoped to /static/js/ and cannot answer a navigation to
/static/insights. The alternative — a Service-Worker-Allowed header widening the scope from
js/ — was rejected for failing silently: remove the header and the worker still installs, still
activates, and simply stops controlling the app. The spec is amended in place rather than quietly
diverged from.

The icons are generated, because four binary blobs are not reviewable

scripts/build_icons.py defines the mark once as three stroke coordinates and emits the SVG and
all three PNGs from it, with a stdlib rasteriser — point-in-polygon and zlib.compress, no Pillow.
--check re-renders and compares bytes, and a test runs it, so a hand-edited PNG fails and a
deliberate change has to be made where it is legible in a diff.

The mark is a monogram, and that was decided by looking. Three nautical marks were drawn and
rendered first — a hull in section over a keel, a hull in profile with a fin, a bulb keel — and at
icon size each read as something else: a funnel, a letter T, and an exclamation mark on a saucer.

One attribute decides whether any of this installs

<link rel="manifest"> is fetched with credentials mode omit by default, and every response
this server sends is gated on the session cookie. Without crossorigin="use-credentials" the
manifest fetch is a 403, the browser reports no manifest, and the app is not installable — with
nothing in the page or the console pointing at the cause.

Verification

…truncated — full description in #551.

feat(web): delete the renderer — the server serves files and JSON, and nothing else (#540) (#553)

Closes #540. Based on main — #551 is merged, so this one is not stacked.

keel/web/render.py is gone: 987 lines of server-side HTML, the seven page handlers that called
it, and its inline stylesheet. The client moved from /static/ to / and is the application now.
Net −896 lines.

The write surface moved, and that is what made a security layer real

/setup/* was an HTML form. It is POST /api/setup/<key> now, and the action set is a 0-line
diff
— keel.commands.setup.ACTIONS, still only idempotent, non-destructive, MECHANICAL steps,
still asserted disjoint from the eleven capability-increasing actions. A browser can set a
deployment up. It still cannot arm a rule, attest an asset or enable autonomy.

What changed is that X-Keel-Client now covers the write path. _api_client_header_ok recorded
that widening as "#536's call to make, once (and only once) the forms it replaces are gone" —
because a <form> cannot set a header, and gating it would have refused every legitimate
submission with no terminal to fall back to on the desktop bundle. The CSRF token moved into
X-Keel-CSRF for the same reason: in a body it proves only that the sender could read it; in a
header it also proves the sender could set one.

A reversal, recorded rather than quietly applied

payload.setup_payload used to argue against ever putting the token in a GET response. That
argument is quoted in place above the new one. Two of its three concerns were already answered
(/api/* is no-store and #538's worker refuses to cache it; the server binds loopback), and the
third — an operator pasting API output — does not survive the observation that settles it: this
token authorises nothing without the session cookie
, and anyone holding that cookie can mint it
themselves. The alternative, a script-readable cookie, would have moved a live token into
document.cookie on an origin whose session cookie is deliberately HttpOnly.

Verification

ruff, mypy and the full suite are green (4825 passed, 3 skipped). Then driven in a real
Chromium against a first-run deployment with nothing set up, which is the path that matters:

…truncated — full description in #553.

feat: delete the curses TUI and the console layer that only it could open (#541) (#554)

Closes #541. Last issue in the Web UI milestone.

keel tui is gone, and with it the eight console modules reachable from nowhere else.
keel/commands/: 24,709 → 13,606 lines. Whole change: +297 / −23,448.

The issue estimated 9,432 lines. The real number is the finding.

The issue reasoned: "tui.py imports from keel.commands.{activity,admission,status} — it is a
front-end over the shared report builders, not their owner."
True about the web UI, and half
the picture about the module: nine modules imported from tui.py (ScreenLine, _blank,
_message_style, CTRL_C_DISCLOSURE, _market_session_style, _human_dt), and the whole console
layer sat on them.

Then the decisive fact: there is no keel console command. The console was reachable only from
inside the dashboard. Deleting tui.py alone would have left 6,009 lines of source and 8,276 of
tests that nothing could open — still type-checked, still pinned, still maintained.

I checked every runtime import of the console layer from outside itself. There was exactly one:
keel/mcp/tools.py reading the research corpora. Those four things are now keel/corpus.py, moved
rather than reimplemented, so the MCP server reads exactly what the console read. Everything else
that looked like a dependency — rules.py, withdrawals.py, update.py, brokers.py, cli.py —
was prose in a comment.

Gate 2: enumerated, not assumed

keel capabilities listed eleven actions. Seven [cli], one [tui], three [console] — and each
of the four non-CLI rows named the CLI command it mirrored. Every one of those commands is still
here (keel autonomy on, keel resume/resume-entries, keel withdrawals attest --enabled,
keel rules promote --force). No capability became unreachable.

The registry is seven rows now, all [cli], all behind the typed terminal gate — a cleaner
statement than the mix it replaced: every capability increase in this build is a CLI command run by
a person at a terminal, and the browser can perform none of them.

One absence looks like a regression and is not. keel rules promote --force skips the whole
gauntlet and is not in the registry. It never was: this is an inventory of gate call sites, and
that flag has none — rules.py records why ("the CLI's --force is a flag the operator already
typed at a terminal"). The console's retry flow did run a typed gate before calling with
force=True, which is why a force-promote row existed at all; the row described the console's
ceremony, not the CLI's flag, and it went with the console. Flagging it because the registry now
lists no bypass, and that is a fact about the registry's scope rather than about the bypass.

Dead code that had already become wrong

…truncated — full description in #554.

fix(web): worker update consent, keel.web strict, and the write boundary as a module (#555)

Three skills run against keel/web: pwa-best-practices, python-pro, improve-codebase-architecture.
Two real bugs in last week's code, one Any leak, and one module split — with the architecture
chosen to match youperiod.app, which the design spec
already names as its reference.

1. The worker took over a page it did not render

install called skipWaiting() unconditionally and activate called clients.claim(). The
argument for that was written into the module: the version-keyed cache makes it safe — new worker,
new cache, old one deleted. Sound about caches, silent about the page already on screen.
skipWaiting + claim takes over a document rendered by the old build and answers its later
requests from the new build's cache.

keel had a second line of defence that made it hard to notice — a new build means a restarted
process, a new session token, and a 403 the banner reports — so the window was narrow and loud
rather than wide and quiet. A hazard covered by an unrelated layer is not a hazard dealt with.

The worker now waits; the footer offers "A newer build is ready — reload"; only a click sends
SKIP_WAITING, and the reload waits for controllerchange so the takeover lands first. The offer
is in the footer, not the engine banner — that banner is the page's one aria-live region and
answers "is keel running".

Driving that fix found a second bug in it: watchForUpdate returned early with no controller,
so on a first visit — the one load where there reliably is none — the updatefound listener was
never attached. The controller check belongs on the offer, not the watching.

Also checked from the same list: id was changed /static/ → / in #540, which orphans installs.
No release has ever shipped the manifest, so it orphaned none. Now that one has, it's frozen.

2. keel/web is --strict, and was two errors away

Both errors were one defect: three-argument type() returns plain type, leaking an Any out of a
function annotated -> KeelServer, under a # type: ignore[return-value] that did not name the
error mypy was reporting
(no-any-return) — a suppression that silences nothing while looking
like it does. Rewritten as nested subclasses; keel.web.* is now gated strict, which is where it
matters most since all of it is reachable from a socket.

test_strictly_typed_packages_ship_a_py_typed_marker caught the consequence, which is the pin
working: PEP 561's marker is a promise to whoever installs a distribution, and keel.web is an
internal subpackage with no installer. A marker at keel/py.typed would promise a guarantee for
keel.*, still ignore_errors — what that test's own docstring warns against. It now reads
distribution roots, with a mutation guard so the filter can't quietly empty its own parametrise
list.

3. The write boundary is a module

…truncated — full description in #555.

fix(release): the workflow has been invalid since #512 — secrets is not allowed in an if: (#558)

Release has not been dispatchable since 2026-08-23, when #512 landed. Every push since has
recorded a failed run; the last twenty are all the same failure.

What was wrong

The four signing steps #512 added were gated on the secrets context in a step-level if::

if: runner.os == 'macOS' && secrets.MACOS_CERT_P12_BASE64 != '' && ...

secrets is not a context GitHub allows in if:. It rejects the file at parse time —
Unrecognized named-value: 'secrets' at lines 500, 598, 608 and 644 — so the entire workflow was
invalid, not just those steps.

Why it went unnoticed for three days

An unparseable workflow can't report its own name. GitHub recorded each failure under the file
path
(.github/workflows/release.yml) rather than name: Release, with zero jobs, on a
push event — for a workflow that is workflow_dispatch-only and should never run on push at
all. Three signals that each individually look like noise: a run nobody triggered, for a workflow
nobody recognises, failing before it did anything.

Then it was masked completely. An unrelated org billing lock began failing every workflow on
2026-08-26 with a three-second "account is locked" error, and this became one red X among many.

The fix, and the one it deliberately isn't

The usual remedy is a job-level env: block, since env is allowed in if:. Rejected here:
that would put every signing certificate and notarisation key into the environment of every step
in the job — uv sync, the PyInstaller build, all of it — which is precisely the exposure #512's own
"protected signing environment" exists to prevent.

So the secrets stay scoped to the single step that reads them, and what leaves that step is a
boolean. steps.signing.outputs.{macos,windows} is yes/no; nothing derived from a certificate
reaches an output (outputs are visible in the run's API payload); the four conditions read the
boolean. shell: bash on that step because it runs on both macOS and Windows runners and the
Windows default is pwsh.

Verification

GitHub's parser is the only authority here, so I let it judge:

  • Pushing this branch at 17:30:51Z produced no release.yml run at all.
  • Every failure on record predates it (17:19, 17:25, 17:28, 17:29 on main).
  • The comparison that makes this meaningful: the earlier push to chore-pwa-arch did produce
    one (09:22), so a feature-branch push does generate the synthetic failure while the file is
    invalid. It no longer does.

Locally: parses as YAML, no duplicate keys, desktop job structurally intact with the new
signing step id and all four conditions rewritten.

What this means for D5

…truncated — full description in #558.

fix(tests): the signing pins assert the guarantee where #558 moved it, and one they missed (#559)

main is red because I merged #558 without running the suite. This fixes it.

What broke

Three tests in tests/test_desktop_packaging.py assert the signing gates by looking for
secrets.X != '' inside each step's if: — the exact expression #558 had to remove, because
GitHub rejects the secrets context in an if: at parse time.

The guarantee is unchanged and still pinned: all five Apple credentials or none, both Windows
credentials or none, and each skip notice firing on the exact complement of its sign step. What
moved is where that is decided — from four if: conditions into one signing step whose outputs
the conditions read — so the assertions move with it.

And a pin for the failure these tests could not see

The old tests asserted that a string appeared in a condition. That says nothing about whether
GitHub can parse the file, which is exactly why they stayed green for the three days the
workflow was undispatchable. Two new pins close that gap:

  • No secrets in any if:, in any job or step — the thing that made the whole workflow
    invalid.
  • No signing credential in a job-level env: — the obvious fix for the first one, and the
    wrong one, because it exposes every certificate to uv sync, the PyInstaller build, and every
    other step in the job. That is the exposure the signing environment exists to prevent.

Verification

Full suite green (4274 passed, 3 skipped), ruff and mypy clean.

All three pins mutation-checked — a test that cannot fail is not a pin:

mutation result
restore secrets.WINDOWS_CERT_PASSWORD != '' to an if: 2 tests fail
drop APP_STORE_CONNECT_ISSUER_ID from the gate step 1 test fails
hoist MACOS_CERT_PASSWORD to job-level env: 1 test fails

The process failure, recorded

I validated #558's YAML, checked its structure, and let GitHub's own parser confirm the fix — and
did not run pytest, because it was "only a workflow file". This repository pins its workflows
with tests; that is the entire reason those three existed. The suite is not optional on a
workflow change here, and I'll treat it that way.

fix(executor): refuse a stop roll that reaches the target, and record why #502 stage 2 is blocked (#560)

Started on #502 stage 2 — "the executor still builds its raw trigger_bracket_gtc dict
against the pre-port CoinbaseClient, and moving it is stage 2's job"
— and found it cannot be
done as scoped. This ships the guarantee that migration would have bought, and records the blocker.

Why stage 2 is blocked on #524

The port's signature is place_order(spec: OrderSpec, *, idempotency_key=None).

The executor calls broker.place_order(product_id, side, order_configuration) — three positional
arguments and a raw venue dict, which is the pre-port CoinbaseClient's shape. The executor is
not on the port at all: it imports only keel_broker_api.results, and every order kind it places
(_order_configuration for market and limit, _bracket_order_configuration for the bracket) is a
hand-built Coinbase dict.

So moving the bracket alone has exactly two options, and both are wrong:

  • translate in the executor — needs keel_broker_coinbase imported into keel/execution/,
    the layering regression the port exists to prevent; or
  • migrate the executor↔broker boundary to specs — that is #524, and it touches every live order
    path, not the bracket.

I've left this in #502 rather than working around it.

The gap that didn't need the migration

BracketGTC.__post_init__ refuses a stop at or above the take-profit — "a coin flip wearing a
protective order's name"
, since the two exits then race at the same level and whichever the venue
evaluates first decides whether the position took a profit or a loss.

_roll_stop has never checked it. It guards against widening (new_stop < prior_stop) and
against a missing target, then places whatever it was handed.

Reachable rather than theoretical: trail_stop_atr computes price - atr * multiplier, and the
live agent cycles once a day, so a gap through the target that reconciliation hasn't caught up
with leaves a recorded target sitting below the newly computed stop.

Refusing is the conservative half — the roll is abandoned and the existing bracket stays in
force
, so the position keeps the protection it already had. The alternative is cancelling a
working bracket to install an inverted one, and if the venue then refuses it, the position is naked
until the next sweep.

>=, not >. Equal is the subtler half: two equal prices read as an ordinary pair of numbers, and
what they describe is a stop and a target racing at the same price. BracketGTC refuses equal legs
as firmly as inverted ones; so does this.

Verification

Full suite green (4276 passed, 3 skipped), ruff and mypy clean.

Both tests mutation-checked:

mutation result
remove the guard 2 tests fail
weaken >= to > the equal-legs test fails

…truncated — full description in #560.

feat(broker-api): a product-catalogue read, the first of #524's two blockers (#561)

First of the two blockers #524 names. Touches no live behaviour — nothing calls
get_instrument yet; this gives the executor something to move onto.

The gap

executor._base_increment_for (#516) needs the finest base_size a venue will accept, and gets
it today by calling broker.list_products() and picking through raw dicts for product_id and
base_increment. That is the pre-port CoinbaseClient's shape. The port had no catalogue read
at all
, which is why the flip isn't a one-line change.

One product, not the catalogue

get_instrument(product_id) -> Instrument | None. The shape is the caller's own argument:
list_products returns ~900 rows on Coinbase and _base_increment_for caches exactly one per
miss, because Repository.set_state commits per call and caching all of them would mean ~900
fsyncs inside the order-placement path. Coinbase's transport has carried get_product all along.

None is an answer, not a failure — which is why this differs from get_order. An order id
was handed to the caller by this venue, so its absence is a real inconsistency. A product id
comes from an operator's allowlist and may simply not be listed here.

Instrument carries product_id and base_increment and nothing else. Quote granularity and
minimum sizes are the same class of fact and would sit here naturally, but nothing reads them, and
a field no caller reads is a field no test meaningfully checks. __post_init__ refuses a
non-positive increment: it is what a caller quantizes against, so a zero crossing the port is a
division error or a silent zero size on the exit path.

Five adapters, three different truths

adapter behaviour
coinbase get_product, unwrapping either envelope; None for missing/unparseable/zero/negative
robinhood get_trading_pairs(symbol) — the venue's min_order_size is this fact, and the port keeps keel's name for it rather than the venue's
fake a fixed increment, plus None for a reserved NOT-LISTED id so the conformance suite actually exercises the absent case
alpaca NotImplementedError
kraken the stub's standard refusal

Alpaca is the one worth reading twice. Alpaca has /v2/assets/{symbol} carrying exactly this;
this adapter's Transport protocol does not declare it. It raises rather than returning None
deliberately — None would tell the executor a symbol is unlisted when the truth is that nobody
wrote the read, which on the live path means silently skipping quantization for every equity.
Same distinction stage 1 of #502 drew about Alpaca's bracket support: "not written yet", not
"impossible".

Conformance

…truncated — full description in #561.

feat(executor): the quote balance comes from the port's shape, and only available counts (#562)

#524's second and last blocker. Like the first (#561), this changes no live behaviour — the
same numbers reach rail 13 by the same path — and it removes one of the dual-shape probes the
issue lists as the payoff.

One question, one answer

_fetch_available_quote called broker.get_accounts() and then probed each row twice — dict
key or attribute, currency or .currency, available_balance or .available_balance — because
it did not know whether it held the pre-port CoinbaseClient or a port adapter. The port answers
list[Balance]; the client answered venue-shaped dicts.

CoinbaseClient.get_balances() now answers in the port's type as well, computing total as
available + hold exactly as keel_broker_coinbase.adapter does — Coinbase exposes no single
"total" field, and two implementations of one word must not disagree while both exist. The executor
asks one question and the fork is gone. When _build_broker finally resolves through load_broker,
this path needs no further change.

get_accounts stays for keel assets holdings (cli.py:557), which reads it through
gather_holdings. Moving that is the flip's business, not this blocker's.

A gap the migration exposed

Balance carries available and total, which the dict shape never distinguished. I
mutation-tested the change and found that swapping .available for .total passed the entire
suite
— because every fake in the repository sets the two equal.

They are not the same number. available is what the venue will let an order draw on; total
includes funds on hold — settling proceeds, collateral behind a resting order. Reading total would
let rail 13 pass an order the account cannot fund, which is precisely the failure that rail
exists to prevent, and it would do it silently because both are plausible balances.

Now pinned with a balance whose figures differ (100 available of 1000 total), plus the
case-insensitive currency match and the no-account-for-this-currency case that None means. The
mutation that passed before fails now.

On the fakes

Converting them surfaced the design point worth recording: the old fakes represented "balance
unknown" as a row carrying available_balance: None. Balance can't express that, and it
shouldn't — in the port's model a currency with no account simply is not in the list.
_fetch_available_quote returns None either way, by falling off the loop rather than testing a
null field.

Verification

4298 passed / 3 skipped, ruff clean, mypy clean across 347 files. Both sides mutation-checked: a
wrong total in the client fails its test, and the .available/.total swap in the executor now
fails too.

#524 after this

…truncated — full description in #562.

feat(executor): the increment read goes through the port, and stops fetching 900 products for one (#563)

#561 gave the port get_instrument and wired nothing to it. This wires it, which was the point.

Third additive step on #524, and like the two before it no live behaviour changes — the same
increments reach the same quantization through the same cache.

The read the port was built for

_base_increment_for called broker.list_products() — about 900 rows on Coinbase — to use one
field of one of them
, then picked through raw dicts for product_id and base_increment. That
was the pre-port CoinbaseClient's only catalogue read.

CoinbaseClient.get_instrument() now answers the port's type too — the same bridge get_balances
is — so the executor asks one question whether it holds this client or a real adapter.
list_products stays exactly where it belongs: keel assets discover, which genuinely wants the
catalogue.

A caching argument that no longer applies

The old docstring defended writing one cache row per miss "even though the response carries every
product"
, because Repository.set_state commits per call and caching all ~900 would mean ~900
fsyncs inside the order-placement path. The argument was sound and the shape was not. Asking
for one product removes the temptation rather than resisting it — there is no longer a catalogue to
decline to cache.

The test that pinned it stays, with its reasoning updated rather than deleted: one venue answer must
still write one row.

And the adapter that refuses

keel-broker-alpaca raises NotImplementedError from get_instrument, deliberately (#561) —
None would tell a caller a symbol is unlisted when the truth is nobody wrote the read.

On this path that must land as unknown, not as a crash. _base_increment_for never raises by
contract: unknown means send the quantity unquantized, and an exit that refused to place because a
catalogue lookup was unimplemented would be a protective order withheld over a missing convenience.
Now pinned with a test.

Verification

4304 passed / 3 skipped, ruff clean, mypy clean across 347 files.

What's left of #524

Only order placement. Every read the executor makes — balances, increments — now goes through
port shapes and needs no further change at the flip. What remains is preview_order/place_order
taking OrderSpec instead of raw dicts, and _build_broker resolving through load_broker.

That one is not additive. It rewrites every live order path, and it's where
_bracket_order_configuration, the ConfirmFn dual arm and the capabilities() grandfather clause
finally get deleted. Worth doing as its own reviewed change rather than tacked onto this.

feat(executor): every order is an OrderSpec, and the second Coinbase renderer is gone (#524) (#569)

The flip. The executor placed orders by handing hand-built Coinbase dicts to a pre-port
client; it now builds OrderSpec values and reads Preview/PlaceResult.

The bytes on the wire are unchanged, and that is verified rather than asserted. Ten order
configurations rendered by the new path were compared against the pre-flip renderers loaded out
of git
— the bracket across four size/price/increment combinations, plus every market leg — and
are byte-identical.

One renderer, not two

CoinbaseClient.preview_order/place_order take a spec and render it through
keel_broker_coinbase.translate.to_order_configuration — the adapter's own function.
executor._bracket_order_configuration is deleted, and with it the test #502 stage 1 shipped to
pin the two byte-identical while both existed. That test's own words: "The test imports both;
production code does not."
There is one now, so there is nothing left to hold in agreement.

_order_configuration becomes _order_spec: BUY is MarketIOCByQuote, SELL is
MarketIOCByBase, the bracket is BracketGTC. #516's quantization is untouched and still runs
before the spec is built, including its deliberate BUY/SELL asymmetry.

The trap I nearly walked into

Every OrderSpec carries an initial_status ClassVar, and using it for the order row's status is
the obvious move and wrong. The port's vocabulary is the venue's (filled_or_rejected,
open); this column is keel's (filled, pending).

reconcile sweeps for pending. Writing open would leave every resting order invisible to the
sweep that exists to observe its fill
— a bracket recorded as open is a protective order keel
would never look at again.

So _initial_status stays, mapping spec.kind to keel's words. What went is the dict inspection
(next(iter(order_configuration), "")), not the vocabulary. Caught by a test asserting 'filled',
which is exactly what that test was for.

Smaller things the types made obvious

  • raw_response stored the entire placement response as JSON so _native_order_id could dig the
    id back out to cancel with. It stores {"order_id": ...} now, from PlaceResult.broker_order_id.
    No migration needed — both shapes answer the same data.get("order_id"), so pre-existing rows
    read unchanged.
  • _preview_book already accepted Preview | dict and the Coinbase adapter already carried the
    book in detail, so #350's spread gate and #332's override warning came through untouched. The
    executor was written anticipating this flip.

Tests

…truncated — full description in #569.

fix(installer): the floor is the artifact's (#557); the config is confirm-mode production (#556) (#580)

Two installer bugs, both about the script telling the truth about what it installs. Closes #557, Closes #556.

#557 — the 3.14 floor refused Pythons the wheels it installs support

Root cause. The installer hardcoded the development tree's Python floor (>= 3.14, from 2d3934f) while the artifact it actually installs — the latest release's wheels (v0.11.2) — declares requires-python = ">=3.11". On Linux, where most distributions do not package 3.14 yet, the script died at its own step 2 refusing the very Pythons the wheels support (install-smoke run 32714791913, ubuntu-latest red since). The candidate pair python3 python3.14 was likewise welded to that floor: an interpreter installed as a bare python3.X (pyenv, deadsnakes) without shimming python3 was turned away.

Fix — the floor that matters to an installer is the floor of the artifact being installed:

  • Steps reordered: the latest release tag is resolved first (step 2, tag extracted with sed since no Python is known yet), then the Python search (step 3) enforces that release's floor.
  • After resolving the tag, the script fetches https://raw.githubusercontent.com/CodeGateSoftware/keel/<tag>/pyproject.toml (curl -fsSL) and parses requires-python = ">=X.Y" with a whitespace/3-component-tolerant sed (a non->= specifier parses to nothing → fallback). The run prints which floor it enforces and where the number came from — auditable.
  • If the fetch or parse fails, it falls back to FALLBACK_FLOOR="3.11" — a constant whose comment names what it stands for (the shipped-wheel floor of the releases this installer can pick) and where it must be updated — and prints that the fallback was used.
  • Candidates are derived from the floor: a 3.11 floor tries python3 then 3.14/3.13/3.12/3.11; the check is sys.version_info >= (${FLOOR_MAJOR}, ${FLOOR_MINOR}). No dev-tree 3.14 remains in enforced code.
  • The no-Python failure names the remedies: the deadsnakes PPA (Ubuntu), pyenv install <floor>, and uv python install <floor>.
  • README.md and docs/desktop-install.md qualify the one-liner for Linux with the current shipped floor (>= 3.11) instead of publishing the dev tree's floor as the installer's requirement.

Local-run evidence (real run, isolated HOME, this machine — notably an environment where python3 was NOT on PATH and the old fixed pair would have died at step 2):

…truncated — full description in #580.

feat: the live per-cycle stop-management step, off by default (#502 stage 2) (#581)

Partially addresses #502 (the two scope items that remained; see "What did NOT land" for the scale_out remainder).

The audit first: the recorded blocker is GONE

#560's block note said stage 2 was inseparable from #524 because the executor was not on the port at all (broker.place_order(product_id, side, order_configuration) — three positional args + a raw dict). #569 landed feat(executor): every order is an OrderSpec, and the second Coinbase renderer is gone (#524), and auditing origin/main at that commit found the blocker resolved and, with it, the first two scope items already shipped:

  • Bracket OrderSpec kind — LANDED. keel_broker_api.orders.BracketGTC (bracket_gtc) exists, __post_init__ refuses equal/inverted leg pairs, and keel_broker_coinbase.translate.to_order_configuration renders it to the venue-accepted trigger_bracket_gtc shape (pinned byte-for-byte in tests/broker_coinbase/test_translate.py). The other adapters refuse it out loud: alpaca and robinhood raise UnsupportedOrder at translation naming the capability gap ("not translated yet" vs "the venue has no bracket/OCO order type"), kraken and fake leave it undeclared in capabilities; the conformance suite's test_the_bracket_declaration_cannot_lie_in_either_direction pins both directions (declared => accepted, undeclared => UnsupportedOrder, never a two-leg synthesis).
  • Executor bracket through the port — LANDED. _bracket_order_configuration is gone; _bracket_spec builds a BracketGTC value and place_bracket/_roll_stop hand it to broker.place_order(spec) through _run_order. No venue dict is built in keel/ anymore (grep trigger_bracket_gtc keel/ is empty), and tests/execution/test_executor.py pins the placed spec is a BracketGTC (lines ~888, ~1617).

So this PR is the part the issue actually opened for: the live wiring, plus two executor enablers it needed.

What lands here

1. roll_stop_to + the roll keeps its tranche link (a16d5bb)

…truncated — full description in #581.

feat(brokers): finish the port flip -- the default venue resolves through the registry (#524) (#582)

Audit of #524's acceptance checkboxes, against origin/main before this PR

# Checkbox Status at origin/main Evidence (file:line at origin/main)
1 _build_broker resolves the venue through load_broker/discover_brokers UNMET for the default venue — the coinbase branch constructed CoinbaseClient directly; the registry path existed only for non-default names keel/commands/_common.py:185-195 (direct construction), :199-201 (registry, other names only)
2 Port exposes what the executor needs (catalog/increments, balances), or executor adapted MET by #561/#562/#563 port: get_instrument (packages/keel-broker-api/keel_broker_api/port.py:132), get_balances -> list[Balance] (port.py:130); executor reads them at keel/execution/executor.py:393 and :441
3 Live path places every order through OrderSpec values MET by #569 — market entries/exits are MarketIOCByQuote/ByBase (executor.py:1271-1315), the bracket is already a BracketGTC OrderSpec (executor.py:1489-1513), placement via broker.place_order(spec) (executor.py:694) —
4 Legacy renderer, dual-shape probes and grandfather clause deleted PARTLY MET — renderer deleted in #569; probes and grandfather REMAINED probes: ConfirmFn dual type (executor.py:161), _preview_book dict arm (executor.py:945-973), ExecutionResult.preview dual type (executor.py:144), confirm gate dict arm (keel/commands/confirm.py:96-122); place_result.get("success") was already gone (executor.py:704). Grandfather: packages/keel-broker-api/keel_broker_api/capabilities.py:30-40 ("no capabilities() at all"), keel/execution/guards.py:843-845, keel/agent.py:913-915, keel/commands/assets.py:63-74
5 Non-Coinbase venue works or refuses explicitly at arm time MET alpaca wiring _common.py:211-236; un-wired venue refused by name (_common.py:203-209); unknown name fails through the registry's LookupError listing what is installed (packages/keel-broker-api/keel_broker_api/registry.py:26-28); pinned by tests/test_paper_equities_profile.py (test_build_broker_refuses_a_venue_without_cli_wiring, test_build_broker_unknown_name_surfaces_the_entry_point_list)

What this PR finishes

…truncated — full description in #582.

feat(rules): each rule declares its parameter space, so the trials budget is derived (#528) (#583)

What

Each rule declares its own parameter space, so the size of the explorable space becomes a property of the rule rather than of the operator's diligence. n_trials — the input to keel's own overfitting correction (research/deflate.expected_max_sharpe) — stops being a number a human remembers to record (PRD §4.1, the Jesse comparison's one genuinely new finding).

Design

ParamSpec + Rule.param_space() (keel/strategy/rules/base.py): a frozen spec per dimension — name, type ("int" | "float" | "decimal"), lo, hi, step, and the constructor kwarg the dimension feeds. type names the RANGE's arithmetic, not the field's storage: target_rr is a Decimal field whose legitimate values are whole numbers, so it declares "int" — which is exactly what both the int-vs-float suggest dispatch and the cells arithmetic read. cells floors a ragged step (the grid never counts a point beyond the range); bounds preserves the int-pair typing the dispatch relies on; plain() renders JSON-plain. The ABC default is the empty space: a rule with nothing sweepable declares exactly that — a truthful zero, not a missing declaration.

Declared spaces (mirroring what tuning.SEARCH_SPACES hand-maintained since #476 — the ranges "reflect what has actually been considered legitimate", per the acceptance criteria):

family declaration declared_cells
turtle_breakout entry_lookback 20–60/5, exit_lookback 10–30/5, adx_threshold 20–35/5, atr_stop_mult 1.5–3.0/0.5, target_rr 3–8/1 4320
rsi_meanrev oversold 15–30/5, overbought 70–85/5, atr_mult 1.0–2.5/0.5, fixed_rr 1–3/1, rsi_period 10–21/1 2304
pullback_continuation ema_fast 5–12, ema_mid 15–30, ema_slow 40–70 (each naming the one ema_periods kwarg it feeds), buffer_ticks 0.01–0.05/0.01 19840
dca — (accumulation, not a risk-defined trade; #476 excluded it for exactly that) 0

A candor note on the steps: they are the resolutions the older manual grids (the 2026-08-11 turtle / 2026-08-12 rsi sweeps) moved in and the convention the cells count is priced at — not what the #476 TPE study moved in. suggest_int/suggest_float carry no step, so that study drew step-1 ints and continuous floats, off these grids; off-grid draws are invisible to a min/max box, so every cells count (declared_cells, explored_cells) is a grid convention, never a set of visited points, and the multiple-comparison budget is n_trials, never the grid size.

…truncated — full description in #583.

docs(research): the fill-model hazard in externally-sourced strategies (#529) (#584)

Closes #529. Milestone: Strategy-API expressiveness. Docs only — no code paths touched; suite green (uv run pytest -q: 4340 passed, 3 skipped).

Where the doc landed, and why

docs/experiments/2026-08-27-external-strategy-evaluation-hazard.md (plus a newest-first index entry in docs/experiments/README.md).

Chosen over docs/research/ because the cost-restatement corpus the issue names ("alongside the cost-restatement documents") lives in docs/experiments/ — the 08-12 fee curve and the 08-13 restatement are the two records every number in this hazard cites — and because docs/experiments/ is where the scout skill already directs a proposer to read before writing any output. That is the meet-point the acceptance bar describes. The doc states up front that it reports no new measurement; it is the reading-side analogue of the invariants the 08-13 record demanded.

Section shape
  1. The hazard — the Jesse self.buy = qty, entry idiom vs keel's #258 next-bar-open market fill; the worked example with both numbers; the compounding with ~2.5% round-trip friction ("optimistic on both axes at once").
    • 1.1 A caveat the worked example must not lose — carried verbatim in spirit from the 08-13 record §3.1: 0.92 → 0.77 is not evidence that resting entries add edge (both sides lose gross; the filter separated bad from worse). It is evidence for #260's landmine. This prevents the doc from being read as an argument for #333.
  2. Why a document and not code — nothing to enforce; the engines are already correct; the hazard is in how a human reads someone else's backtest.
  3. The four questions — each with what to establish / why it matters here (measured keel anchors) / what a wrong answer costs: fill model, cost regime, sample size & window, capability (#333/#502/#447/#260).
  4. The checklist — a copy-paste markdown form: the four questions as yes/no/unknown prompts ("unknown is a real answer") plus a proceed/do-not-port verdict, answered before porting effort.
  5. Where this is enforced for machine-generated proposals — the scout skill's cost_regime/sample_size/evaluation_window citation fields are the same discipline; the two are kept in step.
  6. Provenance of every number — a table mapping each figure to its record of origin.

Cross-reference points (the acceptance criterion)

Three places, each one where a person proposing a candidate would actually see it:

…truncated — full description in #584.

feat(research): the 2026-09-30 pooled-review report driver (#427) (#585)

What this is

The REMAINDER of #427 is running the 2026-09-30 pooled review with the corrected reporting (tracked in #353) and the owner's floor decision. This PR lands the tooling so the review event is one command and its report cannot be written imprecisely. The correction scope itself landed long ago (PR #503: roadmap PRD, glossary, cross-verification §4.1, discussion #359's corrected comment).

Partially addresses #427. What remains open, exactly: (1) the review event itself — 2026-09-30, #353's to run (docs/experiments/2026-09-30-pooled-review.py with default args is that run); (2) the owner's floor decision (below). This PR does not close #427.

Design

Two pieces, matching the significance/optuna shape (pure seams in keel/research/ pinned by tests, thin IO driver in docs/experiments/):

keel/research/pooled_review.py (pinned by tests/research/test_pooled_review.py, 25 tests):

  • Extraction mapping — orders/trade_outcomes rows → the OutcomeRow sequence significance() reads. A trade_outcomes row is authoritative and its pnl_net is carried verbatim (recomputing would drop the entry fee the row folds into pnl but does not store). Where the ledger is empty, round trips are reconstructed from filled orders: BUY matched to SELL FIFO by order id within (profile, product, rule, mode, qty), priced with streak.record_closed_trade's own formula (exit - entry) * qty - both legs' fees. The two records dedup on (profile, product, qty, exit fill). Win/loss by the sign of fee-honest net pnl; pnl exactly 0 is a scratch (counts toward nothing); open positions and unfilled/rejected orders are excluded and counted; timestamp inversions are flagged, not trusted and not hidden.
  • power_sentence(n) — generated at the pool's ACTUAL n through throughput.n_eff/detectable_edge: "at this n_eff (N effective of M pooled), this review can only see an edge of X points or larger (80% power, one-sided 5%)". At n=100 it says "20.0 points" (the corrected #359 comment's own display); at n=12 it says "57.6 points". Pinned to the exact string.
  • render_report — title, event date, method citation, composition table, raw n never without its n_eff, the sentence constructed in, and the not-a-pass/fail-gate statement built in. No verdict vocabulary on the edge anywhere: the significance machinery measures (win rate, break-even, edge, CI) but none of its distinguishable/insufficient_n verdicts render — the only verdict-shaped statement is about POWER.
  • Refusal — zero pooled trades → "nothing to review", exit 2, refusal JSONL row only, never a degenerate report.

…truncated — full description in #585.

feat(brokers): alpaca refuses margin accounts, cash-only by posture (#372) (#586)

Closes #372 · Phase 12 — Stocks via Alpaca (PRD §5 "Cash-account discipline", §8)

What this does

Encodes and enforces the cash-account posture where the venue can tell us, and documents what only the operator can know — with the split stated explicitly, never blended.

The API fact the enforcement rests on (the honest answer to "does Alpaca surface account type?"): there is no account_type field. /v2/account's multiplier is the venue's account margin classification — 1 a "standard limited margin account with 1x BP" where "buying_power = cash" (the cash-equivalent posture: Alpaca opens every account as margin and offers no true cash designation — multiplier 1 is as cash as the venue gets: shorts refused, buying power cannot exceed cash), 2 reg T margin, 4 PDT day-trading margin. So the posture is fully readable — no API gap to work around — and AlpacaAdapter.verify_cash_account() reads it once per broker build: multiplier 1 passes; anything else raises CashAccountRequired naming riba, the PDT threshold the posture sidesteps, and the fix; an absent/unparseable/unreachable classification raises the same fail-closed refusal (rails 12/13's silence-is-not-consent rule), cause chained. The seam is _build_broker — every command that constructs a broker (agent cycle, fetch, monitor, order paths) inherits the refusal before any engine path sees a broker; one extra GET /v2/account per build sits far inside FR-11's rate budget at the daily cadence.

A wrong claim this corrected: the runbook said "the paper account is [a cash account] by default". It is not — Alpaca's default for any account with ≥ $2,000 equity is reg T margin (multiplier 2), and a fresh paper account carries $100k. The enforcement refuses that default on the first cycle until the operator sets the account's max margin multiplier to 1; the runbook and config.paper-equities.yaml now say so.

Enforce vs document — where each constraint lives and why

…truncated — full description in #586.

docs(decisions): ADR 0002 — SQLite persistence, and the triggers that would reopen it (#526) (#587)

Closes #526.

#526 is explicitly not a proposal to change persistence. It asks that the reasoning (one writer; the audit trail IS the schema; the db is a file — copyable, diffable, attachable, backed up before migration) and the four concrete triggers be recorded durably, ADR-style, so the question stops being re-litigated — with the standing rule that a fired trigger is answered with a MEASUREMENT, never "Postgres feels more serious".

Where each piece landed

docs/decisions/0002-sqlite-persistence.md (new) — the ADR, in the 0001 voice (Context / Options / Decision / Consequences, dated, citing #526 and the SE walkthrough §9.6 that raised it):

  • Context — the shape with the issue's numbers: three profiles (live, paper-daily, paper-hourly), each its OWN db file so still one writer per file; live trades once per UTC day, hourly cycles hourly; keel.db ~129MB mostly candles; keel migrate runs per database; backup-before-migration <db>.bak-before-<version>-<stamp>. Cites db.py::connect's WAL rationale (with its #437 measurements) as the precedent, and bounds what WAL buys: concurrent readers with one writer — not two writers.
  • Decision — SQLite stays; the synchronous one-writer design is load-bearing, not a limitation. The SE walkthrough's "why not async?" answer (§10, quoted in #526 — concurrency buys nothing here and costs testability) answers "why not a database server?" in the same breath. Three standing rules, including: one writer per database file (reads welcome — that's what WAL bought), and a fired trigger is answered with a measurement.
  • The triggers — the four, as a table with exactly what fires each, the measurement that answers it, and who observes it. First response to every trigger is its measurement row: trigger 1 starts "measure the actual write contention", not "migrate".
  • Consequences — what this rules out until a trigger fires (supervisor process or write-path web layer against a live file; sub-hourly profiles), migrations stay per-database/backup-first, candle read latency watched with a timer — and any reopening is a NEW record superseding this one.

keel/data/db.py::connect — the WAL comment gains one closing pointer: the standing decision (SQLite, one writer per file, the four triggers) lives in ADR 0002. This is the read-path guard: someone changing the process model reads connect() first.

docs/decisions/README.md — gains an Index listing 0001 (#439) and 0002 (#526); the README previously described the format but listed nothing.

Notes

…truncated — full description in #587.

docs(decisions): the commands/ survey — measured, classified, decided (#525) (#589)

Closes #525.

A survey, not a refactor — the deliverable is numbers and a recorded decision. Lives as ADR 0003 (docs/decisions/0003-commands-layer-survey.md), mirroring 0001's Context/Options/Decision/Consequences shape (0002 is in flight on its own branch; this takes the next number).

Headline numbers

The issue's premise dissolved before the survey ran: #541 (PR #554) deleted the TUI and console-only layers (24,709 → 13,552 lines, +297/−23,448 with tests), and the engine grew in the same window. Restating the issue's table on main @ 16725c1:

at filing today
commands/ 23,188 13,552
engine (7 modules) 17,260 20,798
ratio 1.34x 0.65x
Role split (the issue's checklist item 1)
role lines share
A. Shared report assembly (consumed by CLI + web/MCP) 5,486 40.5%
B. Services & decision orchestration (CLI-only) 4,975 36.7%
C. Front-end glue 1,880 13.9%
D. CLI-only report assembly + rendering 722 5.3%
E. Layer plumbing 489 3.6%

A line-level AST attribution: 1,016 render / 3,734 assembly+service / 1,292 click bodies / 3,320 helpers — and 4,190 lines (31%) of headers, docstrings and imports. The layer is substantially prose: the explanatory surface the issue's "case that this is fine" describes.

Decision paths named (item 2)

_run_backtest (#335) no longer exists — the #390 C4 service extraction split it into resolve_rule_backtest+backtest_resolved+run_rule_backtest, core in strategy.backtest. Ten further decision-bearing paths are named in the ADR with file:line and, crucially, where each one's policy lives: in every money case the deciding comparison (can_promote, transition, screen_asset, build_verdict, sizing.size) is in a compute module; commands/ holds sequencing and refusal wording. Two deliberate display-side mirrors (status._rail11_status, doctor's ATR banding) carry inline arguments for why they are not policy.

Duplication quantified (item 3)

Traced report by report across CLI/web/MCP: zero near-duplicate render paths remain. Each load-bearing report is one shared core plus thin format projections (12–77 lines) — status (gather_status → render_human / status_payload, the web even imports _human_age), doctor (gather_findings → CLI lines / MCP JSON), purification (engine build_report → text / JSON), insights (build_* → CLI renders / web payloads). The historic duplication was real — the TUI — and #554 deleted 11,103 lines of it.

Test parity (item 3b)

tests/commands/: 11,031 lines, 527 test functions — more than any engine area's suite — plus test_service_parity.py (CLI-vs-service byte-equality pins) and test_console_thinness.py (AST scan: no compute imports/calls, Decimal display-only, no broker construction outside seams).

The decision (item 4)

…truncated — full description in #589.

test(web): the thinness pin covers the MCP front-end too (#588) (#590)

Closes #588

One file changes: tests/commands/test_console_thinness.py. No handler, no
service, no production line is touched.

What the scan now covers

_console_module_paths() scans a second glob next to the web one:

paths = sorted(glob.glob(os.path.join(REPO_ROOT, "keel", "web", "*.py")))
paths.extend(sorted(glob.glob(os.path.join(REPO_ROOT, "keel", "mcp", "*.py"))))

...so the six rules now hold over the MCP server (mcp/tools.py 8 handlers,
mcp/server.py transport, mcp/__init__.py) exactly as they hold over
keel/web/. The docstring says why both front-ends are in scope, citing ADR
0003 (docs/decisions/0003-commands-layer-survey.md) and its standing rule 2:
any front-end consumes keel.commands.* services or the engine directly and
never re-renders another front-end's report from raw rows. keel/commands/mcp.py
stays outside the set for the same reason serve.py always has — argv wiring
and socket binding are service work (Rule 5's own line).

The scan-honesty proof

test_the_scan_actually_scanned_the_console_layer now asserts
{"mcp/tools", "mcp/server"} <= keys with the same loud failure message the
web modules get, so a renamed handler module or a silently-empty glob fails
the suite rather than leaving the rules green over less code. Because the pin
passed vacuously green over all eight handlers on arrival, there is also a new
positive control, test_rules_1_and_2_are_proven_false_capable_over_an_mcp_source:
a synthetic mcp/tools source with a keel.execution.sizing import planted in
it fires Rules 1 AND 2 over an mcp/ key, the audited build_report shape
stays silent, and the same callee at a NEW site still fires. Rules 1-2 were
factored out of the fixture into _compute_tree_findings (in _rule6_findings'
shape) so a control can run them at all — extraction is verbatim.

Allowlist entries added (entry-scoped, with reasons)

Entry Reason
IMPORT_ALLOWLIST: ("mcp/tools", "keel.compliance.purification.build_report") The §65.9 report's COMPUTE home, reached the same way the CLI's keel purification body and the web view reach it — one implementation, three front-ends.
CALL_ALLOWLIST: ("mcp/tools", "_purification", "keel.compliance.purification.build_report") The purification tool is a JSON projection of the engine's own report (ADR 0003's table lists it as such); the entry names the exact enclosing function, so a second build_report call anywhere else in the package fails.

These are the same shape, and the same reason, as the
_do_compliance_payload allowance the deleted TUI carried.

…truncated — full description in #590.

chore(release): 0.12.0 (#591)

What this is

The version-bump PR for 0.12.0, following #482/#515/#518's precedent: one commit, every version and every == pin moves together, uv.lock moves with them. Per docs/RELEASING.md, merging this (not the workflow) is what sets the version — the release workflow then refuses to change it.

Minor, not a patch: 69 commits since v0.11.2 — 23 feat, 14 fix (non-merge subjects). New interfaces shipped (the web UI, declared parameter spaces, OrderSpec), so the minor is earned, not conservative.

What ships in 0.12.0

The headline items, enumerated from git log --oneline v0.11.2..HEAD:

  • The web UI rewrite (#531–#555): zero-dependency, view-source, on-device — static serving with checkable headers (#545), JSON reports sorted with Decimal (#547), the four-module client shell (#549), six views + SVG curve + stream that says when it stops (#550), an installable PWA whose worker structurally cannot cache a balance (#551), the old renderer deleted (#553), the curses TUI deleted with it (#554), worker update consent and the write boundary as a module (#555).
  • The broker-port flip completed (#524, #561–#563, #569): the default venue resolves through the registry, every order is an OrderSpec, the second Coinbase renderer is gone, the product-catalogue read lands (#561), the increment read goes through the port (#563), and the pre-port client's grandfather clause retires.
  • Live stop management (#502, #521, #527): a per-cycle stop-management step (off by default), a bracket as one order kind because two legs race (#521), the stop a tranche was sized against is recorded because the current one ratchets away (#527), stops manage under the owning row's policy per product, and a failed roll is loud and isolated (#519/#522 — the crash ledger is written before cancelling).
  • Declared parameter spaces (#528): each rule declares its parameter space; tuning reads the declaration, with explored-vs-declared honesty.
  • Alpaca cash posture (#372): Alpaca refuses margin accounts — cash-only by posture; PDT safety is the cadence, not the posture.
  • Installer fixes (#556, #557): the config shipped is confirm-mode production (not paper), the floor is the artifact's (not the dev tree's), the --force clobber warning, the DMG README floor tells the truth. Plus the installer/update line: curl|bash no-warning path (#511), Inno Setup installer with gated signing (#512, #438), per-release self-update (#510, #439).
  • Pooled-review tooling (#427): the 2026-09-30 pooled-review report driver, and the ledger-fees fix (fees are the exit leg — the measured-fee line cannot understate).
  • Floor: Python 3.14 required (#546).

Docs/decisions also land (ADR 0002 SQLite #526, the commands/ survey #525, the fill-model hazard note #529, the Jesse learnings PRD #530) — release notes will group them per the label table in docs/RELEASING.md.

Files bumped

…truncated — full description in #591.

fix(release): the Windows attestation subjects are newline-separated, not space-separated (#592)

The v0.12.0 dispatch (run 33146487866) failed at the Windows attestation step with Could not find subject at path out/*.zip out/*-setup.exe while BOTH files sat in out/ — the attest action treats the space-separated form as ONE path. The documented multi-subject form is newline-separated, one path per line. The step's comment (and the structure pin) believed space-separated worked; both now say what the action actually does, with the failing run cited. Found while cutting v0.12.0; the release is being re-cut cleanly after this lands.