diff --git a/CHANGELOG.md b/CHANGELOG.md index d6225d12..36db8ef2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,17 @@ any change to one appears here. ## [Unreleased] +## [0.11.0] — Evidence + +One question: **can the record be trusted after the fact, and kept?** + +Every milestone so far added something the receipt records. None asked whether the receipt is +still worth reading a year later, on a database an administrator can write to, after somebody +pruned it. This is the first milestone whose subject is the evidence itself rather than the +decision, and the first that opens by admitting a defect in the thing it is about: the chain has +never detected truncation or append, both reachable in two SQL statements, and both written down +since `SPEC-v0.6.md` §6.4. + ### Added - **`docs/SPEC-v0.11.md`, the v0.11 "Evidence" contract.** Documentation only; the version bump is @@ -86,6 +97,29 @@ any change to one appears here. `anchor_broken` and an anchoring deployment would have to choose between pruning and a permanent tamper signal. + **"Anchored" means the provider says so, at the pair the checkpoint claims.** The first + implementation took the union of what the provider returned and what the store's own `anchors` + table held, so one `INSERT` beside a forged checkpoint row bought supersession and the row's + hash was never compared to anything. Supersession now comes from `provider.since()` alone, and + the anchor's `(seq, hash)` must be the pair the checkpoint asserts. A local row the provider + does not confirm buys nothing. Found by the independent review the build order required for + this item, which also gave `SPEC-v0.11.md` §4.6 the sentence that says which reading is meant. + + **A prune leaves two receipts, and they are distinguishable.** The first records the request, + `--through`, `--older-than` and `--reason`, staged `proposed`; the second records what became + of it, `completed` or `refused`. They were byte-identical at first, and `--older-than` was in + neither, which made the record of a refusal worth nothing. + + **The bound comes from the receipts, not from `receipt_chain`.** That row is the one + `SPEC-v0.11.md` §2.1 assumes an attacker rewrites, and deciding `--through` from it meant one + `UPDATE` turned a prefix prune into a full-chain delete that both readers called clean. + + **The prune's lock is held across the validation and the delete on both backends.** SQLite's + `pruning()` opened `BEGIN IMMEDIATE` and then every `put_anchor` went through `with connection:` + and committed it, so the prune held the lock for one statement; a failed prune could leave + `missing` and `link_broken` on a chain that was intact when it started. The defect had been + found on Postgres during the item and fixed only there, and SQLite is the default backend. + **A checkpoint is a row, not a receipt field.** A receipt naming itself a checkpoint is a string in a document, and `SPEC-v0.3.md` §4.3.1 settled that shape. A prune writes a receipt for a human; the row is what the walk reads. diff --git a/CITATION.cff b/CITATION.cff index b9449123..929dd1b5 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -11,7 +11,7 @@ authors: - family-names: Ghoshal given-names: Arpan email: contact@arpanghoshal.com -version: 0.10.0 +version: 0.11.0 repository-code: https://github.com/CTRLRun/ctrlrun url: https://github.com/CTRLRun/ctrlrun license: Apache-2.0 diff --git a/README.md b/README.md index beaeac8a..f2842cac 100644 --- a/README.md +++ b/README.md @@ -130,7 +130,9 @@ second. [Run it in your browser](https://docs.ctrlrun.dev/try-it) with nothing i **Where it stops.** It does not detect prompt injection: it contains the consequence rather than reading the cause. It cannot promise exactly-once against a remote it does not control, it refuses to *knowingly* act twice, and it rolls nothing back. Receipts are chained, so an alteration -is detected. They are not signed: alteration is not authorship. The badge above means the +is detected; a truncation at the end and a forged append are not, because the head that would catch +them is a row in the same database, and closing that is what `ctrlrun anchor` is for. They are not +signed: alteration is not authorship. The badge above means the **declared guarantees pass** in the setup they ran against, and it does not mean secure, safe, compliant, certified or audited: [what the badge means](https://docs.ctrlrun.dev/verify#what-the-badge-means) diff --git a/adapters/PUBLISHED.toml b/adapters/PUBLISHED.toml index e3a9fc70..a80c6808 100644 --- a/adapters/PUBLISHED.toml +++ b/adapters/PUBLISHED.toml @@ -11,10 +11,23 @@ # `pip install ctrlrun-openai-agents` beside a current kernel then either refuses to resolve or # silently downgrades ctrlrun to 0.5.x. # -# This file is the record of the published side, updated by hand **in the same commit that tags -# a release**. `test_a_widened_kernel_range_is_not_shipped_without_a_new_version` compares it to -# the tree: if an adapter's range has moved away from what was published, its version must have -# moved too, or the release is one nobody can install. +# This file is the record of the published side: **what `pip download` gets today**, never what a +# tag about to be cut will publish. `test_a_widened_kernel_range_is_not_shipped_without_a_new_version` +# compares it to the tree: if an adapter's range has moved away from what was published, its +# version must have moved too, or the release is one nobody can install. Update a row **after** +# the upload succeeds, not beside the change that motivates it. +# +# **The 0.11.0 release pass got that wrong and the guard said nothing.** It widened the tree to +# `<0.12` and edited this file to `1.1.0 = <0.12` in the same commit, so the two sides agreed and +# the comparison returned early. PyPI had, and still has, `ctrlrun-langgraph` 1.1.0 declaring +# `ctrlrun<0.11`: +# +# $ curl -s https://pypi.org/pypi/ctrlrun-langgraph/json | jq -r .info.requires_dist[] +# ctrlrun<0.11,>=0.5 +# +# Which is the v0.10 defect this file was written for, recurring one release later, in the file. +# A record edited to match a claim is not a record. `RECORDED` in `test_packaging.py` freezes the +# range each published version declares, so raising one without a new version now fails. # # The tag carries NO `v`. `publish.yml` reads an adapter tag as everything after the last hyphen, # so `adapters-langgraph-1.1.0` names 1.1.0 and `adapters-langgraph-v1.1.0` names `v1.1.0` and is @@ -27,9 +40,9 @@ # that gets skipped in the run that mattered. [langgraph] -version = "1.0.0" -kernel = ">=0.5,<0.6" +version = "1.1.0" +kernel = ">=0.5,<0.11" [openai-agents] -version = "1.0.0" -kernel = ">=0.5,<0.6" +version = "1.1.0" +kernel = ">=0.5,<0.11" diff --git a/adapters/langgraph/README.md b/adapters/langgraph/README.md index 04b524bc..91ac3f40 100644 --- a/adapters/langgraph/README.md +++ b/adapters/langgraph/README.md @@ -3,7 +3,7 @@ Route a CTRLRun `APPROVE` through **LangGraph's own `interrupt()`**, so the human answers where your LangGraph users already answer. -- **Supported kernel range:** `ctrlrun>=0.5,<0.11` +- **Supported kernel range:** `ctrlrun>=0.5,<0.12` - **Supported framework range:** `langgraph>=1.0,<2.0` - **Primitive reused:** [`interrupt()` and `Command(resume=...)`](https://langchain-ai.github.io/langgraph/how-tos/human_in_the_loop/add-human-in-the-loop/), with a checkpointer. Read 2026-09-05. - **Framework shape:** resumed in place (SPEC-v0.5 §3.5). diff --git a/adapters/langgraph/pyproject.toml b/adapters/langgraph/pyproject.toml index 86d5a3cd..9b3e9285 100644 --- a/adapters/langgraph/pyproject.toml +++ b/adapters/langgraph/pyproject.toml @@ -8,7 +8,7 @@ build-backend = "setuptools.build_meta" [project] name = "ctrlrun-langgraph" -version = "1.1.0" +version = "1.2.0" description = "Route a CTRLRun APPROVE through LangGraph's own interrupt()." readme = "README.md" requires-python = ">=3.11" @@ -26,7 +26,7 @@ classifiers = [ # surface that has not been written. The README states the same two, and T137 asserts that what # it states is what CI installed. dependencies = [ - "ctrlrun>=0.5,<0.11", + "ctrlrun>=0.5,<0.12", "langgraph>=1.0,<2.0", ] diff --git a/adapters/langgraph/src/ctrlrun_langgraph/__init__.py b/adapters/langgraph/src/ctrlrun_langgraph/__init__.py index e82d722c..d0ec31d4 100644 --- a/adapters/langgraph/src/ctrlrun_langgraph/__init__.py +++ b/adapters/langgraph/src/ctrlrun_langgraph/__init__.py @@ -17,7 +17,7 @@ buys one thing over it: the interrupt. If your graph has nowhere for a human to answer, or you are happy for `ApprovalRequired` to reach your own code, use `@protect` and stop here. -Supported kernel range: `ctrlrun>=0.5,<0.11`. Supported framework range: `langgraph>=1.0,<2.0`. +Supported kernel range: `ctrlrun>=0.5,<0.12`. Supported framework range: `langgraph>=1.0,<2.0`. `README.md` states both, and what this adapter's binding check is and is not. """ diff --git a/adapters/openai-agents/README.md b/adapters/openai-agents/README.md index 35db19e8..e004c280 100644 --- a/adapters/openai-agents/README.md +++ b/adapters/openai-agents/README.md @@ -3,7 +3,7 @@ Route a CTRLRun `APPROVE` through the **OpenAI Agents SDK's own tool-approval interruption**, so the human answers where this SDK's users already answer. -- **Supported kernel range:** `ctrlrun>=0.5,<0.11` +- **Supported kernel range:** `ctrlrun>=0.5,<0.12` - **Supported framework range:** `openai-agents>=0.20,<1.0` - **Primitive reused:** [`needs_approval`, `RunResult.interruptions`, `RunState.approve` / `reject`](https://openai.github.io/openai-agents-python/tools/). Read 2026-09-05. - **Framework shape:** decided before invocation (SPEC-v0.5 §3.5). diff --git a/adapters/openai-agents/pyproject.toml b/adapters/openai-agents/pyproject.toml index d692e89c..87292b75 100644 --- a/adapters/openai-agents/pyproject.toml +++ b/adapters/openai-agents/pyproject.toml @@ -8,7 +8,7 @@ build-backend = "setuptools.build_meta" [project] name = "ctrlrun-openai-agents" -version = "1.1.0" +version = "1.2.0" description = "Route a CTRLRun APPROVE through the OpenAI Agents SDK's tool-approval interruption." readme = "README.md" requires-python = ">=3.11" @@ -25,7 +25,7 @@ classifiers = [ # not before, so `>=0.5` would claim compatibility with a surface not yet written. T137 asserts # the README states these and that CI ran inside them. dependencies = [ - "ctrlrun>=0.5,<0.11", + "ctrlrun>=0.5,<0.12", "openai-agents>=0.20,<1.0", ] diff --git a/adapters/openai-agents/src/ctrlrun_openai_agents/__init__.py b/adapters/openai-agents/src/ctrlrun_openai_agents/__init__.py index 8489ad0e..edaa8f3d 100644 --- a/adapters/openai-agents/src/ctrlrun_openai_agents/__init__.py +++ b/adapters/openai-agents/src/ctrlrun_openai_agents/__init__.py @@ -17,7 +17,7 @@ **You probably do not need this.** `@protect` covers anything in this process with no adapter and no framework support. This buys the interrupt and nothing else. -Supported kernel range: `ctrlrun>=0.5,<0.11`. +Supported kernel range: `ctrlrun>=0.5,<0.12`. Supported framework range: `openai-agents>=0.20,<1.0`. `README.md` states both, and states why this adapter's binding is **attribution** where LangGraph's is prevention. diff --git a/docs/SPEC-v0.11.md b/docs/SPEC-v0.11.md index 839ee092..641feea5 100644 --- a/docs/SPEC-v0.11.md +++ b/docs/SPEC-v0.11.md @@ -610,6 +610,28 @@ provider, which is outside the store. So the provider's own record shows that a what `seq`, and when. **A prune becomes something an operator can see in the anchor history even though the receipts are gone**, which is the whole of what retention owes evidence. +**"Itself anchored" means the provider says so, at the pair the checkpoint claims.** This sentence +was added by item 6 because the implementation read the word *anchored* the only other way it can +be read and the hole came straight back. `verify_anchors` built the set of anchored checkpoints +from the union of what the provider returned and what the store's own `anchors` table held, so one +`INSERT` beside the forged checkpoint row bought supersession, and the row's hash was never +compared to anything: + +``` +forged checkpoint at seq 4, local anchors row at seq 4 with hash 'sha256:not-a-hash-at-all' +-> ok=True superseded=1 breaks=[] +``` + +Two conditions, both required. The anchor must come from **`provider.since()` alone**, never from +the local table, which is inside the blast radius by definition. And its `(seq, hash)` must be the +pair the checkpoint asserts, because an anchor that is merely *present at that seq* proves nothing +about the hash the prune is asking the reader to accept. A local row the provider does not confirm +buys nothing. + +This is the third time in this document that §4.6 has been the section at fault, after round two +wrote it and round three found it cancelling against §3.2. The class is stable: the anchor and the +prune are each right alone. + The ordering follows from it and is not negotiable: **anchor the checkpoint, then delete.** A crash between them leaves an anchored checkpoint for a prune that did not happen, which over-reports and is the safe direction. The reverse leaves a prefix erased with nothing accounting for it, which is @@ -973,3 +995,139 @@ Written by item 6, in one pass, from the CHANGELOG line each item leaves. truth depends on a later item is a test that item owes, named in its table, or it is not in the document. Every forward-looking sentence above is either a MUST in §1.1, a row in §9 naming its item, or a row in §10. + +--- + +### 13.1 What the items found that the document did not + +Six items, and **every one of them found something by running the code rather than by reading +it**. That is the pattern worth carrying forward more than any individual finding. + +**Item 1 found a second defect underneath the one it was sent for.** `verify_chain`'s docstring +has claimed since v0.6 that *"position comes from the store's `seq` column"*, and it was false as +shipped: both backends selected `json, hash` and ordered by a column they never read, so every +position came out of the document, which is the half a tamperer controls. One `UPDATE` setting a +document's `seq` to 99 reported `missing 2`, `content_altered 99`, `missing 100` and +`link_broken 3`: four breaks at three positions, two of them rows that do not exist. + +**Item 1 shipped with a hole, and a review found it within the hour.** `json.loads` ran in the +generator expression that fed the new reader, *outside* its guard, so a row whose stored `json` +is not JSON at all raised through every reader exactly as before, and worse: `JSONDecodeError` +is not a `CTRLRunError`, so the CLI printed a traceback where 0.10.0 printed a clean message. +The tests missed it because **every tamper they ran changed a row's content**, and `{}` and a +float among the controls are both valid JSON. `T520` now parametrizes over seven ways a row can +fail to parse. + +**Item 2's mutation run found a design gap, not a test gap.** Restoring §3.2's rejected joint +ordering of the two anchor kinds survived every test, because nothing could *produce* a +checkpoint anchor below an interval one for the per-kind rule to have to allow: `make_anchor` +anchored the head and nothing else. But §4.6 needs exactly that, since a prune's checkpoint sits +below the head. The rule was right and unreachable. + +**Item 3 found that the prune held no lock at all**, in three stacked defects, by running two +prunes against a real Postgres server. The lock was taken inside the delete, so the validation +above it was unprotected; `put_anchor` and `put_checkpoint` each commit, so the transaction ended +mid-prune; and the connection is `autocommit=True` with every write taking an explicit `BEGIN`, +so a bare `SELECT ... FOR UPDATE` committed the instant it returned. **The symptom was a passing +test**: the losing prune was refused by the *anchor ordering*, which is shared state reached by +accident and would order differently under a different provider. + +**Item 4's proof was wrong in the way that looks exactly like a pass.** Run as +`PYTHONPATH=src python scripts/five_schema_chain.py`, the variable is inherited by every child, +so all five "released wheels" imported the build under test. It printed a chain of ten receipts +that verified perfectly and reported **one** schema version. A run that checked nothing was +indistinguishable from a run that checked everything, except in the number the script exists to +produce. + +**Item 5 corrected this document's own §7 in passing.** §7 says *"from events already written"*. +The action name is not on the event: `ACTION_PROPOSED` carries an `action_hash` and nothing that +maps it back. The answer comes from receipts, which every decided action leaves, **a denial +included** -- so an action that is always denied counts as exercised, and a design reading only +`EXECUTION_COMMITTED` would have told operators to delete the deny rule that was working. + +### 13.2 The rule these six have in common + +**A test passing is not the evidence. Running the real thing is.** Five of the six findings above +were invisible to a green suite, and three of them had a green test asserting the property that +was broken. The mutation runs caught what they caught because a mutation that survives is a +finding about the test; the rest needed a probe against a real store, a real server, or a real +released wheel. + +The corollary this milestone adds to `SPEC-v0.10.md` §11: **an equivalent mutation is a design +finding.** Item 2's joint-ordering mutation survived because the input that distinguishes the two +rules could not be constructed, and that was not a gap in the tests, it was a gap in the code. +Item 4's string-versus-number comparison survived for the same shape of reason, and the answer +was to make the derivation take its inputs so a test could hand it `v10`. + +### 13.3 Two overclaims that were already published + +Neither was found by a test, because neither was in code. + +**`OWASP-SOLUTIONS-LANDSCAPE.md` said the anchor detects "truncation and append."** It does not +detect an append: a forged receipt lands above every anchored `seq`, so no anchored pair stops +reproducing and a later anchor freezes the forged chain as readily as an honest one. That +document is the OWASP submission. `T531` now runs a forged append and requires both reports to +stay clean, so the limit is a tested property rather than a sentence somebody has to remember. + +**`ROADMAP.md`'s v0.11 line said enforcement coverage comes "from events already written."** It +cannot, for §7's reason above. + +The rule worth stating: **a claim about what a feature does not do needs a test as much as a +claim about what it does**, because nothing else will ever contradict it. + +### 13.4 The review item 3 required, and the seven defects it found after the merge + +The build order said *independent review is REQUIRED for items 2 and 3*, and of the two, *item 3's +is the one not to skip: a prune is the only operation in this library that destroys evidence, and a +defect there is a loss rather than a refusal.* The review ran. It found **seven defects**, every one +demonstrated with a script against the merged code, and it finished after the item had merged, which +is its own finding and is recorded below. + +**The first one is the reason the instruction exists.** §4.6's supersession rule was implemented as +the union of what the provider returned and what the store's own `anchors` table held. One `INSERT` +beside a forged checkpoint row bought supersession, with a hash nothing ever compared. The section +that named this attack in round three of the spec review reproduced it in code, which is as clear a +statement as this project has yet produced that **a defect named in a document is not a defect +closed.** §4.6 now says which of the two readings is meant. + +| Defect | What it cost | +|---|---| +| supersession decided from the local `anchors` table | §4.6 bought nothing: an erased prefix, a forged checkpoint and one local row read as `superseded` | +| the SQLite prune dropped its lock at its first write | `with connection:` committed `BEGIN IMMEDIATE`. A failed prune left `missing` and `link_broken` on a chain intact when it started | +| a hold placed during a prune was ignored | `put_hold` took no row lock and landed between the validation and the delete. Its receipts were deleted | +| `--through` above the head was bounded by `receipt_chain` | one `UPDATE` to the row §2.1 assumes is rewritten turned a prefix prune into a full-chain delete both readers called clean | +| rule 2's simulation dropped `UnreadableReceipt` and re-derived the head | one unparseable row cost the whole retention feature, refusing every honest prune | +| the checkpoint could assert a `(seq, hash)` pair that never existed | the `seq` came from `--through` rather than from the boundary receipt, and the prune then anchored the invented pair | +| a refused prune and a successful one left byte-identical receipts | and `--older-than` was in neither, so the record of a refusal recorded nothing | + +**Two of the seven are the same mistake in two places.** The SQLite lock defect had already been +found on Postgres during the item, and was fixed where it was found. SQLite is the default backend. +A fix applied to the instance and not to the class is §12.2's shape arriving in code. + +**The mutation run afterwards produced a finding about the code rather than the tests.** Deleting +rule 2's comparison outright, `if caused:` to `if False:`, survived the entire file. Once the +checkpoint names a pair that exists and the simulation is the store rather than a tidier version of +it, **no store state can reach that branch**: every construction is refused earlier, by the head +bound, the forward-only checkpoint, or the missing-hash refusal. The comparison stays as a backstop, +and `T556c` drives it directly, because a guard nothing can trigger is still a guard somebody will +edit. + +**What this says about ordering.** The build order put the required review inside the item and the +merge did not wait for it, so `main` carried all seven for the length of two more items. The rule +this milestone adds: **a required review is a merge gate, not a step in the item**, and an item that +names one is not done when its tests pass. + +### 13.5 What is still owed + +- **`SPEC-v0.11.md` §9 assigns the frozen-name list to item 2 and item 1 created it.** Item 1 had + two rows of its own and §9's whole point is that nothing turns red at release that could have + turned red during the item. The document should say item 1. +- **`pruning()` is not in §9's table.** It is the store method that makes §4.5's lock rule + implementable, and §4.5 requires the lock without naming a surface for it. +- **`AnchorProvider.make` returns `(token, time)` and §3.2's table says "an opaque token."** §3.3 + caches the time and §10 refuses an anchor whose time runs backwards; a time the provider does + not supply is one CTRLRun would read from its own clock, which rule 1 forbids. The three cannot + all hold with a bare token. +- **`StateStore.checkpoint`'s read shipped in item 2, and §9 assigns it to item 3.** §4.6's + supersession rule is part of what `anchor_broken` means, so an anchor without it would report + every anchor older than the retention window as tampering, forever. diff --git a/pyproject.toml b/pyproject.toml index 42f2db66..373c1c23 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "ctrlrun" -version = "0.10.0" +version = "0.11.0" description = "The execution safety layer for AI agents." # Mirrors the repository's GitHub topics, so PyPI search and GitHub search agree. keywords = [ diff --git a/tests/test_packaging.py b/tests/test_packaging.py index 90151cc8..0b490d71 100644 --- a/tests/test_packaging.py +++ b/tests/test_packaging.py @@ -896,6 +896,62 @@ def test_a_widened_kernel_range_is_not_shipped_without_a_new_version(adapter): ) +#: What each **published** adapter version declares, frozen. Read off PyPI once and never +#: derived from the tree: +#: +#: $ curl -s https://pypi.org/pypi/ctrlrun-langgraph/json | jq -r .info.requires_dist[] +#: ctrlrun<0.11,>=0.5 +#: +#: Checked 2026-09-14, during the 0.11.0 release pass, for both adapters at both versions. +RECORDED: dict[str, dict[str, str]] = { + "langgraph": {"1.0.0": ">=0.5,<0.6", "1.1.0": ">=0.5,<0.11"}, + "openai-agents": {"1.0.0": ">=0.5,<0.6", "1.1.0": ">=0.5,<0.11"}, +} + + +@pytest.mark.parametrize("adapter", ADAPTER_DIRECTORIES) +def test_the_published_record_is_a_record_and_not_a_plan(adapter): + """A released version's range is a historical fact. Editing it is how the guard above dies. + + The test above compares `adapters/PUBLISHED.toml` to the tree and returns early when they + agree, which is correct **as long as the file says what is on PyPI**. The 0.11.0 release pass + widened the tree to `<0.12` and edited the file to `1.1.0 = <0.12` in the same commit, so the + two sides agreed, the comparison returned early, and the adapter version never moved. PyPI + still had 1.1.0 declaring `ctrlrun<0.11`, so `pip install ctrlrun-langgraph` beside a 0.11.0 + kernel would refuse to resolve or downgrade the kernel to 0.10.x: exactly the defect + `PUBLISHED.toml` exists to prevent, one release after it was written to prevent it. + + Both sides agreeing is the state a guard cannot distinguish from correct, so this fixes the + published half in place. A version already uploaded cannot change what it declares, so + raising its range here now fails, and the only way forward is the one that works: a new + version, a tag, and a row added after the upload lands. + + **Hand-maintained deliberately**, on the same rule as `PUBLISHED.toml` itself: a check that + needs the network is a check that gets skipped in the run that mattered. + """ + import tomllib as _tomllib + + published_file = REPO_ROOT / "adapters" / "PUBLISHED.toml" + if not published_file.exists(): + pytest.skip("adapters/ is not in this distribution, which SPEC-v0.5 §6.1 requires") + + with published_file.open("rb") as handle: + published = _tomllib.load(handle)[adapter] + + version, kernel = published["version"], published["kernel"] + known = RECORDED[adapter] + assert version in known, ( + f"adapters/PUBLISHED.toml records {adapter} {version}, which is not a version this " + f"suite has seen on PyPI ({', '.join(sorted(known))}). Record a version AFTER the " + "upload succeeds, and add it to RECORDED from PyPI's own requires_dist." + ) + assert kernel == known[version], ( + f"adapters/PUBLISHED.toml says {adapter} {version} declares ctrlrun{kernel}; PyPI says " + f"ctrlrun{known[version]}. A released version cannot change what it declares. If the " + "range needs widening, bump the adapter version and tag it." + ) + + @pytest.mark.parametrize("adapter", ADAPTER_DIRECTORIES) def test_each_adapter_declares_a_kernel_range_that_contains_this_kernel(adapter): """SPEC-v0.5 §6.3's two ranges, checked against the kernel that is actually here.