Skip to content

keel v0.8.0

Choose a tag to compare

@github-actions github-actions released this 17 Aug 10:37
· 62 commits to main since this release
a37997f

Built from a37997f. Version binds to this hash:
keel --version reports keel 0.8.0+a37997f48cf7 [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.8.0-py3-none-any.whl
keel versions

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

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

Configure

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

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

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

Other changes

ci: gate on mypy, so #266's ungating cannot quietly come undone (#268)

mypy ran in no workflow before this — it appeared only in a passing comment in code-quality.yml. #266 brought keel.* under the checker, but nothing enforced it: a type error in keel/ was a clean CI run.

Why a step, not a job

Added to the existing test job rather than as a typecheck: job of its own. The main ruleset requires the status context test, which comes from that job's id — a separate job would report a context nothing requires, so a red mypy would not block a merge. That is the same failure shape ci.yml's own header already warns about for renamed jobs. As a step, it inherits the gate.

release.yml gets it too, and deliberately re-runs it rather than trusting CI's result: that workflow is dispatched against whatever main is at the time, which need not be a commit any CI run went green on.

Neither workflow repeats the paths — they come from [tool.mypy]'s files in pyproject.toml, so a package moving in or out of the checked set stays a one-line edit there instead of one mirrored across two workflows.

CI only closes half the hole

Running mypy catches a type error in keel/. It cannot catch keel.* being re-added to an ignore_errors override — that silences the package wholesale, mypy exits 0 while checking nothing there, and #266's ungating is reverted with a green build to show for it.

test_keel_is_not_exempt_from_type_checking closes that. Both halves verified:

scenario mypy exit suite
type error injected into keel/sim/report.py 1 (fails CI)
keel.* restored to ignore_errors 0 (CI green) new test fails
neither 0 green

The middle row is the point: the guard catches exactly what CI cannot see.

tests.* and keel_core.* remain legitimately exempt, per the comments beside each in pyproject.toml. The guard pins only the module that was deliberately brought under the checker.

Verification

  • mypy — clean, 224 source files
  • ruff check keel tests packages — clean
  • pytest2727 passed, 1 skipped (2726 + the new guard)

fix(data): chunk poll_once's catch-up request under Coinbase's candle cap (#269)

The bug

poll_once requested its entire catch-up range in a single get_candles call:

fetch_start = last_ts + gran_sec if last_ts is not None else latest_closed
fetched = client.get_candles(product_id, granularity, fetch_start, latest_closed)

Coinbase rejects any range over ~350 candles:

400 INVALID_ARGUMENT ... "number of candles requested should be less than 350"

Why it can't recover on its own

The request size is a function of how stale the product is, and staleness only grows while the poll is failing. Every subsequent poll therefore asks for a strictly larger range and fails the same way — there is no path back to a legal request without intervention.

The live instance: ZEC-USD at ONE_HOUR sat 552 hours stale, i.e. a ~552-candle request, and had been failing hourly for two days, widening by ~24 candles a day. The other 18 products were current, so nothing else masked or explained it.

Note the empty-repo case was never affected: with last_ts is None, fetch_start = latest_closed, so exactly one candle is requested. Wedging requires cached-but-stale candles.

The fix

poll_once now pages [fetch_start, latest_closed] forward in windows of at most MAX_CANDLES_PER_REQUEST candles, importing the existing constant from keel.data.history rather than declaring a second one — a duplicated venue limit is the same bug class being fixed here.

The new _poll_catch_up helper mirrors history._fill_forward's windowing idiom so the two read the same way, with two deliberate differences:

  • windows are sized (MAX - 1) * gran_sec, so the inclusive [start, end] range is at most MAX candles (_fill_forward's + MAX * step is one over, harmless under the ~350 cap but not a property worth copying);
  • an empty window does not break the loop, since a mid-history hole must not block catch-up of newer candles.

Each window is upserted as it arrives, so a failure partway through leaves the earlier windows persisted and the next poll resumes further along instead of restarting.

cb_client.get_candles is deliberately unchanged — it correctly passes start/end through, and batching belongs in the caller.

Preserved exactly: the small-range path (still a single request), the empty-repo path (still one candle at latest_closed), the last_ts >= latest_closed no-op, and the return value's meaning.

Tests (written first, confirmed failing for the right reason)

Against the old code the three new chunking tests fail on the oversized single call — assert 552 <= 300 and assert 1 > 1 — not on any import or fixture error:

FAILED test_poll_once_chunks_a_gap_larger_than_the_coinbase_cap
FAILED test_poll_once_never_requests_more_than_the_candle_cap
FAILED test_poll_once_chunk_windows_are_contiguous_and_non_overlapping
3 failed, 12 passed

They reproduce the real 552-hour ZEC-USD gap and assert that catch-up issues multiple calls and persists the complete contiguous series, that no request exceeds MAX_CANDLES_PER_REQUEST candles (asserted on the start/end args the fake client actually received), and that the windows tile the range exactly — no duplicate and no gap at a chunk boundary. A fourth test guards that a small gap still takes exactly one request.

Gates

…truncated — full description in #269.

fix(compliance): stop discovery hiding assets the gate would admit (#270)

The problem

keel assets discover was silently dropping assets that clear the admission gate comfortably, and its own output gave an operator no way to notice. Two defects, one symptom.

(a) A one-day statistic compared against a multi-year threshold

--min-volume-24h defaulted to 1,000,000, pinned EQUAL to the admission floor ScreenPolicy.min_median_daily_volume.

That pinning (2026-08-08, superseding a 5,000,000 floor that had hidden FET at $2.94M/24h while it measured 4.8x the admission floor) had the right intent: a pre-filter must never be stricter than the criterion it screens for. It had the wrong mechanism. The two numbers are not the same statistic:

statistic window
discovery --min-volume-24h venue's reported quote_24h_volume a single 24-hour snapshot
admission min_median_daily_volume median of volume x close all cached history

Equal numbers cannot make one non-stricter than the other when the two sides measure different things — one quiet trading day pushes the snapshot below a floor the asset's own median clears many times over.

Measured 2026-08-15, five assets were silently dropped whose real gate statistic sits far above the admission floor:

asset median daily quote volume vs admission floor
ATOM 3,077,474 3.08x
AAVE 6,315,463 6.32x
BCH 5,464,940 5.46x
CRV 3,329,753 3.33x
ALGO 3,780,207 3.78x

Four of the five had 24h volumes clustered between 852,133 and 979,000 on that one quiet day — comfortably under the 1,000,000 floor.

The discovery floor now sits an order of magnitude below the admission floor (100,000), leaving real room beneath that measured cluster. This supersedes the 2026-08-08 pinning without abandoning its goal: the test that asserted the two floors are equal now asserts discovery's is strictly less than the gate's, which is the property the original fix was reaching for. The admission floor itself is untouched — it is the real criterion.

(b) Nothing recorded what was excluded

discover_candidates dropped products with a bare continue and returned only survivors; the CLI printed only N venue products -> M candidates. A filter that can silently remove admissible assets must be auditable from its own output.

It now returns a DiscoveryResult carrying per-reason exclusion counts, surfaced by both keel assets discover and the TUI's discover overlay:

5 venue products -> 2 candidates (quote=USD, 24h volume >= 100,000, excluding the current allowlist)
excluded 3: wrong quote currency 1, not online 0, trading disabled 0, view only 0, already on allowlist 1, unreadable 24h volume 0, below 24h volume floor 1

Every reason is listed even at 0, so the line has a fixed shape an operator can scan rather than one that changes with the data. The new named return type — rather than a tuple — is deliberate: an un-updated caller fails loudly at the attribute access instead of silently mis-indexing. Candidate is unchanged, and discover_candidates stays pure and offline.

Changes

…truncated — full description in #270.

fix(data): chunk the remaining candle-request windows under the venue cap (#271)

The bug

Coinbase rejects any candle request spanning more than ~350 candles:

400 INVALID_ARGUMENT ... "number of candles requested should be less than 350"

#269 fixed this in market_feed.poll_once. An independent review then found two more sites with the same defect. Both are fixed here, so every candle-request windowing site in the codebase now agrees.

Both are latent. Neither is firing today, and this PR is preventative rather than a repair of live damage. What makes them worth closing is the failure mode: each fails quietly, and one of them fails quietly forever.

Defect 1 — repair.py cannot heal an interior hole larger than the cap

gaps.detect puts no cap on n_missing, so an interior hole can be arbitrarily large. repair_series asked for the whole hole in one call:

fetched = client.get_candles(
    product, granularity, window.start_ts - step, window.end_ts + step
)

A gap of roughly 349+ candles therefore 400s. Because the per-window except swallows the exception into result.errors and continues, the failure is silent and permanent — every scheduled repair retries the same oversized request, and the only symptom is a series that quietly never repairs.

This is not currently triggering

Measured across the deployment (24 products, ONE_HOUR and ONE_DAY), the largest interior gap is:

product granularity interior gap
ICP-USD ONE_HOUR 15 bars
WLD-USD ONE_HOUR 6
PAXG-USD ONE_HOUR 6
ZEC-USD ONE_HOUR 5

Series with an interior gap ≥ 349, i.e. that repair cannot heal: 0. The worst hole in the entire cache is 15 bars against a ~349 limit.

So the justification is "close a latent trap before it bites", not "heal an existing hole". It still earns its place: an interior gap that large is entirely reachable — a multi-day venue outage, a delisting and relisting, or a product added back after a long absence — and a silent, self-perpetuating failure is precisely the kind that goes unnoticed until much later.

Correcting the record: an earlier draft of this PR claimed the 553-candle ZEC-USD figure from the #269 incident was an interior hole repair could never heal. That was wrong. Those 553 candles were end-of-series staleness — a missing tail, which poll_once/ensure_history handle by catching up forward. It was never an interior gap, so repair was never going to be asked to heal it. 553 survives in this PR only as the oversized-gap size used in the tests.

The fix

The widened window is now paged in MAX_CANDLES_PER_REQUEST-sized chunks via a new _fetch_window_chunked helper, importing the constant from keel.data.history rather than declaring a second one — a duplicated venue limit is the same bug class being fixed. Each chunk is upserted as it arrives, so a mid-window failure leaves the earlier chunks persisted and the next run resumes further along instead of repeating the whole doomed request.

Semantics preserved exactly (they are load-bearing and commented):

…truncated — full description in #271.

ci: run every workflow on merge to main, except the release (#272)

The repository became public on 2026-08-15. GitHub bills no Actions minutes for standard runners on public repositories, so the cost argument that kept code-quality and migrate off push no longer applies.

Final trigger set

workflow triggers on merge to main?
ci.yml pull_request, push [main], workflow_dispatch yes (unchanged)
code-quality.yml push [main], schedule, workflow_dispatch new
migrate.yml push [main], workflow_dispatch new
release.yml workflow_dispatch no — deliberately

release.yml stays manual

By explicit instruction, and it could not work otherwise: its version input is required, a push event supplies no inputs, so the job would fail at its first validation step. Its own header already states the rule — "Nothing about a money-moving tool should ship on a merge."

The safety property that makes migrate on push sound

This is a property of the event, not of anyone's care. db_path is a workflow_dispatch input; a push carries no inputs, so on a merge ${{ inputs.db_path }} renders empty and the job always takes its migration_smoke.py branch. No merge can supply a value that makes it write to a real database. The comment says so, and says not to add a default target or read one from a repo variable — either would remove the property.

code-quality keeps its schedule, and still has no pull_request

The weekly run is kept rather than replaced: a CVE published against an unchanged pin is invisible to a per-merge trigger and obvious to a scheduled one.

pull_request remains absent, and on a public repo that is now a security choice rather than a cost one — fork PRs receive no repository secrets, so every one would fail at the preflight for a reason the contributor cannot fix.

⚠️ Known consequence

SONAR_TOKEN and SNYK_TOKEN do not exist at repo or org level, so code-quality will fail its preflight on every merge until they are added. That job exists to say why in one line rather than let the scanners fail obscurely — but it will now say it on every merge rather than once a week. Add the two secrets, or say the word and I will make the preflight skip cleanly instead of failing red.

fix(compliance): review follow-ups to #270 — re-pin the admission floor, stop a NaN crash (#273)

Follow-ups from the independent adversarial review of #270. That PR was merged before these landed, so they come as a separate change against current main.

1. HIGH — the admission floor lost its only value pin

#270 changed the floor assertion from DEFAULT_MIN_QUOTE_24H_VOLUME == ScreenPolicy().min_median_daily_volume to <. The relationship guard is right and stays. But it was also the only test pinning the admission floor's value, and nothing replaced it.

Verified on main before this change: editing ScreenPolicy.min_median_daily_volume from 1000000 to 200000 — a 5× loosening of the criterion deciding which assets a money-moving tool may buy — passed all 2756 tests.

This adds test_the_admission_liquidity_floor_is_pinned_to_its_actual_value alongside the < test. The two are not redundant: one guards the relationship (discovery must never be stricter than the gate), the other guards the criterion itself.

Mutation proof on this branch:

floor -> 200000 :  1 failed, 2760 passed
  FAILED test_the_admission_liquidity_floor_is_pinned_to_its_actual_value
reverted        :  2761 passed, 1 skipped

2. MEDIUM — NaN volume crashed assets discover outright

The parse was inside a try, the comparison was not. Decimal("NaN") parses fine, then < raises decimal.InvalidOperation and takes the whole command down. Separately "Infinity" parsed and silently became a candidate.

Both are now counted as unreadable_volume, with the reasoning commented. Verified end to end:

survived crash -> ['GOOD']
unreadable counted -> 2
accounting: 1 + 2 == 3 -> True

3. MEDIUM — the survivor-count identity was load-bearing but unguarded

render_discover_report derives survivor_count = venue_product_count - excluded.total. Nothing asserted the underlying invariant, and an inconsistent DiscoverReport rendered nonsense (10 venue products -> -89 candidates). Adds a test pinning len(candidates) + excluded.total == len(products), and makes the subtraction defensive.

4. LOW/MEDIUM — the incident record was wrong, and the regression test pinned the wrong end

The comments described the 852,133–979,000 cluster as including ALGO. Measured against the live venue, the cluster is ATOM, AAVE, BCH, CRV; ALGO was a separate low outlier at 437,712.

Worse, the regression test pinned only 852133 — the top. A future floor of 500,000 would have passed it while silently re-hiding ALGO. Re-pinned to ALGO's 437712, which is what actually constrains the floor. Verified: a floor of 500,000 fails the corrected test and would not have failed the old one.

5. LOW — frozen=True was cosmetic

DiscoveryResult and DiscoverReport were frozen=True but held a mutable list[Candidate] — appendable and unhashable. Now tuple[Candidate, ...].

Gates

ruff check keel tests packages   All checks passed!
mypy                             Success: no issues found in 224 source files
pytest -q                        2761 passed, 1 skipped

test(data): pin that a partially-failed window is never recorded absent (#274)

Follow-up from the independent adversarial review of #271. No production code changes — the shipped behaviour is correct. This closes a hole in the tests.

The gap

repair.py's most dangerous rule is "a gap window may be recorded absent-at-source only if EVERY chunk completed". Getting it wrong permanently writes off a hole the venue was never fully asked about.

test_one_bad_chunk_is_not_recorded_absent_and_a_later_window_still_repairs claims to cover this. It doesn't. It fails the second chunk, so the first chunk lands 299 bars and the surviving window's key shifts from (BASE+1d, BASE+553d) to (BASE+300d, BASE+553d). probed_keys matches by exact key, so the window is skipped regardless of what probed_ok contains — its windows_absent_at_source == 0 and get_gap_probes() == [] assertions are vacuous in their own setup.

The fix

Fail the first chunk instead. Nothing is upserted, the remaining window keeps its original key, and the probed_keys gate is genuinely exercised.

Verified discriminating

With probed_ok.append(window) added to the failure path:

FAILED test_a_window_whose_FIRST_chunk_fails_is_not_recorded_absent

Note the pre-existing single-chunk test_a_failed_fetch_is_NOT_recorded_as_absent does defend this rule for the one-chunk case — so the review's "not pinned by any test" was slightly overstated. The real gap was the multi-chunk path, which is what this covers.

Gates

ruff    All checks passed!
mypy    Success: no issues found in 224 source files
pytest  2757 passed, 1 skipped

docs: status review of 2026-08-16 (#275)

A single-page review of where keel stands, for a status meeting on 2026-08-16.

Covers: the live deployment, the one order ever placed and why, rules and strategies, the experiment record, compliance rails, the asset sweep, venue feasibility, engineering and versioning, and the roadmap.

Thesis

The instrument works; the strategies don't. Across the universe, 0 of 20 rules are net-positive at the taker rate actually paid on hourly bars (median n=268), and 0 of 20 reach n>=100 on daily. Daily and hourly rankings correlate at rho = -0.009. Cost, not signal, is the binding constraint — and establishing that rigorously is the project's main result to date.

⚠️ Account figures are withheld deliberately

This repository is public. The version presented from carries the live account's equity high-water mark, the open position's quantity, entry fill, fee, cost basis and mark, the exact order timestamp, and the configured position caps.

None of that is in this file. It is personal account state, it would enter git history permanently, and no claim in the analysis depends on it. Every structural fact, verdict, measurement and roadmap item is intact, and the page says the figures were withheld rather than leaving a silent gap.

Placement

docs/reports/, alongside the existing docs/superpowers/reports/*.html engine-validation reports, which set the precedent for committing a rendered report.

Verified: parses cleanly, no residual account figures (grep for each withheld value returns nothing), and both light and dark themes are defined at token level.

ci(code-quality): skip cleanly when unconfigured, fail only when dispatched (#276)

Why it's failing

Exactly what the job was written to report:

Missing: SONAR_TOKEN SNYK_TOKEN
##[error]Process completed with exit code 1

Neither secret exists at repo or org level (gh secret list is empty in both), so the preflight exits 1 and both scans are skipped. Nothing is broken in the code — the workflow is correctly telling us it isn't configured. It has failed on every merge since #272 added the push trigger.

Why that answer stopped being right

Failing loudly was correct when this ran weekly and on dispatch: an unconfigured repo produced one red X a week, and the message named the missing secrets rather than letting Sonar and Snyk fail with their own unhelpful auth errors.

With push: [main], the same behaviour makes main permanently red for a condition that is not a defect. A red X on every merge is worse than a missing scan — it's a signal the reader learns to ignore, and it hides the next real failure.

What changes

The preflight now reports rather than decides, and who asked determines the verdict:

trigger tokens missing outcome
workflow_dispatch yes Fails, loudly — unchanged. Silently ignoring a direct request is worse.
push / schedule yes Skips both scans, writes the missing secrets and where to get them to the run summary, emits a ::notice. Build stays green.
either no Scans run normally.

The scan jobs are gated on a configured output rather than a job-level condition, because secrets can't be referenced from a job-level if:.

The skip is announced, never silent — the same standard this repo already holds discovery to. A scan that didn't run and says so is honest; one that quietly does nothing isn't.

Verified

Ran the preflight script directly in all three states:

  • push, no tokens → exit 0, configured=false, summary renders correctly
  • dispatch, no tokens → exit 1
  • tokens present → exit 0, configured=true

This does not add the secrets

That's still yours to do, and it's the real fix — these scans have never run. Once SONAR_TOKEN and SNYK_TOKEN exist, the scans execute and none of the above applies.

docs: record the open-source direction in the status review (#292)

The review was written before the direction was decided. Its roadmap listed four engineering issues and ended on an open strategic question — both now stale.

What changed

A new "Direction" section. The measured result (0 of 20 net-positive) doesn't mean the project has nothing — it means the strategies were never the asset. The rails are. keel is now positioned as an auditable Shariah compliance engine, with a reference agent attached.

It also states the governance answer where a reader will look for it: keel does not issue rulings. Attestation is a human input that fails closed, nothing is derived from price data, and the ruling lives in the attestation rather than the code — so a Hanafi and a Shafi'i operator can both use it without the project taking a side.

The roadmap is now two tracks.

track contents
Engineering The same four issues, each now showing its milestone. #259/#260 had none at all — Phase 9 — Execution & backtest fidelity was created for them, alongside the two closed defects of the same class that once voided every performance figure here.
Open source 15 issues across three gated phases. Phase 6 is blocking and unglamorous: public but legally all-rights-reserved, so nobody may contribute and nobody can find it.

The strategic question now has a third answer — let the engine be the contribution — sitting alongside reduce-cost and raise-per-trade-edge.

Unchanged

Account figures remain withheld and re-verified — grep for each returns nothing. The page still parses, and both themes still resolve at token level.

docs: move the status review to docs/presentations/, and add the paper → live section (#293)

Two commits: the file move you asked for, and a new section on the paper deployment.

1. Move

docs/reports/2026-08-16-status-review.html
  → docs/presentations/keel-status-review-2026-08-15.html

Establishes docs/presentations/ with a <title>-<date>.html convention. Recorded as a git mv, not a copy — two copies of a deck drift, and the stale one is what someone eventually presents from. Nothing in the repo referenced the old path.

The filename carries the date it was saved; the masthead inside carries 16 August, the date it's presented. That's deliberate.

2. Paper → live

The deck mentioned paper in a single clause. It now has a section, because the paper-to-live path is what decides whether a rule may ever touch real money — and its current state is a finding, not a footnote.

What paper is: separate database and config, synthetic cash ($500$550), 19 allowlisted assets against live's six, 20 attested. 22 rules on the books but only 19 load — status isn't a label, it decides whether the agent reads the rule at all, so one candidate turtle and two disabled DCA rules are inert. Zero signals, orders, positions and outcomes to date.

What it's for: candidatepaperlive, with a deterministic gate between the last two rungs — n>=100, positive expectancy, RR >= 1.5, a class win-rate floor, and a PBO result. Those floors were deliberately not relaxed together: when win rate was loosened to 0.30 for trend-following, min_trades was held at 100 because the axes are independent and only one had a justification.

Why it can't work. min_trades is per rule and per product, and does not pool. Measured daily turtle rates are 1.19–3.20 trades/year, putting n=100 31 to 84 years away. Adding assets doesn't help — a new asset is a new rule with its own ~14 trades, not a pooled 114.

What follows, said plainly: five of the seven live rules carry promoted_at = NULL. They were seeded straight to live and left there, deliberately and on the record; only the BTC dca rule and the DOGE turtle went through the normal path. So what bounds the risk is not the promotion gate, which never ran — it's the caps, the nineteen rails and the allowlist. The bypass is of the evidence gate, not the safety rails.

Verified

Parses; no stray backticks; account figures still withheld (re-grepped, zero hits).

fix(data): chunk backfill's candle requests too, the last unpaged window site (#295)

Follow-on to #269 / #271. Supersedes #294, which I opened against 29d9b20 before those landed and have now closed as redundant.

What this is

#269 chunked market_feed.poll_once. #271 swept for the same defect and fixed repair.py and history.py. market_feed.backfill was not reached by either, and has the identical shape — _missing_ranges groups absent timestamps into contiguous ranges, and each range was requested in one call, so a hole wider than the venue's ~350-candle cap 400s exactly as the poll path did. On an empty repo the entire history window is one such range.

It is latent, and I'd rather say so than dress it up

backfill has no production caller today — keel fetch goes through history.ensure_history. Nothing is failing on this right now.

Worth closing anyway on #271's own stated grounds, that every candle-request windowing site in the codebase should agree. A public data-layer entry point that 400s the moment anything calls it is a trap left armed for whoever calls it next.

Why it was easy to miss

The (MAX_CANDLES_PER_REQUEST - 1) arithmetic existed only inside _poll_catch_up, so backfill had nothing to be inconsistent with — it just looked like ordinary pre-#269 code. #271 had to correct that exact off-by-one after finding it duplicated in history.py, which is the same bug class the shared MAX_CANDLES_PER_REQUEST import exists to prevent.

So the windowing now has one definition, _request_windows, used by both backfill and _poll_catch_up. _poll_catch_up keeps its own filter and seen dedup and is otherwise unchanged; folding it onto the helper also removed a window_start = window_end + gran_sec line left stranded when its while became a for.

Tests

Written first and confirmed failing for the right reason — both cap tests failed on a single 575-candle request against the unfixed code:

  • test_backfill_never_requests_more_than_the_candle_cap
  • test_backfill_chunk_windows_are_contiguous_and_cover_the_gap
  • test_backfill_still_uses_one_request_for_a_gap_within_the_cap — regression guard, passes before and after

Gates

  • pytest2762 → 2765 passed, 1 skipped
  • ruff check — clean
  • mypy keel — clean, 77 source files

chore(license): add Apache-2.0 and declare it in all six distributions (#296)

Closes #277

The repo is public but legally all-rights-reserved: no LICENSE file means nobody may
fork, modify, or contribute. This blocks every remaining open-source phase.

What

  • LICENSE at the root: canonical Apache-2.0 (fetched from GitHub's license API, not
    retyped), appendix filled with Copyright 2026 CodeGate Software.
  • license = "Apache-2.0" (PEP 639 SPDX form) in all six pyproject.tomls. Verified
    against a real build: both the root and a sub-package wheel now carry
    License-Expression: Apache-2.0 in METADATA — the field license scanners read, which
    the deprecated table form does not produce.
  • CONTRIBUTING.md: why Apache-2.0 over AGPL-3.0 and MIT (patent grant + warranty
    disclaimer matter for software that moves money), recorded so the decision is
    challengeable in place.
  • tests/test_licensing.py (written first, red, then green): canonical banner + END OF
    TERMS marker, filled appendix with no [yyyy] template left, SPDX licence in every
    distribution, decision recorded. Same repo-hygiene style as test_packaging.py.

Acceptance from #277

  • LICENSE at repo root with the chosen text, copyright line, and year
  • pyproject.toml declares the same licence (all six distributions)
  • gh repo view --json licenseInfo reports it — verified post-merge (GitHub detects
    the file on main; will confirm and tick)
  • Decision and reasoning recorded in CONTRIBUTING.md

Gates: pytest 2774 passed / 1 skipped, ruff check clean, mypy clean.

Until this merges: do not accept external pull requests — the issue's warning holds;
nothing changes for contributors until this is on main.

docs(governance): state the boundary — keel enforces rulings, it does not issue them (#297)

Closes #280

Once contributors arrive from different madhāhib and jurisdictions they will disagree about
rulings, and in a religious context ungoverned disagreement fragments the project. The
architecture already answers the question — a classification is a human input
(keel assets attest --source --attested-by), screen_asset enforces it, attestation=None
is a rejection — so this PR makes the answer explicit, before any other Phase 6–8 document is
written against the wrong assumption.

What

  • README's first screen (before the first ##, where a stranger decides what this is):
    the quotable sentence — keel is not a fatwa engine. It is an enforcement engine for a
    ruling you supply.
    — plus the two-operators-two-schools consequence.
  • CONTRIBUTING.md: the same sentence verbatim, framed as PR scope:
    • a PR changing a default classification needs a cited source and is discussed
      it applies one contributor's fiqh to every operator who upgrades;
    • a PR changing the mechanism is ordinary engineering;
    • disagreement routes locallykeel assets attest writes your ruling to your
      database — and is not merged upstream, so the project never becomes a fiqh court with a
      merge button.
  • tests/test_governance.py (written first, red, then green): boundary sentence on the
    README first screen; same sentence verbatim in CONTRIBUTING (drift-proof); both PR kinds
    told apart with their bars; the local-not-upstream route named.

Acceptance from #280

  • Short, quotable statement in the README's first screen
  • Same statement in CONTRIBUTING.md, framed as PR scope
  • Explicit: default-classification PRs need a source and discussion; mechanism PRs are
    ordinary engineering
  • Stated disagreement route that does not require the project to adjudicate

Gates: pytest 2778 passed / 1 skipped, ruff check clean, mypy clean.

docs(security): add SECURITY.md and a private reporting channel (#298)

Closes #279

keel holds exchange API credentials and places live orders. Once strangers read the source,
someone will eventually find a way to make it misbehave — and until now the only options were
a public issue (disclosing to everyone simultaneously) or nothing.

What

  • SECURITY.md at the repo root:
    • Private channel: GitHub private vulnerability reporting — the Report a
      vulnerability
      button under the Security tab. Enabled in repo settings (verified:
      gh api .../private-vulnerability-reporting{"enabled":true}); the file alone gives
      no channel, per the issue's note.
    • Scope: the defining class — a rail that can be bypassed is a security issue, not
      merely a bug
      (allowlist, caps, drawdown breakers, kill-switch), credential/secret
      exposure, and corruption of the attestation/audit trail.
    • Out of scope: strategy performance and market losses, a user's own key handling, the
      exchange's own outages.
    • Response expectations, honestly sized for one maintainer: acknowledge within 3 days,
      severity + plan within 14, coordinated disclosure, credit by default.
  • tests/test_security_policy.py (written first, red, then green): pins the channel, the
    day-count commitment, the rail-bypass scope line, and the out-of-scope list. The
    GitHub-side setting is outside the tree and was verified against the live repo instead.

Gates (rebased on main after #297): pytest 2782 passed / 1 skipped, ruff check clean,
mypy clean.

docs(contributing): state the bar — documentation standard, gates, tests-first (#299)

Closes #282

The repository's documentation standard is its signature and its single biggest barrier to
contribution — nobody will guess it. An unstated bar filters for clairvoyance: PRs arrive at
ordinary quality, get heavy review, and the contributor quietly leaves.

What CONTRIBUTING.md now states

  • The documentation standard, taught from a worked example: the _open_exposure_by_asset
    docstring in keel/execution/guards.py is quoted in full, then dissected on the three
    properties that make it acceptable — it says why, it names what was measured, and it
    says what it would take to change the decision.
  • Dev setup and the gates: uv sync --all-extras --dev, uv run ruff check,
    uv run mypy, uv run pytest -q — pasteable, because a gate nobody can paste is a gate
    nobody runs.
  • Tests first, with evidence: failing tests shown to fail for the right reason — an
    assertion, not an import error — carried in the PR.
  • Conventional Commits, matching history; the prefix is load-bearing for releases.
  • Three-tier scope: welcome / discuss-first (rails, default classifications, new deps,
    keel-broker-api surface) / out of scope (rulings as defaults, "make the bot profitable",
    weakening fails-closed paths).
  • The honest-upfront framing the issue asks for: the bar is high, said so at the top, with
    why.
  • Governance (rulings vs. machinery) and licence sections from Phase 6 kept intact.

Tests (tests/test_contributing.py, red first): gates stated verbatim; the worked
example present in CONTRIBUTING.md and still existing in guards.py (drift-proof, via
wrap/blockquote-normalized matching); the three properties named; tests-first + evidence;
commit convention; scope tiers.

Gates: pytest 2788 passed / 1 skipped, ruff check clean, mypy clean.

docs(readme): rewrite for the newcomer, led by the compliance engine (#300)

Closes #281

The README was a 261-line operator runbook — right for the author's future self, wrong for a
stranger deciding whether to spend an afternoon here.

The new first screen

  1. What keel is, led by the compliance engine — 19 deterministic rails, attested screening
    that fails closed, §65.4 qabd as an executable check; the trading agent is the reference
    implementation on top, not the headline.
  2. The honest result, stated by us first — no shipped rule family net-positive at the
    taker fee actually paid; viable intersection empty under production-faithful execution,
    linking docs/experiments/2026-08-13-restated-under-a-production-faithful-engine.md.
  3. The not-a-fatwa-engine boundary (from Phase 6, kept verbatim).
  4. Try it in five minutes — see below.

Quickstart, verified end-to-end on a clean clone (that's why it says keel rules seed,
not keel init: a clone has the tracked config.yaml and init refuses to overwrite it):

git clone … && cd keel
uv sync --all-extras --dev
cp .env.example .env          # free read-only CDP key
uv run keel rules seed
uv run keel fetch
uv run keel simulate --years 1 --skip-within-cap

Two honest caveats are in the text because they were measured: keel fetch without a CDP
key dies in AuthenticationError (tested), and keel simulate on default rules reports
TRAIN MORE with the failing gates named — presented as the engine working, because it is.

Rest of the structure: condensed How-it-works (rules, rails, screening, confirm/autonomy,
ships-inert, the tighter-stop→larger-position mechanic), an architecture sketch naming the
three load-bearing places (keel/execution/guards.py, keel/agent.py,
packages/keel-broker-* port + entry point), a documentation map, and an ending that routes
questions to Discussions (now enabled) and contributions to CONTRIBUTING.md.

Moved, not deleted: deploying/upgrading releases, the paper-vs-live table and its
interactions, and "how much money moves" now live in docs/operator-runbook.md (Part 2,
verbatim in substance).

Tests (tests/test_readme.py, red first): honest result above the fold + cited record
exists; quickstart commands verbatim incl. the CDP caveat and paper path; the three
architecture locations named; README ends with Discussions/CONTRIBUTING routing; operator
content present in the runbook and absent from the README (the anti-runnable-regression
guard).

Gates: pytest 2794 passed / 1 skipped, ruff check clean, mypy clean.

chore(python): relax the floor to >=3.11, measured, with a CI leg to keep it true (#301)

Closes #283

requires-python = ">=3.14.4" said "the interpreter I develop on", not "the interpreter the
code needs" — and 3.14 is new enough that contributors who lack it fail to build and leave
without opening an issue.

The measurement (the decision's evidence, per the issue):

Python full suite
3.13 2,788 passed / 1 skipped — identical to 3.14
3.12 2,788 passed / 1 skipped
3.11 2,788 passed / 1 skipped
3.10 fails collection: ImportError: cannot import name 'assert_never' from 'typing'

Decision: nothing requires 3.14. The floor is set to >=3.11 — the lowest version the
suite passes on — with typing.assert_never (3.11+) as the one concrete binding constraint.
Development stays pinned to 3.14.4 via .python-version.

What changes

  • requires-python = ">=3.11" in all six distributions; uv.lock re-resolved.
  • ci.yml's test job becomes a 3.11 + 3.14 matrix, so the floor stays a measured claim.
    Both legs stay inside the test job on purpose: the matrix fans out the required status
    context rather than renaming it, so every leg gates the merge — the same lesson the job's
    own comments record for mypy (#268).
  • mypy python_version = "3.11" — check against the floor, not the dev pin.
  • README quickstart and CONTRIBUTING state 3.11 and the reason ("a contributor hitting the
    floor gets a clear message rather than a confusing resolver error" — low floor, stated
    plainly).
  • tests/test_python_floor.py (red first) pins the decision's four halves: one floor in every
    distribution, the binding feature still imported (if assert_never ever disappears, the
    floor can drop — re-measure), the CI leg present, and the docs stating it.

Gates: pytest 2,798 passed / 1 skipped on both 3.14 and 3.11; ruff and mypy clean.

docs(conduct): adopt the Contributor Covenant, with a stance on religious disagreement (#302)

Closes #284

A project inviting international contributors around a religious topic needs a stated
behavioural baseline before it needs anything else.

What

  • CODE_OF_CONDUCT.md: canonical Contributor Covenant text fetched from GitHub's
    codes-of-conduct API (the same trusted-source trick as the Apache-2.0 LICENSE in #296
    not retyped), with the [INSERT CONTACT METHOD] placeholder filled with the maintainer's
    address.
  • One project-specific addition, appended as its own section:
    • disagreement about a religious ruling is expected and legitimate — not disloyalty,
      not trolling, not a conduct problem;
    • disparaging a madhhab, a school, a scholar, or a contributor's own practice is not
      enforced exactly like any other harassment ("the Hanafi position on X is wrong" is an
      argument; "nobody serious follows that" is an attack);
    • the technical route for disagreement is a local attestation (keel assets attest) or
      the Compliance & classification Discussions category — never a pull-request thread, and
      never a remark about the person holding the other view.

Tests (tests/test_code_of_conduct.py, red first): all five Covenant section headings
present (the file is the Covenant, not a bespoke policy); no [INSERT placeholder survives
(the unfilled-template failure shape); a concrete reachable contact is named; and the
religious-disagreement stance exists with its three required elements (madhhab line,
expected-and-legitimate, local-attestation route).

Gates: pytest 2,798 passed / 1 skipped, ruff check clean, mypy clean.

docs(templates): issue forms with a distinct compliance-question route, and a PR template that asks the right questions (#303)

Closes #285

Templates do two jobs: extract what triage needs, and state the project's standards at the
moment of submission.

Issue forms (.github/ISSUE_TEMPLATE/):

  • bug_report.yml — demands the three facts a report is useless without: keel --version
    output (stamps commit and build kind), paper-vs-live (they share nothing), and the rail or
    rule involved (the address of the behaviour). Security warning up top.
  • feature_request.yml — problem / proposal / alternatives considered (this project
    records rejected alternatives on purpose), plus a scope checkbox separating mechanism
    changes from classification changes.
  • compliance_question.yml — the distinct route the issue asks for: "should X be treated
    this way" is neither a bug nor a feature, and triaging it as either gets it the wrong
    resolution. Asks for the asset/mechanism, sources being weighed, and what outcome would
    resolve it — including "a change to a default classification (needs sources, discussed
    before any PR)".
  • config.yml — blank issues off; contact links route security to the private channel
    (../security/advisories/new + SECURITY.md note), questions to Discussions, and
    contributors to CONTRIBUTING.md.

PR template (.github/PULL_REQUEST_TEMPLATE.md): what & why, tests-first evidence
(paste the red run), the three gates as checkboxes, and the load-bearing scope check: a
checkbox that declares whether the PR touches a rail or a default classification — checked
means it does, and then source + discussion come before review.

Tests (tests/test_issue_templates.py, red first): forms parse as GitHub issue forms;
bug report carries version/mode/component fields; the compliance route exists and points at
attestation + source; config.yml routes to SECURITY.md; the PR template names the gates,
tests-first, and rail/classification.

Gates: pytest 2,807 passed / 1 skipped, ruff check clean, mypy clean.

docs(fiqh): the fiqh basis document — every encoded ruling, sourced (#288) (#317)

Closes #288.

What & why

A Muslim developer deciding whether to trust keel with money asks a question the code cannot
answer by being read: WHAT fiqh does this machine enforce, and where did each ruling come
from? The answers exist — scattered across guard comments, a KB the size of a bookshelf, and
a handful of experiment records — auditable in principle, auditable by nobody who does not
already know where to look.

This PR adds docs/fiqh-basis.md: the Shariah reasoning encoded in keel, stated ruling by
ruling, each with its in-repo source — the curation screen (sector §28.4, backing
§65.5/§67.2, pays_yield bare-holder semantics, wrapper §71.4a), rail 1 allowlist
enforcement, rail 17 qabd §65.4 (Ayub quoted verbatim, tri-sourced §65.4 · §67.1 OIC
53/4-6 · §71.5 AAOIFI SS 18 3/5, 7-day TTL, entries-only, fails closed), rails 18/19 as
charter, purification §65.9 and USDC-rewards §56.3 — plus what is attested versus computed,
what keel deliberately does not decide, the known open questions (ATOM dilution, staking
§65.14 contested, DOGE §86.4), and how to disagree. The remaining rails are marked
PRUDENTIAL-not-fiqh with §65.6's anti-scalping correction stated in the KB's own words.

The document claims no scholarly review; that is deliberately #289's still-open question.

README's documentation map and CONTRIBUTING's governance section each gain one link line.

Tests-first evidence

tests/test_fiqh_basis.py written first; the document did not exist.

Red run (uv run pytest tests/test_fiqh_basis.py -q):

FAILED tests/test_fiqh_basis.py::test_the_document_exists - AssertionError: d...
FAILED tests/test_fiqh_basis.py::test_the_boundary_sentence_is_stated_verbatim
FAILED tests/test_fiqh_basis.py::test_the_qabd_condition_is_pinned_three_sided
FAILED tests/test_fiqh_basis.py::test_the_attested_never_inferred_split_is_pinned_two_sided
FAILED tests/test_fiqh_basis.py::test_the_fails_closed_posture_is_pinned_two_sided
FAILED tests/test_fiqh_basis.py::test_the_riba_failure_wording_is_pinned_two_sided
FAILED tests/test_fiqh_basis.py::test_the_bare_holder_semantics_are_pinned_to_the_experiment_record
FAILED tests/test_fiqh_basis.py::test_rail_17s_seven_day_ttl_is_pinned_to_the_executor
FAILED tests/test_fiqh_basis.py::test_the_prudential_rails_are_separated_from_the_fiqh_rails_with_the_65_6_correction
FAILED tests/test_fiqh_basis.py::test_the_atom_dilution_open_question_is_stated_not_hidden
FAILED tests/test_fiqh_basis.py::test_every_kb_citation_resolves_to_a_source_file_in_the_repo
FAILED tests/test_fiqh_basis.py::test_the_disagreement_section_names_the_local_attestation_route
FAILED tests/test_fiqh_basis.py::test_the_readme_links_the_document - Asserti...
13 failed in 0.07s

Every failure is the assertion it was written to be (the _doc() helper returns empty for a
missing file, so red is failures, not errors). After writing the document and the two link
lines: 13 passed.

…truncated — full description in #317.

docs(review-path): the scholarly-review stance — not reviewed, path defined (#289) (#318)

What & why

Closes #289. Decides the scholarly-review question the honest way the issue demands: keel offers option 3, said out loud — no scholarly review has occurred — in the README's first screen and in a new "Scholarly review status" section of docs/fiqh-basis.md. Alongside the disclaimer, the review path is fully defined (option 2's machinery, documented as a path, not a claim): what a review will cover, what a reviewer is and is not endorsing, how a review is recorded as a dated addendum that can only ratchet from not-reviewed to reviewed-with-a-named-scope, and the outreach shortlist (IIUM, INCEIF, Durham, Islamic fintech practitioners) with a ready-to-send note. The approach itself is the operator's action and is stated as not taken.

Tests-first evidence

tests/test_scholarly_review.py written first: red run showed 8 failed / 1 passed (the negative no-false-claims test passes trivially before any doc exists — it exists to guard afterwards). After the two insertions (README paragraph after the boundary blockquote; the status section between "How to disagree" and "Sources index"): 9 passed. The pinned no-review sentence — "No scholarly review of keel's fiqh basis has occurred" — is asserted identically in the README first screen and the status section, and the negative test scans README/CONTRIBUTING/fiqh-basis for affirmative overstatement phrasings ("scholar-approved", "reviewed and approved", "certified", "endorsed by", …) so the claim can never quietly soften or inflate.

Gates

  • uv run ruff check keel tests packages — All checks passed!
  • uv run mypy — Success: no issues found in 234 source files
  • uv run pytest -q — 2832 passed, 1 skipped

Scope check

  • No rail, rule, or default classification is touched — documentation and pinning tests only.

docs(arabic): README.ar.md — the Arabic entry point (#290) (#319)

What & why

Closes #290. Adds README.ar.md — the Arabic entry point a large share of the intended audience reads in — with a two-sided language switcher (English README links it under the title; it links back). The page covers the first-screen content in Arabic: what keel is (compliance-engine lead), the honest measured result linking the experiment record, the not-a-fatwa-engine boundary (its English original quoted alongside so translation and source are checkable against each other), the no-scholarly-review stance (#289), the five-minute quickstart with commands verbatim (commands are language-neutral), a five-term glossary (الربا، القبض، العين، الدَّين، الغرر) in their established Islamic-finance renderings, the scope statement (the full docs remain English; this is the entry point, not a promise to translate everything), routing, and disclaimers.

On the acceptance item "terminology reviewed by a native speaker": the Arabic was authored directly to the established renderings (not machine-translated), the glossary teaches the vocabulary the page uses, and the document itself invites terminology corrections via Discussions as a standing request — that is the honest form of review available to the repo today, and it is stated in the document.

Tests-first evidence

tests/test_arabic_readme.py written first: red run 9/9 failed (no README.ar.md). After authoring: 9 passed — pinning the RTL wrapper, two-sided switcher, Arabic boundary + English original, no-review stance + fiqh-basis link, honest result + record link + file existence, verbatim quickstart commands + CDP caveat, the five terms, the scope statement, and the disclaimers.

Gates

  • uv run ruff check keel tests packages — All checks passed!
  • uv run mypy — Success: no issues found in 235 source files
  • uv run pytest -q — 2841 passed, 1 skipped

Scope check

  • No rail, rule, or default classification is touched — documentation and pinning tests only.

ci(security): real scans, tokenless — dependabot, pip-audit over the lock, CodeQL (#291) (#320)

What & why

Part of #291 (the pre-launch gate's code-quality-scans item, which the gate blocks on): the repo has never had static analysis or dependency scanning — SONAR_TOKEN/SNYK_TOKEN were names for secrets nobody created. This PR configures both classes of scan using what a public repository gets for free, on GITHUB_TOKEN alone:

  • .github/dependabot.yml — weekly, grouped updates for all six Python manifests (root + five packages/*) and the github-actions ecosystem. A manifest Dependabot doesn't see is a distribution whose dependencies update silently.
  • .github/workflows/security.yml — a dependencies job that audits uv export of the locked set (pip-audit; the exact pinned versions deployments get), and a codeql job (Python) with security-events: write). Runs on push, PRs, and weekly — CVEs are published against already-pinned versions, not only against new commits. Deliberately NOT the testjob: themainruleset requires thetest` context, and a security finding is information about the state of the world, not a verdict on a proposed change — so these jobs surface findings without gating merges.
  • uv.lock — the audit's first run (locally, verified end-to-end) surfaced PYSEC-2026-3552 in cryptography 49.0.0, fixed in 50.0.0; upgraded in the same commit so the scan ships green rather than red on day one. Full suite green on the upgrade.

tests/test_security_scans.py (red-first, 6 tests) pins: every manifest directory watched + schedules present, the audit reads the lock's export with the six own-distributions excluded, CodeQL init+analyze on Python with the upload permission, the weekly schedule trigger, and — the honest part — that no scan step references any secret at all (SONAR_TOKEN/SNYK_TOKEN never existed; a scan that needs them is a scan that doesn't run).

Tests-first evidence

Red run: 6/6 failed (no configs existed). Green: 6 passed, full suite 2847 passed / 1 skipped. The pip-audit step was verified end-to-end locally: found the cryptography advisory on the pre-upgrade lock, clean on the upgraded one.

Gates

  • uv run ruff check keel tests packages — All checks passed!
  • uv run mypy — Success: no issues found in 236 source files
  • uv run pytest -q — 2847 passed, 1 skipped

Scope check

  • No rail, rule, or default classification is touched — CI configuration, a dependency upgrade the new scan surfaced, and pinning tests.

chore(deps-dev): update uv-build requirement from <0.11.0,>=0.10.4 to >=0.10.4,<0.13.0 in /packages/keel-core in the python-dependencies group (#321)

Updates the requirements on uv-build to permit the latest version.
Updates uv-build to 0.12.4

Release notes

Sourced from uv-build's releases.

0.12.4

Release Notes

Released on 2026-08-13.

Enhancements

  • Prefer post-quantum key exchange and enable opt-in TLS diagnostics (#21054)
  • Accept whitespace before versions in noncompliant wildcard comparisons such as Requires-Python: >= 3.5.* (#21012)
  • Report a specific error when a PEP 723 closing tag contains trailing whitespace or other content (#20944)
  • Omit source-span carets from diagnostics for empty PEP 508 requirements (#21094)

Preview features

  • Add uv check --no-install-project and respect UV_NO_INSTALL_PROJECT to install dependencies without building or installing the project (#21085)
  • Make the ty subprocess invoked by uv check honor uv's color and progress settings, including quiet mode (#21086)

Performance

  • Speed up resolutions with long runs of unavailable package versions by coalescing gaps in the resolver's version ranges (#20804)
  • Speed up Simple API parsing by deserializing PyPI and Pyx file metadata directly (#21041)

Bug fixes

  • Use windowed pythonw.exe launchers for virtual environments created from managed Python minor-version links (#19235)
  • Allow uv lock to proceed when .venv is an unusable project environment (#21068)
  • Respect fork-strategy when ordering forks created from environments or existing lockfile resolution-markers (#21000)
  • Preserve consecutive wildcard Python minor-version exclusions such as !=3.11.*, !=3.12.* in uv.lock (#21045)
  • Preserve inline comments on the final item in dependency arrays when uv add updates it (#21008)
  • Recover from stale base-interpreter cache metadata when an existing virtual environment exposes a version mismatch (#21073)
  • Prevent interpreter cache reuse across different PYTHONEXECUTABLE and __PYVENV_LAUNCHER__ overrides (#21075)
  • …truncated — full description in #321.

    chore(deps-dev): update uv-build requirement from <0.11.0,>=0.10.4 to >=0.10.4,<0.13.0 in /packages/keel-broker-api in the python-dependencies group (#322)

    Updates the requirements on uv-build to permit the latest version.
    Updates uv-build to 0.12.4

    Release notes

    Sourced from uv-build's releases.

    0.12.4

    Release Notes

    Released on 2026-08-13.

    Enhancements

    • Prefer post-quantum key exchange and enable opt-in TLS diagnostics (#21054)
    • Accept whitespace before versions in noncompliant wildcard comparisons such as Requires-Python: >= 3.5.* (#21012)
    • Report a specific error when a PEP 723 closing tag contains trailing whitespace or other content (#20944)
    • Omit source-span carets from diagnostics for empty PEP 508 requirements (#21094)

    Preview features

    • Add uv check --no-install-project and respect UV_NO_INSTALL_PROJECT to install dependencies without building or installing the project (#21085)
    • Make the ty subprocess invoked by uv check honor uv's color and progress settings, including quiet mode (#21086)

    Performance

    • Speed up resolutions with long runs of unavailable package versions by coalescing gaps in the resolver's version ranges (#20804)
    • Speed up Simple API parsing by deserializing PyPI and Pyx file metadata directly (#21041)

    Bug fixes

    • Use windowed pythonw.exe launchers for virtual environments created from managed Python minor-version links (#19235)
    • Allow uv lock to proceed when .venv is an unusable project environment (#21068)
    • Respect fork-strategy when ordering forks created from environments or existing lockfile resolution-markers (#21000)
    • Preserve consecutive wildcard Python minor-version exclusions such as !=3.11.*, !=3.12.* in uv.lock (#21045)
    • Preserve inline comments on the final item in dependency arrays when uv add updates it (#21008)
    • Recover from stale base-interpreter cache metadata when an existing virtual environment exposes a version mismatch (#21073)
    • Prevent interpreter cache reuse across different PYTHONEXECUTABLE and __PYVENV_LAUNCHER__ overrides (#21075)
    • …truncated — full description in #322.

      chore(deps-dev): update uv-build requirement from <0.11.0,>=0.10.4 to >=0.10.4,<0.13.0 in /packages/keel-broker-fake in the python-dependencies group (#323)

      Updates the requirements on uv-build to permit the latest version.
      Updates uv-build to 0.12.4

      Release notes

      Sourced from uv-build's releases.

      0.12.4

      Release Notes

      Released on 2026-08-13.

      Enhancements

      • Prefer post-quantum key exchange and enable opt-in TLS diagnostics (#21054)
      • Accept whitespace before versions in noncompliant wildcard comparisons such as Requires-Python: >= 3.5.* (#21012)
      • Report a specific error when a PEP 723 closing tag contains trailing whitespace or other content (#20944)
      • Omit source-span carets from diagnostics for empty PEP 508 requirements (#21094)

      Preview features

      • Add uv check --no-install-project and respect UV_NO_INSTALL_PROJECT to install dependencies without building or installing the project (#21085)
      • Make the ty subprocess invoked by uv check honor uv's color and progress settings, including quiet mode (#21086)

      Performance

      • Speed up resolutions with long runs of unavailable package versions by coalescing gaps in the resolver's version ranges (#20804)
      • Speed up Simple API parsing by deserializing PyPI and Pyx file metadata directly (#21041)

      Bug fixes

      • Use windowed pythonw.exe launchers for virtual environments created from managed Python minor-version links (#19235)
      • Allow uv lock to proceed when .venv is an unusable project environment (#21068)
      • Respect fork-strategy when ordering forks created from environments or existing lockfile resolution-markers (#21000)
      • Preserve consecutive wildcard Python minor-version exclusions such as !=3.11.*, !=3.12.* in uv.lock (#21045)
      • Preserve inline comments on the final item in dependency arrays when uv add updates it (#21008)
      • Recover from stale base-interpreter cache metadata when an existing virtual environment exposes a version mismatch (#21073)
      • Prevent interpreter cache reuse across different PYTHONEXECUTABLE and __PYVENV_LAUNCHER__ overrides (#21075)
      • …truncated — full description in #323.

        chore(deps-dev): update uv-build requirement from <0.11.0,>=0.10.4 to >=0.10.4,<0.13.0 in /packages/keel-broker-robinhood in the python-dependencies group (#324)

        Updates the requirements on uv-build to permit the latest version.
        Updates uv-build to 0.12.4

        Release notes

        Sourced from uv-build's releases.

        0.12.4

        Release Notes

        Released on 2026-08-13.

        Enhancements

        • Prefer post-quantum key exchange and enable opt-in TLS diagnostics (#21054)
        • Accept whitespace before versions in noncompliant wildcard comparisons such as Requires-Python: >= 3.5.* (#21012)
        • Report a specific error when a PEP 723 closing tag contains trailing whitespace or other content (#20944)
        • Omit source-span carets from diagnostics for empty PEP 508 requirements (#21094)

        Preview features

        • Add uv check --no-install-project and respect UV_NO_INSTALL_PROJECT to install dependencies without building or installing the project (#21085)
        • Make the ty subprocess invoked by uv check honor uv's color and progress settings, including quiet mode (#21086)

        Performance

        • Speed up resolutions with long runs of unavailable package versions by coalescing gaps in the resolver's version ranges (#20804)
        • Speed up Simple API parsing by deserializing PyPI and Pyx file metadata directly (#21041)

        Bug fixes

        • Use windowed pythonw.exe launchers for virtual environments created from managed Python minor-version links (#19235)
        • Allow uv lock to proceed when .venv is an unusable project environment (#21068)
        • Respect fork-strategy when ordering forks created from environments or existing lockfile resolution-markers (#21000)
        • Preserve consecutive wildcard Python minor-version exclusions such as !=3.11.*, !=3.12.* in uv.lock (#21045)
        • Preserve inline comments on the final item in dependency arrays when uv add updates it (#21008)
        • Recover from stale base-interpreter cache metadata when an existing virtual environment exposes a version mismatch (#21073)
        • Prevent interpreter cache reuse across different PYTHONEXECUTABLE and __PYVENV_LAUNCHER__ overrides (#21075)
        • …truncated — full description in #324.

          chore(deps-dev): update uv-build requirement from <0.11.0,>=0.10.4 to >=0.10.4,<0.13.0 in /packages/keel-broker-coinbase in the python-dependencies group (#325)

          Updates the requirements on uv-build to permit the latest version.
          Updates uv-build to 0.12.4

          Release notes

          Sourced from uv-build's releases.

          0.12.4

          Release Notes

          Released on 2026-08-13.

          Enhancements

          • Prefer post-quantum key exchange and enable opt-in TLS diagnostics (#21054)
          • Accept whitespace before versions in noncompliant wildcard comparisons such as Requires-Python: >= 3.5.* (#21012)
          • Report a specific error when a PEP 723 closing tag contains trailing whitespace or other content (#20944)
          • Omit source-span carets from diagnostics for empty PEP 508 requirements (#21094)

          Preview features

          • Add uv check --no-install-project and respect UV_NO_INSTALL_PROJECT to install dependencies without building or installing the project (#21085)
          • Make the ty subprocess invoked by uv check honor uv's color and progress settings, including quiet mode (#21086)

          Performance

          • Speed up resolutions with long runs of unavailable package versions by coalescing gaps in the resolver's version ranges (#20804)
          • Speed up Simple API parsing by deserializing PyPI and Pyx file metadata directly (#21041)

          Bug fixes

          • Use windowed pythonw.exe launchers for virtual environments created from managed Python minor-version links (#19235)
          • Allow uv lock to proceed when .venv is an unusable project environment (#21068)
          • Respect fork-strategy when ordering forks created from environments or existing lockfile resolution-markers (#21000)
          • Preserve consecutive wildcard Python minor-version exclusions such as !=3.11.*, !=3.12.* in uv.lock (#21045)
          • Preserve inline comments on the final item in dependency arrays when uv add updates it (#21008)
          • Recover from stale base-interpreter cache metadata when an existing virtual environment exposes a version mismatch (#21073)
          • Prevent interpreter cache reuse across different PYTHONEXECUTABLE and __PYVENV_LAUNCHER__ overrides (#21075)
          • …truncated — full description in #325.

            chore(deps-dev): update uv-build requirement from <0.11.0,>=0.10.4 to >=0.10.4,<0.13.0 in the python-dependencies group (#326)

            Updates the requirements on uv-build to permit the latest version.
            Updates uv-build to 0.12.4

            Release notes

            Sourced from uv-build's releases.

            0.12.4

            Release Notes

            Released on 2026-08-13.

            Enhancements

            • Prefer post-quantum key exchange and enable opt-in TLS diagnostics (#21054)
            • Accept whitespace before versions in noncompliant wildcard comparisons such as Requires-Python: >= 3.5.* (#21012)
            • Report a specific error when a PEP 723 closing tag contains trailing whitespace or other content (#20944)
            • Omit source-span carets from diagnostics for empty PEP 508 requirements (#21094)

            Preview features

            • Add uv check --no-install-project and respect UV_NO_INSTALL_PROJECT to install dependencies without building or installing the project (#21085)
            • Make the ty subprocess invoked by uv check honor uv's color and progress settings, including quiet mode (#21086)

            Performance

            • Speed up resolutions with long runs of unavailable package versions by coalescing gaps in the resolver's version ranges (#20804)
            • Speed up Simple API parsing by deserializing PyPI and Pyx file metadata directly (#21041)

            Bug fixes

            • Use windowed pythonw.exe launchers for virtual environments created from managed Python minor-version links (#19235)
            • Allow uv lock to proceed when .venv is an unusable project environment (#21068)
            • Respect fork-strategy when ordering forks created from environments or existing lockfile resolution-markers (#21000)
            • Preserve consecutive wildcard Python minor-version exclusions such as !=3.11.*, !=3.12.* in uv.lock (#21045)
            • Preserve inline comments on the final item in dependency arrays when uv add updates it (#21008)
            • Recover from stale base-interpreter cache metadata when an existing virtual environment exposes a version mismatch (#21073)
            • Prevent interpreter cache reuse across different PYTHONEXECUTABLE and __PYVENV_LAUNCHER__ overrides (#21075)
            • …truncated — full description in #326.

              chore(deps): bump the github-actions group with 3 updates (#327)

              Bumps the github-actions group with 3 updates: actions/checkout, astral-sh/setup-uv and github/codeql-action.

              Updates actions/checkout from 4 to 7

              Release notes

              Sourced from actions/checkout's releases.

              v7.0.0

              What's Changed

              New Contributors

              Full Changelog: actions/checkout@v6.0.3...v7.0.0

              v6.1.0

              What's Changed

              • [BREAKING] backport allow-unsafe-pr-checkout to v6 by @​aiqiaoy in actions/checkout#2500
              • …truncated — full description in #327.

                docs(launch): the pre-launch gate and the announcement plan (#291) (#328)

                What & why

                Closes #291 (with #320, the scans half, already merged). docs/launch.md is the gate the issue asks for — nothing is announced until every box below is ticked — with every box ticked and evidence-linked: Phase 6 (licence/discoverability/positioning), Phase 7 (README, CONTRIBUTING, CoC, templates, Discussions, good-first-issues), the fiqh basis (#317), the review-path stance (#318), the Arabic entry point (#319), CI green on main, the tokenless scans actually configured (#320), and the maintainer-response commitment stated in CONTRIBUTING.md. Below the gate: the audience in the issue's order (Islamic fintech practitioner networks → r/islamicfinance + Muslim dev communities → IIUM/INCEIF/Durham → only then HN/Reddit/Lobsters), what the announcement must say — the measured result in the post, with the real numbers — and a ready-to-adapt draft. Plus the explicit non-goal (not for stars).

                Two corrections of the issue's own text, made in the document: the measured result is 0 of 90 and 0 of 82 under production-faithful execution (the "0 of 20" in the issue misremembers the earlier 0-of-19 hourly record), and the scans item is satisfied tokenless (#320) rather than by waiting for SONAR_TOKEN/SNYK_TOKEN. One gate item needed a repo change: CONTRIBUTING.md now carries an honest-for-one-person response commitment (triage 3 days, first PR review a week, security routed to SECURITY.md's SLA).

                The announcement itself is deliberately NOT part of this PR — the gate arms it; walking through the door is a human action.

                Tests-first evidence

                tests/test_launch_gate.py written first: red 8/8 (no doc). Green: 8 passed — pinning the gate rule verbatim, each gate item named, the real numbers + the record link (path-aware: the doc lives in docs/, the link is doc-relative) + file existence, the audience ORDER by position, the non-goal, the draft's load-bearing content (compliance-engine lead, 0 of 90, not a fatwa engine, No scholarly review), CONTRIBUTING's solo-maintainer commitment, and the README documentation-map link.

                Gates

                • uv run ruff check keel tests packages — All checks passed!
                • uv run mypy — Success: no issues found in 237 source files
                • uv run pytest -q — 2856 passed, 1 skipped

                Scope check

                • No rail, rule, or default classification is touched — documentation and pinning tests only.

                chore(release): 0.8.0 (#329)

                What & why

                Version bump across all six distributions. Minor (0.8.0), not patch, because two changes since v0.7.1 are user-facing: the supported-Python floor widened to >=3.11 (measured; CI now runs a 3.11 leg to keep it true, #301), and every distribution now carries Apache-2.0 as PEP 639 licence metadata with py.typed shipped (#264 and the Phase 6 licence work).

                What else is in the arc this release collects: the open-source readiness docs (README rewritten for the stranger #300, docs/fiqh-basis.md #317, the scholarly-review stance #318, the Arabic entry point #319, docs/launch.md #328, Contributor Covenant #302, issue/PR templates #303, SECURITY.md #298, governance #297), the tokenless security scans with Dependabot + weekly pip-audit over the frozen lock + CodeQL (#320, which also fixed PYSEC-2026-3552 by moving cryptography to 50.0.0), the CI matrix (#301) and the mypy gate (#268), plus the dependency/action updates Dependabot has merged since.

                No rail, rule, or screening behaviour changed since v0.7.1 — the diff is packaging, metadata, docs, CI, and the cryptography upgrade the new audit surfaced.

                Tests-first evidence

                tests/test_packaging.py and tests/test_python_floor.py already pin the invariants this bump touches (sibling == pins and the floor); after the bump and uv lock: 2856 passed, 1 skipped; ruff clean; mypy clean; keel --version reports 0.8.0.

                Gates

                • uv run ruff check keel tests packages — All checks passed!
                • uv run mypy — Success: no issues found in 237 source files
                • uv run pytest -q — 2856 passed, 1 skipped

                Scope check

                • Version numbers and uv.lock only; no code, rails, rules, or classifications touched.