Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,94 @@ any change to one appears here.

## [Unreleased]

## [0.12.0] — Hardening

One question: **is the thing this project says about itself checkable?**

No new capability. Three claims that were prose became tests, and each of the three was false or
incomplete when the test was written, which is the milestone's whole argument.

### Changed

- **The module map is acyclic, and `ARCHITECTURE.md` §6's rule is now a test.** §6 has said
*dependencies point downward only* since v0.1, and from v0.7 it was false: a review found
`state -> receipt -> policy -> authority -> state` and the sentence was amended to record the
cycle rather than fix it. Nothing broke at run time, because the two edges out of `policy.py`
are function-level, so `import ctrlrun` resolved in one order and the suite passed for five
milestones.

Broken in two places. `Decision` and `POLICY_UNAPPROVED` moved to **`ctrlrun.decision`**, which
imports nothing from the package: that was the whole of what a receipt needed from the decider,
and an evidence type reaching **up** into it is the edge that most contradicts §6's table. Then
the policy **document grammar** -- schemas, the strict YAML loader, the condition parser and
evaluator, type-strict equality -- moved to **`ctrlrun.grammar`**, so `authority.py` no longer
imports `policy.py` at all. `SPEC-v0.3.md` §4.5 requires the two axes to share **one** condition
evaluator, and that is better served than before: the one evaluator is owned by neither axis.

**No public name moved.** `policy.py` re-exports all thirty-four, so
`from ctrlrun.policy import Decision, Condition, parse_conditions` resolves to the same objects
and `SPEC-v0.1.md` §8's frozen `__init__` block is unchanged.

`tests/test_module_graph.py` walks every module's AST and separates two questions §6 kept
conflating: the **import-order** graph, which is module-level imports, and the **layering**
graph, which counts deferred imports and is what the table describes. The recorded cycle is
invisible to the first, so a guard built only on module-level imports **passes on 0.11.0's
tree**. Writing it found a second cycle nobody had recorded, `jwt_identity` and `revocation`
sharing a security-critical redirect handler through a deferred import; `_NoRedirects` moved
down to `revocation.py` and keeps logging under `ctrlrun.policy`'s logger name so no operator's
handler is re-routed.

- **The gateway told MCP clients to call a Python API.** A `-41002` relayed
`str(ApprovalRequired)` verbatim, and that exception carries the decorator's wording: *"run
`ctrlrun approve …`, then retry inside `ctrlrun.with_approval(…)`"*. On the one path where the
caller may be in any language, and is often a model reading the error as text, it pointed at a
context manager the caller cannot reach. It now says what the gateway's own documentation
already said: a human approves and **this same call runs**.

### Added

- **Property tests over generated inputs** (`tests/test_properties.py`, `hypothesis`). The
invariant that matters is the one v0.11 item 1 violated: **every break a tamper reports names a
row the store actually holds.** That defect reported `content_altered 99` and `missing 100` on
an eight-row chain, because position came from the document rather than the `seq` column.
Reinstating it fails these tests immediately.

The first version of that property was *one tamper is one break*, and hypothesis falsified it on
its second example: altering row `n` also breaks the link at `n + 1`. Two is correct and the
expectation was wrong, which is the same mistake the file exists to catch. `derandomize=True`,
so a counterexample found in CI reproduces locally by construction.

- **A CycloneDX SBOM of the wheel, measured from the wheel** (`scripts/sbom.sh`). Generated by
installing the built wheel into an empty environment and recording what resolves, not by reading
`pyproject.toml`: a manifest-derived SBOM is the project's opinion of its own dependencies. The
answer is two, `PyYAML` and `click`. CI generates and checks it on every pull request, and
`release.yml` writes it into `dist/` **before** the attestation step, so it is signed with the
distributions and attached to the release.

The seed packages are removed before the scan, and that is not cosmetic: `python -m venv` adds
pip, and on 3.11 setuptools too, and a scanner cannot tell *"ctrlrun needs this"* from *"the venv
came with this"*. The first version removed only pip, passed on 3.12 and went red in CI on 3.11
with `['PyYAML', 'click', 'setuptools']`. The assertion compares by **equality**, which is why it
caught a document that would have overstated what a consumer takes on.

### Fixed

- **A shared-directory race in the cookbook tests.** Two tests ran the same recipe in
`examples/cookbook/<name>/` on different xdist workers; one's `rm -f verify-report.json` landed
between the other's write and read, and `bash -euo pipefail` turned it into a failure with
nothing wrong in the library. Measured at four concurrent runs: shared directory 2 of 4 fail,
one copy per run 4 of 4 pass. Each test now runs in its own copy, and the suite stops writing
into the working tree.

- **Both adapter READMEs failed on copy-paste.** Their examples show `identity=...` as an
ellipsis, and dropping it leaves the first call refused with `no principal is available`. Both
now state the requirement and say why it is fail-closed.

