From a1b17d9d4bebfebcf43f52a29be6a39ad8abee76 Mon Sep 17 00:00:00 2001 From: arpan Date: Sat, 12 Sep 2026 04:01:09 +0530 Subject: [PATCH 1/2] Release 0.7.0: version, changelog, README, and two stale version pins The release pass for v0.7 "Execution boundary". No library code changes. pyproject is 0.7.0 and the changelog's [0.7.0] section is undated until the tag. The changelog is rewritten as a release rather than the six bullet lists six parallel lanes each appended to. Two sections come before Added, and both exist because a reader upgrading needs them and the concatenation did not give them: - **Stricter than 0.6.1, with what 0.6.1 did.** A continuation leg never records FAILED, so the gateway answers -41010 where it answered -41011 and permitted a retry of an effect the upstream may have been part-way through. Behind a proxy the gateway claims nothing, where it mapped every ConnectError to NEVER_CONNECTED. ctrlrun.policy/v5 and ctrlrun.receipt/v4, each with what an 0.6.1 reader does with a document it does not know. Migration 0005 and the sentence about stopping every 0.6 process first. A precondition, once one exists, is never skipped. And verify now binds loopback listeners of its own. - **What this release does not close.** The recheck narrows and does not close it. The reconcile route costs a wasted human answer and three provider calls, and reports precondition_unavailable rather than attempt_ceiling when the provider is down. The ceiling bounds attempts and not executor invocations. A refused attempt number is spent. The classifier's register sees only this library's own sends. Attempt identity under a reused action_id. Section 6.4's residual. And the malformed value that blinds every receipt reader, which is now a named item on the roadmap before v1.0. Seven residuals in the specification and none in the changelog would be the prevention-versus-attribution rule failing at the last surface it passes through. README: the NotExecuted step names ctrlrun.transport and what it will not claim, the approval step says a precondition fingerprint narrows the window and does not close it, and the verify sentence says what is true of verify now instead of "with no network". Two things the version bump broke that nothing else would have caught, both found by tests written when the same thing went wrong before: - Both adapters declared ctrlrun>=0.5,<0.7, which excludes the kernel they ship beside, so pip install ctrlrun-langgraph would have refused to resolve or silently downgraded ctrlrun. That is the 0.5,<0.6 defect at 0.6.0 again. Now >=0.5,<0.8 in all six places the two adapters state it. - CITATION.cff still said 0.6.1. T271 runs the demo in a subprocess whose sitecustomize is conftest.py's one network guard. The T11 fixture runs it in this process through CliRunner, where nothing has been taken away, so "under 60 seconds with no network" was two claims of which only the first was measured. Measured with the guard: 0.13 seconds, five scenarios, nothing reached. SPEC-v0.7 section 12.6 records what the release pass settled, including the third copy of the network guard that section 12.2.7 did not know about, in the documentation repository. --- CHANGELOG.md | 286 ++++++++++++++---- CITATION.cff | 2 +- README.md | 16 +- 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.7.md | 57 ++++ pyproject.toml | 2 +- tests/test_demo.py | 38 +++ tests/test_preconditions.py | 8 + 13 files changed, 343 insertions(+), 78 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 59d7762..9041576 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,14 +7,210 @@ All notable changes to this project are documented here. The format follows Public API names are frozen in `docs/SPEC-v0.1.md` §8. Before 1.0 they may still change, and any change to one appears here. -## [Unreleased] +## [0.7.0] — unreleased — Execution boundary + +Every milestone before this one asked what holds *inside* CTRLRun. v0.7 asks whether it holds at +the edges the kernel does not control. The kernel does not decide whether the remote acted, an +executor does. It does not own the clock its leases are measured against, once the store is on +another host. It does not know whether the world still looks the way it did when a human said +yes. Six items answer those edges: + +| What it adds | Where | +|---|---| +| **`ctrlrun.transport`**, the `NotExecuted` classifier, in core and stdlib only. One rule, promoted out of `ctrlrun[gateway]` rather than copied, reachable from `@protect`. | §2 | +| **Clock-skew detection.** `PostgresStateStore` measures its server's clock against this host's and names divergence with a new event. It observes and reports, and changes no decision. | §3 | +| **Attempt numbers that never repeat.** Three Postgres defects that could hand one attempt number out twice, or move it backwards, fixed before v0.7 made the number load-bearing. | §5.6 | +| **The provider idempotency token**, `ctrlrun.idempotency_token()`, derived from `(effect_key, attempt)`: a deterministic handle for reconciliation, and one that changes on a renewal. | §4 | +| **The attempt ceiling**, `max_attempts`, a policy key bounding renewal after `FAILED`. Needs `ctrlrun.policy/v5`. | §5 | +| **Precondition fingerprints**, an approval bound to the resource state it was granted against and rechecked strictly before the reservation. Needs `ctrlrun.receipt/v4`. | §6, §7 | + +Section numbers are `docs/SPEC-v0.7.md`, which is the contract; §9 freezes every public name +added here. `ctrlrun verify` now grades sixteen guarantees under `ctrlrun.guarantees/v3`, G12 to +G16 being new, each with a positive control and each `N/A` only for a reason that is true of the +document it was handed. `pip install ctrlrun` still installs `pyyaml` and `click` and nothing +else, `import ctrlrun` still imports no module from an extra, and `ctrlrun demo` still runs every +scenario in under a minute with no network. + +### Stricter than 0.6.1, with what 0.6.1 did + +Everything here can refuse, or record as unknown, something 0.6.1 accepted or recorded as +settled. Nothing here is a flag, and no setting relaxes any of it. + +- **A continuation leg never records `FAILED`.** A continuation exists only because the remote + answered and is holding the exchange, so nothing on that leg can say the remote did nothing. + At 0.6.1 a refused connection on a continuation, a pre-dispatch JSON-RPC code, the `401` rule + of `v0.2 §6.8` and a tool error under an operator's `not_executed_on_error: true` each recorded + `FAILED`, and the gateway answered the client `-41011` "not executed", **which permitted a + retry** of an effect the upstream may have been part-way through. The gateway now records + `AMBIGUOUS` and answers `-41010`, and the effect needs `ctrlrun resolve`. The upstream's own + response is relayed unchanged, tool error included. +- **Behind a proxy the gateway claims nothing.** httpx reports an unreachable proxy and a TLS + failure with the *target* after the proxy answered the `CONNECT` line with the same + `ConnectError`, and a written `CONNECT` line is a written byte. At 0.6.1 the forwarder mapped + every `ConnectError` to `NEVER_CONNECTED`, therefore to a `failed` receipt and `-41011`. Where + `urllib.request.getproxies()` names a proxy, `ConnectError` and `ProxyError` are now an unknown + outcome: `AMBIGUOUS`, `-41010`, and a `ctrlrun resolve`. `NO_PROXY=*` is honoured and a + narrower `NO_PROXY` is not consulted, so a bypassed host is judged as if it were proxied, which + costs a claim and never makes a false one. With no proxy configured nothing changes. +- **`ctrlrun.policy/v5`.** A document that declares `max_attempts` must declare `v5`; 0.6.1's + newest schema was `v4`, and a `v4` document naming the key is a `PolicyError` at load, with the + key, the action and the line. Every document that loaded at 0.6.1 loads unchanged and renews + without bound, because there is no default ceiling and no value of the key means "unlimited". + An 0.6.1 reader refuses a `v5` document, as it should. +- **`ctrlrun.receipt/v4`.** Two new fields, `precondition_at_request` and + `precondition_at_recheck`. **Upgrade every reader before any writer**: a `v4` JSONL line handed + to 0.6.1 rehashes wrongly and reads as altered. Rendering is stricter in the other direction + too, and visibly: `to_dict()`, `ctrlrun receipts --json` and `ctrlrun inspect` render each + receipt under the schema it was written with, so a pre-v0.6 receipt shows its own `v1` or `v2` + label and keys where 0.6.1 showed `v3`. A key added to a stored receipt, a relabelled `schema`, + a removed one or an unknown one is `content_altered` at its `seq`, where a reader could have + missed it before. +- **Migration `0005_precondition_fingerprint`, and a store no 0.6 process may still hold.** A + database built by 0.6.1's own code migrates keeping every row, and 0.6.1 then refuses it at + open, naming `0005`. **Stop every 0.6 process before any 0.7 process opens the store**: a store + checks migrations only at open, so a 0.6.1 process already running would consume a fingerprinted + approval with no comparison, *and* would rehash every `v4` receipt under `v3`'s keys and report + a correct chain as altered. The trigger is the first receipt a 0.7 process writes, not the first + caller that passes `preconditions=`. +- **A precondition, once one exists, is never skipped.** An approval that carries a fingerprint, + presented by a call that names no provider, is refused rather than consumed: that includes the + gateway and the ACS hook, which name none. A provider that raises, hangs or returns something + with no canonical form refuses the action and reserves nothing. There is no + `skip_preconditions` and no timeout parameter. +- **The attempt ceiling is stricter only where an operator asks for it**, and then it is + absolute: above the ceiling the executor is not called, on any route, and `ActionDenied` names + `attempt_ceiling`. +- **`ctrlrun verify` opens loopback sockets it bound itself.** `v0.4 §3.7`'s "no scenario opens a + socket" becomes *no connection except to the store `--store-url` names and to loopback + listeners verify bound itself*, because G12 needs a peer that can receive a byte. The old + sentence was already untrue under `--store-url postgresql://remote-host/…`. The test suite's + network guard admits exactly that and no more: IPv4 to the `127.0.0.1` literal, at a port this + process bound through a stream socket that is still open. `localhost`, `::1`, `0.0.0.0`, every + `AF_UNIX` path and every datagram send are refused. + +### What this release does not close + +Stated here, and not only in the specification, because each one is a limit somebody operating +this will meet. + +- **A precondition fingerprint narrows the window between a human's approval and the action's + execution, and does not close it.** The recheck is a network call, so it runs outside the atomic + reservation write, and a change that lands after the comparison and before the reservation is + not refused. It takes the exposure from minutes of human deliberation down to milliseconds, + which is worth having and is not prevention. T261b opens that residual window and asserts + exactly that. +- **On the reconcile route a doomed attempt still costs a human answer and three provider + calls.** Under `max_attempts: 1` on an `approve` action whose retry carries a `reconcile` hook, + the approval gate runs before the ceiling's check: a new approval request can be created, a + human can grant it, and the reservation that consumes it is then refused with + `attempt_ceiling`. One wasted answer, never an execution. On that same route the precondition + provider is called **three times**, once on the request pass and twice on the retry, and a + provider that is down there makes the refusal `ApprovalMismatch(reason="precondition_unavailable")` + rather than `attempt_ceiling`, writes no effect record and never runs the `reconcile` hook, so + the operator is told the wrong reason for an attempt that could never have run. The ordinary + sequential route calls the provider zero times and refuses before any human is asked. Closing + either needs a seam that would make the ceiling's check unreachable from any public route, and + a guarantee that could not have failed is not a pass. +- **`max_attempts` bounds attempts, not executor invocations.** A `Suspended` executor holds its + reservation and every `Control.resume` runs on that same attempt, so an elicitation loop is one + dispatch however many rounds it takes. The gateway bounds those with `max_elicitation_rounds`; + a direct `Control.resume` caller has no bound, and this release adds none. +- **A refused attempt number is spent.** Raising `max_attempts` from 2 to 4 after a refusal buys + one further dispatch, not two. +- **The classifier's register sees only this library's own sends.** A claim is about the executor + run, not about one connection, and `Control` marks a register on every send through + `ctrlrun.transport` or `ctrlrun.gateway.transport.request`. An executor that sends part of the + effect through `requests`, through httpx directly, or on a raw socket, and then uses the + classifier, can be handed a `NotExecuted` that is true of these connections and false of the + effect. So can one that raises a claim while a sibling thread's request is still in flight. The + claim holds where every request of the effect goes through the classifier on the executor's + context, and that sentence is in the module docstring, the class docstring and §2.3. +- **A reused `action_id` still leaves the attempt a late write is about undecided**, on every + backend. The attempt number is now monotonic, so no two reservations of one key carry the same + number; what is not closed is attempt *identity*. A transition names its holder by `action_id` + alone, so attempt 1's write, arriving after its lease lapsed and after the same `Action` was + retried, can read attempt 2's record, find its own id in a state it expects, and land there. A + late `fail_effect` is the sharp case: it writes `FAILED` over an executing attempt 2, and + `FAILED` is the state that permits a renewal. A `mark_ambiguous` reaches the same place in one + more step, through a `reconcile` hook that is asked about the effect key and not about the + attempt, so it needs no person. `ctrlrun resolve` carries no attempt number either, so someone + who inspected attempt 1 can resolve attempt 2's ambiguity. The fix inside the frozen + `StateStore` protocol is a store-side memo of the attempt each reservation wrote, which is a + schema change of its own; §12.3a states the argument so the next milestone inherits it. +- **An approval granted and presented inside `request()` is spent before `Control` knows the + request exists** (§6.4). What would take that away is a store call recording a request and its + fingerprint in one write, and `StateStore` is frozen. +- **A malformed *value* of a key a receipt schema declares still raises out of `from_dict`.** One + `UPDATE` putting a float among a receipt's `controls` blinds `ctrlrun receipts`, + `receipts --verify-chain`, `inspect`, `stats` and G11 together, where the schema-level and + added-key cases are each reported at their `seq` and leave every other row readable. 0.6.1 + behaves the same and v0.7 neither introduces nor widens it. Fixing it needs a new name in + `CHAIN_BREAKS`, a closed set on a `v0.6 §6.5` surface, or a reader that walks raw rows; it is a + named item on the roadmap before v1.0. ### Added -- **Python 3.13 and 3.14 are tested and declared.** CI's `check` job runs the full suite on - 3.11, 3.12, 3.13 and 3.14, and the package classifiers name all four. The floor is unchanged: - `requires-python` stays `>=3.11`, and mypy and ruff still check against 3.11. No library code - changed; the one test fix is below. +- **`ctrlrun.transport`, the `NotExecuted` classifier, in core** (SPEC-v0.7 §2, build-list item + 2). `v0.1 §5.5` leaves the one decision the product exists to get right, `FAILED` or + `AMBIGUOUS`, to the executor, and until now the correct rule was reachable only through + `ctrlrun[gateway]`. `ctrlrun.transport.urlopen`, `HTTPConnection` and `HTTPSConnection` are + `urllib` and `http.client` with a counter: they raise `NotExecuted`, chained from the original + exception, **only** where the connection they opened fresh failed before a single request byte + was handed to its socket (DNS failure, refusal, connect timeout, a TLS handshake failure). Every + other failure is the original exception, which the kernel records `AMBIGUOUS`: a reset or a + timeout after the request was offered, a `sendall` that raised part way, a reused connection, a + socket the caller set, an opener the classifier did not build, a proxy that refused a tunnel + after its `CONNECT` line was sent. The count is taken from evidence, never from an exception's + type, and above TLS. No redirect is followed, no HTTP status is ever `NotExecuted`, and no + parameter, attribute or environment variable changes a classification. The module is stdlib + only and is not imported by `import ctrlrun`. + + The rule itself, `ctrlrun.transport.effect_state`, is the one implementation: the gateway's + `Transport` is now the core one, and `gateway/outcome.py` asks the core rule rather than keeping + a copy. `ctrlrun.gateway.transport.request` offers the gateway's httpx mapping to an executor + that uses httpx, on a client built for the one call. The gateway's own `NotExecuted`, for an + upstream it never reached, is now chained from the httpx exception and its receipt names it. + `ctrlrun verify` gains **G12**, "a byte written is ambiguous", under + `ctrlrun.guarantees/v3`, with the refused connection as its positive control. G12 needs a + loopback peer, so verify's rule becomes *no connection except to the store `--store-url` names + and to loopback listeners verify bound itself*, and the test suite's network guard admits + exactly that: IPv4 on the `127.0.0.1` literal, to a port the process bound through a stream + socket that is still open, and nothing else. + + **The claim is about the executor run, not about one connection.** An independent review showed + that every false `NotExecuted` it could produce came from two connections in one effect: the + first delivered the request, the second was refused, and a per-connection classifier judged the + second alone. `xmlrpc.client`'s retry, `FancyURLopener` following a `303`, an opener whose + handler runs on a worker thread, and an executor's own retry-once-on-reset loop all make that + pair. `Control` now opens a register around each executor call; every send through + `ctrlrun.transport` or `ctrlrun.gateway.transport.request` marks it before the first byte, and a + claim needs it unmarked as well as the connection's own evidence. Outside an executor run + nothing is claimed. **The limit is stated in the module, the class and the specification**: the + register sees only this library's own sends, so an executor that sends part of the effect + through another transport and then uses the classifier can be handed a claim that is true of + these connections and false of the effect. A send through this library on a thread that did not + copy the executor's context **is** seen: it belongs to no register, so it marks every register + open in the process, which costs claims in unrelated concurrent runs and never safety. + + **A continuation leg never records `FAILED`, and 0.6.1 did.** A continuation exists only + because the remote answered and is holding the exchange, so nothing on that leg can say the + remote did nothing. `Control.resume` now runs with the register already marked, and the gateway + refuses to record `FAILED` for anything a continuation meets: a refused connection, a + pre-dispatch JSON-RPC code, the `401` rule of `v0.2 §6.8`, and a tool error under an + operator's `not_executed_on_error: true`, which asserts that *that tool* reports errors before + acting and cannot speak for a call it did not answer. At 0.6.1 each of those recorded + `FAILED` and, for a connection never established, answered the client `-41011` "not executed", + which permitted a retry of an effect the upstream may have been part-way through. The upstream's + own response is still relayed unchanged; what changes is the record, which is now `AMBIGUOUS` + and needs `ctrlrun resolve`. + + **Behind a proxy the gateway is stricter than 0.6.1.** httpx reports an unreachable proxy and a + TLS failure with the target after the proxy answered the `CONNECT` line with the same + `ConnectError`, and `ctrlrun.transport` counts a written `CONNECT` line as a byte. Where the + environment names a proxy, `ConnectError` and `ProxyError` are now an unknown outcome: an + intercepted call that would have been recorded `FAILED` with `-41011` is recorded `AMBIGUOUS` + with `-41010`, and needs `ctrlrun resolve`. With no proxy configured nothing changes. + - **Clock-skew detection** (SPEC-v0.7 §3, item 1). `PostgresStateStore` measures its server's clock against the application's at open, and again when an expired lease is declared `AMBIGUOUS` (at most once per `DEFAULT_LEASE`), in one round trip whose half is the @@ -122,11 +318,13 @@ any change to one appears here. that verify does not read, and the report says so beneath the table; `not applicable` only where no action requires approval. The store conformance suite gains a `precondition-fingerprint` case and a broken-store fixture that fails it by name. - +- **Python 3.13 and 3.14 are tested and declared.** CI's `check` job runs the full suite on + 3.11, 3.12, 3.13 and 3.14, and the package classifiers name all four. The floor is unchanged: + `requires-python` stays `>=3.11`, and mypy and ruff still check against 3.11. No library code + changed; the one test fix is below. ### Changed - - **An action entry may declare `max_attempts`, and a renewal over `FAILED` can now be bounded.** This is stricter than 0.6.1 only where an operator asks for it: an action that declares no `max_attempts` renews without bound, exactly as before, and every document that loaded at 0.6.1 @@ -154,66 +352,6 @@ any change to one appears here. - **`APPROVAL_CONSUMED` carries what the presenting pass compared**, where a precondition was compared, so a suspended action's resumed leg, whose receipt is the only one it gets, records the comparison its first leg made. -- **`ctrlrun.transport`, the `NotExecuted` classifier, in core** (SPEC-v0.7 §2, build-list item - 2). `v0.1 §5.5` leaves the one decision the product exists to get right, `FAILED` or - `AMBIGUOUS`, to the executor, and until now the correct rule was reachable only through - `ctrlrun[gateway]`. `ctrlrun.transport.urlopen`, `HTTPConnection` and `HTTPSConnection` are - `urllib` and `http.client` with a counter: they raise `NotExecuted`, chained from the original - exception, **only** where the connection they opened fresh failed before a single request byte - was handed to its socket (DNS failure, refusal, connect timeout, a TLS handshake failure). Every - other failure is the original exception, which the kernel records `AMBIGUOUS`: a reset or a - timeout after the request was offered, a `sendall` that raised part way, a reused connection, a - socket the caller set, an opener the classifier did not build, a proxy that refused a tunnel - after its `CONNECT` line was sent. The count is taken from evidence, never from an exception's - type, and above TLS. No redirect is followed, no HTTP status is ever `NotExecuted`, and no - parameter, attribute or environment variable changes a classification. The module is stdlib - only and is not imported by `import ctrlrun`. - - The rule itself, `ctrlrun.transport.effect_state`, is the one implementation: the gateway's - `Transport` is now the core one, and `gateway/outcome.py` asks the core rule rather than keeping - a copy. `ctrlrun.gateway.transport.request` offers the gateway's httpx mapping to an executor - that uses httpx, on a client built for the one call. The gateway's own `NotExecuted`, for an - upstream it never reached, is now chained from the httpx exception and its receipt names it. - `ctrlrun verify` gains **G12**, "a byte written is ambiguous", under - `ctrlrun.guarantees/v3`, with the refused connection as its positive control. G12 needs a - loopback peer, so verify's rule becomes *no connection except to the store `--store-url` names - and to loopback listeners verify bound itself*, and the test suite's network guard admits - exactly that: IPv4 on the `127.0.0.1` literal, to a port the process bound through a stream - socket that is still open, and nothing else. - - **The claim is about the executor run, not about one connection.** An independent review showed - that every false `NotExecuted` it could produce came from two connections in one effect: the - first delivered the request, the second was refused, and a per-connection classifier judged the - second alone. `xmlrpc.client`'s retry, `FancyURLopener` following a `303`, an opener whose - handler runs on a worker thread, and an executor's own retry-once-on-reset loop all make that - pair. `Control` now opens a register around each executor call; every send through - `ctrlrun.transport` or `ctrlrun.gateway.transport.request` marks it before the first byte, and a - claim needs it unmarked as well as the connection's own evidence. Outside an executor run - nothing is claimed. **The limit is stated in the module, the class and the specification**: the - register sees only this library's own sends, so an executor that sends part of the effect - through another transport and then uses the classifier can be handed a claim that is true of - these connections and false of the effect. A send through this library on a thread that did not - copy the executor's context **is** seen: it belongs to no register, so it marks every register - open in the process, which costs claims in unrelated concurrent runs and never safety. - - **A continuation leg never records `FAILED`, and 0.6.1 did.** A continuation exists only - because the remote answered and is holding the exchange, so nothing on that leg can say the - remote did nothing. `Control.resume` now runs with the register already marked, and the gateway - refuses to record `FAILED` for anything a continuation meets: a refused connection, a - pre-dispatch JSON-RPC code, the `401` rule of `v0.2 §6.8`, and a tool error under an - operator's `not_executed_on_error: true`, which asserts that *that tool* reports errors before - acting and cannot speak for a call it did not answer. At 0.6.1 each of those recorded - `FAILED` and, for a connection never established, answered the client `-41011` "not executed", - which permitted a retry of an effect the upstream may have been part-way through. The upstream's - own response is still relayed unchanged; what changes is the record, which is now `AMBIGUOUS` - and needs `ctrlrun resolve`. - - **Behind a proxy the gateway is stricter than 0.6.1.** httpx reports an unreachable proxy and a - TLS failure with the target after the proxy answered the `CONNECT` line with the same - `ConnectError`, and `ctrlrun.transport` counts a written `CONNECT` line as a byte. Where the - environment names a proxy, `ConnectError` and `ProxyError` are now an unknown outcome: an - intercepted call that would have been recorded `FAILED` with `-41011` is recorded `AMBIGUOUS` - with `-41010`, and needs `ctrlrun resolve`. With no proxy configured nothing changes. ### Fixed @@ -334,6 +472,20 @@ any change to one appears here. a receipt rule that let a fabricated field verify, three false `N/A` reasons, and a verify network rule that was already untrue under `--store-url`. +- **`README.md`** now says what `ctrlrun.transport` is for beside the sentence that names + `NotExecuted`, says that `ctrlrun verify` binds loopback listeners of its own rather than + claiming it opens no socket at all, and says that a precondition fingerprint narrows the window + between a human's answer and the execution. + +- **The documentation site**, `CTRLRun/ctrlrun-docs`, carries the generated pages for this + release: every `ctrlrun.transport` name in the Python API reference, `ctrlrun.policy/v5` and + `ctrlrun.receipt/v4` in the schema references, G12 to G16 in `verify.md` and in the readiness + block, the classifier and the residual precondition window in `THREAT_MODEL.md`, and + `CLAIMS.md` regenerated so every cited line number resolves. `ROADMAP.md` marks v0.7 shipped and + carries two items named before v1.0: a malformed value of a schema-declared key blinding every + receipt reader, and the `state -> receipt -> policy -> authority -> state` import cycle, which + contradicts `ARCHITECTURE.md` §6. + ## [0.6.1] - 2026-09-07 — The audit's fixes, and the gateway's transport Everything found after `v0.6.0` was tagged: twenty-nine defects from an audit of the shipped diff --git a/CITATION.cff b/CITATION.cff index 2e87c62..843933e 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -11,7 +11,7 @@ authors: - family-names: Ghoshal given-names: Arpan email: contact@arpanghoshal.com -version: 0.6.1 +version: 0.7.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 6d763a1..0926495 100644 --- a/README.md +++ b/README.md @@ -243,11 +243,19 @@ Every protected call, whichever way it arrives, goes through the same six steps. much autonomy does *this action* get?). Unknown action, missing policy or missing principal is `deny`. 3. **Approve.** A human answers against the action hash. The approval is single-use, expires, - and matches nothing but that exact action. + and matches nothing but that exact action. Name a `preconditions=` provider and the approval + is also bound to the resource state it was granted against, rechecked strictly before the + reservation: that **narrows** the window between the answer and the execution, from minutes of + deliberation to milliseconds. It does not close it, because the recheck is a network call and + cannot run inside the atomic write. 4. **Reserve.** The effect key, `refund:txn_1` or `namespace:prod-eu:checkout`, is taken in one atomic write. A second caller, in another process or on another host, is refused. 5. **Execute.** Your function runs. Only `NotExecuted`, raised by you, means `FAILED`; every - other exception and every timeout means `AMBIGUOUS`. + other exception and every timeout means `AMBIGUOUS`. Deciding which one you are looking at is + the hard part, so `ctrlrun.transport` does it for you: `urlopen`, `HTTPConnection` and + `HTTPSConnection` from stdlib `urllib` and `http.client`, which raise `NotExecuted` only where + the connection they opened was handed no request byte. After one byte, every failure stays the + exception it was, and the outcome is `AMBIGUOUS`. No setting widens that. 6. **Record.** A portable JSON receipt: who, what, decision, approval, effect key, outcome, and the hash of the policy that decided it, chained to the receipt before it. @@ -255,7 +263,9 @@ State lives in SQLite by default, a file with no server and no ops, and the rese across processes rather than merely across threads. Point it at Postgres when more than one host writes: `pip install "ctrlrun[postgres]"`, one URL, the same guarantees graded by the same suite. Prove it in your own setup with `ctrlrun verify`, which runs the kernel's own failure -scenarios against *your* policy in a scratch store, with no network. +scenarios against *your* policy in a scratch store. It reaches no network: the only sockets it +opens are to the store you named and to loopback listeners it bound itself, which is how it +grades the transport classifier. | Guarantee | `@protect` | Gateway | Adapter | diff --git a/adapters/langgraph/README.md b/adapters/langgraph/README.md index 016e218..cc0ae01 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.7` +- **Supported kernel range:** `ctrlrun>=0.5,<0.8` - **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 057a1fe..5e3d7fe 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.7", + "ctrlrun>=0.5,<0.8", "langgraph>=1.0,<2.0", ] diff --git a/adapters/langgraph/src/ctrlrun_langgraph/__init__.py b/adapters/langgraph/src/ctrlrun_langgraph/__init__.py index 8b175b1..3d41f51 100644 --- a/adapters/langgraph/src/ctrlrun_langgraph/__init__.py +++ b/adapters/langgraph/src/ctrlrun_langgraph/__init__.py @@ -15,7 +15,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.7`. Supported framework range: `langgraph>=1.0,<2.0`. +Supported kernel range: `ctrlrun>=0.5,<0.8`. 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 b86b78b..1c6bed0 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.7` +- **Supported kernel range:** `ctrlrun>=0.5,<0.8` - **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 f3d0bdf..8aaaeef 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.7", + "ctrlrun>=0.5,<0.8", "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 6af9e67..025ff5a 100644 --- a/adapters/openai-agents/src/ctrlrun_openai_agents/__init__.py +++ b/adapters/openai-agents/src/ctrlrun_openai_agents/__init__.py @@ -15,7 +15,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.7`. +Supported kernel range: `ctrlrun>=0.5,<0.8`. 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.7.md b/docs/SPEC-v0.7.md index de2805a..fb16f0c 100644 --- a/docs/SPEC-v0.7.md +++ b/docs/SPEC-v0.7.md @@ -3826,3 +3826,60 @@ so there is nothing there for verify to grade; T261b is where that residual is k and whichever lands second rebases the two lines. ### 12.6 Item 6: the release + +**The changelog is written as a release and not as six bullet lists**, because six items merged in +parallel lanes and each wrote its own entry in the order it landed. A reader upgrading needs two +things the concatenation did not give them: **every behaviour that became stricter, beside what +0.6.1 did**, and **every residual, where an operator reads it rather than only in §12**. Both are +their own section above `Added`, and the residual list is the one this section exists to argue +for: the reconcile route's wasted human answer and three provider calls (§12.4), the ceiling +bounding attempts and not executor invocations (§5.7), the refused attempt number being spent +(§5.5), the register seeing only this library's own sends (§12.2.9), attempt identity under a +reused `action_id` (§12.3a), §6.4's residual, and the recheck that narrows and does not close. +A milestone whose specification states seven residuals and whose changelog states none would be +the prevention-versus-attribution rule failing at the last surface it passes through. + +**The version bump broke two things nothing else would have caught, and both were real.** Both +adapters declared `ctrlrun>=0.5,<0.7`, which **excludes** the kernel they ship beside, so +`pip install ctrlrun-langgraph` would have refused to resolve or silently downgraded `ctrlrun` to +0.6. That is the defect `0.5,<0.6` produced at 0.6.0 and the test written for it +(`test_each_adapter_declares_a_kernel_range_that_contains_this_kernel`) caught this one the +moment `pyproject.toml` moved. The range is now `>=0.5,<0.8` in all six places the two adapters +state it, which is the guard beside it, `test_no_adapter_source_file_states_a_stale_kernel_range`. +And `CITATION.cff` carried `0.6.1`. Neither is a release-pass edit anybody would have thought to +make; both are tests written when the same thing went wrong before. + +**T271 runs the demo in a process with the network taken away.** The T11 fixture runs it in the +test process through `CliRunner`, where nothing has been taken away, so "under 60 seconds with no +network" was two claims of which only the first was measured. T271 runs the CLI in a subprocess +whose `sitecustomize` is `conftest.py`'s one guard, the same one T107, T230, the examples and the +cookbook use. Measured: 0.13 s, five scenarios, nothing reached. + +**The documentation repository's snippet harness had the second guard §12.2.7 warned about.** +`tools/docs_audit/snippets.py` carried its own `NO_NETWORK`, which refused every connect, and the +cookbook's `verify-in-github-actions` recipe runs `ctrlrun verify`, so once G12 existed that +recipe exited 3 on a correct kernel. §12.2.7 moved the *library's* two copies onto one definition +and did not know about this third one, in another repository. It is now a verbatim copy of +`conftest.py`'s guard, with both edges tested there: a self-bound loopback port is admitted, and a +loopback port the process did not bind is not. + +**`render_api` did not enumerate `ctrlrun.transport`, and could not have.** Its page list is +`ctrlrun.__all__` plus a hand-written `EXTRA_NAMES` for what lives behind an extra. `transport.py` +is core, stdlib and deliberately **not** imported by `import ctrlrun` (§2.8, T228), so it is in +neither, and the five public names of the module that decides `FAILED` versus `AMBIGUOUS` had no +reference page while `NotExecuted` had one. They are in `EXTRA_NAMES` now, with a comment saying +why a module that needs no extra is in a list named for extras. + +**Seven `CLAIMS.md` rows cited a line that had become the end of a docstring.** The repointer +refuses rather than guesses, so it reported them and wrote nothing, which is the behaviour that +made this visible at all: they cite the branch where only `NotExecuted` maps to `FAILED`, which is +a statement and not a definition, and the citation had been pointed at `control.py:1416` before +item 3's token binding moved it. Repointed at the `except NotExecuted` clause; thirty more rows +moved with the code. + +**What the readiness block says before the tag, and why it is left that way.** It reads *Version +0.7.0 is in development; PyPI has 0.6.1*, because the generator takes "released" from the newest +**dated** changelog heading and this release's heading is undated until the tag. That is the line +flipping itself on the day the release lands, which is what it was built to do; regenerating the +block is part of the tag and not of this pull request. The **No external security audit** line is +untouched: it is gated on v0.12 (`ROADMAP.md`), never on this release. diff --git a/pyproject.toml b/pyproject.toml index 2805cb2..cb1ad8f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "ctrlrun" -version = "0.6.1" +version = "0.7.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_demo.py b/tests/test_demo.py index 6ea3f5a..769e19c 100644 --- a/tests/test_demo.py +++ b/tests/test_demo.py @@ -7,6 +7,8 @@ import json import os import re +import subprocess +import sys import time from dataclasses import replace from fnmatch import fnmatch @@ -546,6 +548,42 @@ def test_T11_demo_runs_in_under_sixty_seconds(demo_run): assert elapsed < 60 +def test_T271_demo_runs_offline_in_a_process_with_the_network_taken_away(tmp_path, no_network): + """The definition of done says *under 60 seconds with no network*, and the fixture above + cannot show it: it runs in this process, through `CliRunner`, where nothing has been taken + away. "Runs with no network" stays a claim until something takes the network away + (`CONTRIBUTING.md`), so this runs the installed console script in a subprocess whose + `sitecustomize` is `conftest.py`'s one guard, the same one T107, T230, the examples and the + cookbook use. + + The guard admits exactly what `SPEC-v0.7.md` §8.9 admits and nothing more: an IPv4 connect + to the `127.0.0.1` literal at a port this process bound through a stream socket that is + still open. The demo binds none, so it reaches nothing at all, and a demo that grew a + fetch would fail here rather than in a reader's terminal. + """ + environment = dict(os.environ) + environment["PYTHONPATH"] = ":".join( + part for part in (str(no_network), environment.get("PYTHONPATH", "")) if part + ) + + started = time.monotonic() + finished = subprocess.run( + [sys.executable, "-c", "from ctrlrun.cli.main import main; main()", "demo"], + cwd=tmp_path, + env=environment, + capture_output=True, + text=True, + timeout=120, + ) + elapsed = time.monotonic() - started + + assert finished.returncode == 0, f"{finished.stdout}\n{finished.stderr}" + assert elapsed < 60, f"the demo took {elapsed:.1f}s with the network taken away" + for number, heading in enumerate(SCENARIO_HEADINGS, start=1): + assert f"{number}. {heading}" in finished.stdout + assert "runs with no network" not in finished.stderr + + def test_T93_demo_prints_all_five_scenario_headings(demo_run): result, _, _ = demo_run diff --git a/tests/test_preconditions.py b/tests/test_preconditions.py index 0fe696a..2c22840 100644 --- a/tests/test_preconditions.py +++ b/tests/test_preconditions.py @@ -1963,9 +1963,17 @@ def _guarantee_titles() -> str: #: #: Sentences that **disclaim**: they say the recheck narrows, and name what it does not do. DISCLAIMS: dict[str, tuple[str, ...]] = { + "README.md": ( + "It does not close it, because the recheck is a network call and cannot run inside the " + "atomic write.", + ), "CHANGELOG.md": ( "A precondition fingerprint **narrows** the window between a human's approval and the " "action's execution; it does not close it.", + # The release's own "What this release does not close" list, which states the residual + # where an operator reads it rather than only in the specification. + "narrows the window between a human's approval and the action's execution, and does not " + "close it.", "and precondition fingerprints, which **narrow** the window between a human's approval " "and the action's execution and do not close it", ), From fed73782b04d063212a42134a6802aa2739bb81e Mon Sep 17 00:00:00 2001 From: arpan Date: Sat, 12 Sep 2026 04:39:32 +0530 Subject: [PATCH 2/2] T271 joins PYTHONPATH with os.pathsep, as its three siblings already do Review finding, and a true one. On Windows with a non-empty inherited PYTHONPATH a colon join makes one invalid entry, sitecustomize is never imported, and the subprocess runs with no guard at all -- a test that passes because it measured nothing, which is exactly the shape this test exists to close. test_examples.py and test_cookbook.py both use os.pathsep; this now matches them. --- tests/test_demo.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_demo.py b/tests/test_demo.py index 769e19c..f6ab3b8 100644 --- a/tests/test_demo.py +++ b/tests/test_demo.py @@ -562,7 +562,7 @@ def test_T271_demo_runs_offline_in_a_process_with_the_network_taken_away(tmp_pat fetch would fail here rather than in a reader's terminal. """ environment = dict(os.environ) - environment["PYTHONPATH"] = ":".join( + environment["PYTHONPATH"] = os.pathsep.join( part for part in (str(no_network), environment.get("PYTHONPATH", "")) if part )