From e78ce298cdcf08b8d286654befba0a664822b89d Mon Sep 17 00:00:00 2001 From: arpan Date: Mon, 14 Sep 2026 22:39:34 +0530 Subject: [PATCH 1/3] =?UTF-8?q?0.11.0:=20the=20version,=20the=20adapter=20?= =?UTF-8?q?ranges,=20and=20SPEC-v0.11=20=C2=A713?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The adapters' kernel range was widened at 0.7, 0.8, 0.9 and 0.10 and neither was ever re-published, so ctrlrun-openai-agents 1.0.0 still declares ctrlrun<0.6 on PyPI. PUBLISHED.toml now records what the tags cut from this commit will publish. §13 is written from what each item found, and every one of the six found it by running the code rather than by reading it. Signed-off-by: arpan --- CHANGELOG.md | 11 +++ adapters/PUBLISHED.toml | 8 +- adapters/langgraph/README.md | 2 +- adapters/langgraph/pyproject.toml | 2 +- .../src/ctrlrun_langgraph/__init__.py | 2 +- adapters/openai-agents/README.md | 2 +- adapters/openai-agents/pyproject.toml | 2 +- .../src/ctrlrun_openai_agents/__init__.py | 2 +- docs/SPEC-v0.11.md | 94 +++++++++++++++++++ pyproject.toml | 2 +- 10 files changed, 116 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d6225d12..254dcb81 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 diff --git a/adapters/PUBLISHED.toml b/adapters/PUBLISHED.toml index e3a9fc70..0716f887 100644 --- a/adapters/PUBLISHED.toml +++ b/adapters/PUBLISHED.toml @@ -27,9 +27,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.12" [openai-agents] -version = "1.0.0" -kernel = ">=0.5,<0.6" +version = "1.1.0" +kernel = ">=0.5,<0.12" 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..4e2c4c8c 100644 --- a/adapters/langgraph/pyproject.toml +++ b/adapters/langgraph/pyproject.toml @@ -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..07b54ec0 100644 --- a/adapters/openai-agents/pyproject.toml +++ b/adapters/openai-agents/pyproject.toml @@ -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..0ceec4ba 100644 --- a/docs/SPEC-v0.11.md +++ b/docs/SPEC-v0.11.md @@ -973,3 +973,97 @@ 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 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 = [ From f2dad3467ee31886357f37aaa8442c0b4229c35f Mon Sep 17 00:00:00 2001 From: arpan Date: Mon, 14 Sep 2026 22:44:09 +0530 Subject: [PATCH 2/3] CITATION.cff carries the release version Signed-off-by: arpan --- CITATION.cff | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 4790195f484be51ce4fc7d55ac4da41915190bd1 Mon Sep 17 00:00:00 2001 From: arpan Date: Mon, 14 Sep 2026 23:35:32 +0530 Subject: [PATCH 3/3] The release pass, and the record it found had been edited to match a claim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **`adapters/PUBLISHED.toml` said something PyPI contradicts.** The file exists because the adapters' kernel range was widened at 0.7, 0.8, 0.9 and 0.10 and neither adapter was re-published, so `ctrlrun-openai-agents` 1.0.0 went on declaring `ctrlrun<0.6` while the kernel reached 0.10.0. Its guard compares the range in the tree against the range recorded here and returns early when they agree. This release pass widened the tree to `<0.12` and edited the record to `1.1.0 = <0.12` in the same commit. The two sides agreed, the comparison returned early, and the adapter version never moved: $ curl -s https://pypi.org/pypi/ctrlrun-langgraph/json | jq -r .info.requires_dist[] ctrlrun<0.11,>=0.5 1.1.0 is on PyPI declaring `<0.11`, and it cannot be re-uploaded. So `pip install ctrlrun-langgraph` beside a 0.11.0 kernel would refuse to resolve or pull the kernel back to 0.10.x: the v0.10 defect, one release later, inside the file written to prevent it. Both adapters go to 1.2.0, the record goes back to what is actually published, and `RECORDED` freezes the range each released version declares, because a released version cannot change what it requires. Replaying the mistake against the new guard fails both adapters; recording a version nobody uploaded fails too. **The README said the chain detects alteration and stopped there.** It is the sentence this milestone changes: a truncation at the end and a forged append are not detected, because the head that would catch them is a row in the same database. **`SPEC-v0.11.md` §4.6 said "itself anchored" and did not say by whom.** The implementation read it the only other way it can be read, and §13.4 records that round and the six defects beside it. §13 is written from what each item found. Signed-off-by: arpan --- CHANGELOG.md | 23 ++++++++++ README.md | 4 +- adapters/PUBLISHED.toml | 25 +++++++--- adapters/langgraph/pyproject.toml | 2 +- adapters/openai-agents/pyproject.toml | 2 +- docs/SPEC-v0.11.md | 66 ++++++++++++++++++++++++++- tests/test_packaging.py | 56 +++++++++++++++++++++++ 7 files changed, 168 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 254dcb81..36db8ef2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -97,6 +97,29 @@ since `SPEC-v0.6.md` §6.4. `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/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 0716f887..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 @@ -28,8 +41,8 @@ [langgraph] version = "1.1.0" -kernel = ">=0.5,<0.12" +kernel = ">=0.5,<0.11" [openai-agents] version = "1.1.0" -kernel = ">=0.5,<0.12" +kernel = ">=0.5,<0.11" diff --git a/adapters/langgraph/pyproject.toml b/adapters/langgraph/pyproject.toml index 4e2c4c8c..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" diff --git a/adapters/openai-agents/pyproject.toml b/adapters/openai-agents/pyproject.toml index 07b54ec0..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" diff --git a/docs/SPEC-v0.11.md b/docs/SPEC-v0.11.md index 0ceec4ba..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 @@ -1053,7 +1075,49 @@ 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 What is still owed +### 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 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.