From 7eb6abb4184048656a8d9fbd9b29161346369a9b Mon Sep 17 00:00:00 2001 From: arpan Date: Sat, 12 Sep 2026 03:58:52 +0530 Subject: [PATCH 1/2] The site for 0.7.0: G12, the transport module, and two items named before v1.0 The delta over #4, which brought the site up to 15 guarantees. Item 2 merged after that push, so G12 was the one guarantee missing, and the module it grades had no reference pages at all. Generated, against the release branch: - render_api enumerates ctrlrun.transport. It is core and stdlib, and `import ctrlrun` deliberately does not import it, so its five names are not in `ctrlrun.__all__` and nothing listed them. A reference that named NotExecuted and not the module that raises it correctly left a reader with the one decision and none of the help. Five new pages and the docs.json group. - The readiness block: 16 guarantees, 5,164 tests, and "Version 0.7.0 is in development; PyPI has 0.6.1", which is what the generator says until the tag gives the changelog heading a date. The "No external security audit" line is untouched: it is gated on v0.12, not on this release. - CLAIMS.md re-pointed, every file.py:NNN resolving against the line it cites. Seven rows cited the NotExecuted branch at a line that is now the end of a docstring; they point at the `except NotExecuted` clause, and 30 more moved. Hand-written: - verify.md carries all sixteen guarantees in the table, where it had eleven under the v2 catalogue, and the transcript is a real run. G12 is the only guarantee that opens a socket, so "no scenario opens a socket" becomes what the rule actually is, which was already untrue under a remote --store-url. - The two CI transcripts gain G12 and read 12/12. - ROADMAP: v0.7 shipped; v0.11's "three shapes" corrected to four receipt schema versions; and the v0.7 line claiming "one human approval plus an executor that always reports nothing happened is unlimited dispatches" corrected, because item 4 measured it and it is false in enforce mode. Every renewal of an approved action needs a new granted approval. The case the ceiling exists for is the action the policy allows outright. - Two items named before v1.0, both found by reviews and deferred with written decisions. In v0.11, a malformed value of a key a receipt schema declares raising out of from_dict, so one UPDATE blinds receipts, --verify-chain, inspect, stats and G11 together. In v0.12, the import cycle state -> receipt -> policy -> authority -> state, which contradicts ARCHITECTURE.md section 6's "dependencies point downward"; the sentence there saying policy.py does not import authority.py is no longer true and now says so. - ARCHITECTURE gains the transport.py row and the postgres.py row the spec asked for, and the continuation rule beside the gateway paragraph. recovery.mdx says it where an operator reads it: a continuation leg never records FAILED, and the gateway answers -41010 where 0.6.1 answered -41011 and permitted a retry. - THREAT_MODEL gains the v0.7 limitations: the recheck narrows and does not close, the classifier speaks only for this library's sends, the cost of claiming nothing where it cannot observe, and attempt identity under a reused action_id. - OWASP mapping gains the G12 row with what it does not close. The snippet harness's network guard is the library's own, verbatim. It refused every connect, so a documentation snippet running `ctrlrun verify` exited 3 on a correct kernel once G12 existed. Two guards of different widths is the drift the library's fixture exists to prevent, so this is a copy rather than a second rule, with both edges tested: a self-bound loopback port is admitted, and a loopback port this process did not bind is not. --- docs.json | 5 + docs.mdx | 6 +- docs/ARCHITECTURE.md | 30 +++- docs/CLAIMS.md | 56 ++++---- docs/OWASP-AGENTIC-TOP10.md | 1 + docs/ROADMAP.md | 14 +- docs/THREAT_MODEL.md | 34 +++++ docs/cookbook/verify-in-github-actions.mdx | 9 +- docs/guides/verify-in-ci.mdx | 14 +- docs/production/index.mdx | 6 +- docs/production/recovery.mdx | 12 ++ docs/reference/api/Control.mdx | 2 +- docs/reference/api/EffectRecord.mdx | 2 +- docs/reference/api/EffectState.mdx | 2 +- docs/reference/api/ReconcileOutcome.mdx | 2 +- docs/reference/api/context.mdx | 2 +- docs/reference/api/idempotency_token.mdx | 2 +- docs/reference/api/index.mdx | 5 + docs/reference/api/protect.mdx | 2 +- .../api/transport-HTTPConnection.mdx | 53 +++++++ .../api/transport-HTTPSConnection.mdx | 29 ++++ docs/reference/api/transport-Transport.mdx | 32 +++++ docs/reference/api/transport-effect_state.mdx | 26 ++++ docs/reference/api/transport-urlopen.mdx | 44 ++++++ docs/reference/api/with_approval.mdx | 2 +- docs/verify.md | 56 ++++++-- generated/readiness.full.mdx | 6 +- generated/readiness.json | 6 +- generated/readiness.mdx | 6 +- generated/readiness.readme.md | 6 +- tests/test_docs_audit.py | 48 ++++++- tests/test_verify_page.py | 2 +- tools/docs_audit/render_api.py | 10 ++ tools/docs_audit/snippets.py | 135 +++++++++++++++--- 34 files changed, 565 insertions(+), 102 deletions(-) create mode 100644 docs/reference/api/transport-HTTPConnection.mdx create mode 100644 docs/reference/api/transport-HTTPSConnection.mdx create mode 100644 docs/reference/api/transport-Transport.mdx create mode 100644 docs/reference/api/transport-effect_state.mdx create mode 100644 docs/reference/api/transport-urlopen.mdx diff --git a/docs.json b/docs.json index 9e77e46..784d82c 100644 --- a/docs.json +++ b/docs.json @@ -308,6 +308,11 @@ "docs/reference/api/parse_conditions", "docs/reference/api/postgres-PostgresStateStore", "docs/reference/api/protect", + "docs/reference/api/transport-HTTPConnection", + "docs/reference/api/transport-HTTPSConnection", + "docs/reference/api/transport-Transport", + "docs/reference/api/transport-effect_state", + "docs/reference/api/transport-urlopen", "docs/reference/api/verify-run", "docs/reference/api/with_approval" ], diff --git a/docs.mdx b/docs.mdx index d1097ec..a5bdf48 100644 --- a/docs.mdx +++ b/docs.mdx @@ -218,9 +218,9 @@ the framework's own interrupt, and a framework with no such primitive does not n ## Where it stands {/* generated from the suite, pyproject and the soak (mdx) — run the generator */} -- **Version 0.6.1**, on [PyPI](https://pypi.org/project/ctrlrun/), Python 3.11 and later. -- **4,999 tests**, every version specified before it was written and every requirement mutation-tested. -- **15 guarantees you can check in your own setup**, with `ctrlrun verify` against your policy, on your store's backend, in a scratch store it creates. +- **Version 0.7.0 is in development**; [PyPI](https://pypi.org/project/ctrlrun/) has 0.6.1. Python 3.11 and later. +- **5,164 tests**, every version specified before it was written and every requirement mutation-tested. +- **16 guarantees you can check in your own setup**, with `ctrlrun verify` against your policy, on your store's backend, in a scratch store it creates. - **One host: a file.** SQLite, no server, no ops. **Many hosts: Postgres**, the same guarantees, graded by the same suite. - **Soaked for 20m 0s on postgres**: 889,735 actions, 0 unattributed ambiguous outcomes, positive control fired. Nothing here establishes what only accumulates over days. [What it does not establish](https://ctrlrun.dev/docs/production/soak). - **Each receipt carries the hash of the one before it**, so an alteration is detected and named. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index a773bd3..f2f7204 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -172,9 +172,11 @@ Pragmas: `journal_mode=WAL`, `busy_timeout=5000`, `synchronous=NORMAL`. | `authority.py` | `Grant`, `Subject`, `Authority`, matching, containment, delegation planning | approvals, effect state, executors, sinks | | `approval.py` | request/grant/consume, providers | executors | | `adapter.py` | `FrameworkInterrupt`, `PendingApproval`, `ApprovalAnswer`, `InterruptApprovalProvider`, `needs_approval`, `banner` | the policy evaluator, authority, effect state, executors, sinks, any framework | -| `effect.py` | key templating, state enum, transition rules | SQLite | +| `effect.py` | key templating, state enum, transition rules, the idempotency token from `(effect_key, attempt)` | SQLite | +| `transport.py` | the transport half of SPEC-v0.1 §5.5's asymmetry: what a transport observed, what that records, and the counting connections for `http.client` and `urllib` | policy, approvals, storage, sinks, `Control`, anything from an extra | | `migrations.py` | the schema, the ordered migration list, the runner, and whether a database may open at all | policy, decorator, sinks, `Control` | | `state.py` | `StateStore` protocol + SQLite/in-memory impls | policy, decorator, sinks | +| `postgres.py` | the `StateStore` on Postgres, its migrations, its lost-commit resolution, and the clock it measures against this host's | policy, decorator, sinks | | `control.py` | `Control` orchestration, decorator, context, suspend/resume | CLI | | `receipt.py` | Receipt/Event models, `EventSink`, JSONL sink | everything else | | `verify/` | the guarantee registry, scenario derivation, the scratch store, reporting | the gateway, `otel`, `jwt_identity`; anything from an extra | @@ -198,6 +200,15 @@ itself. A gateway that owned the reservation would be a second module composing and a second implementation of SPEC-v0.1 §5.5's asymmetry, which is the one rule in this codebase that must not drift. +v0.7 narrows that asymmetry on one path, in both places at once so they cannot drift apart: +**a continuation leg never records `FAILED`**. A continuation exists only because the remote +answered once already and is holding the exchange, so nothing on the second leg can truthfully +say the remote did nothing. `Control.resume` opens its executor run already marked, so +`transport.py` will not claim there, and the gateway refuses `FAILED` for every path that could +reach it on a continuation, an operator's `not_executed_on_error` included. An executor's own +`NotExecuted` is still believed; what changed is that nothing in the library hands it one +(SPEC-v0.7 §12.2.12). + The same holds for authority (v0.3). `authority.py` reads the store through the `StateStore` protocol and **writes nothing and appends nothing**: `Authority.evaluate` returns a result and `plan_delegation` returns the record it *would* write, and `Control` performs every write and @@ -231,9 +242,20 @@ v0.3 makes the same exception once more, for the same reason: `authority.py` imp condition parser and evaluator (`Condition`, `parse_conditions`) from `policy.py`, because a grant's `constraints:` is in exactly a rule's `when:` syntax and the two axes MUST share one evaluator (SPEC-v0.3 §4.5). A second condition evaluator would be a second place for `True` to -start comparing equal to `1`. `policy.py` does not import `authority.py`, so there is no cycle, -and policy still cannot see a principal: `agent_eq` and every other reserved name are still -refused at load (§4.7). +start comparing equal to `1`. Policy still cannot see a principal: `agent_eq` and every other +reserved name are still refused at load (§4.7). + +**The sentence that followed that one said *`policy.py` does not import `authority.py`, so there +is no cycle*, and a v0.7 review found it is no longer true.** `policy.py` reaches +`authority.py` from inside two functions, `state.py` imports `receipt.py`, `receipt.py` imports +`policy.py` and `authority.py` imports `state.py`, so there is a cycle: +`state` → `receipt` → `policy` → `authority` → `state`. It does not break `import ctrlrun`, +because the two edges out of `policy.py` are function-level and run after every module is +loaded, which is exactly why it went unnoticed. What it costs is this section's own rule: with +the cycle in place *dependencies point downward only* is a statement about import order rather +than about the module map, and the map is what a reader uses to work out what may know about +what. Whether to break it, and which edge to break, is a **named item before v1.0** on the +roadmap rather than a change made in a release pass. Recorded 2026-09-12. ## 7. What changes after v0.1 (and what doesn't) diff --git a/docs/CLAIMS.md b/docs/CLAIMS.md index f0e18f5..4b5086c 100644 --- a/docs/CLAIMS.md +++ b/docs/CLAIMS.md @@ -25,9 +25,9 @@ by its quoted claim, and `tests/test_docs_audit.py` fails if a named row is not | Claim | Code | Proof | |---|---|---| -| "The last check before an AI agent does something it can't undo." | `Control.execute` — `control.py:752` — resolves the principal, evaluates authority and policy, consumes the approval and reserves the effect key **before** the executor runs; nothing in the wrapper calls the function first | `test_T1_a_blind_retry_is_refused_and_never_reaches_the_remote`, `test_T3_the_fake_remote_is_called_exactly_once` | +| "The last check before an AI agent does something it can't undo." | `Control.execute` — `control.py:756` — resolves the principal, evaluates authority and policy, consumes the approval and reserves the effect key **before** the executor runs; nothing in the wrapper calls the function first | `test_T1_a_blind_retry_is_refused_and_never_reaches_the_remote`, `test_T3_the_fake_remote_is_called_exactly_once` | | "Autonomy belongs to the action, not the agent." | `Policy.evaluate(action)` — `policy.py:534` — passes only the action's **name and arguments** to `_ActionPolicy.evaluate` (`policy.py:534`), whose signature has no principal in it. A rule cannot read who is acting even by accident. `agent_eq` and `user_eq` are refused at load by `RESERVED_ARGUMENTS` (`policy.py:186`) rather than silently matching nothing. | `test_T6_an_action_name_is_matched_exactly`, `test_a_condition_naming_an_action_field_is_refused_at_load` | -| "A consequential action happens at most once, exactly as approved, and leaves a receipt — and when the outcome is unknown, CTRLRun says so instead of guessing." | At most once: `plan_reservation` — `effect.py:170`. Exactly as approved: the approval is bound to `action_hash` and consumed with the reservation — `_authorize_and_reserve` — `state.py:929`. Or not at all: a refusal raises before the executor — `Control.execute` — `control.py:752`. Says so instead of guessing: only `NotExecuted` maps to `FAILED` — `control.py:1416` — and everything else is `AMBIGUOUS`. A receipt: `Receipt` — `receipt.py:150`. **This sentence read *happens once … or not at all* until 0.6**, a two-way disjunction that excluded the third outcome the product exists for: a lost reply is neither, and the README's own first section says so. | `test_T3_exactly_one_agent_reserves_and_seven_are_blocked`, `test_T2_a_mutated_action_presenting_the_approval_raises_ApprovalMismatch`, `test_T1_a_lost_response_leaves_the_effect_ambiguous`, `test_T11_every_demo_receipt_carries_every_field_in_the_spec` | +| "A consequential action happens at most once, exactly as approved, and leaves a receipt — and when the outcome is unknown, CTRLRun says so instead of guessing." | At most once: `plan_reservation` — `effect.py:248`. Exactly as approved: the approval is bound to `action_hash` and consumed with the reservation — `_authorize_and_reserve` — `state.py:929`. Or not at all: a refusal raises before the executor — `Control.execute` — `control.py:756`. Says so instead of guessing: only `NotExecuted` maps to `FAILED` — `control.py:1451` — and everything else is `AMBIGUOUS`. A receipt: `Receipt` — `receipt.py:150`. **This sentence read *happens once … or not at all* until 0.6**, a two-way disjunction that excluded the third outcome the product exists for: a lost reply is neither, and the README's own first section says so. | `test_T3_exactly_one_agent_reserves_and_seven_are_blocked`, `test_T2_a_mutated_action_presenting_the_approval_raises_ApprovalMismatch`, `test_T1_a_lost_response_leaves_the_effect_ambiguous`, `test_T11_every_demo_receipt_carries_every_field_in_the_spec` | | "A Python library that sits between the decision to act and the call that acts." | `@protect` — `control.py` — wraps the callable that acts, and `Control.execute` runs every check before invoking it. The category noun was on `docs.mdx` and in `pyproject.toml`'s `description` and nowhere in the README until 0.6, so a reader had to infer what CTRLRun **is** from three slogans. | `test_the_header_carries_the_fixed_copy_and_the_five_badges`, `test_T1_a_blind_retry_is_refused_and_never_reaches_the_remote` | | "Runs in production on a single file, or on Postgres across hosts" | SQLite: `SQLiteStateStore` reserves inside the `BEGIN IMMEDIATE` of `_authorize_and_reserve` — `state.py:1113` — which is a write lock on the file and holds across OS processes. Postgres: `PostgresStateStore` over `UNIQUE(effect_key)` with `INSERT … ON CONFLICT DO NOTHING` and checked row counts (SPEC-v0.6 §4.2), the same `StateStore` protocol, extended by nothing | `test_T3_exactly_one_agent_reserves_and_seven_are_blocked` (8 OS processes, both backends), `test_T141_the_shipped_backends_pass`, `test_T154_postgres_passes_the_store_conformance_suite` | @@ -35,7 +35,7 @@ by its quoted claim, and `tests/test_docs_audit.py` fails if a named row is not | Claim | Code | Proof | |---|---|---| -| "A lost reply is `AMBIGUOUS`, never `FAILED`, and a retry against an `AMBIGUOUS` effect is refused — until a human, or a `reconcile` hook, says what happened." | Only `NotExecuted` maps to `FAILED` — `control.py:1416`; a retry against an `AMBIGUOUS` key is refused by `plan_reservation` — `effect.py:170`; the two things permitted to move the record on and nothing else — `resolve` — `cli/main.py:518` — and `Control._reconciled` — `control.py:1913` | `test_T1_a_lost_response_leaves_the_effect_ambiguous`, `test_T1_a_blind_retry_is_refused_and_never_reaches_the_remote`, `test_T160_there_is_no_reaper`, `test_T13_a_hook_answering_not_executed_moves_the_record_to_failed` | +| "A lost reply is `AMBIGUOUS`, never `FAILED`, and a retry against an `AMBIGUOUS` effect is refused — until a human, or a `reconcile` hook, says what happened." | Only `NotExecuted` maps to `FAILED` — `control.py:1451`; a retry against an `AMBIGUOUS` key is refused by `plan_reservation` — `effect.py:248`; the two things permitted to move the record on and nothing else — `resolve` — `cli/main.py:518` — and `Control._reconciled` — `control.py:1948` | `test_T1_a_lost_response_leaves_the_effect_ambiguous`, `test_T1_a_blind_retry_is_refused_and_never_reaches_the_remote`, `test_T160_there_is_no_reaper`, `test_T13_a_hook_answering_not_executed_moves_the_record_to_failed` | | "The customer is refunded twice, and nothing in the stack noticed." — said of a stack without CTRLRun; the demo runs the same sequence with it, and counts the calls the remote received | `ctrlrun demo` scenario 1, which retries against a fake remote that counts its calls and prints the count | `test_T3_the_fake_remote_is_called_exactly_once`, `test_T1_a_blind_retry_is_refused_and_never_reaches_the_remote` | ## Protect your first action @@ -50,30 +50,30 @@ by its quoted claim, and `tests/test_docs_audit.py` fails if a named row is not | Claim | Code | Proof | |---|---|---| -| "A lost reply is `AMBIGUOUS`, never `FAILED`, and a retry against an `AMBIGUOUS` effect is refused." | Only `NotExecuted` maps to `FAILED` — `control.py:1416`; `plan_reservation` — `effect.py:170` | `test_T1_a_lost_response_leaves_the_effect_ambiguous`, `test_T1_a_blind_retry_is_refused_and_never_reaches_the_remote` | +| "A lost reply is `AMBIGUOUS`, never `FAILED`, and a retry against an `AMBIGUOUS` effect is refused." | Only `NotExecuted` maps to `FAILED` — `control.py:1451`; `plan_reservation` — `effect.py:248` | `test_T1_a_lost_response_leaves_the_effect_ambiguous`, `test_T1_a_blind_retry_is_refused_and_never_reaches_the_remote` | | "reserved atomically across processes and hosts; one worker wins" | `reserve_effect` — `state.py:510`; `PostgresStateStore.reserve_effect` — `postgres.py:720` | `test_T3_exactly_one_agent_reserves_and_seven_are_blocked`, `test_T154_postgres_passes_the_store_conformance_suite` | | "bound to the hash of the exact action a human saw, used once, and refused for anything else" | `_authorize_and_reserve` — `state.py:939` | `test_T2_a_mutated_action_presenting_the_approval_raises_ApprovalMismatch`, `test_T4_replaying_the_approval_raises_ApprovalMismatch_with_reason_consumed` | | "An action the policy does not list is denied" | `Policy.evaluate` — `policy.py:534` | `test_T6_unknown_action_is_denied_with_reason_unknown_action` | -| "Authority first ... then policy" / "authority first" | `Control.execute` evaluates authority before policy and a denial appends `AUTHORITY_DENIED` and never `POLICY_EVALUATED` — `control.py:752` | `test_T74_a_denial_leaves_no_pending_approval_request` | +| "Authority first ... then policy" / "authority first" | `Control.execute` evaluates authority before policy and a denial appends `AUTHORITY_DENIED` and never `POLICY_EVALUATED` — `control.py:756` | `test_T74_a_denial_leaves_no_pending_approval_request` | | "Neither axis reads the agent's instructions" | `Policy.evaluate` — `policy.py:534` — sees the action's name and arguments; `Authority.evaluate` — `authority.py:806` — sees the action and the principal; neither is handed a prompt, a message or a tool result | `test_T6_an_action_name_is_matched_exactly`, `test_T67_a_principal_with_no_grant_is_denied` | | "canonical arguments (sorted keys, no floats) ... Its SHA-256 is the action hash" | `canonicalize` / `action_hash` — `action.py`; `float` refused at any depth — `action.py:71` | `test_T7_canonical_form_is_exactly_the_specified_serialization`, `test_T7_nested_dicts_are_sorted_recursively` | | "The approval is single-use, expires, and matches nothing but that exact action." | `_authorize_and_reserve` — `state.py:939` — checks expiry at consumption | `test_T5_expiry_is_checked_at_consumption_not_only_at_grant`, `test_T4_replaying_the_approval_raises_ApprovalMismatch_with_reason_consumed` | -| "Only `NotExecuted`, raised by you, means `FAILED`." | `control.py:1416`; `NotExecuted` — `errors.py:157` | `test_T1_a_lost_response_leaves_the_effect_ambiguous` | +| "Only `NotExecuted`, raised by you, means `FAILED`." | `control.py:1451`; `NotExecuted` — `errors.py:157` | `test_T1_a_lost_response_leaves_the_effect_ambiguous` | | "the hash of the policy that decided it, chained to the receipt before it" | `Policy.policy_hash` — `policy.py:672`; `prev_hash`, `GENESIS_HASH` for the first — `receipt.py:78` | `test_T172_every_receipt_carries_the_hash_and_the_declared_version`, `test_T164_an_altered_receipt_is_content_altered_at_its_seq` | ## Three ways to use it | Claim | Code | Proof | |---|---|---| -| "You probably do not need an adapter" | Three ways in, and `@protect` (`control.py:3021`) covers this process while the gateway covers MCP — an adapter buys only the interrupt | `test_T139_the_adapter_section_says_when_you_do_not_need_one_up_front` | +| "You probably do not need an adapter" | Three ways in, and `@protect` (`control.py:3056`) covers this process while the gateway covers MCP — an adapter buys only the interrupt | `test_T139_the_adapter_section_says_when_you_do_not_need_one_up_front` | | "`ctrlrun init` writes a starter" | `init` — `cli/main.py:340` | CI's `package` job runs `ctrlrun init` from the wheel and asserts `ctrlrun.yaml` exists | -| "The human runs `ctrlrun approve ` and the agent calls again inside `ctrlrun.with_approval(request_id)`" | `approve` — `cli/main.py:364`; `with_approval` — `control.py:260`; `ApprovalRequired` (`errors.py:88`) carries `request_id` | `test_T2_a_mutated_action_presenting_the_approval_raises_ApprovalMismatch` (the granted path first), `test_T4_replaying_the_approval_raises_ApprovalMismatch_with_reason_consumed` | +| "The human runs `ctrlrun approve ` and the agent calls again inside `ctrlrun.with_approval(request_id)`" | `approve` — `cli/main.py:364`; `with_approval` — `control.py:264`; `ApprovalRequired` (`errors.py:88`) carries `request_id` | `test_T2_a_mutated_action_presenting_the_approval_raises_ApprovalMismatch` (the granted path first), `test_T4_replaying_the_approval_raises_ApprovalMismatch_with_reason_consumed` | | "No agent changes" | `INTERCEPTED_METHOD` is `tools/call` and every other method is relayed unchanged — `gateway/mcp.py:40` | `test_a_non_intercepted_method_is_relayed_with_no_ctrlrun_outcome` | -| "Point the MCP client at the gateway instead of at the tool server" | `Gateway.handle` — `gateway/server.py:385`; `serve` — `gateway/__init__.py:41` | `test_T19_the_upstream_receives_the_canonical_arguments` | +| "Point the MCP client at the gateway instead of at the tool server" | `Gateway.handle` — `gateway/server.py:388`; `serve` — `gateway/__init__.py:41` | `test_T19_the_upstream_receives_the_canonical_arguments` | | "Tools become actions named `mcp..`" | `Gateway._intercept` — `gateway/server.py:442` | `test_T19_the_action_is_named_for_the_alias_and_the_tool` | | "they are declared in the policy" (effect and resource templates for a tool call) | `Policy.effect_template` / `resource_template` — `policy.py:774`; `McpOptions` — `policy.py:496` | `test_T16_a_v2_document_loads_and_exposes_its_templates`, `test_T16_a_decorator_and_a_policy_template_produce_the_same_action_hash` | | "Everything but `tools/call` is relayed untouched" | `parse_request(...).intercept` — `gateway/mcp.py:84` | `test_every_other_method_is_relayed_not_intercepted` | -| "A lost response over the wire blocks the retry exactly as it does in process" | `classify` — `gateway/outcome.py:144`, translated into v0.1 §5.5's own vocabulary by the gateway's executor | `test_T23_the_identical_call_sent_again_is_refused_and_the_upstream_called_once` | +| "A lost response over the wire blocks the retry exactly as it does in process" | `classify` — `gateway/outcome.py:129`, translated into v0.1 §5.5's own vocabulary by the gateway's executor | `test_T23_the_identical_call_sent_again_is_refused_and_the_upstream_called_once` | | "the gateway prints, on the line that starts it, every action in your policy that has no `effect:` template" | `_announce` — `gateway/__init__.py:146` | `test_the_startup_block_names_the_environment_identity_and_authority`, `test_the_startup_block_says_so_when_there_is_no_authority_section` | | "route an `approve` decision through **the framework's own interrupt**" | `FrameworkInterrupt` — `adapter.py:180` — is a Protocol with one method returning a value; it holds no state and writes nothing | `test_T135b_the_adapter_reuses_the_sdks_primitive_and_reimplements_nothing` | | "one core provider writes the grant through the same calls `ctrlrun approve` makes" / "There is never a second place to say yes" | `InterruptApprovalProvider.wait` — `adapter.py:254` — calls `grant_approval` / `deny_approval`, and an adapter calls neither | `test_T130_each_broken_fixture_fails_the_suite_named_for_it` | @@ -89,19 +89,19 @@ by its quoted claim, and `tests/test_docs_audit.py` fails if a named row is not | "Unknown actions are denied; there is no default-allow." | `Policy.evaluate` — `policy.py:534` | `test_T6_unknown_action_is_denied_with_reason_unknown_action` | | "Amounts are integer minor units; floats are rejected outright" | `float` refused at any depth — `action.py:71` | `test_T7_canonical_form_is_exactly_the_specified_serialization` | | "The policy cannot see who is asking — deliberately, since v0.1" | `Policy.evaluate` still takes only the action's name and arguments; `RESERVED_ARGUMENTS` — `policy.py:534` — refuses `agent_eq` and every other principal-addressing condition at load, in a document of **every** schema version | `test_T74b_a_reserved_name_in_a_policy_rule_is_a_load_error`, `test_T74b_a_reserved_name_in_a_grant_constraint_is_a_load_error` | -| "the second axis, `authority:`" | `Authority.evaluate` — `authority.py:798`; `Control._authority_result` — `control.py:618` | `test_T67_a_principal_with_no_grant_is_denied` | +| "the second axis, `authority:`" | `Authority.evaluate` — `authority.py:798`; `Control._authority_result` — `control.py:622` | `test_T67_a_principal_with_no_grant_is_denied` | | "opt-in, and then fail-closed" | `_optional_authority` returns `None` for a document with no section — `control.py`; `Control.authority is None` is v0.2 behaviour exactly | `test_T66_a_document_with_no_authority_section_leaves_control_authority_none`, `test_T66_no_authority_event_is_appended_without_a_section`, and T66's session-wide guard in `tests/conftest.py` | | "every principal needs a grant and no grant means denied" | `NO_AUTHORITY` — the fail-closed default of `Authority.evaluate` (`authority.py:64`), reached for reads and for actions with no effect key alike | `test_T67_an_action_the_policy_allows_outright_still_needs_a_grant` | | "A grant carries no `decision:`" | `_GRANT_KEYS` — `authority.py` — is a closed set that does not contain `decision` | `test_T73b_grant_refuses_what_the_loader_refuses` | | "combine as the **stricter of the two**" | `Control.evaluate` returns the combined result — `control.py`; a denial on either axis is a denial | `test_T70_the_stricter_of_the_two_wins` | -| "narrow it at runtime with `ctrlrun delegate`" | `Control.delegate` — `control.py:2591`; `Authority.plan_delegation` — `authority.py:877`; `ctrlrun delegate` — `cli/main.py:898` | `test_t75_the_delegation_authorizes_an_action_within_its_limits` | +| "narrow it at runtime with `ctrlrun delegate`" | `Control.delegate` — `control.py:2626`; `Authority.plan_delegation` — `authority.py:877`; `ctrlrun delegate` — `cli/main.py:898` | `test_t75_the_delegation_authorizes_an_action_within_its_limits` | | "provably a subset of its parent on every dimension, at creation and again at every evaluation" | `contained_dimension` — `authority.py:603` — runs from `plan_delegation` (`authority.py:877`) **and** from the chain walk in `Authority.evaluate` (`authority.py:806`) | `test_t76_each_dimension_violated_alone`, `test_t77b_a_narrowed_parent_narrows_its_children` | | "omitting a dimension the parent constrains is rejected rather than inherited" | `contained_dimension` treats an absent child dimension as unconstrained and therefore wider — `authority.py:603`; the subject half is `_subject_contained` (`authority.py:634`) | `test_t81_omission_is_not_unlimited`, `test_T73b_a_subject_addressed_to_every_principal_is_refused`, `test_t76_each_dimension_violated_alone` | -| "`ctrlrun revoke` cuts a chain of any depth with one write" | `Control.revoke` — `control.py:2607` — writes one row — `revoke_delegation` — `state.py:659` and visits no children; every evaluation walks to the root | `test_t78_a_revoked_parent_denies_its_grandchild`, `test_put_delegation_is_never_an_upsert` | -| "`mode: observe` … records what *would* have been blocked, without blocking anything" | `_parse_mode` — `policy.py:657`; `Control._observed` — `control.py:1036`; `_WouldHave` — `receipt.py:244`; `ReceiptResult.OBSERVED` — `receipt.py:156` | `test_T82_observe_executes_what_enforce_would_deny`, `test_T83_a_duplicate_is_recorded_and_still_runs` | +| "`ctrlrun revoke` cuts a chain of any depth with one write" | `Control.revoke` — `control.py:2642` — writes one row — `revoke_delegation` — `state.py:659` and visits no children; every evaluation walks to the root | `test_t78_a_revoked_parent_denies_its_grandchild`, `test_put_delegation_is_never_an_upsert` | +| "`mode: observe` … records what *would* have been blocked, without blocking anything" | `_parse_mode` — `policy.py:657`; `Control._observed` — `control.py:1040`; `_WouldHave` — `receipt.py:244`; `ReceiptResult.OBSERVED` — `receipt.py:156` | `test_T82_observe_executes_what_enforce_would_deny`, `test_T83_a_duplicate_is_recorded_and_still_runs` | | "One top-level line" | `mode:` is refused anywhere but the top level — `reject_nested_mode`, `policy.py:657` | `test_T84_mode_is_refused_anywhere_but_the_top_level` | | "`ctrlrun stats` gives you the numbers" | `stats` — `cli/main.py:727`; counted from `would_have.blocked_reason` and nothing else | `test_T86_stats_counts_what_observe_mode_recorded`, `test_T86_stats_reaches_no_network` | -| "It is not a dry run: it executes" | `_observed` runs the executor on every path, including the ones enforce mode would have refused — `control.py:1036` | `test_T82_observe_executes_what_enforce_would_deny`, `test_T83_an_executor_that_fails_on_a_held_key_still_writes_the_record` | +| "It is not a dry run: it executes" | `_observed` runs the executor on every path, including the ones enforce mode would have refused — `control.py:1040` | `test_T82_observe_executes_what_enforce_would_deny`, `test_T83_an_executor_that_fails_on_a_held_key_still_writes_the_record` | ## Prove it holds in your setup @@ -138,8 +138,8 @@ catalogue, `GUARANTEES` (`verify/guarantees.py:41`). |---|---|---| | "An approval is bound to the exact action; a mutated or replayed one is refused." | `action_hash` — `action.py`; the approval record stores it and `_authorize_and_reserve` compares it — `state.py:523`; single use is the `granted → consumed` transition in the same `BEGIN IMMEDIATE` | `test_T2_a_mutated_action_presenting_the_approval_raises_ApprovalMismatch`, `test_T4_replaying_the_approval_raises_ApprovalMismatch_with_reason_consumed`, `test_T5_expiry_is_checked_at_consumption_not_only_at_grant` | | "One logical effect happens at most once, across threads, processes and hosts." | `reserve_effect` — `state.py:510`, decided inside the `BEGIN IMMEDIATE` of `_authorize_and_reserve` (`state.py:939`) against `effect_key TEXT PRIMARY KEY` (`migrations.py:107`; `COLLATE "C"` on Postgres, §4.4) | `test_T3_exactly_one_agent_reserves_and_seven_are_blocked` (8 OS processes, both backends), `test_T3_the_fake_remote_is_called_exactly_once` | -| "An unknown outcome is AMBIGUOUS, never FAILED, and blocks a blind retry." | Only `NotExecuted` maps to `FAILED` — `control.py:1416`. Every other exception, timeouts included, yields `AMBIGUOUS`. A retry against an `AMBIGUOUS` key is refused — `effect.py:94`, the one place `plan_reservation` decides it for every store | `test_T1_a_blind_retry_writes_a_blocked_receipt`, `test_T1_the_ambiguous_record_survives_the_blocked_retry`, `test_T1_a_lost_response_leaves_the_effect_ambiguous` | -| "An unknown action, a missing policy or a missing principal is denied." | Unknown action: `Policy.evaluate` — `policy.py:534` — answers `deny` for a name the document does not list. Missing or malformed policy: `Policy.from_file` — `policy.py:691` — raises `PolicyError`, and there is no `Control` without a policy. Missing principal: `_refuse_no_principal` — `control.py:2903` | `test_T6_unknown_action_raises_ActionDenied_with_reason_unknown_action`, `test_missing_policy_file_is_a_policy_error`, `test_malformed_policy_document_is_a_policy_error`, `test_T62_a_declining_provider_with_no_context_is_no_principal` | +| "An unknown outcome is AMBIGUOUS, never FAILED, and blocks a blind retry." | Only `NotExecuted` maps to `FAILED` — `control.py:1451`. Every other exception, timeouts included, yields `AMBIGUOUS`. A retry against an `AMBIGUOUS` key is refused — `effect.py:172`, the one place `plan_reservation` decides it for every store | `test_T1_a_blind_retry_writes_a_blocked_receipt`, `test_T1_the_ambiguous_record_survives_the_blocked_retry`, `test_T1_a_lost_response_leaves_the_effect_ambiguous` | +| "An unknown action, a missing policy or a missing principal is denied." | Unknown action: `Policy.evaluate` — `policy.py:534` — answers `deny` for a name the document does not list. Missing or malformed policy: `Policy.from_file` — `policy.py:691` — raises `PolicyError`, and there is no `Control` without a policy. Missing principal: `_refuse_no_principal` — `control.py:2938` | `test_T6_unknown_action_raises_ActionDenied_with_reason_unknown_action`, `test_missing_policy_file_is_a_policy_error`, `test_malformed_policy_document_is_a_policy_error`, `test_T62_a_declining_provider_with_no_context_is_no_principal` | | "With authority on, every principal needs a grant, and delegation cannot widen one." | `NO_AUTHORITY` — the fail-closed default of `Authority.evaluate` (`authority.py:64`); `contained_dimension` — `authority.py:603` — runs from `plan_delegation` (`authority.py:877`) and from the chain walk in `Authority.evaluate` | `test_T67_a_principal_with_no_grant_is_denied`, `test_t76_each_dimension_violated_alone` | | "Every executed action leaves a portable JSON receipt" | `ReceiptResult` — `receipt.py:140`; `Event` — `receipt.py:206`; the store is authoritative — `append_event` — `state.py:670`; the JSONL export — `JSONLEventSink` — `receipt.py:583` | `test_T11_every_demo_receipt_carries_every_field_in_the_spec`, `test_T11_every_demo_receipt_parses_back_into_a_Receipt` | @@ -149,24 +149,24 @@ catalogue, `GUARANTEES` (`verify/guarantees.py:41`). |---|---|---| | "On SQLite that is `BEGIN IMMEDIATE`" | `_authorize_and_reserve` — `state.py:939` | `test_T3_exactly_one_agent_reserves_and_seven_are_blocked` | | "a unique index on the effect key and compare-and-set updates whose row counts are checked" | `reserve_effect` — `postgres.py:720` — `INSERT … ON CONFLICT DO NOTHING` against `effect_key TEXT PRIMARY KEY COLLATE "C"` (`migrations.py:107`) | `test_T3_exactly_one_agent_reserves_and_seven_are_blocked` (8 OS processes, both backends) | -| "Same `StateStore` protocol, extended by nothing" | `PostgresStateStore.reserve_effect` — `postgres.py:720` — and every other method implement `v0.1 §5.3`'s frozen protocol; the decisions stay in `plan_reservation` (`effect.py:170`) | `test_T154_postgres_passes_the_store_conformance_suite` | +| "Same `StateStore` protocol, extended by nothing" | `PostgresStateStore.reserve_effect` — `postgres.py:720` — and every other method implement `v0.1 §5.3`'s frozen protocol; the decisions stay in `plan_reservation` (`effect.py:248`) | `test_T154_postgres_passes_the_store_conformance_suite` | | "graded by the suite written for SQLite" | `ctrlrun.conformance.store.run` — `conformance/store/__init__.py:53` | `test_T140_every_fixture_fails_the_suite_named_for_it` | -| "It will not *knowingly* execute the same logical effect twice, and will never treat an unknown outcome as a failure." | `plan_reservation` — `effect.py:170` (refuse retry on `AMBIGUOUS`) and `control.py:1416` (only `NotExecuted` → `FAILED`) | `test_T1_a_blind_retry_is_refused_and_never_reaches_the_remote`, `test_T1_a_lost_response_leaves_the_effect_ambiguous` | -| "a lost connection during `COMMIT` ... are `AMBIGUOUS`" | `_resolve_lost_insert` — `postgres.py:872`; `_resolve_lost_update` — `postgres.py:1350`; only `NotExecuted` maps to `FAILED` — `control.py:1416` | `test_T155_a_connection_killed_during_commit_is_resolved_by_the_re_read`, `test_T155_no_effect_is_ever_recorded_failed_by_a_lost_commit` | +| "It will not *knowingly* execute the same logical effect twice, and will never treat an unknown outcome as a failure." | `plan_reservation` — `effect.py:248` (refuse retry on `AMBIGUOUS`) and `control.py:1451` (only `NotExecuted` → `FAILED`) | `test_T1_a_blind_retry_is_refused_and_never_reaches_the_remote`, `test_T1_a_lost_response_leaves_the_effect_ambiguous` | +| "a lost connection during `COMMIT` ... are `AMBIGUOUS`" | `_resolve_lost_insert` — `postgres.py:872`; `_resolve_lost_update` — `postgres.py:1350`; only `NotExecuted` maps to `FAILED` — `control.py:1451` | `test_T155_a_connection_killed_during_commit_is_resolved_by_the_re_read`, `test_T155_no_effect_is_ever_recorded_failed_by_a_lost_commit` | | "the store re-reads the row to find out which" | The six branches, named and logged — `A2_LANDED` — `postgres.py:131` | `test_T155b_a_landed_commit_on_a_transition_is_seen_as_landed`, `test_T155d_a_commit_the_server_never_received_retries_the_insert` | -| "A crashed worker's effect stays `AMBIGUOUS` until a human runs `ctrlrun resolve` or a `reconcile` hook asks the remote what happened" | An expired lease is `AMBIGUOUS` and nothing sweeps it — `LEASE_EXPIRED` — `effect.py:94`; who resolved it — `resolved_by` — `effect.py:130`; `resolve` — `cli/main.py:518` | `test_T159_ambiguous_survives_a_restart_and_still_refuses_a_blind_retry`, `test_T160_there_is_no_reaper`, `test_T161_a_human_resolution_records_who` | -| "the only thing besides a human permitted to move a record out of `AMBIGUOUS`" | `Control._reconciled` — `control.py:1913`; `RECONCILED_STATES` — `effect.py` | `test_T13_a_hook_answering_not_executed_moves_the_record_to_failed`, `test_T14_a_hook_answering_committed_refuses_the_retry_as_a_duplicate` | +| "A crashed worker's effect stays `AMBIGUOUS` until a human runs `ctrlrun resolve` or a `reconcile` hook asks the remote what happened" | An expired lease is `AMBIGUOUS` and nothing sweeps it — `LEASE_EXPIRED` — `effect.py:172`; who resolved it — `resolved_by` — `effect.py:208`; `resolve` — `cli/main.py:518` | `test_T159_ambiguous_survives_a_restart_and_still_refuses_a_blind_retry`, `test_T160_there_is_no_reaper`, `test_T161_a_human_resolution_records_who` | +| "the only thing besides a human permitted to move a record out of `AMBIGUOUS`" | `Control._reconciled` — `control.py:1948`; `RECONCILED_STATES` — `effect.py` | `test_T13_a_hook_answering_not_executed_moves_the_record_to_failed`, `test_T14_a_hook_answering_committed_refuses_the_retry_as_a_duplicate` | | "and only in the direction its answer points" | `"unknown"` is absent from `RECONCILED_STATES` — `effect.py` | `test_T15_a_hook_that_cannot_answer_leaves_the_record_ambiguous` | -| "Unknown action, missing policy, malformed policy, missing principal, missing or mismatched approval and inconsistent state are all `deny`." | `Policy.evaluate` — `policy.py:534`; `Policy.from_file` — `policy.py:691`; `_refuse_no_principal` — `control.py:2903`; `_authorize_and_reserve` — `state.py:939` | `test_T6_unknown_action_raises_ActionDenied_with_reason_unknown_action`, `test_malformed_policy_document_is_a_policy_error`, `test_T62_a_declining_provider_with_no_context_is_no_principal`, `test_T2_a_mutated_action_presenting_the_approval_raises_ApprovalMismatch` | +| "Unknown action, missing policy, malformed policy, missing principal, missing or mismatched approval and inconsistent state are all `deny`." | `Policy.evaluate` — `policy.py:534`; `Policy.from_file` — `policy.py:691`; `_refuse_no_principal` — `control.py:2938`; `_authorize_and_reserve` — `state.py:939` | `test_T6_unknown_action_raises_ActionDenied_with_reason_unknown_action`, `test_malformed_policy_document_is_a_policy_error`, `test_T62_a_declining_provider_with_no_context_is_no_principal`, `test_T2_a_mutated_action_presenting_the_approval_raises_ApprovalMismatch` | | "No flag makes a consequential action permissive by default" | There is no such option on `Control`, on `@protect`, on the CLI or in the policy schema's closed key sets — `_TOP_LEVEL_KEYS` — `policy.py:110` | `test_T84_mode_is_refused_anywhere_but_the_top_level`, `test_T101b_zero_applicable_guarantees_is_not_a_pass` | -| "With `authority:` on, every principal needs a grant, delegation cannot widen one, and `ctrlrun revoke` cuts a chain with one write." | `Authority.evaluate` — `authority.py:798`; `contained_dimension` — `authority.py:603`; `Control.revoke` — `control.py:2607` | `test_T67_a_principal_with_no_grant_is_denied`, `test_t76_each_dimension_violated_alone`, `test_t78_a_revoked_parent_denies_its_grandchild` | +| "With `authority:` on, every principal needs a grant, delegation cannot widen one, and `ctrlrun revoke` cuts a chain with one write." | `Authority.evaluate` — `authority.py:798`; `contained_dimension` — `authority.py:603`; `Control.revoke` — `control.py:2642` | `test_T67_a_principal_with_no_grant_is_denied`, `test_t76_each_dimension_violated_alone`, `test_t78_a_revoked_parent_denies_its_grandchild` | | "verifies a bearer token against a JWKS or a pinned key" | `JWTIdentityProvider._verified` — `jwt_identity.py:175`; the algorithm comes from the configured list and never from the token | `test_T88_a_valid_token_becomes_a_principal`, `test_T89_every_invalid_token_is_refused_by_cause` | | "maps the verified claims onto a principal" | `_principal` — `jwt_identity.py` — copies only the claims named in `claim_names` | `test_T88_only_the_named_claims_reach_the_principal` | | "`pip install \"ctrlrun[identity]\"`" | `identity = ["pyjwt[crypto]>=2.8"]` in `pyproject.toml`; imported lazily by `_jwt()` — `jwt_identity.py` | `test_T92_constructing_without_the_extra_names_the_install_command`, `test_T92_importing_ctrlrun_pulls_in_no_jwt_module` | | "CTRLRun issues no credential and defines no identity format" | There is no minting, signing or issuing code path in the package: `jwt_identity.py` calls `decode` and never `encode` | `test_the_package_never_encodes_a_token` | -| "every receipt records which policy decided it" | `Policy.policy_hash` — `policy.py:672`, over `_canonical_policy` — `policy.py:824`; carried into the receipt by `_record` — `control.py:2812` | `test_T172_every_receipt_carries_the_hash_and_the_declared_version`, `test_T172_two_policies_sharing_a_version_string_are_told_apart_by_the_hash` | +| "every receipt records which policy decided it" | `Policy.policy_hash` — `policy.py:672`, over `_canonical_policy` — `policy.py:824`; carried into the receipt by `_record` — `control.py:2847` | `test_T172_every_receipt_carries_the_hash_and_the_declared_version`, `test_T172_two_policies_sharing_a_version_string_are_told_apart_by_the_hash` | | "the policy's declared `version:` and a hash of its canonical content" | `version:` is recorded and never authoritative; `policy_hash` is what tells two documents apart — `policy.py:662` | `test_T171_the_declared_version_alone_does_not_change_the_hash`, `test_T171_comments_key_order_and_whitespace_do_not_change_the_hash` | -| "the approval is re-checked against the policy in force at execution" | `Control.execute` — `control.py:752`; `_spend_unneeded_approval` — `control.py:1853` | `test_T173_the_DENY_row_refuses_and_leaves_the_approval_granted`, `test_T173_the_ALLOW_row_invalidates_the_approval_it_did_not_need` | +| "the approval is re-checked against the policy in force at execution" | `Control.execute` — `control.py:756`; `_spend_unneeded_approval` — `control.py:1888` | `test_T173_the_DENY_row_refuses_and_leaves_the_approval_granted`, `test_T173_the_ALLOW_row_invalidates_the_approval_it_did_not_need` | | "Each receipt carries the hash of the one before it" | `Receipt.chain_hash` — `receipt.py:393`; `prev_hash` — `receipt.py:352`; `GENESIS_HASH` — `receipt.py:78`; `put_receipt` takes the head row's lock first — `postgres.py:1838` | `test_T164_an_altered_receipt_is_content_altered_at_its_seq`, `test_T164_reordering_two_receipts_is_detected_either_way` | | "`ctrlrun receipts --verify-chain` reports it by `seq`" | `verify_chain` — `receipt.py:701`; the six names — `CHAIN_BREAKS` — `receipt.py:638` | `test_the_verify_chain_flag_reports_a_break_by_seq_and_by_name`, `test_verify_chain_reads_a_postgres_store_through_store_url` | | "migrations are automatic at open, forward-only" | `migrate` — `migrations.py:544`, called from both stores' constructors; `HEAD` — `migrations.py:325` | `test_T147_a_v05_database_migrates_and_keeps_every_row`, `test_T150_reopening_does_not_rerun` | @@ -223,11 +223,11 @@ restating the code; the ones that are new to the site carry their own code and p | `concepts/decisions` | three decisions, first match wins, unknown denied, principal-addressing conditions refused at load | the "Write down what the agent may do" rows above | | `concepts/approval-binding` | A1–A4, the mismatch leaving the approval granted, one core provider writing every grant | the matrix row "An approval is bound to the exact action…", the "Three ways to use it" adapter rows, and `test_T2_a_mutated_action_leaves_the_approval_granted` | | `concepts/approval-binding` | the `DENY` and `ALLOW` rows when the policy changed between grant and consumption | "the approval is re-checked against the policy in force at execution" above | -| `concepts/effect-keys` | reservation atomic across threads, processes and hosts; an expired lease is `AMBIGUOUS`, never free; `COMMITTED` refuses, `FAILED` permits, `AMBIGUOUS` refuses a blind retry | the matrix row "One logical effect happens at most once…"; `LEASE_EXPIRED` — `effect.py:94`; `test_T160_an_expired_lease_frees_nothing_and_no_read_transitions_it`, `test_T8_a_failed_attempt_permits_a_retry_that_commits` | +| `concepts/effect-keys` | reservation atomic across threads, processes and hosts; an expired lease is `AMBIGUOUS`, never free; `COMMITTED` refuses, `FAILED` permits, `AMBIGUOUS` refuses a blind retry | the matrix row "One logical effect happens at most once…"; `LEASE_EXPIRED` — `effect.py:172`; `test_T160_an_expired_lease_frees_nothing_and_no_read_transitions_it`, `test_T8_a_failed_attempt_permits_a_retry_that_commits` | | `concepts/outcomes-and-ambiguous` | the outcome table; only a human or a reconcile hook moves a record on, and only in the direction the answer points; nothing sweeps; a lost `COMMIT` on Postgres is `AMBIGUOUS` | the matrix row "An unknown outcome is AMBIGUOUS…", the reconciliation rows, "A crashed worker's effect stays `AMBIGUOUS`…" and the Postgres rows above; `test_T160_there_is_no_reaper` | | `concepts/receipts-and-evidence` | the receipt's fields, the JSONL sink, the policy hash and version, the chain and what it does not prove | the matrix row "Every executed action leaves a portable JSON receipt", the receipt-chain and policy-versioning rows above, and `test_T11_every_demo_receipt_carries_every_field_in_the_spec` | | `concepts/authority-and-delegation` | opt-in then fail-closed, no `decision:` on a grant, stricter of the two, containment at creation and at every evaluation, omission rejected, one-write revocation, identity consumed | the authority rows under "Write down what the agent may do" and "What it guarantees" above | -| `concepts/observe-mode` | executes, records `would_have`, one top-level line, counted by `ctrlrun stats`, never asks a human | the observe-mode rows above; `_observed` — `control.py:2850` | +| `concepts/observe-mode` | executes, records `would_have`, one top-level line, counted by `ctrlrun stats`, never asks a human | the observe-mode rows above; `_observed` — `control.py:2885` | | `concepts/fail-closed` | the refusal table, one exception per row | the matrix row "An unknown action, a missing policy or a missing principal is denied.", `ActionDenied` — `errors.py:29`, `DuplicateEffect` — `errors.py:126`, `AmbiguousEffect` — `errors.py:141`, and `test_a_policy_deny_is_denied_the_same_way_as_an_unknown_action` | ## The docs site: Production diff --git a/docs/OWASP-AGENTIC-TOP10.md b/docs/OWASP-AGENTIC-TOP10.md index 7ec22f1..0e92469 100644 --- a/docs/OWASP-AGENTIC-TOP10.md +++ b/docs/OWASP-AGENTIC-TOP10.md @@ -74,6 +74,7 @@ mechanism, not the entry. | **G9** delegation cannot escalate | A delegated grant is valid only if it is provably a subset of its parent on every dimension — and a child that **drops** a dimension its parent constrains is rejected rather than treated as unconstrained. | `ASI03:2026`, `ASI10:2026` | Containment is checked at creation and again on every evaluation by walking the chain to its root, and omission is never inheritance — so an agent handed authority cannot mint itself more of it, and a revocation anywhere in the chain cuts everything beneath it. | | **G10** unknown exception is ambiguous | `NotExecuted` is the only outcome that means "the remote did nothing". Everything else, timeouts included, is `AMBIGUOUS`. | `ASI08:2026` | The mapping from an executor's exception to an outcome is asymmetric on purpose: a timeout is not a failure, so a framework's retry-on-error cannot be the thing that decides whether money moved twice. | | **G11** an altered receipt is detected | Each receipt carries the hash of the one before it. Altering, deleting or reordering one breaks the chain, and the break is reported by name — `content_altered`, `hash_missing`, `link_broken`, `missing`, `head_mismatch`, `unchained` — and by `seq`. | `ASI09:2026` (partly) | The evidence an operator reads after an incident is the thing an attacker who got that far has the most reason to edit. This does not stop them: it makes **changing what a receipt says, while keeping the receipts after it**, cost a rewrite of all of them plus the head, rather than one statement. What it does not close is the end of the log — erasing a suffix, or appending to it, each cost two statements and are undetected, because the head is a row in the same database and not an external anchor. v0.6 has no anchor and claims none. It is **not** a signature and says nothing about who wrote the log; somebody who can rewrite every row including the head recomputes the chain and it verifies, and `THREAT_MODEL.md` still lists a malicious administrator as out of scope. | +| **G12** a byte written is ambiguous | `ctrlrun.transport` classifies a transport failure from evidence rather than from an exception type: it raises `NotExecuted` only where a connection it opened was handed no request byte, in an executor run that had offered none. After one byte, a reset, a read timeout, a reused connection or a second connection in the same run is the original exception, and the effect is `AMBIGUOUS`. | `ASI08:2026`, `ASI09:2026` (partly) | `FAILED` versus `AMBIGUOUS` is the one decision this library exists to get right, and the kernel does not make it: an executor does. Until v0.7 the correct rule lived only behind `ctrlrun[gateway]`, so the surface most people use had a docstring and no implementation, and the obvious hand-written classifier maps `ConnectionResetError` to "nothing happened" after the whole request reached the remote. That is a licence to act twice, written by a well-meaning integrator. What this closes is the absence of a correct rule in core. What it does not close: 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. No parameter, attribute or environment variable widens what counts as `FAILED`. | | **G13** clock divergence is named | The kernel measures how far this host's clock disagrees with the store's, on a store that has a clock of its own, and reports the divergence as `CLOCK_SKEW_DETECTED` when it passes the configured threshold. It changes no decision: every lease is still compared against the application clock exactly as before. | `ASI08:2026` (partly) | Once the store is shared, each host brings its own clock, and the failure is fail-closed and therefore quiet. A host running ahead sees a live lease as expired and marks `AMBIGUOUS` a record whose real holder is mid-flight and about to succeed; a host running behind refuses for longer than it should. Neither says why. What this closes is the silence, not the skew: an operator reading a receipt learns that two clocks disagreed and by how much, so the response to the first failure is not itself the second one. It does not synchronize anything, and a skew below the threshold is not reported. | | **G14** token changes across a renewal | `ctrlrun.idempotency_token()` answers inside an executor with a token derived from `(effect_key, attempt)`: stable within one attempt, including across a resume, and different after a renewal. Send it to a provider as its idempotency key. | `ASI08:2026` (partly) | A provider handed the effect key alone would answer the one retry the kernel permits, permitted *because the executor proved nothing happened*, with the cached failure of the attempt that failed. A token that moves with the attempt keeps a provider's cache from becoming a second source of stale outcomes. What it is for is reconciliation, a deterministic handle to ask a provider what became of an attempt whose outcome is unknown; it does not make a retry safe, and after `AMBIGUOUS` the kernel still refuses one. It is unique only as far as the operator's effect keys are, and nothing here checks two stores sharing a provider account. | | **G15** renewal past the ceiling refused | An action entry may declare `max_attempts`; above it the executor is not called, the record is released as `FAILED`, a `blocked` receipt names the ceiling, and `ActionDenied(reason="attempt_ceiling")` is raised. The refused attempt number is spent. | `ASI08:2026` (partly), `ASI10:2026` (partly) | Without a ceiling a renewal after `FAILED` is unbounded, so an agent that keeps proposing an action that keeps failing keeps dispatching, and one human yes on an `APPROVE` action bought unlimited dispatches. The ceiling is the operator's, not the kernel's: an entry that declares none renews exactly as before, and the decision is taken on the attempt number the store assigned, after the reservation, so two callers cannot both pass a read taken before reserving. It bounds attempts on one effect key, not what an agent does across many. | diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 40664e7..38792ad 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -246,14 +246,16 @@ It gates no release and none gates it. It adds no public name, no flag and no gu Standards: none new. RFC 9728 is consumed by the tool server, not by the gateway, and this track does not change that unless the run above says it must. -## v0.7 — Execution boundary +## v0.7 — Execution boundary ✅ shipped Every guarantee shipped so far is a guarantee about what happens *inside* CTRLRun. But the kernel does not decide whether the remote side acted — an executor does, by raising `NotExecuted` or not. 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. v0.7 asks what the kernel owes at each of those edges. - **A transport classifier in core.** `FAILED` versus `AMBIGUOUS` is the one decision this project exists to get right, and the kernel does not make it — the user's executor does. The correct rule is already written and already implemented, in the gateway's `outcome.py`: the connection was never established, or the peer said in band and before dispatch that it rejected the request; everything after the first byte is `AMBIGUOUS`. It is reachable today only by installing `ctrlrun[gateway]`, while `@protect` — the surface the README leads with — gets a docstring. One rule, one implementation, reachable from core. - **Clock-skew detection.** v0.6 moved the store to another host so several hosts could share it; lease liveness stayed on the application clock. Skew is fail-closed and therefore quiet — a host running ahead marks a live reservation `AMBIGUOUS` while its real holder is mid-flight and about to succeed, and nothing names the cause. This makes divergence observable. It does not change how a lease is evaluated. - **A provider idempotency token**, derived from the effect key *and the attempt number*. Derived from the effect key alone it would be stable across v0.1 §5.4's renewal, and a provider would replay its cached failure for the one retry the kernel permits precisely because the executor proved nothing happened. Its main value is a deterministic handle for reconciliation to observe with — not a licence for anything to act twice. -- **A ceiling on renewal after `FAILED`.** There is none today: one human approval plus an executor that always reports "nothing happened" is unlimited dispatches, each recorded as an ordinary retry. An operator-set policy key, and an amendment to §5.4 written as an amendment. +- **A ceiling on renewal after `FAILED`.** There is none today: an executor that always reports "nothing happened" renews without bound, each dispatch recorded as an ordinary retry. An operator-set policy key, and an amendment to §5.4 written as an amendment. + + **The sentence that stood here said *one human approval plus an executor that always reports "nothing happened" is unlimited dispatches*, and building item 4 proved it wrong in enforce mode. Corrected here rather than quietly rephrased.** One granted approval buys one dispatch: every renewal of an approved action needs a new granted approval, so the human is asked again each time. "Granted" is not always a person (a scripted provider, an automated `wait=True` loop and approvals granted ahead of a gateway all count), and observe mode needs none. The case the ceiling actually exists for is **the action the policy allows outright**, which renews without anyone being asked at all. What the ceiling bounds is that, plus renewals on the `ALLOW` path and the reconcile route; `SPEC-v0.7.md` §5.5 records that an adapter can still put a human in front of an attempt the ceiling will then refuse, which costs a wasted answer and never an execution. - **Precondition fingerprints.** An approval binds to an action hash and an expiry, and to nothing about the world it was granted against. A human approves a deletion when the balance is zero; thirty minutes later it is not, and the action hash has not moved. The operator supplies a fingerprint, it is hashed through the canonicalizer so raw resource state never reaches a receipt, and it is rechecked before the reservation. **It narrows the window between decision and execution; it does not close it** — the recheck cannot run inside the atomic reservation write, so a residual gap remains, and that sentence appears wherever the feature does. Exit: `ctrlrun.guarantees/v3` — G12 a byte written and the peer killed is `AMBIGUOUS`, never `FAILED`, by a classifier that observes rather than infers · G13 skew between the store's clock and a host's is named, with a positive control that stays silent when the clocks agree · G14 the provider token changes across a renewal, provably · G15 a renewal past the operator's ceiling is refused · G16 a precondition whose fingerprint has moved is refused before the reservation — each with a positive control, each `N/A` with a reason where the configuration names no ceiling or no fingerprint. And the precondition recheck documented as narrowing everywhere it is described. @@ -325,13 +327,15 @@ One question: can the record be trusted after the fact, and kept? - **An external anchor for the receipt chain.** The chain detects alteration and says on every page that it does not detect truncation or append — both measured at two statements, undetected, because the head is a row in the same database. v0.11 anchors the head outside the database at an interval (an RFC 3161 timestamp, or an equivalent the operator supplies) so a suffix erased or appended between two anchors is detected and named, in the same vocabulary as the six existing break kinds. No keys of its own: it consumes a timestamp and issues nothing, which is why it is here and signing is not. - **Retention and legal hold.** There is no retention policy today and `docs/postgres.md` says so, while `docs/CONTROL-MAPPING.md` maps receipt retention to a clause. v0.11 pays that debt: a chain-preserving prune that leaves a checkpoint receipt verifiable across the gap, and a hold that refuses to prune, both recorded as receipts themselves. - **Enforcement coverage.** From events already written: policy entries never exercised, gateway tools never routed, `@protect` actions never seen. The runtime half of `ctrlrun scan`, under the same rule — a clean result is not a verdict, no score, no percentage, no badge. -- **One chain, several receipt schemas.** `ctrlrun.receipt/v3` is the schema today, and the rule since `SPEC-v0.3.md` §12.2 is that every reader upgrades before any writer switches, so an older receipt on disk still parses. v0.8 (the verified approver; the grant id under break-glass) and v0.9 (budget consumption) each add fields and each bump the version, so a chain kept across the upgrades holds receipts of three shapes — and nothing yet proves that `verify` walks it end to end, hash by hash, each receipt hashed by the rule its own version wrote. v0.11 proves it, here, because this is the milestone about whether the record can be trusted after the fact. No new field: the version string already exists. What is new is the test, and the rule that a receipt whose version the binary does not know is *named* and not reported as a break — which is the same distinction v0.6 §3.2 draws for a `schema_version` row the binary does not know. Added 2026-09-10. +- **One chain, several receipt schemas.** `ctrlrun.receipt/v4` is the schema today, and the rule since `SPEC-v0.3.md` §12.2 is that every reader upgrades before any writer switches, so an older receipt on disk still parses. v0.8 (the verified approver; the grant id under break-glass) and v0.9 (budget consumption) each add fields and each bump the version, so a chain kept from v0.6 across them holds **four receipt schema versions**: `v3`, which 0.6 wrote, `v4`, which v0.7 added, and the two that follow. This sentence said *three shapes* and named `v3` as the schema today. It was written before v0.7's precondition fields bumped the schema, and v0.7's release pass corrects it here rather than quietly. And nothing yet proves that `verify` walks it end to end, hash by hash, each receipt hashed by the rule its own version wrote. v0.11 proves it, here, because this is the milestone about whether the record can be trusted after the fact. No new field: the version string already exists. What is new is the test, and the rule that a receipt whose version the binary does not know is *named* and not reported as a break — which is the same distinction v0.6 §3.2 draws for a `schema_version` row the binary does not know. Added 2026-09-10. + +- **A malformed value in a receipt row blinds every reader of the chain, and one `UPDATE` is enough.** Found while building v0.7's item 5, deferred there with a written decision, and named here because it is the evidence surface and this is the evidence milestone. A receipt whose *schema label* is unknown, and a receipt carrying an *added key*, are each reported at their `seq` and leave every other row readable. A malformed **value** of a key the schema declares is not: a float among a receipt's `controls` raises out of `Receipt.from_dict`, so `ctrlrun receipts`, `receipts --verify-chain`, `ctrlrun inspect`, `ctrlrun stats` and `G11` all stop together, and a single tampered row hides the whole document rather than naming itself. 0.6.1 behaves the same way and v0.7 neither introduced nor widened it. Fixing it needs one of two things, and both are amendments rather than patches: a new name in `CHAIN_BREAKS`, which is a closed set on a `SPEC-v0.6.md` §6.5 surface, or a reader that walks raw rows and reports per row without constructing a `Receipt` at all. `SPEC-v0.7.md` §12.5 carries the argument. Added 2026-09-12. **Does not close.** Authorship. An anchor proves the log existed in this form at that time; it does not prove who wrote it, and a malicious administrator who rewrites everything before the next anchor is still out of scope. Signed receipts stay off the roadmap for the reason `SPEC-v0.6.md` §11 gives. Do not build: a SIEM · dashboards over receipts · a receipt query language · export formats beyond JSON and OTel. -Exit: the truncation and append cases that `SPEC-v0.6.md` §6.4 lists as undetected now detect, with the anchor as the positive control; a prune across a checkpoint verifies; a held range refuses to prune; a chain written across three receipt schema versions verifies end to end, and the checkpoint receipt of a prune carries the version current when it was written. +Exit: the truncation and append cases that `SPEC-v0.6.md` §6.4 lists as undetected now detect, with the anchor as the positive control; a prune across a checkpoint verifies; a held range refuses to prune; a chain written across four receipt schema versions verifies end to end, and the checkpoint receipt of a prune carries the version current when it was written. Standards: RFC 3161 consumed as code. None claimed. @@ -339,6 +343,8 @@ Standards: RFC 3161 consumed as code. None claimed. Fuzzing, property tests, concurrency stress, failure injection, benchmarks, upgrade testing, compatibility guarantees, CodeQL/SAST/SBOM/signed artifacts. +- **The import cycle `state` → `receipt` → `policy` → `authority` → `state`, and the sentence in `docs/ARCHITECTURE.md` §6 it contradicts.** Found by a v0.7 review, deferred with a written decision, and named here because deciding which edge to break is design work and not a release pass. `state.py` imports `receipt.py`, `receipt.py` imports `policy.py` for `Decision`, `authority.py` imports `state.py`, and `policy.py` reaches `authority.py` from inside two functions. Nothing is broken at run time: the two edges out of `policy.py` are function-level, so `import ctrlrun` still loads in one order and every test passes, which is why it went unnoticed. What it costs is §6's own rule, **dependencies point downward only**, which is a claim about the module map and not about import order, and the map is what tells a contributor what a module may know about. The candidate fixes are a shared type for `Decision` below both, or moving the two deferred imports out of `policy.py`; each is a public-surface question of its own. `docs/ARCHITECTURE.md` §6 states the cycle where the false sentence was. Added 2026-09-12. + **An external security review is optional and gates nothing, decided 2026-09-10.** It was a line in the list above and a sentence in v1.0's exit; both are gone. A review by a third party is bought, scheduled and scoped by whoever pays for it, and a milestone that waits on a purchase is a milestone with a date nobody on this project controls. If one happens, its scope names the approver path (v0.8) explicitly, because it is the surface a product selling action governance is attacked through, and its report is published beside `docs/how-this-is-built.md` with what it did and did not look at. Until then that page says what has and has not been reviewed, which is the same sentence it says today. Standards: none new. diff --git a/docs/THREAT_MODEL.md b/docs/THREAT_MODEL.md index 747bf13..7637839 100644 --- a/docs/THREAT_MODEL.md +++ b/docs/THREAT_MODEL.md @@ -190,6 +190,40 @@ model. They shipped in 0.2.0 and every one of them describes behaviour you can r proposal and execution does not invalidate one. Matching a grant on a claim is out of scope (§13): it needs an answer to "what does a missing claim mean" that v0.3 does not have. +## Known v0.7 limitations + +- **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 to the operator's provider, + so it runs strictly before `consume_approval_and_reserve` and cannot run inside it. A change + to the resource that lands after the comparison and before the reservation is **not** refused. + What the mechanism buys is the difference between minutes of human deliberation and + milliseconds of kernel work, which is worth having and is attribution rather than prevention. + `ctrlrun verify`'s G16 grades a change made before the comparison, because that is the half a + correct kernel refuses; the residual half is pinned by a test (`SPEC-v0.7.md` §6.7) and is not + graded, because there is nothing there for a correct kernel to do. +- **The `NotExecuted` classifier speaks only for the requests it sent.** `ctrlrun.transport` + claims `NotExecuted` only where a connection it opened was handed no request byte and no send + went out anywhere in the executor run. It can only see **this library's own sends**. 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 claim 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 the module says so where a reader would look. The error is in + the same direction as the integration bug above, and for the same reason it is the most + dangerous one available. +- **A classifier that cannot observe does not claim, and that costs true refusals.** Outside an + executor run nothing is claimed at all, and a send on a thread that did not copy the + executor's context marks *every* open run, so an unrelated concurrent run can lose a claim it + was entitled to. Both are deliberate: the cost is `AMBIGUOUS` where `FAILED` was true, never + the other way round. +- **A reused `action_id` leaves late writes attributable to the wrong attempt.** Attempt numbers + never repeat since v0.7, on every backend, but a transition still names its holder by + `action_id` alone. A caller that rebuilds the same `Action` after a retry reuses the id, so a + write from a lapsed attempt can land on a newer one. `SPEC-v0.7.md` §12.3a states the + consequences, including the one where a late `FAILED` permits a renewal beside a dispatch that + is still running, and records why the fix is a schema change deferred rather than an + unavailable one. + ## Disclosure Report vulnerabilities privately to contact@arpanghoshal.com. Do not open public issues for security reports. `SECURITY.md` has the process and what counts as a vulnerability. diff --git a/docs/cookbook/verify-in-github-actions.mdx b/docs/cookbook/verify-in-github-actions.mdx index 689f6be..33c482d 100644 --- a/docs/cookbook/verify-in-github-actions.mdx +++ b/docs/cookbook/verify-in-github-actions.mdx @@ -4,8 +4,8 @@ description: "Run ctrlrun verify against your policy on every push with the CTRL --- Your policy lives in the repository with the agent. Every push should prove the guarantees it -declares still hold against it, in a scratch store, with no network, and fail the build if one -does not. That is one workflow step. +declares still hold against it, in a scratch store, reaching nothing outside the runner, and +fail the build if one does not. That is one workflow step. ## The policy @@ -62,7 +62,7 @@ by tag where you want a ref nobody can move. The agent sees nothing; this is the operator's check. The build sees: ```text -CTRLRun verify — ctrlrun 0.6.1, catalogue ctrlrun.guarantees/v3 +CTRLRun verify — ctrlrun 0.7.0, catalogue ctrlrun.guarantees/v3 policy /home/runner/work/agent/agent/ctrlrun.yaml (ctrlrun.policy/v2, mode: enforce) authority none store sqlite, scratch (created and destroyed for this run) @@ -77,6 +77,7 @@ G8 expired authority refused N/A no authority section G9 delegation cannot escalate N/A no authority section G10 unknown exception is ambiguous PASS k8s.delete_namespace G11 an altered receipt is detected PASS k8s.delete_namespace +G12 a byte written is ambiguous PASS k8s.delete_namespace G13 clock divergence is named N/A the store verify was given reads only the application's clock, so there is no second clock to diverge from; pass --store-url postgresql://… to grade this G14 token changes across a renewal PASS k8s.delete_namespace (attempt 1 and its renewal carry different tokens) G15 renewal past the ceiling refused N/A no action verify can drive to allow or approve declares both `effect:` and `max_attempts` @@ -90,7 +91,7 @@ G16 a moved fingerprint is refused PASS k8s.delete_namespace two stores sharing a provider account must not produce the same effect-key string for different effects, and nothing here can check that) -11/11 declared guarantees pass. 4 not applicable: G8, G9, G13, G15. +12/12 declared guarantees pass. 4 not applicable: G8, G9, G13, G15. ``` The first line is on stderr, from G7's own scenario driving an action with no principal — the diff --git a/docs/guides/verify-in-ci.mdx b/docs/guides/verify-in-ci.mdx index 50cbb76..f7935eb 100644 --- a/docs/guides/verify-in-ci.mdx +++ b/docs/guides/verify-in-ci.mdx @@ -4,8 +4,8 @@ description: "Run ctrlrun verify against your policy on every push with the CTRL --- `ctrlrun verify` runs the kernel's own failure scenarios against your policy, in a scratch -store, with fake executors and no network, and reports what passed, what failed, and what it -could not check. In CI that is one step, and the badge it writes means one thing: the declared +store, with fake executors and nothing reached outside the runner. It reports what passed, +what failed, and what it could not check. The badge it writes means one thing: the declared guarantees pass. **Prerequisites:** a repository with a `ctrlrun.yaml`, and GitHub Actions. @@ -32,7 +32,7 @@ guarantees pass. ``` ```text - CTRLRun verify — ctrlrun 0.6.1, catalogue ctrlrun.guarantees/v3 + CTRLRun verify — ctrlrun 0.7.0, catalogue ctrlrun.guarantees/v3 policy /home/you/agent/ctrlrun.yaml (ctrlrun.policy/v2, mode: enforce) authority none store sqlite, scratch (created and destroyed for this run) @@ -47,6 +47,7 @@ guarantees pass. G9 delegation cannot escalate N/A no authority section G10 unknown exception is ambiguous PASS k8s.delete_namespace G11 an altered receipt is detected PASS k8s.delete_namespace + G12 a byte written is ambiguous PASS k8s.delete_namespace G13 clock divergence is named N/A the store verify was given reads only the application's clock, so there is no second clock to diverge from; pass --store-url postgresql://… to grade this G14 token changes across a renewal PASS k8s.delete_namespace (attempt 1 and its renewal carry different tokens) G15 renewal past the ceiling refused N/A no action verify can drive to allow or approve declares both `effect:` and `max_attempts` @@ -60,18 +61,17 @@ guarantees pass. two stores sharing a provider account must not produce the same effect-key string for different effects, and nothing here can check that) - 11/11 declared guarantees pass. 4 not applicable: G8, G9, G13, G15. + 12/12 declared guarantees pass. 4 not applicable: G8, G9, G13, G15. ``` The first line is on **stderr**, from G7's own scenario: an action with no principal is refused and the refusal logs, which is the guarantee passing. Every row names `k8s.delete_namespace` because verify takes the **first action that fits, in alphabetical - order**, not because of anything about the refund. + order**. Four guarantees are not applicable: no `authority:` section (G8, G9), no second clock in a scratch store (G13), no `max_attempts` to refuse past (G15). Each is reported with its - reason, excluded from the denominator and listed separately. Never `15/15`, and no flag - folds them in. + reason and excluded from the denominator. Never `16/16`, and no flag folds them in. diff --git a/docs/production/index.mdx b/docs/production/index.mdx index bc58e3d..3b5c402 100644 --- a/docs/production/index.mdx +++ b/docs/production/index.mdx @@ -27,9 +27,9 @@ need. `test_the_first_line_of_the_section_says_which_store_and_why` asserts the ## Where it stands {/* generated from the suite, pyproject and the soak (full) — run the generator */} -- **Version 0.6.1**, on [PyPI](https://pypi.org/project/ctrlrun/), Python 3.11 and later. -- **4,999 tests**, every version specified before it was written and every requirement mutation-tested. [Read more](/docs/how-this-is-built). -- **15 guarantees you can check in your own setup**, with `ctrlrun verify` against your policy, on your store's backend, in a scratch store it creates. [Read more](/docs/security/verify-guarantees). +- **Version 0.7.0 is in development**; [PyPI](https://pypi.org/project/ctrlrun/) has 0.6.1. Python 3.11 and later. +- **5,164 tests**, every version specified before it was written and every requirement mutation-tested. [Read more](/docs/how-this-is-built). +- **16 guarantees you can check in your own setup**, with `ctrlrun verify` against your policy, on your store's backend, in a scratch store it creates. [Read more](/docs/security/verify-guarantees). - **One host: a file.** SQLite, no server, no ops. **Many hosts: Postgres**, the same guarantees, graded by the same suite. [Read more](/docs/production/postgres). - **Soaked for 20m 0s on postgres**: 889,735 actions, 0 unattributed ambiguous outcomes, positive control fired. Nothing here establishes what only accumulates over days. [Read more](/docs/production/soak). - **Each receipt carries the hash of the one before it**, so an alteration is detected and named. [Read more](/docs/production/receipt-integrity). diff --git a/docs/production/recovery.mdx b/docs/production/recovery.mdx index 0e3e172..d518ec7 100644 --- a/docs/production/recovery.mdx +++ b/docs/production/recovery.mdx @@ -51,6 +51,18 @@ with a shared store a **different** process may finish it. One resumption is adm more, because the token is consumed in the transaction that admits it. If the lease lapsed first, the resumption is refused and the effect becomes `AMBIGUOUS` by the ordinary path. +**A continuation leg can never record `FAILED`, since v0.7.** A continuation exists only because +the remote answered once already and is holding the exchange, so nothing on the second leg can +say the remote did nothing. `ctrlrun.transport` refuses to claim `NotExecuted` on a resumed run, +and the MCP gateway records an unknown outcome for every path that could otherwise reach `FAILED` +there: a connection it never established, a pre-dispatch JSON-RPC code, the `401` rule, and a +tool error under an operator's `not_executed_on_error: true`, which is a claim about the call +that tool answers and not about the one carrying the effect. At 0.6.1 each of those recorded +`FAILED`, and the gateway answered `-41011` "not executed", which permitted a retry of an effect +the upstream may have been part-way through; it is `-41010` and a `ctrlrun resolve` now. An +executor that raises `NotExecuted` on its own evidence is still believed, as it always was. What +changed is that nothing in the library will hand it one on a continuation. + ## What this does not do - **Nothing sweeps.** No background thread, no timer, no cron, no reap command. The lapse is diff --git a/docs/reference/api/Control.mdx b/docs/reference/api/Control.mdx index 449d9b7..b4ca510 100644 --- a/docs/reference/api/Control.mdx +++ b/docs/reference/api/Control.mdx @@ -5,7 +5,7 @@ description: "Policy, state and evidence composed around a single action (SPEC-v {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.Control` — class, defined at `src/ctrlrun/control.py:457` +`ctrlrun.Control` — class, defined at `src/ctrlrun/control.py:461` ```python from ctrlrun import Control diff --git a/docs/reference/api/EffectRecord.mdx b/docs/reference/api/EffectRecord.mdx index c0f7d69..17bef6c 100644 --- a/docs/reference/api/EffectRecord.mdx +++ b/docs/reference/api/EffectRecord.mdx @@ -5,7 +5,7 @@ description: "What a StateStore holds for one effect key (ARCHITECTURE §5)." {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.EffectRecord` — class, defined at `src/ctrlrun/effect.py:106` +`ctrlrun.EffectRecord` — class, defined at `src/ctrlrun/effect.py:184` ```python from ctrlrun import EffectRecord diff --git a/docs/reference/api/EffectState.mdx b/docs/reference/api/EffectState.mdx index 559ca45..209d443 100644 --- a/docs/reference/api/EffectState.mdx +++ b/docs/reference/api/EffectState.mdx @@ -5,7 +5,7 @@ description: "Where a logical effect stands (SPEC-v0.1 §5.2)." {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.EffectState` — class, defined at `src/ctrlrun/effect.py:81` +`ctrlrun.EffectState` — class, defined at `src/ctrlrun/effect.py:159` ```python from ctrlrun import EffectState diff --git a/docs/reference/api/ReconcileOutcome.mdx b/docs/reference/api/ReconcileOutcome.mdx index d731a39..fe52382 100644 --- a/docs/reference/api/ReconcileOutcome.mdx +++ b/docs/reference/api/ReconcileOutcome.mdx @@ -5,7 +5,7 @@ description: "What a `reconcile` hook may answer about an effect key (SPEC-v0.2 {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.ReconcileOutcome` — attribute, defined at `src/ctrlrun/effect.py:52` +`ctrlrun.ReconcileOutcome` — attribute, defined at `src/ctrlrun/effect.py:54` ```python from ctrlrun import ReconcileOutcome diff --git a/docs/reference/api/context.mdx b/docs/reference/api/context.mdx index 3a4020e..f1d38a4 100644 --- a/docs/reference/api/context.mdx +++ b/docs/reference/api/context.mdx @@ -5,7 +5,7 @@ description: "Bind the principal for calls made inside the block." {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.context` — function, defined at `src/ctrlrun/control.py:236` +`ctrlrun.context` — function, defined at `src/ctrlrun/control.py:240` ```python from ctrlrun import context diff --git a/docs/reference/api/idempotency_token.mdx b/docs/reference/api/idempotency_token.mdx index f02de15..1887964 100644 --- a/docs/reference/api/idempotency_token.mdx +++ b/docs/reference/api/idempotency_token.mdx @@ -5,7 +5,7 @@ description: "The provider idempotency token for the attempt this executor is ru {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.idempotency_token` — function, defined at `src/ctrlrun/control.py:204` +`ctrlrun.idempotency_token` — function, defined at `src/ctrlrun/control.py:208` ```python from ctrlrun import idempotency_token diff --git a/docs/reference/api/index.mdx b/docs/reference/api/index.mdx index 44624fc..5b94720 100644 --- a/docs/reference/api/index.mdx +++ b/docs/reference/api/index.mdx @@ -74,6 +74,11 @@ a name with no docstring fails a test, so every page has one. | [`ctrlrun.parse_conditions`](/docs/reference/api/parse_conditions) | function | Parse a `when:`-shaped mapping into conditions, keyed by the raw condition key. | | [`ctrlrun.protect`](/docs/reference/api/protect) | function | Bind a function to an action name: every call becomes a decided, recorded Action. | | [`ctrlrun.with_approval`](/docs/reference/api/with_approval) | function | Present a granted approval to the calls made inside the block (SPEC-v0.1 §4.3). | +| [`ctrlrun.transport.Transport`](/docs/reference/api/transport-Transport) | class | What a transport observed, where no answer came back (SPEC-v0.2 §6.8, SPEC-v0.7 §2.1). | +| [`ctrlrun.transport.effect_state`](/docs/reference/api/transport-effect_state) | function | The rule (SPEC-v0.7 §2.1): `FAILED` for `NEVER_CONNECTED`, `AMBIGUOUS` for everything else. | +| [`ctrlrun.transport.urlopen`](/docs/reference/api/transport-urlopen) | function | `urllib.request.urlopen` for `http` and `https`, classified (SPEC-v0.7 §2.3). | +| [`ctrlrun.transport.HTTPConnection`](/docs/reference/api/transport-HTTPConnection) | class | `http.client.HTTPConnection`, plus `NotExecuted` from `connect()` where it is proven. | +| [`ctrlrun.transport.HTTPSConnection`](/docs/reference/api/transport-HTTPSConnection) | class | `http.client.HTTPSConnection`, counting above TLS. | | [`ctrlrun.postgres.PostgresStateStore`](/docs/reference/api/postgres-PostgresStateStore) | class | Approvals, effects and evidence in a Postgres schema (SPEC-v0.6 §4). | | [`ctrlrun.otel.OTelEventSink`](/docs/reference/api/otel-OTelEventSink) | class | Export every `Event` and `Receipt` as OpenTelemetry spans (SPEC-v0.2 §8). | | [`ctrlrun.jwt_identity.JWTIdentityProvider`](/docs/reference/api/jwt_identity-JWTIdentityProvider) | class | Verify a bearer JWT and map its verified claims onto a `Principal` (SPEC-v0.3 §3.4). | diff --git a/docs/reference/api/protect.mdx b/docs/reference/api/protect.mdx index 4edd261..4270aa2 100644 --- a/docs/reference/api/protect.mdx +++ b/docs/reference/api/protect.mdx @@ -5,7 +5,7 @@ description: "Bind a function to an action name: every call becomes a decided, r {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.protect` — function, defined at `src/ctrlrun/control.py:3021` +`ctrlrun.protect` — function, defined at `src/ctrlrun/control.py:3056` ```python from ctrlrun import protect diff --git a/docs/reference/api/transport-HTTPConnection.mdx b/docs/reference/api/transport-HTTPConnection.mdx new file mode 100644 index 0000000..f87022f --- /dev/null +++ b/docs/reference/api/transport-HTTPConnection.mdx @@ -0,0 +1,53 @@ +--- +title: "HTTPConnection" +description: "`http.client.HTTPConnection`, plus `NotExecuted` from `connect()` where it is proven." +--- + +{/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} + +`ctrlrun.transport.HTTPConnection` — class, defined at `src/ctrlrun/transport.py:112` + +```python +from ctrlrun.transport import HTTPConnection +``` + + +```python +class HTTPConnection(http.client.HTTPConnection) + sock: Any + def connect() -> None + def send(data: http.client._DataType | str) -> None +``` + +`http.client.HTTPConnection`, plus `NotExecuted` from `connect()` where it is proven. + +A drop-in subclass: the constructor and every method are `http.client`'s, and what reaches the +wire is byte for byte what `http.client` sends. Two things are recorded for the life of the +object, and neither is ever cleared: + +- **the mark**: set in `send`, immediately before the first byte is handed to the socket and + after any connect `send` itself triggers, so a `sendall` that raises part way counts as having + written. `http.client` writes every request byte, a tunnel's `CONNECT` line included, through + `send` (T229b pins that on every supported Python). The same send marks the executor run's + register, and where there is none, every register open in the process (§12.2.13); +- **a foreign socket**: any socket assigned to `sock` other than by this object's own + `connect()`. + +`connect()` raises `NotExecuted`, chained from the original exception, only for an `Exception` +from the connect it wraps, inside an executor run whose register is unmarked, on an object +whose mark is unset and which never held a foreign socket. Everything else propagates as it was +raised: a reused connection, a second connection after any byte of the run was offered, a +caller's socket, a call outside any executor run, a failure after a byte was offered, an +exception in this code's own bookkeeping, and any `BaseException` (an interrupt is never turned +into a retry permission). + +The object's own mark is not subsumed by the register: a thread that did not copy the +executor's context has no register, and a request it delivers on this connection is still +remembered here when the executor's thread reuses the object. Bytes a caller writes to `sock` +itself, rather than through `send`, are outside both, exactly as bytes sent by another client +are. + +## Next + +- [Python API index](/docs/reference/api/index). +- [Get started](/docs/get-started/quickstart) · [Why](/docs/why). diff --git a/docs/reference/api/transport-HTTPSConnection.mdx b/docs/reference/api/transport-HTTPSConnection.mdx new file mode 100644 index 0000000..ff60641 --- /dev/null +++ b/docs/reference/api/transport-HTTPSConnection.mdx @@ -0,0 +1,29 @@ +--- +title: "HTTPSConnection" +description: "`http.client.HTTPSConnection`, counting above TLS." +--- + +{/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} + +`ctrlrun.transport.HTTPSConnection` — class, defined at `src/ctrlrun/transport.py:189` + +```python +from ctrlrun.transport import HTTPSConnection +``` + + +```python +class HTTPSConnection(HTTPConnection, http.client.HTTPSConnection) +``` + +`http.client.HTTPSConnection`, counting above TLS. + +`send` is `HTTPConnection.send`, inherited and not overridden, so the count is of application +bytes offered to the TLS socket: the handshake's records are written below it and are not a +request, which is why a handshake failure is `NotExecuted` (§2.3). CPython's `ssl` has no API +for TLS 1.3 early data, so no application byte can leave inside the handshake. + +## Next + +- [Python API index](/docs/reference/api/index). +- [Get started](/docs/get-started/quickstart) · [Why](/docs/why). diff --git a/docs/reference/api/transport-Transport.mdx b/docs/reference/api/transport-Transport.mdx new file mode 100644 index 0000000..65b02ef --- /dev/null +++ b/docs/reference/api/transport-Transport.mdx @@ -0,0 +1,32 @@ +--- +title: "Transport" +description: "What a transport observed, where no answer came back (SPEC-v0.2 §6.8, SPEC-v0.7 §2.1)." +--- + +{/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} + +`ctrlrun.transport.Transport` — class, defined at `src/ctrlrun/transport.py:65` + +```python +from ctrlrun.transport import Transport +``` + + +```python +class Transport(StrEnum) + NEVER_CONNECTED + AFTER_REQUEST_SENT + UNREADABLE_RESPONSE + STREAM_ENDED_EARLY + CLIENT_DISCONNECTED +``` + +What a transport observed, where no answer came back (SPEC-v0.2 §6.8, SPEC-v0.7 §2.1). + +Moved here from `ctrlrun.gateway.outcome`, members and values unchanged; the gateway's name is +this object. + +## Next + +- [Python API index](/docs/reference/api/index). +- [Get started](/docs/get-started/quickstart) · [Why](/docs/why). diff --git a/docs/reference/api/transport-effect_state.mdx b/docs/reference/api/transport-effect_state.mdx new file mode 100644 index 0000000..a86a4f9 --- /dev/null +++ b/docs/reference/api/transport-effect_state.mdx @@ -0,0 +1,26 @@ +--- +title: "effect_state" +description: "The rule (SPEC-v0.7 §2.1): `FAILED` for `NEVER_CONNECTED`, `AMBIGUOUS` for everything else." +--- + +{/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} + +`ctrlrun.transport.effect_state` — function, defined at `src/ctrlrun/transport.py:93` + +```python +from ctrlrun.transport import effect_state +``` + + +```python +def effect_state(observed: Transport) -> EffectState +``` + +The rule (SPEC-v0.7 §2.1): `FAILED` for `NEVER_CONNECTED`, `AMBIGUOUS` for everything else. + +Decided by identity, so a string that merely equals a member's value is `AMBIGUOUS`. + +## Next + +- [Python API index](/docs/reference/api/index). +- [Get started](/docs/get-started/quickstart) · [Why](/docs/why). diff --git a/docs/reference/api/transport-urlopen.mdx b/docs/reference/api/transport-urlopen.mdx new file mode 100644 index 0000000..13da67b --- /dev/null +++ b/docs/reference/api/transport-urlopen.mdx @@ -0,0 +1,44 @@ +--- +title: "urlopen" +description: "`urllib.request.urlopen` for `http` and `https`, classified (SPEC-v0.7 §2.3)." +--- + +{/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} + +`ctrlrun.transport.urlopen` — function, defined at `src/ctrlrun/transport.py:212` + +```python +from ctrlrun.transport import urlopen +``` + + +```python +def urlopen(url: str | urllib.request.Request, data: bytes | None = None, *, timeout: float | None = socket._GLOBAL_DEFAULT_TIMEOUT, context: ssl.SSLContext | None = None) -> http.client.HTTPResponse +``` + +`urllib.request.urlopen` for `http` and `https`, classified (SPEC-v0.7 §2.3). + +Raises `NotExecuted`, chained from the original exception, only where the connection it opened +failed before any request byte was offered, in this call or earlier in the same executor run, +and only inside an executor run (`HTTPConnection` says why). Every other failure is `urllib`'s +own exception, +which the kernel records `AMBIGUOUS`: a reset or a timeout after the request was offered, a +proxy that refused a tunnel after its `CONNECT` line was sent, a malformed URL or an unknown +scheme (nothing was connected, so nothing is claimed). + +**No redirect is followed.** A `POST` answered `303` may already have created what it points +to, so a `30x` is `urllib.error.HTTPError` like any other status, and no status is ever +`NotExecuted` (§2.4). Proxies from the environment are honoured, since the count is taken on +whatever socket the connection writes to. The opener carries the proxy, default-error and +error-processor handlers, the classifier's `http` and `https` handlers, and `urllib`'s +unknown-scheme handler, which only raises; it has no redirect, authentication, `ftp:`, `file:` +or `data:` handler, and no opener can be passed in. + +The executor may still raise `NotExecuted` on its own evidence, such as a provider's documented +validation error; that is its claim, and the most dangerous integration bug there is an +executor that raises it after the remote acted. + +## Next + +- [Python API index](/docs/reference/api/index). +- [Get started](/docs/get-started/quickstart) · [Why](/docs/why). diff --git a/docs/reference/api/with_approval.mdx b/docs/reference/api/with_approval.mdx index a0660b6..d1d04f2 100644 --- a/docs/reference/api/with_approval.mdx +++ b/docs/reference/api/with_approval.mdx @@ -5,7 +5,7 @@ description: "Present a granted approval to the calls made inside the block (SPE {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.with_approval` — function, defined at `src/ctrlrun/control.py:259` +`ctrlrun.with_approval` — function, defined at `src/ctrlrun/control.py:263` ```python from ctrlrun import with_approval diff --git a/docs/verify.md b/docs/verify.md index 8037298..4bb327c 100644 --- a/docs/verify.md +++ b/docs/verify.md @@ -14,7 +14,7 @@ what could not be tested at all. ```console $ ctrlrun verify -CTRLRun verify — ctrlrun 0.6.1, catalogue ctrlrun.guarantees/v3 +CTRLRun verify — ctrlrun 0.7.0, catalogue ctrlrun.guarantees/v3 policy examples/authority/payments.yaml (ctrlrun.policy/v3, mode: enforce) authority same document, 3 grants store sqlite, scratch (created and destroyed for this run) @@ -30,6 +30,7 @@ G8 expired authority refused PASS head-of-support G9 delegation cannot escalate PASS head-of-support (6 of 6 dimensions) G10 unknown exception is ambiguous PASS stripe.refund G11 an altered receipt is detected PASS stripe.refund +G12 a byte written is ambiguous PASS stripe.refund G13 clock divergence is named N/A the store verify was given reads only the application's clock, so there is no second clock to diverge from; pass --store-url postgresql://… to grade this G14 token changes across a renewal PASS stripe.refund (attempt 1 and its renewal carry different tokens) G15 renewal past the ceiling refused N/A no action verify can drive to allow or approve declares both `effect:` and `max_attempts` @@ -44,12 +45,15 @@ G16 a moved fingerprint is refused PASS stripe.refund same effect-key string for different effects, and nothing here can check that) -13/13 declared guarantees pass. 2 not applicable: G13, G15. +14/14 declared guarantees pass. 2 not applicable: G13, G15. ``` It reads the policy document — `$CTRLRUN_CONFIG`, else `./ctrlrun.yaml` — and the authority -document beside it. It executes nothing real: every executor is an in-process fake, no scenario -opens a socket, and it writes nothing outside a temporary directory. +document beside it. It executes nothing real: every executor is an in-process fake and it writes +nothing outside a temporary directory. It opens **no connection except to the store +`--store-url` names and to loopback listeners it bound itself**, which is G12's peer and nothing +else. That sentence read *no scenario opens a socket* until v0.7, and it was already untrue +under a `--store-url postgresql://remote-host/…`. --- @@ -106,13 +110,22 @@ G4 one winner under concurrency N/A no action declares an `effect:` temp G5 ambiguous blocks a blind retry N/A no action declares an `effect:` template G8 expired authority refused N/A no authority section G9 delegation cannot escalate N/A no authority section - -6/6 declared guarantees pass. 5 not applicable: G3, G4, G5, G8, G9. +G13 clock divergence is named N/A the store verify was given reads only the + application's clock, so there is no second clock to + diverge from; pass --store-url postgresql://… to + grade this +G14 token changes across a renewal N/A no action declares an `effect:` template +G15 renewal past the ceiling refused N/A no action verify can drive to allow or approve + declares both `effect:` and `max_attempts` + +8/8 declared guarantees pass. 8 not applicable: G3, G4, G5, G8, G9, G13, G14, G15. ``` -That run is `6/6`, never `11/11`. There is no flag that folds an N/A into the count, and there +That run is `8/8`, never `16/16`. There is no flag that folds an N/A into the count, and there will not be one: a number that counts guarantees nobody exercised is a number that means -nothing. +nothing. Point the same document at Postgres and G13 becomes a graded `PASS`, so the run reads +`9/9` with seven not applicable: the denominator moves with what the setup can actually +exercise, which is the whole idea. An N/A is always a statement about your **document**, derived from it. A scenario verify could not build for any other reason is an internal error and exits 3 — never an N/A, and never a @@ -122,9 +135,9 @@ failure attributed to your kernel. ## The guarantees -Eleven, in `ctrlrun.guarantees/v2`. Every one is the deployed form of an acceptance test that +Sixteen, in `ctrlrun.guarantees/v3`. Every one is the deployed form of an acceptance test that already exists and passes in this repository; verify adds no guarantee of its own and weakens -none. +none. G12 to G16 arrived with v0.7, one per item of that milestone. | id | invariant | N/A when | |---|---|---| @@ -139,6 +152,29 @@ none. | **G9** | A delegated grant is valid only if it is provably a subset of its parent on every dimension — and a child that **drops** a dimension its parent constrains is rejected rather than treated as unconstrained. | No `authority:` section, or no grant is delegable. | | **G10** | `NotExecuted` is the only outcome that means "the remote did nothing". Everything else, timeouts included, is `AMBIGUOUS`. | Every action in the policy is denied. | | **G11** | Each receipt carries the hash of the one before it, so altering one is detected and **named** — `content_altered`, `hash_missing`, `link_broken`, `missing`, `head_mismatch`, `unchained`. Its positive control is that the unaltered chain verifies. | The policy declares no actions at all. A denied action still writes a receipt, so a policy that denies everything is still checked. | +| **G12** | `ctrlrun.transport` claims `NotExecuted` only for a connection it opened that was handed no request byte, in an executor run that had offered none. After one byte every failure is `AMBIGUOUS`: a reset, a read timeout, a reused connection, a second connection in the same run. | No action verify can drive to `allow` or `approve` can be selected: every action is denied, or none that reaches a decision is covered by a grant. **Never `N/A` because of the environment** — a sandbox that refuses a loopback bind is exit 3, an internal error, because that is a fact about the machine and not about your document. | +| **G13** | A store with a clock of its own that disagrees with this host's by more than the threshold, beyond the measurement's own bound, is reported by `CLOCK_SKEW_DETECTED`, and a lease is decided exactly as it would be without the report. | The store verify was given reads only the application's clock, so there is no second clock to diverge from. Pass `--store-url postgresql://…` to grade it. | +| **G14** | The provider idempotency token an executor reads is stable within an attempt and **different after a renewal**, so a provider cannot answer the one retry the kernel permits with the failed attempt's cached result. | As G3, or the ceiling on the only selectable action forbids a renewal. | +| **G15** | A renewal past the action's `max_attempts` is refused before the executor is called, on the attempt number the store assigned. | No action verify can drive to `allow` or `approve` declares both `effect:` and `max_attempts`, or every declared ceiling is above the bound verify can reach. | +| **G16** | An approval whose precondition fingerprint has moved since it was granted is refused **before** the reservation, with `ApprovalMismatch` and a reason of its own, and the approval is left granted. | No action requires approval. | + +**G12 to G16 arrived with v0.7**, one per item: the transport classifier, clock skew, the +idempotency token, the attempt ceiling and precondition fingerprints. Two of them need a word +about what they do *not* grade. + +**G12 is the only guarantee that opens a socket**, and it opens one it bound itself. It needs a +peer that can receive a byte and then die, so verify binds loopback listeners at ephemeral ports +and drives `ctrlrun.transport.HTTPConnection` straight at them, never `urlopen`, which would +honour a `HTTP_PROXY` your host happens to set. Four observable rows and one control: a peer that +reads a byte and resets, a read that times out after the request was delivered, a reused +connection whose next connect fails, and a second connection in the same run. The control is a +port verify bound and never listened on, which is refused on Linux and times out on macOS, and +there `NotExecuted` **is** the right answer. The guarantee is the asymmetry, so both halves are +asserted or neither is. + +**G16 grades a change before the comparison, and there is nothing else to grade.** A precondition +that moves *after* the recheck and before the reservation is not refused by a correct kernel, so +verify does not report it as a failure. That residual is stated wherever the feature is described. G9 reports **which dimensions it exercised**. A parent that constrains one dimension does not score as though it had covered six, because that would be the N/A rule violated one level down. diff --git a/generated/readiness.full.mdx b/generated/readiness.full.mdx index 98a9d3d..77e408b 100644 --- a/generated/readiness.full.mdx +++ b/generated/readiness.full.mdx @@ -1,7 +1,7 @@ {/* generated from the suite, pyproject and the soak (full) — run the generator */} -- **Version 0.6.1**, on [PyPI](https://pypi.org/project/ctrlrun/), Python 3.11 and later. -- **4,999 tests**, every version specified before it was written and every requirement mutation-tested. [Read more](/docs/how-this-is-built). -- **15 guarantees you can check in your own setup**, with `ctrlrun verify` against your policy, on your store's backend, in a scratch store it creates. [Read more](/docs/security/verify-guarantees). +- **Version 0.7.0 is in development**; [PyPI](https://pypi.org/project/ctrlrun/) has 0.6.1. Python 3.11 and later. +- **5,164 tests**, every version specified before it was written and every requirement mutation-tested. [Read more](/docs/how-this-is-built). +- **16 guarantees you can check in your own setup**, with `ctrlrun verify` against your policy, on your store's backend, in a scratch store it creates. [Read more](/docs/security/verify-guarantees). - **One host: a file.** SQLite, no server, no ops. **Many hosts: Postgres**, the same guarantees, graded by the same suite. [Read more](/docs/production/postgres). - **Soaked for 20m 0s on postgres**: 889,735 actions, 0 unattributed ambiguous outcomes, positive control fired. Nothing here establishes what only accumulates over days. [Read more](/docs/production/soak). - **Each receipt carries the hash of the one before it**, so an alteration is detected and named. [Read more](/docs/production/receipt-integrity). diff --git a/generated/readiness.json b/generated/readiness.json index 4d3d968..dbf0ac5 100644 --- a/generated/readiness.json +++ b/generated/readiness.json @@ -1,5 +1,5 @@ { - "guarantees": 15, + "guarantees": 16, "released": "0.6.1", "soak": { "actions": 889735, @@ -9,6 +9,6 @@ "positive_control": true, "unexplained": 0 }, - "tests": 4999, - "version": "0.6.1" + "tests": 5164, + "version": "0.7.0" } diff --git a/generated/readiness.mdx b/generated/readiness.mdx index 4fd96f9..14c288c 100644 --- a/generated/readiness.mdx +++ b/generated/readiness.mdx @@ -1,7 +1,7 @@ {/* generated from the suite, pyproject and the soak (mdx) — run the generator */} -- **Version 0.6.1**, on [PyPI](https://pypi.org/project/ctrlrun/), Python 3.11 and later. -- **4,999 tests**, every version specified before it was written and every requirement mutation-tested. -- **15 guarantees you can check in your own setup**, with `ctrlrun verify` against your policy, on your store's backend, in a scratch store it creates. +- **Version 0.7.0 is in development**; [PyPI](https://pypi.org/project/ctrlrun/) has 0.6.1. Python 3.11 and later. +- **5,164 tests**, every version specified before it was written and every requirement mutation-tested. +- **16 guarantees you can check in your own setup**, with `ctrlrun verify` against your policy, on your store's backend, in a scratch store it creates. - **One host: a file.** SQLite, no server, no ops. **Many hosts: Postgres**, the same guarantees, graded by the same suite. - **Soaked for 20m 0s on postgres**: 889,735 actions, 0 unattributed ambiguous outcomes, positive control fired. Nothing here establishes what only accumulates over days. [What it does not establish](https://ctrlrun.dev/docs/production/soak). - **Each receipt carries the hash of the one before it**, so an alteration is detected and named. diff --git a/generated/readiness.readme.md b/generated/readiness.readme.md index 36a0723..95bd6bc 100644 --- a/generated/readiness.readme.md +++ b/generated/readiness.readme.md @@ -1,7 +1,7 @@ -- **Version 0.6.1**, on [PyPI](https://pypi.org/project/ctrlrun/), Python 3.11 and later. -- **4,999 tests**, every version specified before it was written and every requirement mutation-tested. -- **15 guarantees you can check in your own setup**, with `ctrlrun verify` against your policy, on your store's backend, in a scratch store it creates. +- **Version 0.7.0 is in development**; [PyPI](https://pypi.org/project/ctrlrun/) has 0.6.1. Python 3.11 and later. +- **5,164 tests**, every version specified before it was written and every requirement mutation-tested. +- **16 guarantees you can check in your own setup**, with `ctrlrun verify` against your policy, on your store's backend, in a scratch store it creates. - **One host: a file.** SQLite, no server, no ops. **Many hosts: Postgres**, the same guarantees, graded by the same suite. - **Soaked for 20m 0s on postgres**: 889,735 actions, 0 unattributed ambiguous outcomes, positive control fired. Nothing here establishes what only accumulates over days. [What it does not establish](https://ctrlrun.dev/docs/production/soak). - **Each receipt carries the hash of the one before it**, so an alteration is detected and named. diff --git a/tests/test_docs_audit.py b/tests/test_docs_audit.py index 42ed78d..c1c4324 100644 --- a/tests/test_docs_audit.py +++ b/tests/test_docs_audit.py @@ -110,7 +110,53 @@ def test_a_block_that_reaches_for_the_network_fails(tmp_path): ) assert not outcome.ok - assert "offline" in outcome.failures[0].message + assert "no network" in outcome.failures[0].message + + +def test_a_block_may_reach_a_loopback_listener_it_bound_itself(tmp_path): + """What the guard admits since v0.7, and the reason it had to: `ctrlrun verify`'s G12 needs + a peer that can receive a byte, and the widening is exactly the library's own + (SPEC-v0.7 §12.2.7). A self-bound loopback port is not a network and never leaves the host. + + The negative half stays next door. This is the half that says the widening is real rather + than a guard somebody quietly deleted. + """ + block = ( + "```python runnable\n" + "import socket\n" + "listener = socket.socket()\n" + "listener.bind(('127.0.0.1', 0))\n" + "listener.listen(1)\n" + "client = socket.create_connection(listener.getsockname())\n" + "client.close()\n" + "listener.close()\n" + "```\n" + ) + + outcome = snippets.run_document(_page(tmp_path, block)) + + assert outcome.ok, [str(failure) for failure in outcome.failures] + + +def test_the_guard_still_refuses_a_loopback_port_nothing_here_bound(tmp_path): + """The other edge of the same widening: admitting *any* loopback port would admit a local + forwarding proxy, an SSH tunnel or a container's published port, each of which leaves the + host. Only a port this process bound is admitted.""" + block = ( + "```python runnable\n" + "import socket\n" + "probe = socket.socket()\n" + "probe.bind(('127.0.0.1', 0))\n" + "port = probe.getsockname()[1]\n" + "probe.close()\n" + "socket.create_connection(('127.0.0.1', port))\n" + "```\n" + ) + + outcome = snippets.run_document(_page(tmp_path, block)) + + assert not outcome.ok + assert "no network" in outcome.failures[0].message def test_the_network_guard_is_what_made_it_fail(tmp_path): diff --git a/tests/test_verify_page.py b/tests/test_verify_page.py index 3f6ad92..ee22303 100644 --- a/tests/test_verify_page.py +++ b/tests/test_verify_page.py @@ -87,7 +87,7 @@ def test_the_verify_page_says_what_not_applicable_means(): page = " ".join(_repository_file(VERIFY_DOC).split()) assert "Not applicable is not a pass" in page - assert "never `11/11`" in page + assert "never `16/16`" in page assert "no flag that folds an N/A into the count" in page assert "declared guarantees pass" in page diff --git a/tools/docs_audit/render_api.py b/tools/docs_audit/render_api.py index 6be28f1..6406382 100644 --- a/tools/docs_audit/render_api.py +++ b/tools/docs_audit/render_api.py @@ -30,7 +30,17 @@ MARKER = "generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page" #: Public names that live behind an extra or above the kernel and are not in `ctrlrun.__all__`. +#: +#: `ctrlrun.transport` is the exception that is core and stdlib: `import ctrlrun` deliberately +#: does not import it (SPEC-v0.7 §2.8, T228), so its names are not in `ctrlrun.__all__` and the +#: five of them are listed here instead. A reference that named `NotExecuted` and not the module +#: that raises it correctly would leave a reader with the one decision and none of the help. EXTRA_NAMES: tuple[tuple[str, str], ...] = ( + ("ctrlrun.transport", "Transport"), + ("ctrlrun.transport", "effect_state"), + ("ctrlrun.transport", "urlopen"), + ("ctrlrun.transport", "HTTPConnection"), + ("ctrlrun.transport", "HTTPSConnection"), ("ctrlrun.postgres", "PostgresStateStore"), ("ctrlrun.otel", "OTelEventSink"), ("ctrlrun.jwt_identity", "JWTIdentityProvider"), diff --git a/tools/docs_audit/snippets.py b/tools/docs_audit/snippets.py index 2b651d2..c55d077 100644 --- a/tools/docs_audit/snippets.py +++ b/tools/docs_audit/snippets.py @@ -20,10 +20,12 @@ - A `yaml runnable` block is loaded with `Policy.from_yaml`, and with `Authority.from_yaml` when it carries an `authority:` section, and is then written to `ctrlrun.yaml` — or to the name given by a `file=` token, which any block may carry. -- **No network.** Every subprocess gets a `sitecustomize` that refuses sockets, name resolution - and connections, the same guard `tests/test_examples.py` puts under the examples. A snippet - that reaches for the network fails here rather than in a reader's terminal, where it would - fail differently. +- **No network.** Every subprocess gets a `sitecustomize` that refuses every connection except + one to a loopback listener the process bound itself, which is the library's own + `tests/conftest.py` guard character for character. A snippet that reaches for the network + fails here rather than in a reader's terminal, where it would fail differently. The one + exception is what `ctrlrun verify`'s G12 needs: a peer on `127.0.0.1` at a port this process + bound, which is not a network and never leaves the host (SPEC-v0.7 §12.2.7). Exit status is the number of failures, capped at 1. """ @@ -47,29 +49,128 @@ #: Installed as `sitecustomize` on the subprocess's `PYTHONPATH`. Replacing the socket *type* #: with a function breaks anything that subclasses it — `ssl` does — so the refusal goes on -#: the operations, exactly as `tests/test_examples.py` does it. -NO_NETWORK = """\ +#: the operations. **This is `tests/conftest.py`'s `_NO_NETWORK_GUARD` in the library, +#: verbatim.** It refused every connect until v0.7, and `ctrlrun verify` then needed a +#: loopback peer for G12, so a snippet running verify exited 3 on a correct kernel. Two +#: guards of different widths is the drift the library's own fixture exists to prevent, so +#: this is a copy of that one rather than a second rule: IPv4 to the literal `127.0.0.1`, at +#: a port this process bound through a stream socket that is still open, and nothing else. +NO_NETWORK = '''\ +"""Imported by `site` at startup: no connection except to a loopback listener bound here.""" + import socket +import weakref _real = socket.socket +_real_create_connection = socket.create_connection +_real_getaddrinfo = socket.getaddrinfo +_LOOPBACK = "127.0.0.1" +#: (host, port) -> the ids of the open *stream* sockets that bound it, taken from getsockname() +#: after the bind, so a bind to port 0 is recorded at the port the kernel chose. A datagram bind +#: is never recorded, because TCP and UDP ports are separate spaces: a UDP bind to a port another +#: process's TCP listener holds must admit nothing there. And a pair is forgotten when the last +#: socket holding it closes, detaches or is collected, because the kernel may hand a released +#: port to another process at once. +_bound = {} + + +def _forget(pair, holder): + holders = _bound.get(pair) + if holders is not None: + holders.discard(holder) + if not holders: + del _bound[pair] + + +def _refuse(what): + raise RuntimeError(f"tried to {what}; this process runs with no network") + + +def _literal(address): + """The one address admitted: a two-element tuple whose host is the string "127.0.0.1". + + Every `AF_UNIX` address is a path and every IPv6 address a four-element tuple or another + string, so neither is ever this, and both are refused by this check alone. There is no + separate family check: it would refuse exactly what this refuses, with the same message. + """ + return ( + isinstance(address, tuple) + and len(address) == 2 + and type(address[0]) is str + and address[0] == _LOOPBACK + ) -class _Refusing(_real): - def connect(self, *args, **kwargs): - raise RuntimeError("a documentation snippet tried to connect; snippets run offline") +def _admitted(address): + return _literal(address) and bool(_bound.get((address[0], address[1]))) - def connect_ex(self, *args, **kwargs): - raise RuntimeError("a documentation snippet tried to connect; snippets run offline") +class _Guarded(_real): + """A socket that binds only to 127.0.0.1 and connects only to what the process bound. -def _refuse(*args, **kwargs): - raise RuntimeError("a documentation snippet tried to resolve a name; snippets run offline") + Replacing the *type* with a function breaks anything that subclasses it, and `ssl` does, so + the refusal goes on the operations instead. + """ + def bind(self, address): + if not _literal(address): + _refuse(f"bind {address!r}") + super().bind(address) + if self.type == socket.SOCK_STREAM: + pair = tuple(self.getsockname()[:2]) + _bound.setdefault(pair, set()).add(id(self)) + self._guard_release = weakref.finalize(self, _forget, pair, id(self)) -socket.socket = _Refusing -socket.create_connection = _refuse -socket.getaddrinfo = _refuse -""" + def _release(self): + release = getattr(self, "_guard_release", None) + if release is not None: + release() + + def close(self): + self._release() + super().close() + + def detach(self): + self._release() + return super().detach() + + def connect(self, address): + if self.type != socket.SOCK_STREAM or not _admitted(address): + _refuse(f"connect to {address!r}") + return super().connect(address) + + def connect_ex(self, address): + if self.type != socket.SOCK_STREAM or not _admitted(address): + _refuse(f"connect to {address!r}") + return super().connect_ex(address) + + def sendto(self, *args): + if self.type != socket.SOCK_STREAM: + _refuse(f"send a datagram {args[-1]!r}") + return super().sendto(*args) + + def sendmsg(self, *args): + if self.type != socket.SOCK_STREAM: + _refuse("send a datagram") + return super().sendmsg(*args) + + +def _create_connection(address, *args, **kwargs): + if not _admitted(address): + _refuse(f"connect to {address!r}") + return _real_create_connection(address, *args, **kwargs) + + +def _getaddrinfo(host, *args, **kwargs): + if type(host) is not str or host != _LOOPBACK: + _refuse(f"resolve {host!r}") + return _real_getaddrinfo(host, *args, **kwargs) + + +socket.socket = _Guarded +socket.create_connection = _create_connection +socket.getaddrinfo = _getaddrinfo +''' @dataclass(frozen=True) From 2757441a1f3fdd5e29f05640f02e9ce4b44cf06c Mon Sep 17 00:00:00 2001 From: arpan Date: Sat, 12 Sep 2026 04:39:19 +0530 Subject: [PATCH 2/2] Answer the review: three sentences that were not quite true, and one decline - verify.md said G12 is the only guarantee that opens a socket. G13 runs over the connection --store-url names, so it is not. G12 is the only one that binds and drives a peer of its own, which is what the sentence was reaching for. - recovery.mdx led with "a continuation leg can never record FAILED", which is false of an executor that raises NotExecuted on its own evidence. The paragraph qualified it two sentences later; the heading sentence now carries its own scope: nothing in the library hands a continuation leg a FAILED. - ROADMAP's ceiling bullet did not say what an entry with no max_attempts does. It renews without bound, exactly as at 0.6.1, and no value means unlimited. Declined, with the reasoning in the file rather than only here: the guard forgets a bound pair when its socket closes, detaches or is collected, so a snippet that closed the descriptor behind the socket's back would leave the pair admitted. True, and true of the library's guard, because this is the same text. Fixing it here and not there forks the two guards again, which is the defect this copy exists to remove; the edit belongs in the library applied to both at once. --- docs/ROADMAP.md | 2 +- docs/production/recovery.mdx | 7 ++++--- docs/verify.md | 4 +++- tools/docs_audit/snippets.py | 11 +++++++++++ 4 files changed, 19 insertions(+), 5 deletions(-) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 38792ad..da7e07b 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -253,7 +253,7 @@ Every guarantee shipped so far is a guarantee about what happens *inside* CTRLRu - **A transport classifier in core.** `FAILED` versus `AMBIGUOUS` is the one decision this project exists to get right, and the kernel does not make it — the user's executor does. The correct rule is already written and already implemented, in the gateway's `outcome.py`: the connection was never established, or the peer said in band and before dispatch that it rejected the request; everything after the first byte is `AMBIGUOUS`. It is reachable today only by installing `ctrlrun[gateway]`, while `@protect` — the surface the README leads with — gets a docstring. One rule, one implementation, reachable from core. - **Clock-skew detection.** v0.6 moved the store to another host so several hosts could share it; lease liveness stayed on the application clock. Skew is fail-closed and therefore quiet — a host running ahead marks a live reservation `AMBIGUOUS` while its real holder is mid-flight and about to succeed, and nothing names the cause. This makes divergence observable. It does not change how a lease is evaluated. - **A provider idempotency token**, derived from the effect key *and the attempt number*. Derived from the effect key alone it would be stable across v0.1 §5.4's renewal, and a provider would replay its cached failure for the one retry the kernel permits precisely because the executor proved nothing happened. Its main value is a deterministic handle for reconciliation to observe with — not a licence for anything to act twice. -- **A ceiling on renewal after `FAILED`.** There is none today: an executor that always reports "nothing happened" renews without bound, each dispatch recorded as an ordinary retry. An operator-set policy key, and an amendment to §5.4 written as an amendment. +- **A ceiling on renewal after `FAILED`.** There is none today: an executor that always reports "nothing happened" renews without bound, each dispatch recorded as an ordinary retry. An operator-set policy key, and an amendment to §5.4 written as an amendment. **The key is the operator's and there is no default**: an entry that declares no `max_attempts` renews without bound, exactly as at 0.6.1, and no value of the key means "unlimited". Where an entry does declare one, G15 grades the refusal of a renewal past it. **The sentence that stood here said *one human approval plus an executor that always reports "nothing happened" is unlimited dispatches*, and building item 4 proved it wrong in enforce mode. Corrected here rather than quietly rephrased.** One granted approval buys one dispatch: every renewal of an approved action needs a new granted approval, so the human is asked again each time. "Granted" is not always a person (a scripted provider, an automated `wait=True` loop and approvals granted ahead of a gateway all count), and observe mode needs none. The case the ceiling actually exists for is **the action the policy allows outright**, which renews without anyone being asked at all. What the ceiling bounds is that, plus renewals on the `ALLOW` path and the reconcile route; `SPEC-v0.7.md` §5.5 records that an adapter can still put a human in front of an attempt the ceiling will then refuse, which costs a wasted answer and never an execution. - **Precondition fingerprints.** An approval binds to an action hash and an expiry, and to nothing about the world it was granted against. A human approves a deletion when the balance is zero; thirty minutes later it is not, and the action hash has not moved. The operator supplies a fingerprint, it is hashed through the canonicalizer so raw resource state never reaches a receipt, and it is rechecked before the reservation. **It narrows the window between decision and execution; it does not close it** — the recheck cannot run inside the atomic reservation write, so a residual gap remains, and that sentence appears wherever the feature does. diff --git a/docs/production/recovery.mdx b/docs/production/recovery.mdx index d518ec7..517cf24 100644 --- a/docs/production/recovery.mdx +++ b/docs/production/recovery.mdx @@ -51,9 +51,10 @@ with a shared store a **different** process may finish it. One resumption is adm more, because the token is consumed in the transaction that admits it. If the lease lapsed first, the resumption is refused and the effect becomes `AMBIGUOUS` by the ordinary path. -**A continuation leg can never record `FAILED`, since v0.7.** A continuation exists only because -the remote answered once already and is holding the exchange, so nothing on the second leg can -say the remote did nothing. `ctrlrun.transport` refuses to claim `NotExecuted` on a resumed run, +**Nothing in the library hands a continuation leg a `FAILED`, since v0.7.** A continuation exists +only because the remote answered once already and is holding the exchange, so nothing on the +second leg can say the remote did nothing. `ctrlrun.transport` refuses to claim `NotExecuted` on +a resumed run, and the MCP gateway records an unknown outcome for every path that could otherwise reach `FAILED` there: a connection it never established, a pre-dispatch JSON-RPC code, the `401` rule, and a tool error under an operator's `not_executed_on_error: true`, which is a claim about the call diff --git a/docs/verify.md b/docs/verify.md index 4bb327c..f78cea1 100644 --- a/docs/verify.md +++ b/docs/verify.md @@ -162,7 +162,9 @@ none. G12 to G16 arrived with v0.7, one per item of that milestone. idempotency token, the attempt ceiling and precondition fingerprints. Two of them need a word about what they do *not* grade. -**G12 is the only guarantee that opens a socket**, and it opens one it bound itself. It needs a +**G12 is the only guarantee that binds and drives a peer of its own.** It is not the only one +that opens a socket: G13 needs a store with a clock of its own, so it runs over the connection +`--store-url` names. It needs a peer that can receive a byte and then die, so verify binds loopback listeners at ephemeral ports and drives `ctrlrun.transport.HTTPConnection` straight at them, never `urlopen`, which would honour a `HTTP_PROXY` your host happens to set. Four observable rows and one control: a peer that diff --git a/tools/docs_audit/snippets.py b/tools/docs_audit/snippets.py index c55d077..33b0bcf 100644 --- a/tools/docs_audit/snippets.py +++ b/tools/docs_audit/snippets.py @@ -55,6 +55,17 @@ #: guards of different widths is the drift the library's own fixture exists to prevent, so #: this is a copy of that one rather than a second rule: IPv4 to the literal `127.0.0.1`, at #: a port this process bound through a stream socket that is still open, and nothing else. +#: +#: **A review finding declined here on purpose, recorded rather than left to be rediscovered.** +#: `_bound` forgets a pair when the socket that holds it closes, detaches or is collected, so +#: a snippet that closed the *descriptor* behind the socket's back — `os.close(sock.fileno())` +#: — would leave the pair admitted while the port went back to the kernel. That is true, and it +#: is true of the library's guard too, because this is the same text. **Fixing it here and not +#: there would fork the two guards again**, which is the whole defect this copy exists to +#: remove (SPEC-v0.7 §12.2.7, §12.2.11), and the edit belongs in `tests/conftest.py` applied to +#: both at once. It is also not what this guard defends against: it runs the snippets in this +#: repository's own pages, not code somebody else supplied, and reaching the gap needs a +#: snippet that deliberately closes a file descriptor out from under a live socket. NO_NETWORK = '''\ """Imported by `site` at startup: no connection except to a loopback listener bound here."""