Skip to content

keel v0.14.1

Choose a tag to compare

@github-actions github-actions released this 09 Sep 18:41
· 113 commits to main since this release
468a749

Built from 468a749. Version binds to this hash:
keel --version reports keel 0.14.1+468a749d0430 [release].

Install

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

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

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

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

Configure

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

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

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

Other changes

docs(research): the Alpaca options venue reality, marked doc-only where it is (#636) (#753)

Discovery for #636. No code, no rail change, no config — one document under docs/research/.

It does not close #636, and says so first

#636 forbids deciding from documentation alone (#412's lesson). This session holds no Alpaca credential, so everything here is documentation, and every finding carries a mark: [DOC] quoted from Alpaca, [DOC-GAP] unanswered, [KEEL] verified in this source tree, [ARITH] derived with the arithmetic shown.

The five findings that matter

F1 — the load-bearing question is undocumented. Alpaca offers no cash accounts ("All accounts are set up as margin accounts"); keel already handles that with the multiplier == 1 proxy via max_margin_multiplier. Whether options approval survives that setting is stated nowhere. If enabling options forces the multiplier above 1, verify_cash_account raises and the adapter stops building — options would be a destructive change to the equities trading that already ships.

F5 — rail 19 already refuses an OCC symbol, and says why. SPY250127C00608000 has no hyphen, so parse_spot_product_id returns None. The rail runs in every mode, both sides, DCA included — deliberately outside LIVE_STATE_RAILS, so paper cannot skip it: there is no rehearsal path. No config widens it, and the comment says "Spot-only is this agent's CHARTER, not an operator preference." Options aren't blocked by a missing feature; they're blocked by the rail that encodes the constitution. That sizes the amendment #637 is considering.

F3 — the venue disposes of inventory by itself. ITM contracts auto-exercise at $0.01, and where buying power is short, "Alpaca will sell-out the position within 1 hour before expiry." F2 — assignment arrives by polling only: "Options assignments are not delivered through websocket events." It is also the first inventory event with no originating keel order.

F6 — the fees are negligible, and that is the trap. Round trip ≈ $0.084/contract; a cash-secured put's opening leg is 4.6 bp of premium. The real cost is the bid-ask spread, which no document states. Arguing this ADR from the fee schedule would repeat exactly the error 2026-09-05-restatement-restated.md corrected for crypto — where the flat 5bp floor turned out to be 52.3bp median and the verdict went to 0 of 240.

F7 — unexecutable today regardless. A cash-secured put is collateralised at strike × 100, unfractionalisable: $1,800 for the cheapest plausible underlying against config.live.yaml's budget_usd: 50. ~36× the deployment's clip — decidable with no jurisprudence at all.

One note on method

The fee table is read from the PDF directly (schedule revised 2026-09-01) because an automated extraction of that same file reported OCC at $0.65 against an actual $0.025. The doc-only hazard #636 warns about reproduced itself inside this pass, so it's recorded in the document.

What's left

Paper accounts get Level 3 automatically, so the mechanical half (F2's activity records, F4's multi-leg payload, real bid-ask on touchable strikes) is verifiable free and needs no options agreement. But paper grants Level 3 to everyone, so it can never answer F1 — that needs Alpaca support in writing, or a live application. The document marks which half is which.

fix(web): the nav may not say where you are until the rows agree (#754) (#757)

Closes #754.

show() set document.title and aria-current="page", then awaited the read. For the length of the fetch every sighted signal said "you are on Balances" over Positions' rows — a stale figure under a fresh label, the one claim _session_banner refuses to make and the balances tiles carry an as-of stamp to prevent.

The claim waits for the DOM that backs it

commitNavigation is now the single place that says "you are here" — tab title and aria-current together — and paint calls it after rebuildInto, on all three branches. A stopped view and a refused view are outcomes of this navigation too; leaving the nav on the previous route while the content reports this one is the same mismatch in different clothes.

A click is still acknowledged, with a weaker claim

Deferring aria-current removes every immediate signal from a click, which on a slow route reads as a dead link and invites a second one. data-pending is that signal, and it is deliberately not aria-current: dotted rather than solid, muted rather than foreground. A pending link that looked like the current one would restore exactly the confusion the deferral removes.

No is_navigating flag, because one already exists

aria-busy is raised in exactly one place and always was — show, plus index.html's initial value for the first paint. Neither the 15-second poll nor an SSE repaint raises it, so the visual hangs off it directly; a second flag would be a redundant statement of one fact. Pinned by counting the assignments rather than locating one.

Content dims rather than clears — clearing costs a layout jump and claims more than is true — and takes pointer-events: none, because acting on a figure about to be replaced is the failure mode of a dimmed view left live. The bar is indeterminate: the server sends one response, not a stream, and a percentage would be a claim about something nothing counts.

Two of my own tests were vacuous, and mutation is what found them

The first show() assertion failed against a correct implementation — the new comment explains that it no longer sets aria-current, and a raw substring scan cannot tell an explanation from an assignment. A test a docstring can break is one a docstring can satisfy.

Worse: the CSS assertion was "aria-busy" in _CSS, which survived renaming the selector to #content[data-nope="true"], because the comment above the rule still said the word. Both scans now strip comments first and assert the selector.

Four mutants killed — commit-before-swap, renamed busy selector, dropped pending rule, removed keyframes.

Reduced motion needed no new rule

The blanket query — written before there was any animation to honour — already covers keel-busy, flattening it to a static bar rather than removing the signal. Its comment claimed the file had no animation; updated to say what it now does.

Checks

769 passed (tests/web/ + test_console_thinness.py), ruff clean.

Unchanged and listed in the module docstring: no test here proves the dim is perceptible, that a pending link reads as pending, or that any browser honours the query. Those are hand checks against a running keel serve.

feat(web): the deployment card — the badge's tooltip, reachable without a pointer (#755) (#758)

Closes #755.

modeBadge writes the identifying --db/--config pair into a title, and title is a hover tooltip. There is no hover on a phone — the device #648/#656 exist for — so the half of the badge that actually identifies a deployment was unreachable exactly where the terminal is least available.

The mode word identifies nothing on its own: two paper deployments both say paper, and a live sandbox and a paper-equities profile can both say confirm.

┌──────────────────────────────────────────────┐
│  deployment   keel-live                      │
│  mode         confirm                        │
│  config       config.live-sandbox.yaml       │
│  database     keel-live.db                   │
│  origin       http://127.0.0.1:8765          │
│  ──────────────────────────────────────────  │
│  Mode and profile switching are terminal     │
│  ceremonies with a runbook. This console     │
│  reports the deployment it was pointed at    │
│  and cannot change it.                       │
└──────────────────────────────────────────────┘

A native disclosure, which is what makes it safe

The badge is now the <summary> of a <details> — keyboard-operable, touch-operable and announced, with no handler to bind and no button to build. That is what lets deploymentCard join sessionChip and paperBanner in test_neither_the_chip_nor_the_banner_builds_anything_clickable rather than be exempted from it. venueCard already uses the element the same way.

That matters more than convenience: this is the surface where a reader looks for the control, which makes it the likeliest place for one to appear later. A "switch to live" affordance arriving through a door marked accessibility is still the growth funnel the chip's docstring refuses.

The card says so in words too, and the test pins the sentence's content — refusing four invitation wordings — because a reference-only assertion would pass over a note reworded into a CTA.

Tags changed, layout did not

<span class="right"> and <span id="session-chip"> are <div>s now: <details> is flow content and a <span> may hold only phrasing content. Both already set their own display: inline-flex, so nothing moves. The popover is positioned out of flow — opening it must not reflow the header, which on a narrow screen would push the nav under the reader's finger mid-tap.

title is kept, per the issue: it works for keyboard and pointer and costs nothing.

Two existing scans caught me

  • SWITCHING_NOTE was spelled with +, which test_render_contains_no_arithmetic forbids in render.js — concatenation is .concat throughout that file so the derivation-free scan can stay simple.
  • The header test pins the badge's tag, so <summary> failed it. Updated with the reason: what it protects — the badge exists carrying that id and both classes, which modeBadge and header .mode:empty key on — is unchanged.

Registered in _CONFIG_READERS in the same commit that adds it, which is the rule that table states.

Checks

6,319 passed / 3 skipped, ruff clean. Three mutants killed: card built but never mounted, refusal reworded as an invitation, db path dropped.

Not proven here: that the popover is legible on a real phone, or that a tap opens it. Hand checks against a running keel serve.

feat(cli): keel open — the way back into a console nobody watched start (#756) (#759)

Step 3 of #756. Step 4 — the four com.keel.serve.*.plist files — is unusable without it, which is why it comes first.

$ keel open --port 8765
Opening the keel console at:

    http://127.0.0.1:8765/?token=ab12...

This address carries the running server's session token. Stopping keel revokes it.

⚠️ This writes a session token to disk. Read this part.

security.py layer 3 rests on the token being ephemeral, and serve prints the claim every run: "the token is new every run and is never written to disk." A lockfile makes that sentence false. So it is written under exactly one condition:

The record exists only when the URL is not being shown to a human.

sys.stdout.isatty() decides.

  • Attached to a terminal — nothing is written, the posture is byte-for-byte what it was, and the sentence is still printed because it is still true.
  • Detached (launchd, a pipe) — a different sentence is printed, naming the file, its mode and keel open. Telling an operator their live credential is not on disk while writing it there is the class of false safety assurance _session_banner exists to refuse.

The argument is not that persisting a token is fine. It is that on the detached path the token is already in StandardOutPath at the daemon's umask. The real choice is between that log line and a 0600 file in a 0700 directory, deleted on shutdown and checked for staleness before it is offered. The second is strictly less exposure, and it is the only one that makes unattended serve usable.

security.py layer 3 now says which of the two runs it is describing.

If you'd rather have uniform behaviour — always write, or never write and drop keel open — that is a one-line change and I'd rather you made it deliberately than inherited mine.

The file is a hint, never the authority

forget covers the clean stop; a SIGKILL or power cut does not. live_record re-checks the pid with signal 0 before offering anything — handing over a URL that refuses the browser reads as keel being broken rather than stopped. pid <= 0 is refused before the call, because os.kill(0, 0) signals the process group and would report a bad record as live.

Written through a temporary and renamed, so no reader sees a half-written record. Corrupt JSON reads as absent rather than raising. One file per port — #756 counts four deployments, and one shared file would make the last server to start the only reachable one. The token is never in the filename: a listing is readable by anything that can list the directory, even when the file is not.

Three absences, three sentences

No server, a server started from a terminal, and a server that was killed look identical from here. "not found" would send an operator hunting for a bug in the two cases where nothing is wrong, so each gets its own message — and the interactive one names the terminal that has the URL.

Checks

6,352 passed / 3 skipped, ruff and mypy clean. Four mutants killed: record written while a human watches, record never forgotten, liveness check skipped, file at 0644.

Not covered here: that launchd actually produces a non-tty stdout on your machine. That is step 4's first observation.

feat(ops): four console daemons, one per profile, surviving reboot and crash (#756) (#760)

Step 4 of #756, and the last piece of the request. Only usable because #759 shipped keel open first.

profile plist port config + database
supervised-live com.keel.serve.live 8765 config.live-sandbox.yaml + keel-live.db
paper-forward com.keel.serve.paperforward 8766 config.paperforward.yaml + keel.db
paper-hourly com.keel.serve.paper-hourly 8767 config.paper-hourly.yaml + keel-paperhourly.db
paper-equities com.keel.serve.paper-equities 8768 config.paper-equities.yaml + keel-equities.db
cd ~/keel
for job in live paperforward paper-hourly paper-equities; do
  cp "com.keel.serve.$job.plist" ~/Library/LaunchAgents/
  launchctl bootstrap "gui/$(id -u)" ~/Library/LaunchAgents/"com.keel.serve.$job.plist"
done
cd ~/keel && ./.venv/bin/keel open --port 8765

The decisions worth reviewing

Ports are pinned, and that is not cosmetic. Unpinned, all four ask for 8765 and three die on a bind error — which from outside looks like a missing console rather than a clash. Asserted per file and across the table, because a collision is invisible to a per-file check.

Every daemon goes through its wrapper, never keel directly. --db defaults to keel.db, so a plist spelling its own arguments could serve the paper ledger under the live profile's name, convincingly. The wrappers pin config to database as a pair.

RunAtLoad and KeepAlive are two promises. Reboot and crash are different failures. A file carrying only the first is the shape that looks right until something dies at 3am. The cost is stated in the plists rather than discovered: a server that cannot bind exits non-zero at once and launchd retries forever on its throttle.

Separate jobs from the four detector agents, deliberately — a console that crashed must not take the trading agent with it, and an agent mid-cycle must not restart because a browser tab died.

Two things that bit me

The double hyphen, exactly where its own note says it will. XML forbids -- inside a comment. My first draft documented the port and database options by name in the prose and all four files failed to parse. That is the hazard com.keel.live.plist's style note already records.

My own test tripped on prose, in reverse. test_no_plist_carries_a_token forbade the substring "token" and failed on the plists' own explanation of what the session token is. It asserts over parsed values now: no EnvironmentVariables at all, and no string shaped like a 32-byte secret.

The runbook was making a claim that is no longer true

"The token is never written to disk" held until #756 made it conditional, and test_the_section_documents_the_one_time_token pinned that exact sentence. Both now draw the distinction. A runbook promising an operator their live credential is nowhere on disk, while a file holds it, is worse than one that says which run is which.

Checks

6,394 passed / 3 skipped, ruff clean. Four mutants killed: shared port, KeepAlive removed, wrong wrapper, browser launch left on.

Not proven here, and it is the assumption everything rests on: that a launchd-started process gets a stdout that is not a tty. That is one observation against a real daemon, and it is the first thing to check after installing these — if it were false, the daemons would run but keel open would find no record.

chore(cli): one name for one idea — --no-browser on serve and open alike (#761)

Finding 2 from the #760 review, decided: --no-browser on both commands.

keel open --no-open is a sentence that argues with itself, and --no-browser is what every comparable local daemon already uses (jupyter notebook, tensorboard) — so it's the one an operator guesses correctly without reading anything.

The old spelling still parses, and is no longer advertised

--open/--no-open survives as a hidden alias on serve. Not politeness: the four com.keel.serve.*.plist files shipped with --no-open in them and one may already sit in ~/Library/LaunchAgents. Under KeepAlive, an unknown option is not an error message anybody reads — it is a crash loop retried every ten seconds forever. The plists in this repo move to --no-browser; an installed copy keeps working either way.

default=None on the alias, so "not given" is distinguishable from "given as false". With default=False it would silently override the real flag on every invocation that omitted it — that's the first mutant below, and it passes every structural test in the file.

keel open does not gain the alias. Nothing ever shipped that spelling there, so there's no script to keep working, and adding it for symmetry would preserve the contradiction this removes.

The structural tests prove the names and nothing else

Five of them read the commands' declared options — that says the flags exist and nothing about what they do. A --no-browser that parsed and then opened a browser anyway passes all five, and on a launchd daemon at boot that is a window nobody asked for on a machine that may have no GUI session.

So the sixth runs both spellings through the real command and asserts no launch, then asserts the default still launches — otherwise the first two assertions are vacuous.

One existing call site in test_serve_command.py keeps --no-open deliberately and now says so: it exercises the alias end to end from a test that wasn't written for it, which is the cheapest regression cover there is.

Checks

6,402 passed / 3 skipped, ruff and mypy clean. Three mutants killed, each verified to have actually applied first: alias overriding unconditionally, --no-browser parsed and ignored, alias removed outright.

docs(ops): the install block copied from the wrong directory, and skipped a wrapper (#762)

Reported from a real install of #760's daemons: four cp: No such file or directory, then four Bootstrap failed: 5: Input/output error.

It told the operator to copy files from where they are not

The block opened cd ~/keel and copied the plists from there. These files are authored in the dev repo and deployed by copying, exactly as keel-live's own header says of itself — so on a deployment that has not already been through this, which is every deployment the first time, there is nothing to copy.

And keel-equities had never been deployed at all

The equities detector runs paper-equities-run.sh, so nothing needed that wrapper until the console plist pointed at it. ~/keel had keel-live, keel-paper and keel-paperhourly — not the fourth. A plist naming an absent program is a KeepAlive crash loop, not a message anybody reads.

test_each_serves_its_own_deployment_through_its_own_wrapper asserts the wrapper exists in this repository, which it always does — the wrappers are authored here. Whether it exists in ~/keel is what matters at runtime, and no test can look: the deployment is outside the repo and differs per machine. What is checkable is that the documented install step copies each one, so that is what the new test asserts.

Also added: free the ports first. An interactive keel serve already on 8765 is precisely the bind failure the plists' own comments describe, and it was the state of the machine that hit this.

Three wrong tests before a right one, all the same shape

  1. The block locator matched launchctl bootstrap — and found the detector install block earlier in the file.
  2. Anchored on com.keel.serve instead, it matched two blocks: the install and the bootout. Both anchors together select one.
  3. Then the wrapper assertion survived deleting keel-equities from the loop, because the block's own comment explains why that wrapper matters and the scan could not tell prose from a command. It strips # comments now.

That is the third instance this session of a scan answered by its own explanation, and the third found only by mutation.

Checks

6,412 passed / 3 skipped, ruff clean. Two mutants killed: the wrapper dropped from the loop, and the block returned to copying from the deployment.

fix(cli): keel open says WHERE it looked, because that was the missing fact (#763)

Found by an install that worked. Four console daemons bootstrapped, three wrote their records, and keel open --port 8765 answered "no recorded keel server" — so a success read as a failure.

The command had been run from a source checkout, which is itself a deployment root (is_deployment_root is deliberately generous, and the tree has a keel.db and a config.yaml). So it searched the checkout's run/ while the servers wrote to ~/keel/run/.

The message listed three explanations and the true one was not among them. It could not be — it never said where it had looked.

After

Error: no recorded keel server on port 8765.

  Looked in /Users/…/CodeGate/keel/.claude/worktrees/open-says-where/run, which is the
  deployment this directory belongs to. If your server is a different deployment, run this
  from ITS directory -- `keel open` takes the same bare-invocation path as everything else,
  so a source checkout resolves to the checkout.

  If one is running here, it was started from a terminal …

keel open takes no --db and no --config, so nothing in its signature hints that the answer depends on the working directory. The path is the hint, and --help now says so in one line.

The stale-record branch gets it too — a crashed server and a wrong directory are different problems, and an operator staring at either needs the same fact to tell them apart.

Checks

6,413 passed / 3 skipped, ruff and mypy clean. Three mutants killed: the path dropped from each refusal branch, and the help line removed. Verified by running the command from the wrong tree and reading what it prints.

One thing worth recording for #756

launchd does give keel serve a stdout that is not a tty. Three daemons, three records written. That was the single assumption the whole keel open design rests on and that nothing in CI could test.

docs(decisions): draft ADR 0005 — options, with the verdict line unsigned (#637) (#764)

Drafts #637's record. The verdict line is unsigned — signing is striking one option, keeping the other, and setting Status. Nothing else needs editing.

#637's sequencing is satisfied: #370 landed the equities path, and #636's discovery closed with its findings in #753. The record is due.

Why I didn't write the verdict

An ADR is a standing rule, and this one turns on whether a cash-secured option is admissible — gharar, the character of the premium, whether full collateralisation changes the contract. Constitution line 5 ("every attestation is human-sourced, or refused") means no amount of documentation reading settles that, and a machine drafting the verdict would be the exact failure that line names.

The record says so in its own section rather than leaving the omission to be noticed.

Three findings decide the near term without reaching that question

Which is why option A is drafted as "not now, on operational grounds, question expressly not reached" — so signing it commits the project to no jurisprudential position it hasn't taken.

  • F7 — the capital floor. A cash-secured put is collateralised at strike × 100, unfractionalisable: $1,800 for the cheapest plausible underlying against config.live.yaml's budget_usd: 50. ~36× the deployment's clip — no compliant position is executable at this size, whatever anyone concludes about permissibility.
  • F5 — rail 19 is the charter. It vetoes an OCC symbol in every mode including paper, deliberately outside LIVE_STATE_RAILS, so there is no rehearsal path. Its own comment: "Spot-only is this agent's CHARTER, not an operator preference."
  • F1 — the venue posture is undocumented, and the downside is asymmetric. Whether options approval survives max_margin_multiplier: 1 is stated nowhere. If it doesn't, verify_cash_account stops the adapter building — breaking the equities path that already ships.

F3 is flagged as deserving argument rather than triage: the venue auto-exercises, liquidates within an hour of expiry when buying power is short, and reports assignment by polling only. That's a different relationship to inventory than keel has accepted anywhere.

Reopening triggers

Four, in ADR 0002's style, each a fact rather than a feeling. 1–3 are necessary together — deployment size, F1 answered on a live account (where a "no" closes it permanently, not provisionally), and the permissibility question answered by a human with sources.

Checks

Every citation verified against this tree — adapter.py:262, guards.py:905, config.live.yaml:82, and the research document. A script asserts every relative link in the record resolves: an ADR citing a path that moved is worse than one citing nothing, because it reads as verified.

6,424 passed / 3 skipped.

docs(decisions): sign ADR 0005 — keel does not trade options (#637) (#765)

Closes #637. The maintainer signed Option A; Status: decided.

What was decided, and what deliberately was not

keel does not trade options. Rail 19, the cash rail and the screen are unamended. The refusal rests on operational grounds — unexecutable at this deployment's size (F7), no rehearsal path because rail 19 runs in paper too (F5), and a venue posture that may be incompatible and would take the working equities path with it (F1).

The permissibility question is expressly not reached, and Consequences now says so in the strongest terms available:

These are reasons that would hold for a non-Muslim operator running keel on the same account. Anyone citing this record as keel's jurisprudential answer on options is citing it wrongly — trigger 3 is what would produce that answer.

Both alternatives stay in the record

Option B is not deleted. A decision record showing only the chosen branch records an outcome, not a decision — the reader two years from now needs to see what was weighed. The Decision section names B explicitly as the road not taken.

Two formatting faults caught while signing

  • The signed title wrapped onto a second line, which markdown renders as an H1 plus a stray paragraph
  • "What this record refuses to do" pointed at "Option A below" — a label that stopped existing the moment the verdict replaced the two branches

Both would have shipped as small lies about the document's own shape.

Checks

A script asserts every relative link resolves and that all three options remain recorded. 6,424 passed / 3 skipped.

docs: stop offering a console deleted at #541, and pin the rule (#766)

keel tui went at #541 with ~10k lines of curses. The docs kept sending operators to it, in the present tense, for months. Five stale claims, and one of them was doubly false.

File Was
README.md "the browser view and the TUI answer" — listed as a current way to read keel
docs/glossary.md "the TUI's Help menu renders this file directly"
docs/mcp-server.md two present-tense references
docs/operator-runbook.md:215 "the Account menu's update entry in the TUI console (see 'The TUI console' for the ceremony)"

The glossary was wrong twice over. The Help menu went at #541 and render_glossary went at #540 — so the file's entire claim to authority ("defined HERE and nowhere else, because something renders it") had been hollow for two releases. It now says what's true: since #539 a term becomes an outbound link to the published copy, built from TERMS in web/static/js/docs.js — so a term added here isn't linked until it's added there too.

The runbook line was worse than stale: it cross-referenced a section "The TUI console" that doesn't exist, telling an operator to use a menu that doesn't exist. keel update has one front-end now; it had two until #541. (The browser's "A newer build is ready" button reloads the service worker — it does not replace the binary.)

None of this was catchable. Every statement was prose, and prose has no compiler.

The rule permits the history

A retired surface may be named, provided the paragraph naming it cites the issue that retired it. Mention it, date it.

A repo that scrubbed its own deletions would be worse off than one carrying a stale line — keel update really did have two front-ends, and the runbook explaining why it now has one is doing its job. So all six remaining mentions stay; each is now dated.

Per paragraph, not per line

The first cut required the line naming the surface to cite the issue, and failed on a sentence whose wrap put ​`keel tui`, a command that no longer exists two lines below its own "Until #541".

Prose wraps at 100 characters here, so a line boundary carries no meaning and a rule keyed to one is a rule about the reflow — the same trap that broke a runbook block locator earlier in this series when its introducing sentence was reworded. A paragraph is the unit a reader takes the claim from.

The scan also asserts its own glob still matches the four documents it names; a glob matching nothing would pass over any wording at all.

Checks

6,426 passed / 3 skipped, ruff clean. Two mutants killed: the README offering the TUI again, and the doc glob going blind.

docs(readme): drop an ordinal that counted the deleted console (#767)

Follow-up to #766. The review finding on that PR was pushed to its branch after it merged, so it never reached main — #766 merged at 761a04d, the fix was b74bdcc. This carries it over unchanged.

The finding

README.md said "There is also a fourth way to read all of this: keel mcp…".

git log -S "fourth way to read" puts that phrase at #600, in the same sentence as "the browser view and the TUI answer" — so the TUI was one of the three it counted from. The count has been wrong since #541 deleted the command, and #766 removing and the TUI from the next clause took away the last visible trace of what it referred to.

That left a reader an ordinal with no recoverable referent — worse than before, in exactly the way #766 was about.

Dropped, not renumbered

"Third" would rot the same way the moment a surface is added or retired, and nothing in the README enumerates the set, so no number in that position can be checked by the person reading it. The sentence now just introduces keel mcp.

Also carried over

RETIRED entries must be distinctive tokens, as a note in tests/test_retired_surfaces.py. The match is \b<surface>\b over whole documents — safe for tui, useless for something like api, which would fire on every page and lead the next author to loosen the rule rather than fix a document. The note says to match the command form (keel <name>) when the name is common.

Checks

README.ar.md carries no equivalent claim, and no other a <ordinal> way survives in the README. 6,426 passed / 3 skipped, ruff clean.

docs(presentations): a walkthrough for senior engineers, structured a… (#768)

…s one order's journey

Two files. se.md is the deck: stack, ports-and-adapters topology, the three invariants (fail-closed, ruling-as-data, Decimal-only), and a source walkthrough that follows a single hypothetical BUY through seven files in the order the code touches them -- a narrative rather than a file tour. se-speaker-notes.md is the presenter's copy: per-section timing, a marked 25-minute cut, which files to have open, and the questions this audience actually asks, with answers.

Two things are on the slides rather than hidden in the notes, because a senior room finds them anyway and claiming otherwise costs the whole talk: the broker-port migration is unfinished (_common.py still constructs CoinbaseClient directly, so the live path cannot reach another adapter), and commands/ is 23,188 lines -- larger than the engine it drives. Both are also in the open-problems section, which is the point of presenting to engineers at all.

Testing leads with the failure rather than the counts: two defects produced plausible, internally consistent, wrong output for the life of the project while 2,712 tests passed. Figures used are the verifiable ones (766 test functions across 41 top-level files) rather than a round number that cannot be sourced.

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

What & why

Tests-first evidence

  • Tests written first, seen failing for the right reason

Gates (all must pass)

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

Scope check

  • This PR touches a rail or a default classification — checked means it DOES;
    leave checked only if true, and if so: cite the source and open the discussion
    BEFORE review (CONTRIBUTING.md, "Governance: rulings vs. machinery").
  • New dependency added (needs discussion first)

docs(readme): the map omitted three documents the website publishes (#769)

The Documentation map lists nine documents. engine-docs.manifest.json in keeltrading.com mirrors nineteen — and three of the gaps are what a reader arriving at the repository would want most.

Added Why it matters
docs/decisions/ Five standing rules, each naming what would reopen it — including 0005, signed this week, which refuses options on operational grounds and states that the permissibility question is not reached. The site publishes 0001–0004 as its own decisions section; the README mentioned none of them.
docs/architecture.md The site's own summary calls it "the system as verified, not as imagined". Unlinked here.
docs/glossary.md The single source for the vocabulary — and the file whose authority rests on being linked rather than restated, so a README that doesn't link it is part of the problem it names.

The README was strictly less complete than the site built from it, which is the wrong way round: the repository is the source, the site is the mirror.

A check, not just an edit

Every docs/... target the map names is asserted to exist. A map is worth less than nothing when it points at something that moved — it reads as verified.

Checks

6,426 passed / 3 skipped, ruff clean.

docs(readme): the mark, centred, above the title — in both languages (#770)

<p align="center">
  <img src="keel/web/static/icons/keel.svg" alt="keel" width="96" height="96">
</p>

The same 743-byte vector the app ships as its icon — so the README and the running console show one mark, not two that could drift.

Why the SVG and not the PNG

It carries its own background — a teal #0c5d52 rounded square with off-white strokes — so it renders on GitHub's light theme and its dark theme alike. A mark relying on the page behind it would vanish on one of them, which is the exact failure index.html already records for the favicon: that file is a fixed raster of the light theme, and the header inlines the geometry instead so it can wear each theme's own colours.

Why a relative path is safe here

pyproject.toml sets readme = "README.md", so the file becomes wheel metadata — and relative image paths break wherever that metadata is rendered. I checked before choosing: release.yml publishes wheels to the GitHub Release only, with no PyPI upload step anywhere, so nothing renders this README off GitHub.

An absolute raw.githubusercontent URL would have pinned a branch name into a file that outlives branches, for no benefit.

Both READMEs

README.ar.md gets it too — one of the two isn't "the readme". Placed before its <div dir="rtl"> rather than inside: a centred mark is direction-neutral, and keeping it outside means the RTL context begins exactly where the Arabic text does.

Checks

Asserts the referenced file exists, the alt text is non-empty, and the mark has a <rect … fill=> of its own. 6,426 passed / 3 skipped.

docs(readme): the architecture diagram was missing two venues, and three other claims (#771)

You spotted Alpaca missing. Checking the rest of that block found three more wrong claims in the same eighteen lines.

Claim Reality
keel-broker-alpaca absent Shipped — and docs/RELEASING.md puts it in the production install set: a deployment installs five wheels and that is one of them
keel-broker-kraken absent Shipped (port-complete stub, #313)
~3,000 tests The suite passed 6,000 some time ago
keel/ tree omits web/, mcp/, research/ The README's own opening paragraph advertises keel mcp and the browser console; the diagram underneath showed neither

The test count is now absent rather than approximate

~3,000 was wrong by more than double. A count in prose is a promise the suite breaks every time it grows — the honest options were an untested number or no number, and no number cannot be wrong.

And I changed a correct number to a wrong one

The block said "the 21 rails". I made it 22, taking the highest rail number in guards.py. tests/execution/test_rail_count.py failed, and was right to: rail 15 was retired, so the rails number up to 22 and there are twenty-one. Restored to 21.

That test already derives the count from guards.check and searches every English and numeric spelling repo-wide — it exists because this exact drift happened three times. My first cut of the new file added a second rail check that disagreed with it, which is precisely how a number goes wrong: two sources of truth for one fact. Removed; that test keeps sole ownership.

What the new file checks — the part nothing owned

  • every directory under packages/ appears in the diagram
  • no package is named that doesn't exist
  • no hardcoded test count creeps back

Four mutants killed, including dropping alpaca — the bug as reported.

Checks

6,429 passed / 3 skipped, ruff clean.

chore(release): 0.14.1 — keel open, four console daemons, and a signed ADR (#772)

Version bump only. Sixteen PRs since 0.14.0.

⚠️ One decision to make before merging: is this a patch?

This release contains three feat: commits, one of them a brand-new command (keel open, #759) plus four launchd daemons (#760). Under conventional semver that is a minor bump — 0.15.0 — not a patch.

docs/RELEASING.md states no versioning policy beyond "the input must be semver", so I used the number you asked for. Changing it is one line here plus the workflow input, and it is far cheaper to change now than after the tag exists.

No schema migration this time

SCHEMA_VERSION is 20 at v0.14.0 and 20 now. Unlike 0.14.0 — which carried four steps and led with that fact — an upgrade here needs no keel migrate. Said explicitly because the previous notes made the opposite warning prominent, and silence would read as an omission rather than an absence.

What an operator gets

keel open — the way back into a console nobody watched start. A detached keel serve records its address in <deployment>/run/serve-<port>.json at mode 0600, deleted on shutdown; an interactive one still records nothing, so the posture security.py describes is unchanged wherever a human can read the URL.

Four com.keel.serve.*.plist daemons, one per profile on 8765–8768, RunAtLoad + KeepAlive — observed on the deployment surviving kill -9, with the record atomically replaced and a fresh token handed back.

--no-browser on both commands (#761), with --open/--no-open kept as a hidden alias — the shipped plists carry it, and under KeepAlive an unknown option is a crash loop rather than a message anyone reads.

Two console bugs found by running v0.14.0 rather than testing it: navigation showed the previous view's figures under the new view's label (#754), and the deployment identity was a hover tooltip — unreachable on the phone Phase B is built for (#755). Plus keel mcp's doctor raising on an un-migrated database (#751).

And one decision

ADR 0005 is signed: keel does not trade options, refused on operational grounds, permissibility question expressly not reached. This release is what publishes it — keeltrading.com's manifest resolves latest-release, so the record cannot be mirrored until a tag contains it.

Checks

All eight editable entries in uv.lock at 0.14.1; uv lock --check clean (release.yml runs it first). 6,430 passed / 3 skipped, ruff and mypy clean.

After merge: Actions → Release → Run workflow, entering 0.14.1 (or whatever number you settle on above), and choosing the desktop input.