- **`adapters/PUBLISHED.toml` had gone stale in the other direction.** 0.11.0 published both
adapters at 1.2.0 with `<0.12` and never updated the record, so the file spent a release
claiming 1.1.0 / `<0.11`. Both adapters go to **1.3.0** with `ctrlrun>=0.5,<0.13`, because the
published 1.2.0 excludes this kernel, and `RECORDED` now freezes 1.2.0's range too.

## [0.11.0] — Evidence

One question: **can the record be trusted after the fact, and kept?**
Expand Down
2 changes: 1 addition & 1 deletion CITATION.cff
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ authors:
- family-names: Ghoshal
given-names: Arpan
email: contact@arpanghoshal.com
version: 0.11.0
version: 0.12.0
repository-code: https://github.com/CTRLRun/ctrlrun
url: https://github.com/CTRLRun/ctrlrun
license: Apache-2.0
Expand Down
16 changes: 12 additions & 4 deletions adapters/PUBLISHED.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,14 @@
# ctrlrun<0.11,>=0.5
#
# Which is the v0.10 defect this file was written for, recurring one release later, in the file.
#
# **And it went stale a third time, in the opposite direction.** The 0.11.0 release tagged and
# published both adapters at 1.2.0 with `<0.12`, and nobody came back here afterwards, so this
# file spent a release claiming 1.1.0 / `<0.11` while PyPI served 1.2.0 / `<0.12`. The guard below
# said nothing, because a tree that differs from the record *and* carries a higher version is
# exactly the shape of a correct pending release. The rule in the line above -- update the row
# **after** the upload succeeds -- is the one that was not followed, and it is easy to miss
# precisely because the upload is the last thing that happens.
# 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.
#
Expand All @@ -40,9 +48,9 @@
# that gets skipped in the run that mattered.

[langgraph]
version = "1.1.0"
kernel = ">=0.5,<0.11"
version = "1.2.0"
kernel = ">=0.5,<0.12"

[openai-agents]
version = "1.1.0"
kernel = ">=0.5,<0.11"
version = "1.2.0"
kernel = ">=0.5,<0.12"
2 changes: 1 addition & 1 deletion adapters/langgraph/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.12`
- **Supported kernel range:** `ctrlrun>=0.5,<0.13`
- **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).
Expand Down
4 changes: 2 additions & 2 deletions adapters/langgraph/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "ctrlrun-langgraph"
version = "1.2.0"
version = "1.3.0"
description = "Route a CTRLRun APPROVE through LangGraph's own interrupt()."
readme = "README.md"
requires-python = ">=3.11"
Expand All @@ -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.12",
"ctrlrun>=0.5,<0.13",
"langgraph>=1.0,<2.0",
]

Expand Down
2 changes: 1 addition & 1 deletion adapters/langgraph/src/ctrlrun_langgraph/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.12`. Supported framework range: `langgraph>=1.0,<2.0`.
Supported kernel range: `ctrlrun>=0.5,<0.13`. Supported framework range: `langgraph>=1.0,<2.0`.
`README.md` states both, and what this adapter's binding check is and is not.
"""

Expand Down
2 changes: 1 addition & 1 deletion adapters/openai-agents/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.12`
- **Supported kernel range:** `ctrlrun>=0.5,<0.13`
- **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).
Expand Down
4 changes: 2 additions & 2 deletions adapters/openai-agents/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "ctrlrun-openai-agents"
version = "1.2.0"
version = "1.3.0"
description = "Route a CTRLRun APPROVE through the OpenAI Agents SDK's tool-approval interruption."
readme = "README.md"
requires-python = ">=3.11"
Expand All @@ -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.12",
"ctrlrun>=0.5,<0.13",
"openai-agents>=0.20,<1.0",
]

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.12`.
Supported kernel range: `ctrlrun>=0.5,<0.13`.
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.
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "ctrlrun"
version = "0.11.0"
version = "0.12.0"
description = "The execution safety layer for AI agents."
# Mirrors the repository's GitHub topics, so PyPI search and GitHub search agree.
keywords = [
Expand Down
4 changes: 2 additions & 2 deletions tests/test_packaging.py
Original file line number Diff line number Diff line change
Expand Up @@ -904,8 +904,8 @@ def test_a_widened_kernel_range_is_not_shipped_without_a_new_version(adapter):
#:
#: 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"},
"langgraph": {"1.0.0": ">=0.5,<0.6", "1.1.0": ">=0.5,<0.11", "1.2.0": ">=0.5,<0.12"},
"openai-agents": {"1.0.0": ">=0.5,<0.6", "1.1.0": ">=0.5,<0.11", "1.2.0": ">=0.5,<0.12"},
}


Expand Down