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..f6ab3b8 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"] = os.pathsep.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", ),