From f11be7a4eff55d8010e0d2a98eb7d51be314689e Mon Sep 17 00:00:00 2001 From: arpan Date: Mon, 14 Sep 2026 00:53:34 +0530 Subject: [PATCH 1/7] Regenerate against the kernel's main: v0.10 items 1 and 2 The kernel's main has carried item 1 (the hop) since PR #174 and item 2 (identity and the receipt across a hop) since PR #176, and these pages still described 0.9.0. Every kernel run since then failed at `render_api.py --check` against this repository's main, and PR #14 here, regenerated against the whole five-item stack, was red the other way round. The pages on main now match the kernel's main, which is what the next commit makes the rule. Rendered, by the generators: 15 API pages (four signatures gained `hop`, `Control.__init__` gained `upstream`, line numbers moved), the receipt and event schemas page (`Receipt.hop`, `ctrlrun.receipt/v7`), the readiness block (6,005 tests, 26 guarantees) and its copies on docs.mdx and docs/production/index.mdx, and CLAIMS.md repointed (59 citations, none unresolved). Quoted, from a real run: the `ctrlrun verify` transcripts on docs/verify.md, the CI guide and the GitHub Actions recipe now show catalogue v6 with G25 and G26, 24/24 against the payments example and 15/15 with 11 N/A against the guide's policy. Written: the OWASP mapping gains G25 (a hop narrows or it is refused) and G26 (a hop is named on both sides), each mapped partly to ASI03, ASI07 and ASI10 with its limits stated in the row, and ASI07 moves from "Not yet" to the partial half: authority across a hop is bounded and evidenced, the channel is not CTRLRun's. Two entries are now unaddressed rather than three, and the test that keeps that sentence true says so. Not touched: ROADMAP.md, THREAT_MODEL.md and authority.md, which the release item reconciles; G27 arrives with item 3 and gets its row on that branch. Signed-off-by: arpan --- docs.mdx | 4 +- docs/CLAIMS.md | 78 ++++++++++---------- docs/OWASP-AGENTIC-TOP10.md | 14 ++-- docs/cookbook/verify-in-github-actions.mdx | 8 +- docs/guides/verify-in-ci.mdx | 8 +- docs/production/index.mdx | 4 +- docs/reference/api/Authority.mdx | 2 +- docs/reference/api/AuthorityResult.mdx | 4 +- docs/reference/api/Control.mdx | 2 +- docs/reference/api/Delegation.mdx | 2 +- docs/reference/api/Event.mdx | 2 +- docs/reference/api/EventSink.mdx | 2 +- docs/reference/api/Grant.mdx | 2 +- docs/reference/api/JSONLEventSink.mdx | 2 +- docs/reference/api/Receipt.mdx | 4 +- docs/reference/api/Subject.mdx | 2 +- docs/reference/api/authority-Budget.mdx | 2 +- docs/reference/api/context.mdx | 2 +- docs/reference/api/idempotency_token.mdx | 2 +- docs/reference/api/protect.mdx | 4 +- docs/reference/api/with_approval.mdx | 2 +- docs/reference/receipt-and-event-schemas.mdx | 3 +- docs/verify.md | 8 +- generated/readiness.full.mdx | 4 +- generated/readiness.json | 4 +- generated/readiness.mdx | 4 +- generated/readiness.readme.md | 4 +- tests/test_owasp_mapping.py | 13 ++-- 28 files changed, 98 insertions(+), 94 deletions(-) diff --git a/docs.mdx b/docs.mdx index 319b539..3a5b033 100644 --- a/docs.mdx +++ b/docs.mdx @@ -219,8 +219,8 @@ the framework's own interrupt, and a framework with no such primitive does not n {/* generated from the suite, pyproject and the soak (mdx) — run the generator */} - **Version 0.9.0**, on [PyPI](https://pypi.org/project/ctrlrun/), Python 3.11 and later. -- **5,975 tests**, every version specified before it was written and every requirement mutation-tested. -- **24 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. +- **6,005 tests**, every version specified before it was written and every requirement mutation-tested. +- **26 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/CLAIMS.md b/docs/CLAIMS.md index 263e8b5..09c10af 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:1127` — 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:1181` — 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:596` — passes only the action's **name and arguments** to `_ActionPolicy.evaluate` (`policy.py:596`), 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:224`) 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:248`. Exactly as approved: the approval is bound to `action_hash` and consumed with the reservation — `_authorize_and_reserve` — `state.py:1196`. Or not at all: a refusal raises before the executor — `Control.execute` — `control.py:1127`. Says so instead of guessing: only `NotExecuted` maps to `FAILED` — `_outcome` — `control.py:1960` — and everything else is `AMBIGUOUS`. A receipt: `Receipt` — `receipt.py:242`. **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:1196`. Or not at all: a refusal raises before the executor — `Control.execute` — `control.py:1181`. Says so instead of guessing: only `NotExecuted` maps to `FAILED` — `_outcome` — `control.py:2054` — and everything else is `AMBIGUOUS`. A receipt: `Receipt` — `receipt.py:250`. **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:1476` — 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` — `_outcome` — `control.py:1134`; 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:587` — and `Control._reconciled` — `control.py:2595` | `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` — `_outcome` — `control.py:1188`; 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:587` — and `Control._reconciled` — `control.py:2702` | `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,29 +50,29 @@ 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` — `_outcome` — `control.py:1960`; `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` | +| "A lost reply is `AMBIGUOUS`, never `FAILED`, and a retry against an `AMBIGUOUS` effect is refused." | Only `NotExecuted` maps to `FAILED` — `_outcome` — `control.py:2054`; `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:637`; `PostgresStateStore.reserve_effect` — `postgres.py:740` | `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:1196` | `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:596` | `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:1127` | `test_T74_a_denial_leaves_no_pending_approval_request` | -| "Neither axis reads the agent's instructions" | `Policy.evaluate` — `policy.py:596` — sees the action's name and arguments; `Authority.evaluate` — `authority.py:1199` — 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` | +| "Authority first ... then policy" / "authority first" | `Control.execute` evaluates authority before policy and a denial appends `AUTHORITY_DENIED` and never `POLICY_EVALUATED` — `control.py:1181` | `test_T74_a_denial_leaves_no_pending_approval_request` | +| "Neither axis reads the agent's instructions" | `Policy.evaluate` — `policy.py:596` — sees the action's name and arguments; `Authority.evaluate` — `authority.py:1256` — 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:79` | `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:1196` — 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`." | `_outcome` — `control.py:1960`; `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:734`; `prev_hash`, `GENESIS_HASH` for the first — `receipt.py:117` | `test_T172_every_receipt_carries_the_hash_and_the_declared_version`, `test_T164_an_altered_receipt_is_content_altered_at_its_seq` | +| "Only `NotExecuted`, raised by you, means `FAILED`." | `_outcome` — `control.py:2054`; `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:734`; `prev_hash`, `GENESIS_HASH` for the first — `receipt.py:125` | `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:4576`) 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:4744`) 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:353` | 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:377`; `with_approval` — `control.py:346`; `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:377`; `with_approval` — `control.py:386`; `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: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:901`; `McpOptions` — `policy.py:543` | `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` | +| "Everything but `tools/call` is relayed untouched" | `parse_request(...).intercept` — `gateway/mcp.py:93` | `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: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` | @@ -89,26 +89,26 @@ 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:596` | `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:79` | `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:596` — 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:1191`; `Control._authority_result` — `control.py:962` | `test_T67_a_principal_with_no_grant_is_denied` | +| "the second axis, `authority:`" | `Authority.evaluate` — `authority.py:1248`; `Control._authority_result` — `control.py:1004` | `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:77`), reached for reads and for actions with no effect key alike | `test_T67_an_action_the_policy_allows_outright_still_needs_a_grant` | +| "every principal needs a grant and no grant means denied" | `NO_AUTHORITY` — the fail-closed default of `Authority.evaluate` (`authority.py:84`), 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:3849`; `Authority.plan_delegation` — `authority.py:1430`; `ctrlrun delegate` — `cli/main.py:1045` | `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:889` — runs from `plan_delegation` (`authority.py:1430`) **and** from the chain walk in `Authority.evaluate` (`authority.py:1199`) | `test_t76_each_dimension_violated_alone`, `test_t77b_a_narrowed_parent_narrows_its_children` | -| "provably a subset of its parent on every dimension, at creation and again at every evaluation; a consequence budget is consumed inside the reservation's own transaction, and a rolling window bounds what may start rather than recalling what already did" | Containment as in the row above. The budget: `check_charges` — `state.py:569` — is evaluated inside `reserve_effect`'s own transaction on all three backends, and `_charges_for` — `authority.py:1285` — charges every ancestor in the chain. The window is rolling and bounds the next reserve only: `_spent` sums `[now - window, now]` and nothing reads it again after a reservation is taken | `test_T408_a_charge_and_its_reservation_are_one_transaction`, `test_T409_N_processes_racing_one_budget_spend_at_most_the_limit`, `test_T412b_every_ancestor_is_charged_through_a_real_chain`, `test_T408c_the_rolling_window_forgets` | -| "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:889`; the subject half is `_subject_contained` (`authority.py:968`) | `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:4148` — writes one row — `revoke_delegation` — `state.py:835` 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:719`; `Control._observed` — `control.py:1503`; `_WouldHave` — `receipt.py:336`; `ReceiptResult.OBSERVED` — `receipt.py:248` | `test_T82_observe_executes_what_enforce_would_deny`, `test_T83_a_duplicate_is_recorded_and_still_runs` | +| "narrow it at runtime with `ctrlrun delegate`" | `Control.delegate` — `control.py:3956`; `Authority.plan_delegation` — `authority.py:1500`; `ctrlrun delegate` — `cli/main.py:1045` | `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:946` — runs from `plan_delegation` (`authority.py:1500`) **and** from the chain walk in `Authority.evaluate` (`authority.py:1256`) | `test_t76_each_dimension_violated_alone`, `test_t77b_a_narrowed_parent_narrows_its_children` | +| "provably a subset of its parent on every dimension, at creation and again at every evaluation; a consequence budget is consumed inside the reservation's own transaction, and a rolling window bounds what may start rather than recalling what already did" | Containment as in the row above. The budget: `check_charges` — `state.py:569` — is evaluated inside `reserve_effect`'s own transaction on all three backends, and `_charges_for` — `authority.py:1355` — charges every ancestor in the chain. The window is rolling and bounds the next reserve only: `_spent` sums `[now - window, now]` and nothing reads it again after a reservation is taken | `test_T408_a_charge_and_its_reservation_are_one_transaction`, `test_T409_N_processes_racing_one_budget_spend_at_most_the_limit`, `test_T412b_every_ancestor_is_charged_through_a_real_chain`, `test_T408c_the_rolling_window_forgets` | +| "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:946`; the subject half is `_subject_contained` (`authority.py:1025`) | `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:4293` — writes one row — `revoke_delegation` — `state.py:835` 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:719`; `Control._observed` — `control.py:1565`; `_WouldHave` — `receipt.py:344`; `ReceiptResult.OBSERVED` — `receipt.py:256` | `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:719` | `test_T84_mode_is_refused_anywhere_but_the_top_level` | | "`ctrlrun stats` gives you the numbers" | `stats` — `cli/main.py:866`; 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:1503` | `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:1565` | `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 | Claim | Code | Proof | |---|---|---| -| "runs the kernel's own failure scenarios against the configuration in front of it" | `ctrlrun.verify.run` — `verify/__init__.py:154`; the eleven guarantees — `GUARANTEES` — `verify/guarantees.py:47`; the scenarios — `verify/scenarios.py` | `test_T100_the_authority_example_passes_every_non_authority_guarantee` (11/11), `test_T100_a_v1_document_with_no_templates_and_no_grants` | +| "runs the kernel's own failure scenarios against the configuration in front of it" | `ctrlrun.verify.run` — `verify/__init__.py:154`; the eleven guarantees — `GUARANTEES` — `verify/guarantees.py:49`; the scenarios — `verify/scenarios.py` | `test_T100_the_authority_example_passes_every_non_authority_guarantee` (11/11), `test_T100_a_v1_document_with_no_templates_and_no_grants` | | "in a scratch store, with fake executors, and no network" | One scratch store per guarantee under a temporary directory — `verify/scenarios.py`, `Engine.control`; `state_path()` is never called and `Control.from_file()` is never used | `test_T103_the_operators_store_is_byte_identical_before_and_after`, `test_T103_a_store_that_does_not_exist_is_not_created`, `test_T107_a_full_run_completes_with_no_network` | | "Your `.ctrlrun/state.db` is byte-identical before and after" | The scratch path is a `tempfile.mkdtemp` removed in a `finally` — `verify/__init__.py` | `test_T103_the_operators_store_is_byte_identical_before_and_after` (SHA-256 and `st_mtime_ns`), `test_T103_CTRLRUN_STATE_is_not_read_and_not_created` | | "Not applicable is not a pass" | `Report.applicable` is passes plus failures — `verify/report.py`; every N/A reason is a statement about the document — `verify/guarantees.py` | `test_T101_a_policy_with_no_approve_rule_makes_G1_and_G2_not_applicable`, `test_T102_a_policy_with_no_effect_templates_makes_G3_G4_and_G5_not_applicable` | @@ -133,16 +133,16 @@ keeps it honest: ## The capability matrix Rendered from `capabilities.yaml`; the six rows are the six groups of the verify -catalogue, `GUARANTEES` (`verify/guarantees.py:47`). +catalogue, `GUARANTEES` (`verify/guarantees.py:49`). | Claim | Code | Proof | |---|---|---| | "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:665`; 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:637`, decided inside the `BEGIN IMMEDIATE` of `_authorize_and_reserve` (`state.py:1196`) 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` — `_outcome` — `control.py:1960`. 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:596` — answers `deny` for a name the document does not list. Missing or malformed policy: `Policy.from_file` — `policy.py:753` — raises `PolicyError`, and there is no `Control` without a policy. Missing principal: `_refuse_no_principal` — `control.py:4458` | `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:77`); `contained_dimension` — `authority.py:889` — runs from `plan_delegation` (`authority.py:1430`) 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:232`; `Event` — `receipt.py:298`; the store is authoritative — `append_event` — `state.py:846`; the JSONL export — `JSONLEventSink` — `receipt.py:792` | `test_T11_every_demo_receipt_carries_every_field_in_the_spec`, `test_T11_every_demo_receipt_parses_back_into_a_Receipt` | +| "An unknown outcome is AMBIGUOUS, never FAILED, and blocks a blind retry." | Only `NotExecuted` maps to `FAILED` — `_outcome` — `control.py:2054`. 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:596` — answers `deny` for a name the document does not list. Missing or malformed policy: `Policy.from_file` — `policy.py:753` — raises `PolicyError`, and there is no `Control` without a policy. Missing principal: `_refuse_no_principal` — `control.py:4626` | `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:84`); `contained_dimension` — `authority.py:946` — runs from `plan_delegation` (`authority.py:1500`) 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:240`; `Event` — `receipt.py:306`; the store is authoritative — `append_event` — `state.py:846`; the JSONL export — `JSONLEventSink` — `receipt.py:817` | `test_T11_every_demo_receipt_carries_every_field_in_the_spec`, `test_T11_every_demo_receipt_parses_back_into_a_Receipt` | ## What it guarantees @@ -152,24 +152,24 @@ catalogue, `GUARANTEES` (`verify/guarantees.py:47`). | "a unique index on the effect key and compare-and-set updates whose row counts are checked" | `reserve_effect` — `postgres.py:740` — `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:740` — 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:248` (refuse retry on `AMBIGUOUS`) and `_outcome` — `control.py:1960` (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:931`; `_resolve_lost_update` — `postgres.py:1557`; only `NotExecuted` maps to `FAILED` — `_outcome` — `control.py:1960` | `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 `_outcome` — `control.py:2054` (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:931`; `_resolve_lost_update` — `postgres.py:1557`; only `NotExecuted` maps to `FAILED` — `_outcome` — `control.py:2054` | `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:140` | `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:172`; who resolved it — `resolved_by` — `effect.py:208`; `resolve` — `cli/main.py:587` | `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:2595`; `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` | +| "the only thing besides a human permitted to move a record out of `AMBIGUOUS`" | `Control._reconciled` — `control.py:2702`; `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:596`; `Policy.from_file` — `policy.py:753`; `_refuse_no_principal` — `control.py:4458`; `_authorize_and_reserve` — `state.py:1196` | `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:596`; `Policy.from_file` — `policy.py:753`; `_refuse_no_principal` — `control.py:4626`; `_authorize_and_reserve` — `state.py:1196` | `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:123` | `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:1191`; `contained_dimension` — `authority.py:889`; `Control.revoke` — `control.py:4148` | `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:1248`; `contained_dimension` — `authority.py:946`; `Control.revoke` — `control.py:4293` | `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:208`; 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:734`, over `_canonical_policy` — `policy.py:963`; carried into the receipt by `_record` — `control.py:4353` | `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:734`, over `_canonical_policy` — `policy.py:963`; carried into the receipt by `_record` — `control.py:4517` | `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:724` | `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:1127`; `_spend_unneeded_approval` — `control.py:2535` | `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:514`; `prev_hash` — `receipt.py:444`; `GENESIS_HASH` — `receipt.py:117`; `put_receipt` takes the head row's lock first — `postgres.py:2092` | `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:904`; the six names — `CHAIN_BREAKS` — `receipt.py:841` | `test_the_verify_chain_flag_reports_a_break_by_seq_and_by_name`, `test_verify_chain_reads_a_postgres_store_through_store_url` | +| "the approval is re-checked against the policy in force at execution" | `Control.execute` — `control.py:1181`; `_spend_unneeded_approval` — `control.py:2642` | `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:533`; `prev_hash` — `receipt.py:452`; `GENESIS_HASH` — `receipt.py:125`; `put_receipt` takes the head row's lock first — `postgres.py:2092` | `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:935`; the six names — `CHAIN_BREAKS` — `receipt.py:872` | `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:633`, called from both stores' constructors; `HEAD` — `migrations.py:414` | `test_T147_a_v05_database_migrates_and_keeps_every_row`, `test_T150_reopening_does_not_rerun` | | "An older binary against a newer schema refuses immediately" | `_refuse` — `migrations.py:559`; `SchemaMismatch` — `errors.py` | `test_T148_an_older_binary_refuses_a_newer_database`, `test_T148_no_other_table_is_read_before_the_refusal` | | "Releases carry PyPI provenance attestations from GitHub Actions" | `.github/workflows/publish.yml` — `pypa/gh-action-pypi-publish` pinned at v1.14.2, which generates and uploads PEP 740 attestations by default since v1.11.0 (its release notes, read 2026-09-06), with no `attestations: false`; the `pypi` job's only permission is `id-token: write` | `test_the_publish_workflow_attests_through_trusted_publishing`, `test_every_action_is_pinned_to_a_commit` | @@ -177,7 +177,7 @@ catalogue, `GUARANTEES` (`verify/guarantees.py:47`). | "`WebhookApprovalProvider` sends an approval request to a webhook, such as Slack, and takes the answer back through the same grant calls" | `WebhookApprovalProvider` — `webhook.py:141` — one signed POST on `APPROVAL_REQUESTED`; the inbound answer lands through `grant_approval` / `deny_approval` like the CLI's | `test_T27_the_outbound_post_carries_a_signature_over_the_exact_bytes_sent`, `test_T27_the_payload_carries_what_the_spec_names` | | "one OpenTelemetry span per action, one span event per step" | `OTelEventSink` — `otel.py:45` | `test_T29_one_action_produces_one_span_named_for_the_action`, `test_T29_every_event_becomes_a_span_event_named_by_its_type` | | "argument values stay out of it unless you ask for them" | `OTelEventSink(arguments=...)` — `otel.py:45` | `test_T29_argument_values_are_not_attributes_by_default` | -| "Receipts in a `ctrlrun.policy/v4` document can cite the `controls:` an action satisfies" | `PolicyControl` — `policy.py:327`; `Receipt` — `receipt.py:373` — carries `controls`; attribution only, never a decision | `test_T175_the_receipt_carries_the_union_of_the_action_and_the_matched_rule`, `test_T175_a_control_is_attribution_and_changes_no_decision` | +| "Receipts in a `ctrlrun.policy/v4` document can cite the `controls:` an action satisfies" | `PolicyControl` — `policy.py:327`; `Receipt` — `receipt.py:398` — carries `controls`; attribution only, never a decision | `test_T175_the_receipt_carries_the_union_of_the_action_and_the_matched_rule`, `test_T175_a_control_is_attribution_and_changes_no_decision` | | "a rule can condition on the `data:` labels present in an action's arguments" | `DataLabel` — `policy.py:568`; `Policy.data_scope` — `policy.py:588`; `data_scope_in` in `v0.1 §3.2`'s grammar with no new operator | `test_T176_the_derived_set_is_the_labels_of_the_arguments_actually_supplied`, `test_T176_the_derived_set_drives_a_decision` | ## What it can't, stated as limits @@ -202,7 +202,7 @@ The README also makes negative claims. They matter as much as the positive ones. |---|---|---| | "the same `StateStore` protocol, extended by nothing, graded by the suite written for SQLite rather than one written for it" | `PostgresStateStore` — `postgres.py` — satisfies `StateStore` and adds no method (SPEC-v0.6 §9.1); `ctrlrun.conformance.store.SUITES` is the SQLite suite, run against both | `test_T141_the_shipped_backends_pass`, `test_T154_postgres_passes_the_store_conformance_suite` | | "automatic at open and forward-only, with no flag that opens a database un-migrated. An older binary against a newer schema refuses immediately." | `migrate` — `migrations.py:633` — called from both stores' constructors; `_refuse` — `migrations.py:559` — raises `SchemaMismatch` on a newer `user_version` | `test_T147_a_v05_database_migrates_and_keeps_every_row`, `test_T148_an_older_binary_refuses_a_newer_database`, `test_T152b_no_flag_opens_a_database_without_migrating` | -| "an edit, a deletion from the middle or a reordering is detected and named by `seq`" | `verify_chain` — `receipt.py:442` — and the six break names in `CHAIN_BREAKS` | `test_T164_an_altered_receipt_is_content_altered_at_its_seq`, `test_T164_reordering_two_receipts_is_detected_either_way`, `test_the_verify_chain_flag_reports_a_break_by_seq_and_by_name` | +| "an edit, a deletion from the middle or a reordering is detected and named by `seq`" | `verify_chain` — `receipt.py:450` — and the six break names in `CHAIN_BREAKS` | `test_T164_an_altered_receipt_is_content_altered_at_its_seq`, `test_T164_reordering_two_receipts_is_detected_either_way`, `test_the_verify_chain_flag_reports_a_break_by_seq_and_by_name` | | "It detects **alteration**, which is not authorship: receipts are not signed." | No signing code, and a release scan keeps the vocabulary out | `test_T180_the_release_documents_do_not_blur_alteration_and_authorship` | | "every receipt records the policy that decided it, so a receipt from six months ago says what the rules were" | `Policy.policy_hash` — `policy.py:734` — over the parsed decision inputs, recorded on the receipt | `test_T172_every_receipt_carries_the_hash_and_the_declared_version`, `test_T171_any_decision_input_changes_the_hash`, `test_T171_the_declared_version_alone_does_not_change_the_hash` | @@ -228,7 +228,7 @@ restating the code; the ones that are new to the site carry their own code and p | `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:4401` | +| `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:4569` | | `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 @@ -239,7 +239,7 @@ sentence that rots quietly. | Page | Claim | Proved by | |---|---|---| -| `production/index` | the readiness block — version, test count, guarantee count, the two stores, the soak, the chain, the licence | rendered by `tools/docs_audit/render_readiness.py` from `pyproject.toml`, `pytest --collect-only`, the `GUARANTEES` catalogue — `verify/guarantees.py:47` — and `research/soak/results/`; `test_the_readiness_block_is_the_generators_in_every_place_it_appears` asserts the same block in the README, the docs home and this page, and `test_the_readiness_block_refuses_a_shrunken_suite_and_accepts_a_grown_one` makes the count a floor | +| `production/index` | the readiness block — version, test count, guarantee count, the two stores, the soak, the chain, the licence | rendered by `tools/docs_audit/render_readiness.py` from `pyproject.toml`, `pytest --collect-only`, the `GUARANTEES` catalogue — `verify/guarantees.py:49` — and `research/soak/results/`; `test_the_readiness_block_is_the_generators_in_every_place_it_appears` asserts the same block in the README, the docs home and this page, and `test_the_readiness_block_refuses_a_shrunken_suite_and_accepts_a_grown_one` makes the count a floor | | `production/index` | the **Not yet** list: no external security audit, no third-party review of the kernel, no sector packs | stated rather than measured, because nothing in a repository can measure an absence. A fourth line — *no soak of the length the roadmap asks for* — was **derived** from the published run until `SPEC-v0.6.md` §8.1 removed the duration from the criterion on 2026-09-07, which removed the thing being derived; the run's own duration is still printed on the soak line above the list. The list lives inside the generated block so it cannot be scrolled past. `test_the_not_yet_list_is_inside_the_block_and_not_below_it`, `test_the_not_yet_list_is_the_constant_and_derives_nothing_from_the_soak` and `test_the_readiness_block_does_not_report_the_soak_as_an_unmet_gate` assert all of it; removing a stated line is its own pull request with the row that makes the new sentence true | | `production/index` | "SQLite is the default and it is production-grade on one host… Postgres is for many hosts" | the header row above; `test_the_first_line_of_the_section_says_which_store_and_why` asserts the order, because Postgres first would tell a reader with one host something false | | `production/how-reservation-works` | the two rows: an exception before `COMMIT` is a failed write; one during it is unknown and is re-read | SPEC-v0.6 §4.3 Tables A, A1 and A2; `test_T155_a_connection_killed_during_commit_is_resolved_by_the_re_read`, `test_T155e_a_commit_the_server_never_received_re_issues_the_update`, `test_T155c_the_re_read_identity_check_is_not_an_action_id_match`, `test_T156_a_failed_re_read_refuses_to_proceed`; `test_the_two_rows_of_the_lost_commit_are_not_merged` asserts the page keeps them apart | diff --git a/docs/OWASP-AGENTIC-TOP10.md b/docs/OWASP-AGENTIC-TOP10.md index f2752da..69f2a22 100644 --- a/docs/OWASP-AGENTIC-TOP10.md +++ b/docs/OWASP-AGENTIC-TOP10.md @@ -1,12 +1,12 @@ --- title: "OWASP Top 10 for Agentic Applications" -description: "A reading of somebody else's taxonomy against the guarantees CTRLRun tests, naming the four entries it does not address." +description: "A reading of somebody else's taxonomy against the guarantees CTRLRun tests, naming the two entries it does not address." sidebarTitle: "OWASP Agentic Top 10" --- This is a **reading** of somebody else's taxonomy against the guarantees CTRLRun tests. It is not a compliance claim, a conformance claim, a certification, or a statement that CTRLRun -covers the OWASP Top 10 for Agentic Applications. Three of the ten entries are not addressed +covers the OWASP Top 10 for Agentic Applications. Two of the ten entries are not addressed by CTRLRun at all, and they are listed by name below. Every row maps a guarantee to an entry, and every guarantee is backed by a passing acceptance @@ -87,6 +87,8 @@ mechanism, not the entry. | **G22** held budget refuses next reserve | A grant may carry a consequence budget: a metric, a limit and a rolling window. The sum is consumed **when the effect is reserved, in the same transaction**, and an action that would take it past the limit is refused `budget_exhausted` with an `ACTION_DENIED` event and a `DENIED` receipt. Every grant in the delegation chain is charged, so a child cannot spend its parent's budget over again. | `ASI08:2026` (partly), `ASI01:2026` (partly), `ASI10:2026` (partly) | Until v0.9 the authority model bounded **one action** and never an aggregate: `amount_lte: 5000` says nothing about the thousand actions that each pass it, so an agent acting entirely within its permissions could empty an account one permitted refund at a time. Three limits stated wherever the feature is described. A budget **cannot recall an action already in flight**: the window rolls forward and a reservation taken a second before it rolls is committed regardless. An `AMBIGUOUS` effect **holds** its charge until a human resolves it, which is deliberate and which means an agent that can manufacture ambiguity can exhaust a budget it cannot spend, a denial of service against its own principal that is fail-closed. And a budget counts a **metric an operator named** — an action argument — not a consequence: nothing here ranks, scores or classifies what an action means. | | **G23** a failing scope provider refuses | Where a deployment configures one, a scope provider answers *is this record this principal's?* and the kernel matches the answer against the action's resource, strictly **before** the reservation. Two distinct refusals, never conflated: `scope_unavailable` when the provider raises, answers the wrong shape, or answers something the canonicalizer refuses; `out_of_scope` when it answered and the record is not covered. Only the **hash** of the answer reaches the receipt. | `ASI06:2026` (partly), `ASI01:2026` (partly), `ASI02:2026` (partly) | This is the bite on an identifier an attacker chose, which the `ASI06` row below said nothing had: a grant permits `records.read` on `customer:*`, and until v0.9 nothing had an opinion about *whose* record `customer:90210` is. Two limits. **A scope provider is worth what its source is worth**: it is the operator's own code answering from the operator's own system of record, and a poisoned source answers wrongly with the kernel none the wiser. And whether a deployment configures one is a fact about its code, which `ctrlrun verify` cannot read: verify grades the mechanism against a provider it supplies. The gateway and the ACS hook cannot name a provider at all. | | **G24** grant refused off its task | A grant may name `tasks:`, the unit of work the authority is for, attenuated by the same `child ⊆ parent` rule as actions, resources and environments. An action bound to a task the grant does not cover is refused by reason and not by exception type. A grant that names no `tasks:` authorises any task, so every grant written before v0.9 keeps its meaning. | `ASI01:2026` (partly), `ASI03:2026` (partly), `ASI10:2026` (partly) | **Task binding limits blast radius; it does not detect a hijack.** The task id is supplied by the caller, so an agent that has been talked into a different goal will often still be operating inside the task it was legitimately given, and the kernel cannot tell a hijacked run from an honest one. What it stops is a credential or a delegation issued for one unit of work being spent on another, which is the shape a stolen grant most often takes. The task never enters the action hash, so binding one changes no existing receipt. | +| **G25** a hop narrows or it is refused | When one agent hands work to another across a boundary the kernel does not control, what the receiver holds is a *hop*: a delegation record in the store, narrowed on every dimension the issuing grant constrains, refused by name on the dimension it would widen, and with nothing inherited by omission. An action proposed under a hop is evaluated against that hop's grant alone, with no fallback to any other grant the receiver happens to hold, and what it spends is the issuing agent's budget, so a hop never creates a second root. No flag admits a wider hop. | `ASI03:2026` (partly), `ASI07:2026` (partly), `ASI10:2026` (partly) | This is the multi-agent shape of the containment rule that has bounded delegation since v0.3: the boundary changes nothing about what a receiver may do. What it stops is privilege growing as work moves between agents, and an agent two hops down holding more than the root granted. Two limits. **The kernel decides which grant a hop-borne action is judged by; it cannot compel a receiver to present its hop.** A receiver that holds a grant of its own and proposes the action without naming the hop is judged by that grant, and the receipt records which of the two happened. And **a hop does not cross stores**: agents whose stores differ get a refusal by name, not a weaker check. | +| **G26** a hop is named on both sides | Both ends of a hop are in the evidence. The receiver's receipt records the hop it ran under, and the issuer's `DELEGATION_CREATED` event names the hop it created and, when the issuer supplied it, the action that created it, so the same hop id is found from either side's records without the other side's cooperation. Who the receiver is comes from the deployment's `IdentityProvider` and is never read off the payload. | `ASI07:2026` (partly), `ASI03:2026` (partly), `ASI10:2026` (partly) | What crosses the boundary is a reference of two strings to a record in the store, not a claim in a token, so there is nothing to forge, replay or widen in transit and nothing to verify but the record. Two limits. **The channel is not CTRLRun's**: it defines no wire format, no agent card and no transport, so how the reference travels and whether the transport authenticates its peers is the deployment's, and a caller the `IdentityProvider` resolves as the receiving principal *is* that principal. And **a relay's created hop is tied to the action that created it only when the relay supplies `action_id`**; the kernel refuses to infer the link, because an inferred one fails in the evidence direction. | --- @@ -98,9 +100,8 @@ The half that makes the table above credible. One honest sentence each; nothing |---|---|---| | `ASI04:2026` | Agentic Supply Chain Vulnerabilities | Out of scope. CTRLRun never inspects a package, a model, a tool registry or an MCP server's provenance; it decides actions, and a poisoned dependency reaches it as an ordinary caller. | | `ASI05:2026` | Unexpected Code Execution | Out of scope. Nothing here sandboxes an interpreter or constrains what a process may run. CTRLRun sits between an agent and one remote effect, not between an agent and its own runtime. | -| `ASI07:2026` | Insecure Inter-Agent Communication | Not yet. Authority does not propagate across agent hops in this release — a grant is evaluated where the action is proposed, and there is no A2A model. `docs/ROADMAP.md` puts that in v0.10; until then, an agent handing work to another agent is outside what these guarantees say anything about. | -And the three entries where the mapping above is **partial**, with the part that is not covered +And the four entries where the mapping above is **partial**, with the part that is not covered stated here rather than left implied: | Entry | Title | Covered | Not covered | @@ -108,6 +109,7 @@ stated here rather than left implied: | `ASI06:2026` | Memory & Context Poisoning | G6 and G1 constrain what an agent acting on a poisoned context can *do*: the action must still be named in the policy, so a belief an attacker planted cannot reach a tool the agent was never entitled to use, and an approval granted for one action cannot be spent on another. This is the same downstream constraint that makes `ASI01` partial, and it is here for the same reason. | CTRLRun never reads a model's memory, its context or its prompt, so it neither detects nor prevents the poisoning. And the shape poisoning most often takes is the one the kernel has least to say about: **corrupted arguments to an action the agent is entitled to take** — the right operation against the wrong record. Policy conditions, resource patterns and v0.6 data scope bite on part of that, and since v0.9 **G23 bites on the identifier itself**: where a deployment configures a scope provider, the kernel asks its system of record whether `customer:90210` is this principal's before reserving anything. That closes the sharpest version of this and not the category. The provider is the operator's own code reading the operator's own data, so a poisoned source answers wrongly with the kernel none the wiser, and a deployment that configures no provider is exactly where it was. | | `ASI01:2026` | Agent Goal Hijack | G1 and G6 constrain what a hijacked agent can *do*: it still meets the policy, and it still cannot present an approval granted for a different action. | CTRLRun does not detect or prevent the hijack. It never sees the prompt, the plan or the reasoning, so an agent whose goal was replaced proposes actions exactly as a healthy one would — and every action inside its policy and its grants will run. | | `ASI09:2026` | Human-Agent Trust Exploitation | G1 and G2 close the shape where an approval a human gave for one action is spent on another, or spent twice. | CTRLRun does not authenticate the *approver*, does not model separation of duties, and has no opinion on whether the human was misled into approving. A human persuaded to approve the right action for the wrong reason gets a valid approval, and the receipt records it as one. | +| `ASI07:2026` | Insecure Inter-Agent Communication | G25 and G26, since v0.10, bound what authority can cross a hop and put both ends of it in the evidence: a receiver holds no more than the issuer narrowed to, the issuer's budget is what it spends, and the same hop id is on both sides' records. | CTRLRun does not protect the channel. It defines no wire format, no agent card and no transport, so message integrity, peer authentication and replay on the wire are the deployment's, and the kernel's only view of the peer is what the `IdentityProvider` resolves. A hop between agents whose stores differ is refused rather than propagated. | --- @@ -126,5 +128,5 @@ every report as `descends_from`. --- This document is regenerated when the guarantee catalogue changes, and when OWASP publishes a -new edition. It was written against `ctrlrun.guarantees/v2` and the **2026** edition of the -OWASP Top 10 for Agentic Applications. +new edition. It was written against `ctrlrun.guarantees/v2`, last revised for `ctrlrun.guarantees/v6`, and +the **2026** edition of the OWASP Top 10 for Agentic Applications. diff --git a/docs/cookbook/verify-in-github-actions.mdx b/docs/cookbook/verify-in-github-actions.mdx index 455cc04..b2b5b89 100644 --- a/docs/cookbook/verify-in-github-actions.mdx +++ b/docs/cookbook/verify-in-github-actions.mdx @@ -62,11 +62,10 @@ 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.9.0, catalogue ctrlrun.guarantees/v5 +CTRLRun verify — ctrlrun 0.9.0, catalogue ctrlrun.guarantees/v6 policy /private/var/folders/gp/jrq5dccs6xldv1jw839n7dsw0000gn/T/tmpmax85d9e/ctrlrun.yaml (ctrlrun.policy/v2, mode: enforce) authority none store sqlite, scratch (created and destroyed for this run) - G1 mutated approval refused PASS k8s.delete_namespace G2 replayed approval refused PASS k8s.delete_namespace G3 duplicate effect refused PASS k8s.delete_namespace @@ -102,12 +101,13 @@ G21 unapproved policy decides no PASS stripe.refund G22 held budget refuses next reserve N/A no authority section G23 a failing scope provider refuses N/A no action this configuration admits carries a resource G24 grant refused off its task N/A no authority section +G25 a hop narrows or it is refused N/A no authority section +G26 a hop is named on both sides N/A no authority section (a token is unique only as far as your effect keys are: two stores sharing a provider account must not produce the same effect-key string for different effects, and nothing here can check that) - -15/15 declared guarantees pass. 9 not applicable: G8, G9, G13, G15, G17, G19, G22, G23, G24. +15/15 declared guarantees pass. 11 not applicable: G8, G9, G13, G15, G17, G19, G22, G23, G24, G25, G26. ``` 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 0788c36..6b40170 100644 --- a/docs/guides/verify-in-ci.mdx +++ b/docs/guides/verify-in-ci.mdx @@ -32,11 +32,10 @@ guarantees pass. ``` ```text - CTRLRun verify — ctrlrun 0.9.0, catalogue ctrlrun.guarantees/v5 + CTRLRun verify — ctrlrun 0.9.0, catalogue ctrlrun.guarantees/v6 policy /private/var/folders/gp/jrq5dccs6xldv1jw839n7dsw0000gn/T/tmpdi6bvpqd/ctrlrun.yaml (ctrlrun.policy/v2, mode: enforce) authority none store sqlite, scratch (created and destroyed for this run) - G1 mutated approval refused PASS k8s.delete_namespace G2 replayed approval refused PASS k8s.delete_namespace G3 duplicate effect refused PASS k8s.delete_namespace @@ -72,12 +71,13 @@ guarantees pass. G22 held budget refuses next reserve N/A no authority section G23 a failing scope provider refuses N/A no action this configuration admits carries a resource G24 grant refused off its task N/A no authority section + G25 a hop narrows or it is refused N/A no authority section + G26 a hop is named on both sides N/A no authority section (a token is unique only as far as your effect keys are: two stores sharing a provider account must not produce the same effect-key string for different effects, and nothing here can check that) - - 15/15 declared guarantees pass. 9 not applicable: G8, G9, G13, G15, G17, G19, G22, G23, G24. + 15/15 declared guarantees pass. 11 not applicable: G8, G9, G13, G15, G17, G19, G22, G23, G24, G25, G26. ``` The first line is on **stderr**, from G7's own scenario: an action with no principal is diff --git a/docs/production/index.mdx b/docs/production/index.mdx index 6fb3d53..2f29bee 100644 --- a/docs/production/index.mdx +++ b/docs/production/index.mdx @@ -28,8 +28,8 @@ need. `test_the_first_line_of_the_section_says_which_store_and_why` asserts the {/* generated from the suite, pyproject and the soak (full) — run the generator */} - **Version 0.9.0**, on [PyPI](https://pypi.org/project/ctrlrun/), Python 3.11 and later. -- **5,975 tests**, every version specified before it was written and every requirement mutation-tested. [Read more](/docs/how-this-is-built). -- **24 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). +- **6,005 tests**, every version specified before it was written and every requirement mutation-tested. [Read more](/docs/how-this-is-built). +- **26 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/reference/api/Authority.mdx b/docs/reference/api/Authority.mdx index 838591e..20028a7 100644 --- a/docs/reference/api/Authority.mdx +++ b/docs/reference/api/Authority.mdx @@ -5,7 +5,7 @@ description: "The `authority:` section, loaded and evaluable (SPEC-v0.3 §4)." {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.Authority` — class, defined at `src/ctrlrun/authority.py:1134` +`ctrlrun.Authority` — class, defined at `src/ctrlrun/authority.py:1191` ```python from ctrlrun import Authority diff --git a/docs/reference/api/AuthorityResult.mdx b/docs/reference/api/AuthorityResult.mdx index efe4e1d..e6aaee5 100644 --- a/docs/reference/api/AuthorityResult.mdx +++ b/docs/reference/api/AuthorityResult.mdx @@ -5,7 +5,7 @@ description: "What the authority axis decided, and which grant it decided on (§ {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.AuthorityResult` — class, defined at `src/ctrlrun/authority.py:619` +`ctrlrun.AuthorityResult` — class, defined at `src/ctrlrun/authority.py:621` ```python from ctrlrun import AuthorityResult @@ -14,7 +14,7 @@ from ctrlrun import AuthorityResult ```python class AuthorityResult - def __init__(passed: bool, reason: str, grant_id: str | None = None, delegation_id: str | None = None, depth: int = 0, dimension: str | None = None, missing_parent_id: str | None = None, expired_parent_id: str | None = None, depth_exceeded: int | None = None, cycle_at: str | None = None) + def __init__(passed: bool, reason: str, grant_id: str | None = None, delegation_id: str | None = None, depth: int = 0, hop: str | None = None, dimension: str | None = None, missing_parent_id: str | None = None, expired_parent_id: str | None = None, depth_exceeded: int | None = None, cycle_at: str | None = None) ``` What the authority axis decided, and which grant it decided on (§4.8). diff --git a/docs/reference/api/Control.mdx b/docs/reference/api/Control.mdx index 5a3c7f0..05e37e5 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:677` +`ctrlrun.Control` — class, defined at `src/ctrlrun/control.py:717` ```python from ctrlrun import Control diff --git a/docs/reference/api/Delegation.mdx b/docs/reference/api/Delegation.mdx index dfce962..b7c5bd9 100644 --- a/docs/reference/api/Delegation.mdx +++ b/docs/reference/api/Delegation.mdx @@ -5,7 +5,7 @@ description: "A grant created at runtime by a principal who already holds one (S {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.Delegation` — class, defined at `src/ctrlrun/authority.py:579` +`ctrlrun.Delegation` — class, defined at `src/ctrlrun/authority.py:581` ```python from ctrlrun import Delegation diff --git a/docs/reference/api/Event.mdx b/docs/reference/api/Event.mdx index 993749b..8c16abb 100644 --- a/docs/reference/api/Event.mdx +++ b/docs/reference/api/Event.mdx @@ -5,7 +5,7 @@ description: "One ordered step in the life of an action (SPEC-v0.1 §6.2)." {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.Event` — class, defined at `src/ctrlrun/receipt.py:297` +`ctrlrun.Event` — class, defined at `src/ctrlrun/receipt.py:305` ```python from ctrlrun import Event diff --git a/docs/reference/api/EventSink.mdx b/docs/reference/api/EventSink.mdx index ea24ea4..709fced 100644 --- a/docs/reference/api/EventSink.mdx +++ b/docs/reference/api/EventSink.mdx @@ -5,7 +5,7 @@ description: "Somewhere a copy of every `Event` and `Receipt` goes (SPEC-v0.2 § {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.EventSink` — class, defined at `src/ctrlrun/receipt.py:773` +`ctrlrun.EventSink` — class, defined at `src/ctrlrun/receipt.py:798` ```python from ctrlrun import EventSink diff --git a/docs/reference/api/Grant.mdx b/docs/reference/api/Grant.mdx index ff85c8e..da0d436 100644 --- a/docs/reference/api/Grant.mdx +++ b/docs/reference/api/Grant.mdx @@ -5,7 +5,7 @@ description: "One permission: this subject may propose these actions, under thes {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.Grant` — class, defined at `src/ctrlrun/authority.py:416` +`ctrlrun.Grant` — class, defined at `src/ctrlrun/authority.py:431` ```python from ctrlrun import Grant diff --git a/docs/reference/api/JSONLEventSink.mdx b/docs/reference/api/JSONLEventSink.mdx index deda44e..39c7af6 100644 --- a/docs/reference/api/JSONLEventSink.mdx +++ b/docs/reference/api/JSONLEventSink.mdx @@ -5,7 +5,7 @@ description: "The JSONL half of the evidence: two append-only files in one direc {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.JSONLEventSink` — class, defined at `src/ctrlrun/receipt.py:792` +`ctrlrun.JSONLEventSink` — class, defined at `src/ctrlrun/receipt.py:817` ```python from ctrlrun import JSONLEventSink diff --git a/docs/reference/api/Receipt.mdx b/docs/reference/api/Receipt.mdx index 115e1b2..713bdc6 100644 --- a/docs/reference/api/Receipt.mdx +++ b/docs/reference/api/Receipt.mdx @@ -5,7 +5,7 @@ description: "Portable evidence of one action that reached a terminal state (SPE {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.Receipt` — class, defined at `src/ctrlrun/receipt.py:389` +`ctrlrun.Receipt` — class, defined at `src/ctrlrun/receipt.py:397` ```python from ctrlrun import Receipt @@ -14,7 +14,7 @@ from ctrlrun import Receipt ```python class Receipt - def __init__(receipt_id: str, action_id: str, action: str, action_hash: str, principal: Principal, resource: str | None, arguments: Mapping[str, Any], environment: str, decision: Decision, decision_reason: str, result: ReceiptResult, started_at: datetime, finished_at: datetime, approval_id: str | None = None, approver: str | None = None, effect_key: str | None = None, attempt: int = 1, error: str | None = None, execution: ReceiptResult | None = None, would_have: _WouldHave | None = None, seq: int | None = None, prev_hash: str | None = None, policy_hash: str | None = None, policy_version: str | None = None, controls: tuple[str, ...] = (), hash: str | None = None, precondition_at_request: str | None = None, precondition_at_recheck: str | None = None, approvers: tuple[VerifiedApprover, ...] = (), authority_grant_id: str | None = None, task: str | None = None, scope_hash: str | None = None, budget_charges: tuple[Mapping[str, Any], ...] = (), schema: str = RECEIPT_SCHEMA) + def __init__(receipt_id: str, action_id: str, action: str, action_hash: str, principal: Principal, resource: str | None, arguments: Mapping[str, Any], environment: str, decision: Decision, decision_reason: str, result: ReceiptResult, started_at: datetime, finished_at: datetime, approval_id: str | None = None, approver: str | None = None, effect_key: str | None = None, attempt: int = 1, error: str | None = None, execution: ReceiptResult | None = None, would_have: _WouldHave | None = None, seq: int | None = None, prev_hash: str | None = None, policy_hash: str | None = None, policy_version: str | None = None, controls: tuple[str, ...] = (), hash: str | None = None, precondition_at_request: str | None = None, precondition_at_recheck: str | None = None, approvers: tuple[VerifiedApprover, ...] = (), authority_grant_id: str | None = None, task: str | None = None, scope_hash: str | None = None, hop: str | None = None, budget_charges: tuple[Mapping[str, Any], ...] = (), schema: str = RECEIPT_SCHEMA) ``` Portable evidence of one action that reached a terminal state (SPEC-v0.1 §6.1). diff --git a/docs/reference/api/Subject.mdx b/docs/reference/api/Subject.mdx index b2c0968..00c07c1 100644 --- a/docs/reference/api/Subject.mdx +++ b/docs/reference/api/Subject.mdx @@ -5,7 +5,7 @@ description: "Who a grant is addressed to: an agent pattern, a user pattern, or {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.Subject` — class, defined at `src/ctrlrun/authority.py:320` +`ctrlrun.Subject` — class, defined at `src/ctrlrun/authority.py:335` ```python from ctrlrun import Subject diff --git a/docs/reference/api/authority-Budget.mdx b/docs/reference/api/authority-Budget.mdx index 9c41dbe..e28ed36 100644 --- a/docs/reference/api/authority-Budget.mdx +++ b/docs/reference/api/authority-Budget.mdx @@ -5,7 +5,7 @@ description: "How much, over what, in how long (SPEC-v0.9 §2.2)." {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.authority.Budget` — class, defined at `src/ctrlrun/authority.py:366` +`ctrlrun.authority.Budget` — class, defined at `src/ctrlrun/authority.py:381` ```python from ctrlrun.authority import Budget diff --git a/docs/reference/api/context.mdx b/docs/reference/api/context.mdx index ca76059..da74139 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:322` +`ctrlrun.context` — function, defined at `src/ctrlrun/control.py:362` ```python from ctrlrun import context diff --git a/docs/reference/api/idempotency_token.mdx b/docs/reference/api/idempotency_token.mdx index 1f7139f..db6d8f2 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:290` +`ctrlrun.idempotency_token` — function, defined at `src/ctrlrun/control.py:330` ```python from ctrlrun import idempotency_token diff --git a/docs/reference/api/protect.mdx b/docs/reference/api/protect.mdx index 8e00624..d121baa 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:4576` +`ctrlrun.protect` — function, defined at `src/ctrlrun/control.py:4744` ```python from ctrlrun import protect @@ -13,7 +13,7 @@ from ctrlrun import protect ```python -def protect(name: str, *, effect: str | None = None, resource: str | None = None, wait: bool = False, lease: timedelta | None = None, reconcile: Callable[[str], ReconcileOutcome] | None = None, reconcile_eagerly: bool = False, control: Control | None = None, preconditions: Callable[[Action], Mapping[str, Any]] | None = None, task: str | None = None, scope: Callable[[Action], Mapping[str, Any]] | None = None) -> Callable[[Callable[P, R]], Callable[P, R]] +def protect(name: str, *, effect: str | None = None, resource: str | None = None, wait: bool = False, lease: timedelta | None = None, reconcile: Callable[[str], ReconcileOutcome] | None = None, reconcile_eagerly: bool = False, control: Control | None = None, preconditions: Callable[[Action], Mapping[str, Any]] | None = None, task: str | None = None, hop: str | None = None, scope: Callable[[Action], Mapping[str, Any]] | None = None) -> Callable[[Callable[P, R]], Callable[P, R]] ``` Bind a function to an action name: every call becomes a decided, recorded Action. diff --git a/docs/reference/api/with_approval.mdx b/docs/reference/api/with_approval.mdx index de2b8cc..529c276 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:345` +`ctrlrun.with_approval` — function, defined at `src/ctrlrun/control.py:385` ```python from ctrlrun import with_approval diff --git a/docs/reference/receipt-and-event-schemas.mdx b/docs/reference/receipt-and-event-schemas.mdx index 763e647..1536a7e 100644 --- a/docs/reference/receipt-and-event-schemas.mdx +++ b/docs/reference/receipt-and-event-schemas.mdx @@ -57,8 +57,9 @@ it. A reader checking a chain compares those two. | `authority_grant_id` | `str | None` | `None` | | `task` | `str | None` | `None` | | `scope_hash` | `str | None` | `None` | +| `hop` | `str | None` | `None` | | `budget_charges` | `tuple` | `()` | -| `schema` | `str` | `'ctrlrun.receipt/v6'` | +| `schema` | `str` | `'ctrlrun.receipt/v7'` | | `_stored_document` | `collections.abc.Mapping[str, Any] | None` | `None` | ### Result diff --git a/docs/verify.md b/docs/verify.md index 4ee5e68..5906b05 100644 --- a/docs/verify.md +++ b/docs/verify.md @@ -14,11 +14,10 @@ what could not be tested at all. ```console $ ctrlrun verify -CTRLRun verify — ctrlrun 0.9.0, catalogue ctrlrun.guarantees/v5 +CTRLRun verify — ctrlrun 0.9.0, catalogue ctrlrun.guarantees/v6 policy examples/authority/payments.yaml (ctrlrun.policy/v7, mode: enforce) authority same document, 3 grants store sqlite, scratch (created and destroyed for this run) - G1 mutated approval refused PASS stripe.refund G2 replayed approval refused PASS stripe.refund G3 duplicate effect refused PASS stripe.refund @@ -58,12 +57,13 @@ G23 a failing scope provider refuses PASS stripe.refund own code, which verify cannot read. The gateway and the ACS hook cannot name a provider at all (SPEC-v0.9 §5.2.2)) G24 grant refused off its task PASS head-of-support +G25 a hop narrows or it is refused PASS head-of-support +G26 a hop is named on both sides PASS head-of-support (a token is unique only as far as your effect keys are: two stores sharing a provider account must not produce the same effect-key string for different effects, and nothing here can check that) - -22/22 declared guarantees pass. 2 not applicable: G13, G15. +24/24 declared guarantees pass. 2 not applicable: G13, G15. ``` It reads the policy document — `$CTRLRUN_CONFIG`, else `./ctrlrun.yaml` — and the authority diff --git a/generated/readiness.full.mdx b/generated/readiness.full.mdx index 12b6d44..ca398eb 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.9.0**, on [PyPI](https://pypi.org/project/ctrlrun/), Python 3.11 and later. -- **5,975 tests**, every version specified before it was written and every requirement mutation-tested. [Read more](/docs/how-this-is-built). -- **24 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). +- **6,005 tests**, every version specified before it was written and every requirement mutation-tested. [Read more](/docs/how-this-is-built). +- **26 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 c819159..dd2787d 100644 --- a/generated/readiness.json +++ b/generated/readiness.json @@ -1,5 +1,5 @@ { - "guarantees": 24, + "guarantees": 26, "released": "0.9.0", "soak": { "actions": 889735, @@ -9,6 +9,6 @@ "positive_control": true, "unexplained": 0 }, - "tests": 5975, + "tests": 6005, "version": "0.9.0" } diff --git a/generated/readiness.mdx b/generated/readiness.mdx index 23741ee..fce776d 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.9.0**, on [PyPI](https://pypi.org/project/ctrlrun/), Python 3.11 and later. -- **5,975 tests**, every version specified before it was written and every requirement mutation-tested. -- **24 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. +- **6,005 tests**, every version specified before it was written and every requirement mutation-tested. +- **26 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 9428f97..b499e95 100644 --- a/generated/readiness.readme.md +++ b/generated/readiness.readme.md @@ -1,7 +1,7 @@ - **Version 0.9.0**, on [PyPI](https://pypi.org/project/ctrlrun/), Python 3.11 and later. -- **5,975 tests**, every version specified before it was written and every requirement mutation-tested. -- **24 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. +- **6,005 tests**, every version specified before it was written and every requirement mutation-tested. +- **26 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_owasp_mapping.py b/tests/test_owasp_mapping.py index b55b0e4..fa3c5e3 100644 --- a/tests/test_owasp_mapping.py +++ b/tests/test_owasp_mapping.py @@ -116,9 +116,10 @@ def test_T121_every_entry_in_the_cited_edition_appears_in_one_half_or_the_other( assert code in mapping or code in not_covered, code -def test_T121_the_three_uncovered_entries_are_listed_by_name(): - """§6.1's disclaimer says three of the ten are not addressed at all, and this is the test - that keeps that sentence true rather than merely written. +def test_T121_the_two_uncovered_entries_are_listed_by_name(): + """§6.1's disclaimer said three of the ten are not addressed at all; v0.10 put authority + across a hop (G25, G26) and moved `ASI07` into the partial half, so it is two. This is the + test that keeps that sentence true rather than merely written. `code in not_covered` on its own does not say it: a *partly* addressed entry appears in that half too, by the design the next test asserts, so the membership check cannot tell @@ -129,19 +130,19 @@ def test_T121_the_three_uncovered_entries_are_listed_by_name(): """ _, not_covered = _sections() - fully_uncovered = {"ASI04:2026", "ASI05:2026", "ASI07:2026"} + fully_uncovered = {"ASI04:2026", "ASI05:2026"} for code in fully_uncovered: assert code in not_covered, code assert ENTRIES[code] in not_covered, code assert code not in _guarantee_table(), code - assert "Three of the ten entries are not addressed by CTRLRun at all" in _flat() + assert "Two of the ten entries are not addressed by CTRLRun at all" in _flat() def test_T121_a_partly_addressed_entry_appears_in_both_halves(): """§6.2 item 4 — the honest place for a hedge is next to the thing it qualifies.""" mapping, not_covered = _sections() - for code in ("ASI01:2026", "ASI06:2026", "ASI09:2026"): + for code in ("ASI01:2026", "ASI06:2026", "ASI07:2026", "ASI09:2026"): assert code in mapping, code assert code in not_covered, code assert "Not covered" in not_covered From c6fde50cebf7dbeebeb95bad3bc6e69e1cd9aa3f Mon Sep 17 00:00:00 2001 From: arpan Date: Mon, 14 Sep 2026 00:53:35 +0530 Subject: [PATCH 2/7] CI checks a branch against the kernel branch of the same name, or main The mirror image of CTRLRun/ctrlrun's `docs` job, which from the same-named branch there checks out the branch here named after the kernel branch under test, and main otherwise. A page describes a commit, and the page for a commit that has not merged cannot be on main yet, so a branch here regenerated for a kernel branch was red until that branch merged, by construction, for the length of a release cycle; PR #14 is the example. Now a branch here is checked against the kernel branch of the same name when there is one. A dispatch still names its commit. Everything else, main included, is checked against the kernel's main, so the pages on main are expected to match it at all times: the docs branch merges when the kernel branch does, the code first. The branch name reaches the shell through `env:` and never inline in the script, and the match is exact and fixed-string, for the reasons the kernel's commit gives. README's "How the two repositories stay in step" says the rule. Signed-off-by: arpan --- .github/workflows/ci.yml | 37 ++++++++++++++++++++++++++++++++++++- README.md | 7 ++++++- 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d86a248..903afe3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,6 +26,41 @@ jobs: with: path: ctrlrun-docs + # Which commit of the library to check these pages against. A dispatch names one. A + # branch here is checked against the `CTRLRun/ctrlrun` branch of the same name when + # there is one, because that is the pair: a change there that alters a docstring or a + # `--help` comes with a branch here, regenerated against it, and each is checked against + # the other (that repository's `docs` job does the mirror image). Everything else is + # checked against `main`, so the pages on `main` match the library's `main` at all times. + # + # Before this every branch here was checked against the library's `main`, and a page + # regenerated for a commit that had not merged yet was red until it did -- by + # construction, for the whole of a release cycle. The fallback is `main` and not a skip, + # for the reason `_core.py` raises: red for a reason beats green for none. + # + # The branch name reaches the shell through `env:`, never inline in the script, so a + # branch called `$(...)` is a string here and not a command. The match is exact and + # fixed-string: `ls-remote` matches a pattern against the tail of a ref, and `.` in a + # version would be a regex wildcard. + - name: Pick the ctrlrun ref + id: ctrlrun + env: + DISPATCHED: ${{ github.event.client_payload.sha }} + BRANCH: ${{ github.head_ref || github.ref_name }} + run: | + set -eu + if [ -n "$DISPATCHED" ]; then + echo "ref=$DISPATCHED" >> "$GITHUB_OUTPUT" + echo "ctrlrun at $DISPATCHED, the commit the dispatch named" + elif git ls-remote --heads https://github.com/CTRLRun/ctrlrun.git "refs/heads/$BRANCH" \ + | cut -f2 | grep -qxF "refs/heads/$BRANCH"; then + echo "ref=$BRANCH" >> "$GITHUB_OUTPUT" + echo "ctrlrun at $BRANCH, the branch of the same name" + else + echo "ref=main" >> "$GITHUB_OUTPUT" + echo "ctrlrun at main: it has no branch named $BRANCH" + fi + # The library these pages document. The checks read it -- a page that says the CLI # prints X is only true if the CLI prints X -- and `tools/docs_audit/_core.py` raises # when it is absent rather than skipping, so a checkout that failed to happen is a red @@ -33,7 +68,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: CTRLRun/ctrlrun - ref: ${{ github.event.client_payload.sha || 'main' }} + ref: ${{ steps.ctrlrun.outputs.ref }} path: ctrlrun - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 diff --git a/README.md b/README.md index 776d15f..c541276 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,12 @@ Each also takes `--check`, which is what CI runs. ## How the two repositories stay in step -- CI here checks out `CTRLRun/ctrlrun` and runs every page check against it. +- CI here checks out `CTRLRun/ctrlrun` and runs every page check against it: the branch of + the same name as the one under test when there is one, `main` otherwise. A change there + that alters a docstring or a `--help` comes with a branch here of the same name, regenerated + against it, and the two merge together, the code first. `main` is checked against `main`. +- That repository's `docs` job is the mirror image: it checks out the branch here named + after the kernel branch under test, or `main`, and runs the same checks. - A push to that repository's `main` sends a `library-changed` dispatch, so a change to the code re-checks the pages that describe it. - A weekly run is the floor, so a dispatch that stops arriving is a red run and not silence. From 5ff43241c2ba44ae9166a8888a57568da4deb5b7 Mon Sep 17 00:00:00 2001 From: arpan Date: Mon, 14 Sep 2026 00:58:27 +0530 Subject: [PATCH 3/7] Pages for v0.10 item 3: upstream identity pinning, G27 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Regenerated against CTRLRun/ctrlrun's v0.10/3-upstream-pinning, the branch of the same name, which the kernel's docs job now checks this one against. Rendered: the API pages and the errors page (the two upstream refusals), the readiness block (6,019 tests, 27 guarantees) and its two copies, CLAIMS.md repointed (54 citations, none unresolved), the three verify transcripts at catalogue v6 with G27. Written: docs/reference/policy-yaml.mdx names `upstream`, the action-entry key `ctrlrun.policy/v8` adds, with its three pins and the three places one rule is checked, and the version table gains the v7 and v8 rows it was missing. The OWASP mapping gains G27 (a swapped upstream is denied), mapped partly to ASI02 and ASI07 with its three limits in the row, and the ASI04 row keeps its "out of scope" verdict and gains the one sentence SPEC-v0.10 §4.1 allows it. Signed-off-by: arpan --- docs.mdx | 4 +- docs/CLAIMS.md | 80 +++++++++++----------- docs/OWASP-AGENTIC-TOP10.md | 3 +- docs/cookbook/verify-in-github-actions.mdx | 3 +- docs/guides/verify-in-ci.mdx | 3 +- docs/production/index.mdx | 4 +- docs/reference/api/Condition.mdx | 2 +- docs/reference/api/Control.mdx | 4 +- docs/reference/api/Decision.mdx | 2 +- docs/reference/api/Policy.mdx | 2 +- docs/reference/api/context.mdx | 2 +- docs/reference/api/idempotency_token.mdx | 2 +- docs/reference/api/parse_conditions.mdx | 2 +- docs/reference/api/protect.mdx | 2 +- docs/reference/api/with_approval.mdx | 2 +- docs/reference/policy-yaml.mdx | 3 + docs/verify.md | 3 +- generated/readiness.full.mdx | 4 +- generated/readiness.json | 4 +- generated/readiness.mdx | 4 +- generated/readiness.readme.md | 4 +- 21 files changed, 73 insertions(+), 66 deletions(-) diff --git a/docs.mdx b/docs.mdx index 3a5b033..6c0b268 100644 --- a/docs.mdx +++ b/docs.mdx @@ -219,8 +219,8 @@ the framework's own interrupt, and a framework with no such primitive does not n {/* generated from the suite, pyproject and the soak (mdx) — run the generator */} - **Version 0.9.0**, on [PyPI](https://pypi.org/project/ctrlrun/), Python 3.11 and later. -- **6,005 tests**, every version specified before it was written and every requirement mutation-tested. -- **26 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. +- **6,019 tests**, every version specified before it was written and every requirement mutation-tested. +- **27 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/CLAIMS.md b/docs/CLAIMS.md index 09c10af..c4449d7 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:1181` — 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:596` — passes only the action's **name and arguments** to `_ActionPolicy.evaluate` (`policy.py:596`), 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:224`) 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:248`. Exactly as approved: the approval is bound to `action_hash` and consumed with the reservation — `_authorize_and_reserve` — `state.py:1196`. Or not at all: a refusal raises before the executor — `Control.execute` — `control.py:1181`. Says so instead of guessing: only `NotExecuted` maps to `FAILED` — `_outcome` — `control.py:2054` — and everything else is `AMBIGUOUS`. A receipt: `Receipt` — `receipt.py:250`. **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` | +| "The last check before an AI agent does something it can't undo." | `Control.execute` — `control.py:1187` — 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:655` — passes only the action's **name and arguments** to `_ActionPolicy.evaluate` (`policy.py:655`), 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:251`) 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:248`. Exactly as approved: the approval is bound to `action_hash` and consumed with the reservation — `_authorize_and_reserve` — `state.py:1196`. Or not at all: a refusal raises before the executor — `Control.execute` — `control.py:1187`. Says so instead of guessing: only `NotExecuted` maps to `FAILED` — `_outcome` — `control.py:2065` — and everything else is `AMBIGUOUS`. A receipt: `Receipt` — `receipt.py:250`. **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:1476` — 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` — `_outcome` — `control.py:1188`; 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:587` — and `Control._reconciled` — `control.py:2702` | `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` — `_outcome` — `control.py:1194`; 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:587` — and `Control._reconciled` — `control.py:2719` | `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,31 +50,31 @@ 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` — `_outcome` — `control.py:2054`; `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` | +| "A lost reply is `AMBIGUOUS`, never `FAILED`, and a retry against an `AMBIGUOUS` effect is refused." | Only `NotExecuted` maps to `FAILED` — `_outcome` — `control.py:2065`; `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:637`; `PostgresStateStore.reserve_effect` — `postgres.py:740` | `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:1196` | `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:596` | `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:1181` | `test_T74_a_denial_leaves_no_pending_approval_request` | -| "Neither axis reads the agent's instructions" | `Policy.evaluate` — `policy.py:596` — sees the action's name and arguments; `Authority.evaluate` — `authority.py:1256` — 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` | +| "An action the policy does not list is denied" | `Policy.evaluate` — `policy.py:655` | `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:1187` | `test_T74_a_denial_leaves_no_pending_approval_request` | +| "Neither axis reads the agent's instructions" | `Policy.evaluate` — `policy.py:655` — sees the action's name and arguments; `Authority.evaluate` — `authority.py:1256` — 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:79` | `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:1196` — 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`." | `_outcome` — `control.py:2054`; `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:734`; `prev_hash`, `GENESIS_HASH` for the first — `receipt.py:125` | `test_T172_every_receipt_carries_the_hash_and_the_declared_version`, `test_T164_an_altered_receipt_is_content_altered_at_its_seq` | +| "Only `NotExecuted`, raised by you, means `FAILED`." | `_outcome` — `control.py:2065`; `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:793`; `prev_hash`, `GENESIS_HASH` for the first — `receipt.py:125` | `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:4744`) 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:4795`) 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:353` | 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:377`; `with_approval` — `control.py:386`; `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:377`; `with_approval` — `control.py:387`; `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:388`; `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:397`; `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:901`; `McpOptions` — `policy.py:543` | `test_T16_a_v2_document_loads_and_exposes_its_templates`, `test_T16_a_decorator_and_a_policy_template_produce_the_same_action_hash` | +| "they are declared in the policy" (effect and resource templates for a tool call) | `Policy.effect_template` / `resource_template` — `policy.py:979`; `McpOptions` — `policy.py:600` | `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:93` | `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: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` | +| "the gateway prints, on the line that starts it, every action in your policy that has no `effect:` template" | `_announce` — `gateway/__init__.py:150` | `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` | | "an adapter never constructs one and never supplies a principal" | `needs_approval` — `adapter.py:428` — resolves the principal from the `Control` so no adapter builds an `Action` | `test_T129_no_public_callable_takes_a_principal`, `test_T129_the_module_exposes_no_way_to_construct_a_control` | @@ -85,24 +85,24 @@ by its quoted claim, and `tests/test_docs_audit.py` fails if a named row is not | Claim | Code | Proof | |---|---|---| -| "cheap to undo is autonomous, anything that leaves the building needs a human, money is by amount with both ends bound" | `Decision` — `policy.py:308` — is exactly `allow`, `approve`, `deny`; rules match first-wins over `Condition` (`policy.py:372`) with the operators `eq`, `neq`, `in`, `lt`, `lte`, `gt`, `gte` — `_OPERATORS` — `policy.py:119` | `test_T6_an_action_name_is_matched_exactly`, `test_T176_the_operators_behave_as_they_do_everywhere_else` | -| "Unknown actions are denied; there is no default-allow." | `Policy.evaluate` — `policy.py:596` | `test_T6_unknown_action_is_denied_with_reason_unknown_action` | +| "cheap to undo is autonomous, anything that leaves the building needs a human, money is by amount with both ends bound" | `Decision` — `policy.py:308` — is exactly `allow`, `approve`, `deny`; rules match first-wins over `Condition` (`policy.py:399`) with the operators `eq`, `neq`, `in`, `lt`, `lte`, `gt`, `gte` — `_OPERATORS` — `policy.py:119` | `test_T6_an_action_name_is_matched_exactly`, `test_T176_the_operators_behave_as_they_do_everywhere_else` | +| "Unknown actions are denied; there is no default-allow." | `Policy.evaluate` — `policy.py:655` | `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:79` | `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:596` — 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:1248`; `Control._authority_result` — `control.py:1004` | `test_T67_a_principal_with_no_grant_is_denied` | +| "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:655` — 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:1248`; `Control._authority_result` — `control.py:1010` | `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:84`), 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:3956`; `Authority.plan_delegation` — `authority.py:1500`; `ctrlrun delegate` — `cli/main.py:1045` | `test_t75_the_delegation_authorizes_an_action_within_its_limits` | +| "narrow it at runtime with `ctrlrun delegate`" | `Control.delegate` — `control.py:4007`; `Authority.plan_delegation` — `authority.py:1500`; `ctrlrun delegate` — `cli/main.py:1045` | `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:946` — runs from `plan_delegation` (`authority.py:1500`) **and** from the chain walk in `Authority.evaluate` (`authority.py:1256`) | `test_t76_each_dimension_violated_alone`, `test_t77b_a_narrowed_parent_narrows_its_children` | | "provably a subset of its parent on every dimension, at creation and again at every evaluation; a consequence budget is consumed inside the reservation's own transaction, and a rolling window bounds what may start rather than recalling what already did" | Containment as in the row above. The budget: `check_charges` — `state.py:569` — is evaluated inside `reserve_effect`'s own transaction on all three backends, and `_charges_for` — `authority.py:1355` — charges every ancestor in the chain. The window is rolling and bounds the next reserve only: `_spent` sums `[now - window, now]` and nothing reads it again after a reservation is taken | `test_T408_a_charge_and_its_reservation_are_one_transaction`, `test_T409_N_processes_racing_one_budget_spend_at_most_the_limit`, `test_T412b_every_ancestor_is_charged_through_a_real_chain`, `test_T408c_the_rolling_window_forgets` | | "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:946`; the subject half is `_subject_contained` (`authority.py:1025`) | `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:4293` — writes one row — `revoke_delegation` — `state.py:835` 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:719`; `Control._observed` — `control.py:1565`; `_WouldHave` — `receipt.py:344`; `ReceiptResult.OBSERVED` — `receipt.py:256` | `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:719` | `test_T84_mode_is_refused_anywhere_but_the_top_level` | +| "`ctrlrun revoke` cuts a chain of any depth with one write" | `Control.revoke` — `control.py:4344` — writes one row — `revoke_delegation` — `state.py:835` 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:778`; `Control._observed` — `control.py:1571`; `_WouldHave` — `receipt.py:344`; `ReceiptResult.OBSERVED` — `receipt.py:256` | `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:778` | `test_T84_mode_is_refused_anywhere_but_the_top_level` | | "`ctrlrun stats` gives you the numbers" | `stats` — `cli/main.py:866`; 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:1565` | `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:1571` | `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 @@ -139,8 +139,8 @@ catalogue, `GUARANTEES` (`verify/guarantees.py:49`). |---|---|---| | "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:665`; 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:637`, decided inside the `BEGIN IMMEDIATE` of `_authorize_and_reserve` (`state.py:1196`) 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` — `_outcome` — `control.py:2054`. 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:596` — answers `deny` for a name the document does not list. Missing or malformed policy: `Policy.from_file` — `policy.py:753` — raises `PolicyError`, and there is no `Control` without a policy. Missing principal: `_refuse_no_principal` — `control.py:4626` | `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` — `_outcome` — `control.py:2065`. 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:655` — answers `deny` for a name the document does not list. Missing or malformed policy: `Policy.from_file` — `policy.py:812` — raises `PolicyError`, and there is no `Control` without a policy. Missing principal: `_refuse_no_principal` — `control.py:4677` | `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:84`); `contained_dimension` — `authority.py:946` — runs from `plan_delegation` (`authority.py:1500`) 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:240`; `Event` — `receipt.py:306`; the store is authoritative — `append_event` — `state.py:846`; the JSONL export — `JSONLEventSink` — `receipt.py:817` | `test_T11_every_demo_receipt_carries_every_field_in_the_spec`, `test_T11_every_demo_receipt_parses_back_into_a_Receipt` | @@ -152,22 +152,22 @@ catalogue, `GUARANTEES` (`verify/guarantees.py:49`). | "a unique index on the effect key and compare-and-set updates whose row counts are checked" | `reserve_effect` — `postgres.py:740` — `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:740` — 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:248` (refuse retry on `AMBIGUOUS`) and `_outcome` — `control.py:2054` (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:931`; `_resolve_lost_update` — `postgres.py:1557`; only `NotExecuted` maps to `FAILED` — `_outcome` — `control.py:2054` | `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 `_outcome` — `control.py:2065` (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:931`; `_resolve_lost_update` — `postgres.py:1557`; only `NotExecuted` maps to `FAILED` — `_outcome` — `control.py:2065` | `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:140` | `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:172`; who resolved it — `resolved_by` — `effect.py:208`; `resolve` — `cli/main.py:587` | `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:2702`; `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` | +| "the only thing besides a human permitted to move a record out of `AMBIGUOUS`" | `Control._reconciled` — `control.py:2719`; `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:596`; `Policy.from_file` — `policy.py:753`; `_refuse_no_principal` — `control.py:4626`; `_authorize_and_reserve` — `state.py:1196` | `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:123` | `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:1248`; `contained_dimension` — `authority.py:946`; `Control.revoke` — `control.py:4293` | `test_T67_a_principal_with_no_grant_is_denied`, `test_t76_each_dimension_violated_alone`, `test_t78_a_revoked_parent_denies_its_grandchild` | +| "Unknown action, missing policy, malformed policy, missing principal, missing or mismatched approval and inconsistent state are all `deny`." | `Policy.evaluate` — `policy.py:655`; `Policy.from_file` — `policy.py:812`; `_refuse_no_principal` — `control.py:4677`; `_authorize_and_reserve` — `state.py:1196` | `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:127` | `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:1248`; `contained_dimension` — `authority.py:946`; `Control.revoke` — `control.py:4344` | `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:208`; 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:734`, over `_canonical_policy` — `policy.py:963`; carried into the receipt by `_record` — `control.py:4517` | `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:724` | `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:1181`; `_spend_unneeded_approval` — `control.py:2642` | `test_T173_the_DENY_row_refuses_and_leaves_the_approval_granted`, `test_T173_the_ALLOW_row_invalidates_the_approval_it_did_not_need` | +| "every receipt records which policy decided it" | `Policy.policy_hash` — `policy.py:793`, over `_canonical_policy` — `policy.py:1041`; carried into the receipt by `_record` — `control.py:4568` | `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:783` | `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:1187`; `_spend_unneeded_approval` — `control.py:2659` | `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:533`; `prev_hash` — `receipt.py:452`; `GENESIS_HASH` — `receipt.py:125`; `put_receipt` takes the head row's lock first — `postgres.py:2092` | `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:935`; the six names — `CHAIN_BREAKS` — `receipt.py:872` | `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:633`, called from both stores' constructors; `HEAD` — `migrations.py:414` | `test_T147_a_v05_database_migrates_and_keeps_every_row`, `test_T150_reopening_does_not_rerun` | @@ -177,8 +177,8 @@ catalogue, `GUARANTEES` (`verify/guarantees.py:49`). | "`WebhookApprovalProvider` sends an approval request to a webhook, such as Slack, and takes the answer back through the same grant calls" | `WebhookApprovalProvider` — `webhook.py:141` — one signed POST on `APPROVAL_REQUESTED`; the inbound answer lands through `grant_approval` / `deny_approval` like the CLI's | `test_T27_the_outbound_post_carries_a_signature_over_the_exact_bytes_sent`, `test_T27_the_payload_carries_what_the_spec_names` | | "one OpenTelemetry span per action, one span event per step" | `OTelEventSink` — `otel.py:45` | `test_T29_one_action_produces_one_span_named_for_the_action`, `test_T29_every_event_becomes_a_span_event_named_by_its_type` | | "argument values stay out of it unless you ask for them" | `OTelEventSink(arguments=...)` — `otel.py:45` | `test_T29_argument_values_are_not_attributes_by_default` | -| "Receipts in a `ctrlrun.policy/v4` document can cite the `controls:` an action satisfies" | `PolicyControl` — `policy.py:327`; `Receipt` — `receipt.py:398` — carries `controls`; attribution only, never a decision | `test_T175_the_receipt_carries_the_union_of_the_action_and_the_matched_rule`, `test_T175_a_control_is_attribution_and_changes_no_decision` | -| "a rule can condition on the `data:` labels present in an action's arguments" | `DataLabel` — `policy.py:568`; `Policy.data_scope` — `policy.py:588`; `data_scope_in` in `v0.1 §3.2`'s grammar with no new operator | `test_T176_the_derived_set_is_the_labels_of_the_arguments_actually_supplied`, `test_T176_the_derived_set_drives_a_decision` | +| "Receipts in a `ctrlrun.policy/v4` document can cite the `controls:` an action satisfies" | `PolicyControl` — `policy.py:360`; `Receipt` — `receipt.py:398` — carries `controls`; attribution only, never a decision | `test_T175_the_receipt_carries_the_union_of_the_action_and_the_matched_rule`, `test_T175_a_control_is_attribution_and_changes_no_decision` | +| "a rule can condition on the `data:` labels present in an action's arguments" | `DataLabel` — `policy.py:627`; `Policy.data_scope` — `policy.py:647`; `data_scope_in` in `v0.1 §3.2`'s grammar with no new operator | `test_T176_the_derived_set_is_the_labels_of_the_arguments_actually_supplied`, `test_T176_the_derived_set_drives_a_decision` | ## What it can't, stated as limits @@ -190,7 +190,7 @@ The README also makes negative claims. They matter as much as the positive ones. | "CTRLRun is not a transaction manager: it rolls nothing back" | There is no compensation, saga or rollback code path in the package; an `AMBIGUOUS` effect is resolved by a human or a reconcile hook and never undone — `RECONCILED_STATES` — `effect.py` | | "The receipt chain detects alteration, and alteration is not authorship." | n/a — a disclaimer, and the scan that keeps it one: `test_T180_the_release_documents_do_not_blur_alteration_and_authorship` | | "erasing the end of the log costs two statements" | No code — this is what the chain does **not** cover, and it is asserted rather than argued: `test_erasing_a_suffix_and_rewinding_the_head_is_two_statements_and_undetected` | -| "CTRLRun does not detect prompt injection" | No code — and that is the point. Nothing in the package reads the agent's instructions: `Policy.evaluate` takes the action's name and arguments (`policy.py:596`) and `Authority` matches a grant against the action, so neither axis has the prompt to inspect. The README's problem table claims containment of the consequence, and this row is the sentence that stops it being read as detection. | `test_T6_an_action_name_is_matched_exactly`, `test_a_condition_naming_an_action_field_is_refused_at_load` | +| "CTRLRun does not detect prompt injection" | No code — and that is the point. Nothing in the package reads the agent's instructions: `Policy.evaluate` takes the action's name and arguments (`policy.py:655`) and `Authority` matches a grant against the action, so neither axis has the prompt to inspect. The README's problem table claims containment of the consequence, and this row is the sentence that stops it being read as detection. | `test_T6_an_action_name_is_matched_exactly`, `test_a_condition_naming_an_action_field_is_refused_at_load` | | "`ctrlrun verify` cannot see your executors" | `docs/verify.md`, "What it does not mean"; `THREAT_MODEL.md`, "Known v0.4 limitations" | | "`ctrlrun scan` … reports the consequential call sites and policy entries CTRLRun is **not** covering" and "has no score, no percentage and no badge" | `ctrlrun/scan/` reads the tree with `ast` and never imports it, resolves no principal, evaluates no policy and opens no store (SPEC-scan §9.2); the limits sentence is emitted on every run including a clean one, and no percentage is computed anywhere | `test_T194_scan_never_imports_the_tree_it_reads`, `test_T205_scan_resolves_no_principal_evaluates_no_policy_and_opens_no_store`, `test_T203_the_limits_sentence_is_in_every_run_including_a_clean_one` | | "`ctrlrun mcp-operator` … It authenticates who answered and records it; it does not check that they were entitled to." | the write tools refuse without a principal and attribute the answer to the verified one; there is no entitlement check, and `docs/SPEC-mcp-operator.md` §10 says so | `test_T184_approve_refuses_without_a_principal`, `test_T184_approve_succeeds_with_one_and_is_attributed`, `test_T183_there_is_no_flag_that_permits_a_remote_bind` | @@ -204,7 +204,7 @@ The README also makes negative claims. They matter as much as the positive ones. | "automatic at open and forward-only, with no flag that opens a database un-migrated. An older binary against a newer schema refuses immediately." | `migrate` — `migrations.py:633` — called from both stores' constructors; `_refuse` — `migrations.py:559` — raises `SchemaMismatch` on a newer `user_version` | `test_T147_a_v05_database_migrates_and_keeps_every_row`, `test_T148_an_older_binary_refuses_a_newer_database`, `test_T152b_no_flag_opens_a_database_without_migrating` | | "an edit, a deletion from the middle or a reordering is detected and named by `seq`" | `verify_chain` — `receipt.py:450` — and the six break names in `CHAIN_BREAKS` | `test_T164_an_altered_receipt_is_content_altered_at_its_seq`, `test_T164_reordering_two_receipts_is_detected_either_way`, `test_the_verify_chain_flag_reports_a_break_by_seq_and_by_name` | | "It detects **alteration**, which is not authorship: receipts are not signed." | No signing code, and a release scan keeps the vocabulary out | `test_T180_the_release_documents_do_not_blur_alteration_and_authorship` | -| "every receipt records the policy that decided it, so a receipt from six months ago says what the rules were" | `Policy.policy_hash` — `policy.py:734` — over the parsed decision inputs, recorded on the receipt | `test_T172_every_receipt_carries_the_hash_and_the_declared_version`, `test_T171_any_decision_input_changes_the_hash`, `test_T171_the_declared_version_alone_does_not_change_the_hash` | +| "every receipt records the policy that decided it, so a receipt from six months ago says what the rules were" | `Policy.policy_hash` — `policy.py:793` — over the parsed decision inputs, recorded on the receipt | `test_T172_every_receipt_carries_the_hash_and_the_declared_version`, `test_T171_any_decision_input_changes_the_hash`, `test_T171_the_declared_version_alone_does_not_change_the_hash` | ## The docs site: Home and Concepts @@ -228,7 +228,7 @@ restating the code; the ones that are new to the site carry their own code and p | `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:4569` | +| `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:4620` | | `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 69f2a22..c82dd89 100644 --- a/docs/OWASP-AGENTIC-TOP10.md +++ b/docs/OWASP-AGENTIC-TOP10.md @@ -89,6 +89,7 @@ mechanism, not the entry. | **G24** grant refused off its task | A grant may name `tasks:`, the unit of work the authority is for, attenuated by the same `child ⊆ parent` rule as actions, resources and environments. An action bound to a task the grant does not cover is refused by reason and not by exception type. A grant that names no `tasks:` authorises any task, so every grant written before v0.9 keeps its meaning. | `ASI01:2026` (partly), `ASI03:2026` (partly), `ASI10:2026` (partly) | **Task binding limits blast radius; it does not detect a hijack.** The task id is supplied by the caller, so an agent that has been talked into a different goal will often still be operating inside the task it was legitimately given, and the kernel cannot tell a hijacked run from an honest one. What it stops is a credential or a delegation issued for one unit of work being spent on another, which is the shape a stolen grant most often takes. The task never enters the action hash, so binding one changes no existing receipt. | | **G25** a hop narrows or it is refused | When one agent hands work to another across a boundary the kernel does not control, what the receiver holds is a *hop*: a delegation record in the store, narrowed on every dimension the issuing grant constrains, refused by name on the dimension it would widen, and with nothing inherited by omission. An action proposed under a hop is evaluated against that hop's grant alone, with no fallback to any other grant the receiver happens to hold, and what it spends is the issuing agent's budget, so a hop never creates a second root. No flag admits a wider hop. | `ASI03:2026` (partly), `ASI07:2026` (partly), `ASI10:2026` (partly) | This is the multi-agent shape of the containment rule that has bounded delegation since v0.3: the boundary changes nothing about what a receiver may do. What it stops is privilege growing as work moves between agents, and an agent two hops down holding more than the root granted. Two limits. **The kernel decides which grant a hop-borne action is judged by; it cannot compel a receiver to present its hop.** A receiver that holds a grant of its own and proposes the action without naming the hop is judged by that grant, and the receipt records which of the two happened. And **a hop does not cross stores**: agents whose stores differ get a refusal by name, not a weaker check. | | **G26** a hop is named on both sides | Both ends of a hop are in the evidence. The receiver's receipt records the hop it ran under, and the issuer's `DELEGATION_CREATED` event names the hop it created and, when the issuer supplied it, the action that created it, so the same hop id is found from either side's records without the other side's cooperation. Who the receiver is comes from the deployment's `IdentityProvider` and is never read off the payload. | `ASI07:2026` (partly), `ASI03:2026` (partly), `ASI10:2026` (partly) | What crosses the boundary is a reference of two strings to a record in the store, not a claim in a token, so there is nothing to forge, replay or widen in transit and nothing to verify but the record. Two limits. **The channel is not CTRLRun's**: it defines no wire format, no agent card and no transport, so how the reference travels and whether the transport authenticates its peers is the deployment's, and a caller the `IdentityProvider` resolves as the receiving principal *is* that principal. And **a relay's created hop is tied to the action that created it only when the relay supplies `action_id`**; the kernel refuses to infer the link, because an inferred one fails in the evidence direction. | +| **G27** a swapped upstream is denied | An action entry may pin the gateway's upstream: the SHA-256 of its TLS leaf certificate, the certificate itself as the connection's only trust anchor, and the digest of the tool's `tools/list` entry. At decision time the pin is compared against what this process last observed for that upstream, and the action is denied `upstream_mismatch` where it differs and `upstream_unverified` where nothing has been observed, so a pin never switches itself off by being unobserved. A pinned certificate makes a swapped server fail the handshake before the first request byte; a mismatch at startup refuses to start. | `ASI02:2026` (partly), `ASI07:2026` (partly) | The sharp case is an approved action name with a different server, or a different tool schema, underneath it: every grant still matches and the receipt still says `stripe.refund`. This closes that one shape and not the category. The decision-time check attributes and the handshake check prevents: the gateway forwards after deciding, so a swap timed between the two is caught at the handshake, not by the DENY. Three limits. **Only the gateway can pin**, because pinning means holding the connection; the in-process decorator and the ACS hook refuse rather than pretend. **A digest-only pin gets no handshake check**, and the startup line says so. And **`ASI04` keeps its verdict below**: nothing here inspects a package, a model, a registry, a build or a signature chain. | --- @@ -98,7 +99,7 @@ The half that makes the table above credible. One honest sentence each; nothing | Entry | Title | Why not | |---|---|---| -| `ASI04:2026` | Agentic Supply Chain Vulnerabilities | Out of scope. CTRLRun never inspects a package, a model, a tool registry or an MCP server's provenance; it decides actions, and a poisoned dependency reaches it as an ordinary caller. | +| `ASI04:2026` | Agentic Supply Chain Vulnerabilities | Out of scope. CTRLRun never inspects a package, a model, a tool registry or an MCP server's provenance; it decides actions, and a poisoned dependency reaches it as an ordinary caller. Since v0.10, G27 covers one slice and no more: an action entry that pins its upstream's certificate or its tool's schema is denied against a server that is not the pinned one. | | `ASI05:2026` | Unexpected Code Execution | Out of scope. Nothing here sandboxes an interpreter or constrains what a process may run. CTRLRun sits between an agent and one remote effect, not between an agent and its own runtime. | And the four entries where the mapping above is **partial**, with the part that is not covered diff --git a/docs/cookbook/verify-in-github-actions.mdx b/docs/cookbook/verify-in-github-actions.mdx index b2b5b89..7c764c0 100644 --- a/docs/cookbook/verify-in-github-actions.mdx +++ b/docs/cookbook/verify-in-github-actions.mdx @@ -103,11 +103,12 @@ G23 a failing scope provider refuses N/A no action this configuration admits G24 grant refused off its task N/A no authority section G25 a hop narrows or it is refused N/A no authority section G26 a hop is named on both sides N/A no authority section +G27 a swapped upstream is denied N/A no action entry pins an upstream (a token is unique only as far as your effect keys are: two stores sharing a provider account must not produce the same effect-key string for different effects, and nothing here can check that) -15/15 declared guarantees pass. 11 not applicable: G8, G9, G13, G15, G17, G19, G22, G23, G24, G25, G26. +15/15 declared guarantees pass. 12 not applicable: G8, G9, G13, G15, G17, G19, G22, G23, G24, G25, G26, G27. ``` 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 6b40170..2ae0649 100644 --- a/docs/guides/verify-in-ci.mdx +++ b/docs/guides/verify-in-ci.mdx @@ -73,11 +73,12 @@ guarantees pass. G24 grant refused off its task N/A no authority section G25 a hop narrows or it is refused N/A no authority section G26 a hop is named on both sides N/A no authority section + G27 a swapped upstream is denied N/A no action entry pins an upstream (a token is unique only as far as your effect keys are: two stores sharing a provider account must not produce the same effect-key string for different effects, and nothing here can check that) - 15/15 declared guarantees pass. 11 not applicable: G8, G9, G13, G15, G17, G19, G22, G23, G24, G25, G26. + 15/15 declared guarantees pass. 12 not applicable: G8, G9, G13, G15, G17, G19, G22, G23, G24, G25, G26, G27. ``` The first line is on **stderr**, from G7's own scenario: an action with no principal is diff --git a/docs/production/index.mdx b/docs/production/index.mdx index 2f29bee..695605d 100644 --- a/docs/production/index.mdx +++ b/docs/production/index.mdx @@ -28,8 +28,8 @@ need. `test_the_first_line_of_the_section_says_which_store_and_why` asserts the {/* generated from the suite, pyproject and the soak (full) — run the generator */} - **Version 0.9.0**, on [PyPI](https://pypi.org/project/ctrlrun/), Python 3.11 and later. -- **6,005 tests**, every version specified before it was written and every requirement mutation-tested. [Read more](/docs/how-this-is-built). -- **26 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). +- **6,019 tests**, every version specified before it was written and every requirement mutation-tested. [Read more](/docs/how-this-is-built). +- **27 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/reference/api/Condition.mdx b/docs/reference/api/Condition.mdx index 78d1465..8d591f6 100644 --- a/docs/reference/api/Condition.mdx +++ b/docs/reference/api/Condition.mdx @@ -5,7 +5,7 @@ description: "One `_: operand` test against an action's arguments {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.Condition` — class, defined at `src/ctrlrun/policy.py:371` +`ctrlrun.Condition` — class, defined at `src/ctrlrun/policy.py:398` ```python from ctrlrun import Condition diff --git a/docs/reference/api/Control.mdx b/docs/reference/api/Control.mdx index 05e37e5..4786a20 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:717` +`ctrlrun.Control` — class, defined at `src/ctrlrun/control.py:718` ```python from ctrlrun import Control @@ -14,7 +14,7 @@ from ctrlrun import Control ```python class Control - def __init__(policy: Policy, store: StateStore, approvals: ApprovalProvider | None = None, *, clock: Callable[[], datetime] = _utc_now, approval_ttl: timedelta = DEFAULT_APPROVAL_TTL, lease: timedelta = DEFAULT_LEASE, sinks: Sequence[EventSink] = (), suspend_timeout: timedelta = DEFAULT_SUSPEND_TIMEOUT, identity: IdentityProvider | None = None, authority: Authority | None = None, environment: str | None = None, approver_identity: ApproverIdentity | None = None, require_approved_policy: bool = False) + def __init__(policy: Policy, store: StateStore, approvals: ApprovalProvider | None = None, *, clock: Callable[[], datetime] = _utc_now, approval_ttl: timedelta = DEFAULT_APPROVAL_TTL, lease: timedelta = DEFAULT_LEASE, sinks: Sequence[EventSink] = (), suspend_timeout: timedelta = DEFAULT_SUSPEND_TIMEOUT, identity: IdentityProvider | None = None, authority: Authority | None = None, environment: str | None = None, approver_identity: ApproverIdentity | None = None, require_approved_policy: bool = False, upstream: str | None = None) ``` Policy, state and evidence composed around a single action (SPEC-v0.1 §8). diff --git a/docs/reference/api/Decision.mdx b/docs/reference/api/Decision.mdx index c3de473..758e88a 100644 --- a/docs/reference/api/Decision.mdx +++ b/docs/reference/api/Decision.mdx @@ -5,7 +5,7 @@ description: "What may happen to an action: exactly three outcomes in v0.1 (SPEC {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.Decision` — class, defined at `src/ctrlrun/policy.py:308` +`ctrlrun.Decision` — class, defined at `src/ctrlrun/policy.py:335` ```python from ctrlrun import Decision diff --git a/docs/reference/api/Policy.mdx b/docs/reference/api/Policy.mdx index 8189ebd..1e5ea45 100644 --- a/docs/reference/api/Policy.mdx +++ b/docs/reference/api/Policy.mdx @@ -5,7 +5,7 @@ description: "Action-level autonomy policy: which actions may run, and under whi {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.Policy` — class, defined at `src/ctrlrun/policy.py:700` +`ctrlrun.Policy` — class, defined at `src/ctrlrun/policy.py:759` ```python from ctrlrun import Policy diff --git a/docs/reference/api/context.mdx b/docs/reference/api/context.mdx index da74139..094bd35 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:362` +`ctrlrun.context` — function, defined at `src/ctrlrun/control.py:363` ```python from ctrlrun import context diff --git a/docs/reference/api/idempotency_token.mdx b/docs/reference/api/idempotency_token.mdx index db6d8f2..92d9b36 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:330` +`ctrlrun.idempotency_token` — function, defined at `src/ctrlrun/control.py:331` ```python from ctrlrun import idempotency_token diff --git a/docs/reference/api/parse_conditions.mdx b/docs/reference/api/parse_conditions.mdx index ef816d6..c9eee51 100644 --- a/docs/reference/api/parse_conditions.mdx +++ b/docs/reference/api/parse_conditions.mdx @@ -5,7 +5,7 @@ description: "Parse a `when:`-shaped mapping into conditions, keyed by the raw c {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.parse_conditions` — function, defined at `src/ctrlrun/policy.py:1248` +`ctrlrun.parse_conditions` — function, defined at `src/ctrlrun/policy.py:1326` ```python from ctrlrun import parse_conditions diff --git a/docs/reference/api/protect.mdx b/docs/reference/api/protect.mdx index d121baa..d9844a9 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:4744` +`ctrlrun.protect` — function, defined at `src/ctrlrun/control.py:4795` ```python from ctrlrun import protect diff --git a/docs/reference/api/with_approval.mdx b/docs/reference/api/with_approval.mdx index 529c276..c4cdc67 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:385` +`ctrlrun.with_approval` — function, defined at `src/ctrlrun/control.py:386` ```python from ctrlrun import with_approval diff --git a/docs/reference/policy-yaml.mdx b/docs/reference/policy-yaml.mdx index 4ba5143..104265a 100644 --- a/docs/reference/policy-yaml.mdx +++ b/docs/reference/policy-yaml.mdx @@ -59,6 +59,7 @@ Exactly one of `decision` and `rules`, plus the optional keys. | `effect` | template | v2 | no reservation: the action runs unguarded against duplicates, and the gateway names it on the line that starts it | `"refund:{payment_id}"`; every placeholder must resolve from the arguments or the action is refused before it runs | | `resource` | template | v2 | no resource in the hash, and a grant's `resources:` cannot match | `"payment:{payment_id}"`; the decorator's `resource=` wins where both exist | | `mcp` | mapping with one key, `not_executed_on_error` (bool) | v2 | `false` | gateway only: whether an upstream's in-band error means the tool did nothing (`FAILED`) or nobody knows (`AMBIGUOUS`). Say `true` only for an upstream that rejects before acting | +| `upstream` | mapping with up to three keys: `tls_cert_sha256` (list of `sha256:…` strings, the upstream's leaf certificate in DER form), `tls_cert_file` (path to a PEM file, one or more certificates), `tool_schema_sha256` (one `sha256:…` string, the tool's `tools/list` entry canonicalised) | v8 | no pin: the action is authorised against whatever answers at the upstream's name | gateway only. Checked in three places for one rule: at startup, where a mismatch refuses to start and prints the observed hash beside the pinned one; at decision time, where the action is denied `upstream_mismatch` when what this process last observed differs from the pin and `upstream_unverified` when nothing has been observed yet, so a pin never switches itself off by being unobserved; and at the handshake, where `tls_cert_file`'s certificates are the connection's only trust anchors and a swapped server fails before the first request byte. A digest-only pin gets the first two and not the third, and the startup line says so. The in-process decorator and the ACS hook hold no connection and cannot pin: the hook refuses at construction, the decorator denies `upstream_unverified` | | `controls` | list of ids | v4 | none | cited on every receipt for this action, in registry order | | `data` | map of argument name → label, either a bare string or a mapping with the one key `label` | v4 | no labels; `data_scope` is empty | labels the arguments carry, for the `data_scope` condition below | | `approvals_required` | integer, at least 1 | v6 | one approval, exactly as before v6 | how many **distinct verified principals** must answer before the approval is consumable. A second yes from a principal that already answered is recorded, moves that entry's timestamp, and does not move the count. The count is decided inside the store's own write on all three backends, never by a read followed by a write. Above `1` in a deployment that configures no approver identity is a **denial**, not a silent downgrade: the kernel cannot tell two anonymous yeses apart. `ctrlrun approve` records no verified approver, so it never counts toward one, and says so when it is used. Inside the policy hash | @@ -155,6 +156,8 @@ reader would have silently misread is one it refuses instead. | `ctrlrun.policy/v4` | `version`, `controls` at the top; `controls`, `data` on an entry and a rule | cite ids nothing defines, or evaluate a `data_scope` rule against nothing | | `ctrlrun.policy/v5` | `max_attempts` on an entry | bound renewal after `FAILED` on one effect key | | `ctrlrun.policy/v6` | `approver_role` on a control entry; `approvals_required` on an action entry; `break_glass` in the authority section | gate nobody while reporting a deployment as checking entitlement, consume on the first grant where several were required, or read an envelope as an ordinary grant | +| `ctrlrun.policy/v7` | `tasks`, `budgets` on a grant in the authority section | authorise a task the grant does not cover, or spend with no ceiling | +| `ctrlrun.policy/v8` | `upstream` on an entry | authorise the action against any server at all, which is the whole of what the key restricts | That is why the key sets are closed by version: each key is refused by every reader that would misread it. diff --git a/docs/verify.md b/docs/verify.md index 5906b05..1358a4a 100644 --- a/docs/verify.md +++ b/docs/verify.md @@ -59,11 +59,12 @@ G23 a failing scope provider refuses PASS stripe.refund G24 grant refused off its task PASS head-of-support G25 a hop narrows or it is refused PASS head-of-support G26 a hop is named on both sides PASS head-of-support +G27 a swapped upstream is denied N/A no action entry pins an upstream (a token is unique only as far as your effect keys are: two stores sharing a provider account must not produce the same effect-key string for different effects, and nothing here can check that) -24/24 declared guarantees pass. 2 not applicable: G13, G15. +24/24 declared guarantees pass. 3 not applicable: G13, G15, G27. ``` It reads the policy document — `$CTRLRUN_CONFIG`, else `./ctrlrun.yaml` — and the authority diff --git a/generated/readiness.full.mdx b/generated/readiness.full.mdx index ca398eb..148d2bb 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.9.0**, on [PyPI](https://pypi.org/project/ctrlrun/), Python 3.11 and later. -- **6,005 tests**, every version specified before it was written and every requirement mutation-tested. [Read more](/docs/how-this-is-built). -- **26 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). +- **6,019 tests**, every version specified before it was written and every requirement mutation-tested. [Read more](/docs/how-this-is-built). +- **27 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 dd2787d..efbacac 100644 --- a/generated/readiness.json +++ b/generated/readiness.json @@ -1,5 +1,5 @@ { - "guarantees": 26, + "guarantees": 27, "released": "0.9.0", "soak": { "actions": 889735, @@ -9,6 +9,6 @@ "positive_control": true, "unexplained": 0 }, - "tests": 6005, + "tests": 6019, "version": "0.9.0" } diff --git a/generated/readiness.mdx b/generated/readiness.mdx index fce776d..a6e6032 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.9.0**, on [PyPI](https://pypi.org/project/ctrlrun/), Python 3.11 and later. -- **6,005 tests**, every version specified before it was written and every requirement mutation-tested. -- **26 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. +- **6,019 tests**, every version specified before it was written and every requirement mutation-tested. +- **27 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 b499e95..e8d11b7 100644 --- a/generated/readiness.readme.md +++ b/generated/readiness.readme.md @@ -1,7 +1,7 @@ - **Version 0.9.0**, on [PyPI](https://pypi.org/project/ctrlrun/), Python 3.11 and later. -- **6,005 tests**, every version specified before it was written and every requirement mutation-tested. -- **26 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. +- **6,019 tests**, every version specified before it was written and every requirement mutation-tested. +- **27 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. From 47d31b2d8b8711f538a00420fcf223ecaca4f4a2 Mon Sep 17 00:00:00 2001 From: arpan Date: Mon, 14 Sep 2026 01:00:04 +0530 Subject: [PATCH 4/7] Pages for v0.10 item 4: one declared order, walked by both modes Regenerated against CTRLRun/ctrlrun's v0.10/4-one-ordered-list, the branch of the same name. Rendered only: API pages whose line numbers moved with the refactor of control.py, the readiness block (6,033 tests) and its two copies, CLAIMS.md repointed (48 citations, none unresolved). Item 4 adds no key, no guarantee and no schema, so nothing here is written by hand. Signed-off-by: arpan --- docs.mdx | 2 +- docs/CLAIMS.md | 64 ++++++++++++------------ docs/production/index.mdx | 2 +- docs/reference/api/Authority.mdx | 2 +- docs/reference/api/Control.mdx | 2 +- docs/reference/api/context.mdx | 2 +- docs/reference/api/idempotency_token.mdx | 2 +- docs/reference/api/protect.mdx | 2 +- docs/reference/api/with_approval.mdx | 2 +- docs/reference/cli.mdx | 2 + generated/readiness.full.mdx | 2 +- generated/readiness.json | 2 +- generated/readiness.mdx | 2 +- generated/readiness.readme.md | 2 +- 14 files changed, 46 insertions(+), 44 deletions(-) diff --git a/docs.mdx b/docs.mdx index 6c0b268..1249bb4 100644 --- a/docs.mdx +++ b/docs.mdx @@ -219,7 +219,7 @@ the framework's own interrupt, and a framework with no such primitive does not n {/* generated from the suite, pyproject and the soak (mdx) — run the generator */} - **Version 0.9.0**, on [PyPI](https://pypi.org/project/ctrlrun/), Python 3.11 and later. -- **6,019 tests**, every version specified before it was written and every requirement mutation-tested. +- **6,033 tests**, every version specified before it was written and every requirement mutation-tested. - **27 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). diff --git a/docs/CLAIMS.md b/docs/CLAIMS.md index c4449d7..cc3a8c5 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:1187` — 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:1294` — 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:655` — passes only the action's **name and arguments** to `_ActionPolicy.evaluate` (`policy.py:655`), 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:251`) 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:248`. Exactly as approved: the approval is bound to `action_hash` and consumed with the reservation — `_authorize_and_reserve` — `state.py:1196`. Or not at all: a refusal raises before the executor — `Control.execute` — `control.py:1187`. Says so instead of guessing: only `NotExecuted` maps to `FAILED` — `_outcome` — `control.py:2065` — and everything else is `AMBIGUOUS`. A receipt: `Receipt` — `receipt.py:250`. **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:1196`. Or not at all: a refusal raises before the executor — `Control.execute` — `control.py:1294`. Says so instead of guessing: only `NotExecuted` maps to `FAILED` — `_outcome` — `control.py:2172` — and everything else is `AMBIGUOUS`. A receipt: `Receipt` — `receipt.py:250`. **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:1476` — 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` — `_outcome` — `control.py:1194`; 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:587` — and `Control._reconciled` — `control.py:2719` | `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` — `_outcome` — `control.py:1301`; 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:589` — and `Control._reconciled` — `control.py:2826` | `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,24 +50,24 @@ 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` — `_outcome` — `control.py:2065`; `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` | +| "A lost reply is `AMBIGUOUS`, never `FAILED`, and a retry against an `AMBIGUOUS` effect is refused." | Only `NotExecuted` maps to `FAILED` — `_outcome` — `control.py:2172`; `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:637`; `PostgresStateStore.reserve_effect` — `postgres.py:740` | `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:1196` | `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:655` | `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:1187` | `test_T74_a_denial_leaves_no_pending_approval_request` | -| "Neither axis reads the agent's instructions" | `Policy.evaluate` — `policy.py:655` — sees the action's name and arguments; `Authority.evaluate` — `authority.py:1256` — 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` | +| "Authority first ... then policy" / "authority first" | `Control.execute` evaluates authority before policy and a denial appends `AUTHORITY_DENIED` and never `POLICY_EVALUATED` — `control.py:1294` | `test_T74_a_denial_leaves_no_pending_approval_request` | +| "Neither axis reads the agent's instructions" | `Policy.evaluate` — `policy.py:655` — sees the action's name and arguments; `Authority.evaluate` — `authority.py:1294` — 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:79` | `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:1196` — 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`." | `_outcome` — `control.py:2065`; `NotExecuted` — `errors.py:157` | `test_T1_a_lost_response_leaves_the_effect_ambiguous` | +| "Only `NotExecuted`, raised by you, means `FAILED`." | `_outcome` — `control.py:2172`; `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:793`; `prev_hash`, `GENESIS_HASH` for the first — `receipt.py:125` | `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:4795`) 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:353` | 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:377`; `with_approval` — `control.py:387`; `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` | +| "You probably do not need an adapter" | Three ways in, and `@protect` (`control.py:4902`) 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:355` | 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:377`; `with_approval` — `control.py:392`; `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:397`; `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` | @@ -89,20 +89,20 @@ 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:655` | `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:79` | `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:655` — 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:1248`; `Control._authority_result` — `control.py:1010` | `test_T67_a_principal_with_no_grant_is_denied` | +| "the second axis, `authority:`" | `Authority.evaluate` — `authority.py:1286`; `Control._authority_result` — `control.py:1010` | `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:84`), 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:4007`; `Authority.plan_delegation` — `authority.py:1500`; `ctrlrun delegate` — `cli/main.py:1045` | `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:946` — runs from `plan_delegation` (`authority.py:1500`) **and** from the chain walk in `Authority.evaluate` (`authority.py:1256`) | `test_t76_each_dimension_violated_alone`, `test_t77b_a_narrowed_parent_narrows_its_children` | -| "provably a subset of its parent on every dimension, at creation and again at every evaluation; a consequence budget is consumed inside the reservation's own transaction, and a rolling window bounds what may start rather than recalling what already did" | Containment as in the row above. The budget: `check_charges` — `state.py:569` — is evaluated inside `reserve_effect`'s own transaction on all three backends, and `_charges_for` — `authority.py:1355` — charges every ancestor in the chain. The window is rolling and bounds the next reserve only: `_spent` sums `[now - window, now]` and nothing reads it again after a reservation is taken | `test_T408_a_charge_and_its_reservation_are_one_transaction`, `test_T409_N_processes_racing_one_budget_spend_at_most_the_limit`, `test_T412b_every_ancestor_is_charged_through_a_real_chain`, `test_T408c_the_rolling_window_forgets` | -| "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:946`; the subject half is `_subject_contained` (`authority.py:1025`) | `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:4344` — writes one row — `revoke_delegation` — `state.py:835` 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:778`; `Control._observed` — `control.py:1571`; `_WouldHave` — `receipt.py:344`; `ReceiptResult.OBSERVED` — `receipt.py:256` | `test_T82_observe_executes_what_enforce_would_deny`, `test_T83_a_duplicate_is_recorded_and_still_runs` | +| "narrow it at runtime with `ctrlrun delegate`" | `Control.delegate` — `control.py:4114`; `Authority.plan_delegation` — `authority.py:1538`; `ctrlrun delegate` — `cli/main.py:1045` | `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:984` — runs from `plan_delegation` (`authority.py:1538`) **and** from the chain walk in `Authority.evaluate` (`authority.py:1294`) | `test_t76_each_dimension_violated_alone`, `test_t77b_a_narrowed_parent_narrows_its_children` | +| "provably a subset of its parent on every dimension, at creation and again at every evaluation; a consequence budget is consumed inside the reservation's own transaction, and a rolling window bounds what may start rather than recalling what already did" | Containment as in the row above. The budget: `check_charges` — `state.py:569` — is evaluated inside `reserve_effect`'s own transaction on all three backends, and `_charges_for` — `authority.py:1393` — charges every ancestor in the chain. The window is rolling and bounds the next reserve only: `_spent` sums `[now - window, now]` and nothing reads it again after a reservation is taken | `test_T408_a_charge_and_its_reservation_are_one_transaction`, `test_T409_N_processes_racing_one_budget_spend_at_most_the_limit`, `test_T412b_every_ancestor_is_charged_through_a_real_chain`, `test_T408c_the_rolling_window_forgets` | +| "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:984`; the subject half is `_subject_contained` (`authority.py:1063`) | `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:4451` — writes one row — `revoke_delegation` — `state.py:835` 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:778`; `Control._observed` — `control.py:1678`; `_WouldHave` — `receipt.py:344`; `ReceiptResult.OBSERVED` — `receipt.py:256` | `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:778` | `test_T84_mode_is_refused_anywhere_but_the_top_level` | -| "`ctrlrun stats` gives you the numbers" | `stats` — `cli/main.py:866`; 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:1571` | `test_T82_observe_executes_what_enforce_would_deny`, `test_T83_an_executor_that_fails_on_a_held_key_still_writes_the_record` | +| "`ctrlrun stats` gives you the numbers" | `stats` — `cli/main.py:915`; 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:1678` | `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 @@ -139,9 +139,9 @@ catalogue, `GUARANTEES` (`verify/guarantees.py:49`). |---|---|---| | "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:665`; 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:637`, decided inside the `BEGIN IMMEDIATE` of `_authorize_and_reserve` (`state.py:1196`) 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` — `_outcome` — `control.py:2065`. 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:655` — answers `deny` for a name the document does not list. Missing or malformed policy: `Policy.from_file` — `policy.py:812` — raises `PolicyError`, and there is no `Control` without a policy. Missing principal: `_refuse_no_principal` — `control.py:4677` | `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:84`); `contained_dimension` — `authority.py:946` — runs from `plan_delegation` (`authority.py:1500`) and from the chain walk in `Authority.evaluate` | `test_T67_a_principal_with_no_grant_is_denied`, `test_t76_each_dimension_violated_alone` | +| "An unknown outcome is AMBIGUOUS, never FAILED, and blocks a blind retry." | Only `NotExecuted` maps to `FAILED` — `_outcome` — `control.py:2172`. 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:655` — answers `deny` for a name the document does not list. Missing or malformed policy: `Policy.from_file` — `policy.py:812` — raises `PolicyError`, and there is no `Control` without a policy. Missing principal: `_refuse_no_principal` — `control.py:4784` | `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:84`); `contained_dimension` — `authority.py:984` — runs from `plan_delegation` (`authority.py:1538`) 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:240`; `Event` — `receipt.py:306`; the store is authoritative — `append_event` — `state.py:846`; the JSONL export — `JSONLEventSink` — `receipt.py:817` | `test_T11_every_demo_receipt_carries_every_field_in_the_spec`, `test_T11_every_demo_receipt_parses_back_into_a_Receipt` | ## What it guarantees @@ -152,28 +152,28 @@ catalogue, `GUARANTEES` (`verify/guarantees.py:49`). | "a unique index on the effect key and compare-and-set updates whose row counts are checked" | `reserve_effect` — `postgres.py:740` — `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:740` — 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:248` (refuse retry on `AMBIGUOUS`) and `_outcome` — `control.py:2065` (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:931`; `_resolve_lost_update` — `postgres.py:1557`; only `NotExecuted` maps to `FAILED` — `_outcome` — `control.py:2065` | `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 `_outcome` — `control.py:2172` (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:931`; `_resolve_lost_update` — `postgres.py:1557`; only `NotExecuted` maps to `FAILED` — `_outcome` — `control.py:2172` | `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:140` | `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:172`; who resolved it — `resolved_by` — `effect.py:208`; `resolve` — `cli/main.py:587` | `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:2719`; `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:589` | `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:2826`; `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:655`; `Policy.from_file` — `policy.py:812`; `_refuse_no_principal` — `control.py:4677`; `_authorize_and_reserve` — `state.py:1196` | `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:655`; `Policy.from_file` — `policy.py:812`; `_refuse_no_principal` — `control.py:4784`; `_authorize_and_reserve` — `state.py:1196` | `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:127` | `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:1248`; `contained_dimension` — `authority.py:946`; `Control.revoke` — `control.py:4344` | `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:1286`; `contained_dimension` — `authority.py:984`; `Control.revoke` — `control.py:4451` | `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:208`; 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:793`, over `_canonical_policy` — `policy.py:1041`; carried into the receipt by `_record` — `control.py:4568` | `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:793`, over `_canonical_policy` — `policy.py:1041`; carried into the receipt by `_record` — `control.py:4675` | `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:783` | `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:1187`; `_spend_unneeded_approval` — `control.py:2659` | `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:1294`; `_spend_unneeded_approval` — `control.py:2766` | `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:533`; `prev_hash` — `receipt.py:452`; `GENESIS_HASH` — `receipt.py:125`; `put_receipt` takes the head row's lock first — `postgres.py:2092` | `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:935`; the six names — `CHAIN_BREAKS` — `receipt.py:872` | `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:633`, called from both stores' constructors; `HEAD` — `migrations.py:414` | `test_T147_a_v05_database_migrates_and_keeps_every_row`, `test_T150_reopening_does_not_rerun` | | "An older binary against a newer schema refuses immediately" | `_refuse` — `migrations.py:559`; `SchemaMismatch` — `errors.py` | `test_T148_an_older_binary_refuses_a_newer_database`, `test_T148_no_other_table_is_read_before_the_refusal` | | "Releases carry PyPI provenance attestations from GitHub Actions" | `.github/workflows/publish.yml` — `pypa/gh-action-pypi-publish` pinned at v1.14.2, which generates and uploads PEP 740 attestations by default since v1.11.0 (its release notes, read 2026-09-06), with no `attestations: false`; the `pypi` job's only permission is `id-token: write` | `test_the_publish_workflow_attests_through_trusted_publishing`, `test_every_action_is_pinned_to_a_commit` | -| "`ctrlrun approve`, `deny`, `resolve`, `inspect`, `receipts` and `stats` work from the shell against any store" | `approve` — `cli/main.py:377`; `receipts` — `cli/main.py:455`; `effects` — `cli/main.py:529`; `resolve` — `cli/main.py:587`; `inspect` — `cli/main.py:626`; `stats` — `cli/main.py:866`; every one takes `--store-url` (SPEC-v0.6 §9.4) | `test_T10_resolve_failed_permits_a_retry`, `test_T18_inspect_json_emits_the_inspection_schema`, `test_T86_stats_counts_what_observe_mode_recorded`, `test_verify_chain_reads_a_postgres_store_through_store_url` | +| "`ctrlrun approve`, `deny`, `resolve`, `inspect`, `receipts` and `stats` work from the shell against any store" | `approve` — `cli/main.py:379`; `receipts` — `cli/main.py:457`; `effects` — `cli/main.py:531`; `resolve` — `cli/main.py:589`; `inspect` — `cli/main.py:633`; `stats` — `cli/main.py:915`; every one takes `--store-url` (SPEC-v0.6 §9.4) | `test_T10_resolve_failed_permits_a_retry`, `test_T18_inspect_json_emits_the_inspection_schema`, `test_T86_stats_counts_what_observe_mode_recorded`, `test_verify_chain_reads_a_postgres_store_through_store_url` | | "`WebhookApprovalProvider` sends an approval request to a webhook, such as Slack, and takes the answer back through the same grant calls" | `WebhookApprovalProvider` — `webhook.py:141` — one signed POST on `APPROVAL_REQUESTED`; the inbound answer lands through `grant_approval` / `deny_approval` like the CLI's | `test_T27_the_outbound_post_carries_a_signature_over_the_exact_bytes_sent`, `test_T27_the_payload_carries_what_the_spec_names` | | "one OpenTelemetry span per action, one span event per step" | `OTelEventSink` — `otel.py:45` | `test_T29_one_action_produces_one_span_named_for_the_action`, `test_T29_every_event_becomes_a_span_event_named_by_its_type` | | "argument values stay out of it unless you ask for them" | `OTelEventSink(arguments=...)` — `otel.py:45` | `test_T29_argument_values_are_not_attributes_by_default` | @@ -228,7 +228,7 @@ restating the code; the ones that are new to the site carry their own code and p | `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:4620` | +| `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:4727` | | `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/production/index.mdx b/docs/production/index.mdx index 695605d..1e464aa 100644 --- a/docs/production/index.mdx +++ b/docs/production/index.mdx @@ -28,7 +28,7 @@ need. `test_the_first_line_of_the_section_says_which_store_and_why` asserts the {/* generated from the suite, pyproject and the soak (full) — run the generator */} - **Version 0.9.0**, on [PyPI](https://pypi.org/project/ctrlrun/), Python 3.11 and later. -- **6,019 tests**, every version specified before it was written and every requirement mutation-tested. [Read more](/docs/how-this-is-built). +- **6,033 tests**, every version specified before it was written and every requirement mutation-tested. [Read more](/docs/how-this-is-built). - **27 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). diff --git a/docs/reference/api/Authority.mdx b/docs/reference/api/Authority.mdx index 20028a7..318a5db 100644 --- a/docs/reference/api/Authority.mdx +++ b/docs/reference/api/Authority.mdx @@ -5,7 +5,7 @@ description: "The `authority:` section, loaded and evaluable (SPEC-v0.3 §4)." {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.Authority` — class, defined at `src/ctrlrun/authority.py:1191` +`ctrlrun.Authority` — class, defined at `src/ctrlrun/authority.py:1229` ```python from ctrlrun import Authority diff --git a/docs/reference/api/Control.mdx b/docs/reference/api/Control.mdx index 4786a20..768bd22 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:718` +`ctrlrun.Control` — class, defined at `src/ctrlrun/control.py:825` ```python from ctrlrun import Control diff --git a/docs/reference/api/context.mdx b/docs/reference/api/context.mdx index 094bd35..9cc2df7 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:363` +`ctrlrun.context` — function, defined at `src/ctrlrun/control.py:368` ```python from ctrlrun import context diff --git a/docs/reference/api/idempotency_token.mdx b/docs/reference/api/idempotency_token.mdx index 92d9b36..c3dbb20 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:331` +`ctrlrun.idempotency_token` — function, defined at `src/ctrlrun/control.py:336` ```python from ctrlrun import idempotency_token diff --git a/docs/reference/api/protect.mdx b/docs/reference/api/protect.mdx index d9844a9..653d65c 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:4795` +`ctrlrun.protect` — function, defined at `src/ctrlrun/control.py:4902` ```python from ctrlrun import protect diff --git a/docs/reference/api/with_approval.mdx b/docs/reference/api/with_approval.mdx index c4cdc67..69bf752 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:386` +`ctrlrun.with_approval` — function, defined at `src/ctrlrun/control.py:391` ```python from ctrlrun import with_approval diff --git a/docs/reference/cli.mdx b/docs/reference/cli.mdx index c9e7fba..8dfb504 100644 --- a/docs/reference/cli.mdx +++ b/docs/reference/cli.mdx @@ -166,6 +166,8 @@ Usage: ctrlrun inspect [OPTIONS] [ACTION_ID] Options: --grant TEXT Show this grant's budgets instead: consumed, held, and what holds it. + --hop TEXT Show this hop or delegation instead: who issued it, and what + each link narrowed. --json Emit one JSON object instead. --store-url TEXT The store to open. Default: $CTRLRUN_STORE_URL, else the SQLite database beside the policy (.ctrlrun/state.db, or diff --git a/generated/readiness.full.mdx b/generated/readiness.full.mdx index 148d2bb..3d17b96 100644 --- a/generated/readiness.full.mdx +++ b/generated/readiness.full.mdx @@ -1,6 +1,6 @@ {/* generated from the suite, pyproject and the soak (full) — run the generator */} - **Version 0.9.0**, on [PyPI](https://pypi.org/project/ctrlrun/), Python 3.11 and later. -- **6,019 tests**, every version specified before it was written and every requirement mutation-tested. [Read more](/docs/how-this-is-built). +- **6,033 tests**, every version specified before it was written and every requirement mutation-tested. [Read more](/docs/how-this-is-built). - **27 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). diff --git a/generated/readiness.json b/generated/readiness.json index efbacac..73f1b5f 100644 --- a/generated/readiness.json +++ b/generated/readiness.json @@ -9,6 +9,6 @@ "positive_control": true, "unexplained": 0 }, - "tests": 6019, + "tests": 6033, "version": "0.9.0" } diff --git a/generated/readiness.mdx b/generated/readiness.mdx index a6e6032..b0fc065 100644 --- a/generated/readiness.mdx +++ b/generated/readiness.mdx @@ -1,6 +1,6 @@ {/* generated from the suite, pyproject and the soak (mdx) — run the generator */} - **Version 0.9.0**, on [PyPI](https://pypi.org/project/ctrlrun/), Python 3.11 and later. -- **6,019 tests**, every version specified before it was written and every requirement mutation-tested. +- **6,033 tests**, every version specified before it was written and every requirement mutation-tested. - **27 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). diff --git a/generated/readiness.readme.md b/generated/readiness.readme.md index e8d11b7..0cc97cc 100644 --- a/generated/readiness.readme.md +++ b/generated/readiness.readme.md @@ -1,6 +1,6 @@ - **Version 0.9.0**, on [PyPI](https://pypi.org/project/ctrlrun/), Python 3.11 and later. -- **6,019 tests**, every version specified before it was written and every requirement mutation-tested. +- **6,033 tests**, every version specified before it was written and every requirement mutation-tested. - **27 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). From fd74f42c3736c2236841a7ec3033d71c618bac72 Mon Sep 17 00:00:00 2001 From: arpan Date: Mon, 14 Sep 2026 03:40:08 +0530 Subject: [PATCH 5/7] Release 0.10.0: regenerate, and the pages that state released behaviour Every generator against the release branch: 83 API pages, cli.mdx, the capabilities and badge copies, the cookbook, and the readiness block pasted fresh into docs.mdx and docs/production/index.mdx. Version 0.10.0, 6,048 tests, 27 guarantees. The hand-written half, each of which states what shipped rather than what was planned. docs/verify.md gains G25, G26 and G27, all three PASS on examples/authority-escalation. OWASP ASI04 moves from "out of scope" to one slice, and the category stays out of scope: a policy entry may pin the upstream it authorises, so a swapped server behind the same name or a tool whose schema moved under an approved action name is a DENY, and CTRLRun still never inspects a package, a model, a build, a registry or a signature chain. ASI07 moves from "Not yet" to partly, and the part is authority rather than the channel. The envelope a second agent receives is a subset of the one the first held, checked at the hop and at every evaluation, and an action under a hop is decided against that hop alone. CTRLRun defines no wire format, secures no channel and authenticates no peer, so message integrity, transport security and agent identity stay the deployment's. ROADMAP marks v0.10 shipped with a reconciled note on the three things that differ from what the section promised: a hop is a record and not a token, which costs cross-store propagation; pinning is enforced at the gateway and refuses in-process; and the first hop is one-way while installing 0.10.0 is not. snippets, lint and links clean. Three CLAIMS references do not resolve and predate v0.10: action.py:79 and migrations.py:107 twice, all three failing identically on the tree before this milestone. Signed-off-by: arpan --- docs.mdx | 6 ++-- docs/OWASP-AGENTIC-TOP10.md | 4 +-- docs/ROADMAP.md | 31 ++++++++++++++++++- docs/production/index.mdx | 6 ++-- docs/reference/api/Action.mdx | 2 +- docs/reference/api/ActionDenied.mdx | 2 +- docs/reference/api/AmbiguousEffect.mdx | 2 +- docs/reference/api/Approval.mdx | 2 +- docs/reference/api/ApprovalAnswer.mdx | 2 +- docs/reference/api/ApprovalMismatch.mdx | 2 +- docs/reference/api/ApprovalProvider.mdx | 2 +- docs/reference/api/ApprovalRequest.mdx | 2 +- docs/reference/api/ApprovalRequired.mdx | 2 +- docs/reference/api/ApprovalTimeout.mdx | 2 +- docs/reference/api/ApproverIdentity.mdx | 2 +- docs/reference/api/Authority.mdx | 2 +- docs/reference/api/AuthorityDenied.mdx | 2 +- docs/reference/api/AuthorityEscalation.mdx | 2 +- docs/reference/api/AuthorityResult.mdx | 4 +-- docs/reference/api/CTRLRunError.mdx | 2 +- docs/reference/api/Condition.mdx | 2 +- docs/reference/api/Control.mdx | 4 +-- docs/reference/api/Decision.mdx | 2 +- docs/reference/api/Delegation.mdx | 2 +- docs/reference/api/DelegationRecord.mdx | 2 +- docs/reference/api/DuplicateEffect.mdx | 2 +- docs/reference/api/EffectKeyError.mdx | 2 +- docs/reference/api/EffectRecord.mdx | 2 +- docs/reference/api/EffectState.mdx | 2 +- docs/reference/api/Event.mdx | 2 +- docs/reference/api/EventSink.mdx | 2 +- docs/reference/api/FrameworkInterrupt.mdx | 2 +- docs/reference/api/Grant.mdx | 2 +- docs/reference/api/HeaderIdentityProvider.mdx | 2 +- docs/reference/api/IdentityContext.mdx | 2 +- docs/reference/api/IdentityError.mdx | 2 +- docs/reference/api/IdentityProvider.mdx | 2 +- docs/reference/api/InMemoryStateStore.mdx | 2 +- .../api/InterruptApprovalProvider.mdx | 2 +- docs/reference/api/InvalidArgument.mdx | 2 +- docs/reference/api/JSONLEventSink.mdx | 2 +- docs/reference/api/LocalApprovalProvider.mdx | 2 +- docs/reference/api/MissingDependency.mdx | 2 +- docs/reference/api/NotExecuted.mdx | 2 +- docs/reference/api/PendingApproval.mdx | 2 +- docs/reference/api/Policy.mdx | 2 +- docs/reference/api/PolicyError.mdx | 2 +- docs/reference/api/Principal.mdx | 2 +- docs/reference/api/Receipt.mdx | 4 +-- docs/reference/api/ReconcileOutcome.mdx | 2 +- docs/reference/api/SQLiteStateStore.mdx | 2 +- docs/reference/api/SchemaMismatch.mdx | 2 +- .../api/ScriptedApprovalProvider.mdx | 2 +- docs/reference/api/StateStore.mdx | 2 +- docs/reference/api/StaticIdentityProvider.mdx | 2 +- docs/reference/api/Subject.mdx | 2 +- docs/reference/api/Suspended.mdx | 2 +- docs/reference/api/VerifiedApprover.mdx | 2 +- .../reference/api/WebhookApprovalProvider.mdx | 2 +- docs/reference/api/acs-AcsControlHook.mdx | 2 +- docs/reference/api/action_hash.mdx | 2 +- docs/reference/api/authority-Budget.mdx | 2 +- docs/reference/api/banner.mdx | 2 +- docs/reference/api/canonical_bytes.mdx | 2 +- docs/reference/api/canonicalize.mdx | 2 +- docs/reference/api/conformance-run.mdx | 2 +- docs/reference/api/conformance-store-run.mdx | 2 +- docs/reference/api/context.mdx | 2 +- docs/reference/api/gateway-serve.mdx | 2 +- docs/reference/api/idempotency_token.mdx | 2 +- .../api/jwt_identity-JWTIdentityProvider.mdx | 2 +- docs/reference/api/needs_approval.mdx | 2 +- docs/reference/api/otel-OTelEventSink.mdx | 2 +- docs/reference/api/parse_conditions.mdx | 2 +- .../api/postgres-PostgresStateStore.mdx | 2 +- docs/reference/api/protect.mdx | 4 +-- docs/reference/api/state-Charge.mdx | 2 +- docs/reference/api/state-Consumption.mdx | 2 +- docs/reference/api/state-check_charges.mdx | 2 +- .../api/transport-HTTPConnection.mdx | 2 +- .../api/transport-HTTPSConnection.mdx | 2 +- docs/reference/api/transport-Transport.mdx | 2 +- docs/reference/api/transport-effect_state.mdx | 2 +- docs/reference/api/transport-urlopen.mdx | 2 +- docs/reference/api/verify-run.mdx | 2 +- docs/reference/api/with_approval.mdx | 2 +- docs/reference/cli.mdx | 2 ++ docs/reference/receipt-and-event-schemas.mdx | 3 +- docs/verify.md | 3 ++ generated/readiness.full.mdx | 6 ++-- generated/readiness.json | 8 ++--- generated/readiness.mdx | 6 ++-- generated/readiness.readme.md | 6 ++-- 93 files changed, 144 insertions(+), 109 deletions(-) diff --git a/docs.mdx b/docs.mdx index 319b539..3551508 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.9.0**, on [PyPI](https://pypi.org/project/ctrlrun/), Python 3.11 and later. -- **5,975 tests**, every version specified before it was written and every requirement mutation-tested. -- **24 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.10.0**, on [PyPI](https://pypi.org/project/ctrlrun/), Python 3.11 and later. +- **6,048 tests**, every version specified before it was written and every requirement mutation-tested. +- **27 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/OWASP-AGENTIC-TOP10.md b/docs/OWASP-AGENTIC-TOP10.md index f2752da..3e6dfd6 100644 --- a/docs/OWASP-AGENTIC-TOP10.md +++ b/docs/OWASP-AGENTIC-TOP10.md @@ -96,9 +96,9 @@ The half that makes the table above credible. One honest sentence each; nothing | Entry | Title | Why not | |---|---|---| -| `ASI04:2026` | Agentic Supply Chain Vulnerabilities | Out of scope. CTRLRun never inspects a package, a model, a tool registry or an MCP server's provenance; it decides actions, and a poisoned dependency reaches it as an ordinary caller. | +| `ASI04:2026` | Agentic Supply Chain Vulnerabilities | **One slice, and the category stays out of scope.** Since 0.10.0 a policy entry may pin the upstream it authorises, by the SHA-256 of the server's leaf certificate or by the hash of a tool's advertised schema, so a swapped MCP server behind the same name, or a tool whose schema moved under an approved action name, is a `DENY`. That is enforced by `ctrlrun gateway`, which is the surface that holds the connection: at startup, at the decision, and at the TLS handshake, where the pinned certificates are the connection's only trust anchors. **Everything else about supply chain remains out of scope**: CTRLRun still never inspects a package, a model, a build, a registry or a signature chain, it decides actions, and a poisoned dependency still reaches it as an ordinary caller. In-process there is no upstream to observe and a pinned action is refused on every call. | | `ASI05:2026` | Unexpected Code Execution | Out of scope. Nothing here sandboxes an interpreter or constrains what a process may run. CTRLRun sits between an agent and one remote effect, not between an agent and its own runtime. | -| `ASI07:2026` | Insecure Inter-Agent Communication | Not yet. Authority does not propagate across agent hops in this release — a grant is evaluated where the action is proposed, and there is no A2A model. `docs/ROADMAP.md` puts that in v0.10; until then, an agent handing work to another agent is outside what these guarantees say anything about. | +| `ASI07:2026` | Insecure Inter-Agent Communication | **Partly, since 0.10.0, and the part is authority rather than the channel.** Authority now propagates across an agent hop: the envelope a second agent receives is a subset of the one the first agent held on every dimension, checked at the hop and again at every evaluation, and an action proposed under a hop is decided against that hop's grant **alone**, with no fallback to anything else the receiving agent holds. A consumption charges the issuer and every ancestor, so a hop spends the issuer's budget rather than creating a second root. G25 grades the narrowing, G26 that both ends of a hop name it. **What it does not do**: CTRLRun defines no wire format, secures no channel, and authenticates no peer, so message integrity, transport security and agent identity are the deployment's, exactly as `ASI06`'s row says of identity. It cannot compel a receiving agent to present the hop it was given, and a receiver whose store cannot read the chain is refused rather than trusted. No A2A conformance claim. | And the three entries where the mapping above is **partial**, with the part that is not covered stated here rather than left implied: diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index b099941..3958e75 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -332,7 +332,36 @@ graded on what this repository ships rather than only on a fixture. Standards: none new. -## v0.10 — Multi-agent +## v0.10 — Multi-agent ✅ shipped + +**Reconciled against what shipped.** Three things differ from what this section promised, and each +is recorded rather than quietly adjusted. + +**A hop is a record in the issuer's store, not a token on the wire.** This section said "propagated +across agent hops" without saying how. `SPEC-v0.10.md` §3.2 settles it: what crosses is a +**reference**, two strings in whatever metadata the transport already carries, and the envelope +itself never travels. The budget rule forces it, because charging every ancestor happens in one +transaction. **The cost is that both agents decide against the same store**, and a deployment where +they do not is refused fail-closed rather than approximated. + +**Upstream pinning is enforced at the gateway, and refuses in-process.** The row below says a +swapped server "is a `DENY`". It is, at `ctrlrun gateway`, which is the surface that holds the +connection. In-process there is no upstream to observe, so a pinned action refuses on **every** +call with `upstream_unverified`, and the ACS hook refuses such a policy at construction: ACS is +advisory and the platform runs the tool, so the hook holds no connection to pin. + +**The first hop is a one-way step.** `SPEC-v0.10.md` §9.3: `created_via` is a closed vocabulary and +the authority walk reads every delegation row before filtering any of them, so a 0.9.x binary +meeting one `hop` row answers `authority_unreadable` for **every action in the deployment**. +Installing 0.10.0 is reversible; creating the first hop is not. + +Exit criteria met: `ctrlrun.guarantees/v6` with G25, G26 and G27 each grading `PASS` on +`examples/authority-escalation` and each grading the same under `--only` as in a full run; one +shipped example exercises a hop and one pins an upstream; a two-hop chain charges every ancestor +under the multi-process standard against Postgres; and observe mode reports the refusal enforce mode +raises. The upgrade was checked against the **released** 0.9.0 from PyPI rather than a fixture. + +## v0.10 — Multi-agent, as planned - A2A integration: task-bound delegated authority (v0.9) with limits, expiry, and depth, propagated across agent hops. - Authority propagation across hops: the envelope a second agent receives is `⊆` the envelope the first agent held, checked at the hop and again at every evaluation, exactly as v0.3 checks a delegation. diff --git a/docs/production/index.mdx b/docs/production/index.mdx index 6fb3d53..68d4322 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.9.0**, on [PyPI](https://pypi.org/project/ctrlrun/), Python 3.11 and later. -- **5,975 tests**, every version specified before it was written and every requirement mutation-tested. [Read more](/docs/how-this-is-built). -- **24 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.10.0**, on [PyPI](https://pypi.org/project/ctrlrun/), Python 3.11 and later. +- **6,048 tests**, every version specified before it was written and every requirement mutation-tested. [Read more](/docs/how-this-is-built). +- **27 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/reference/api/Action.mdx b/docs/reference/api/Action.mdx index fa03332..6ae5c87 100644 --- a/docs/reference/api/Action.mdx +++ b/docs/reference/api/Action.mdx @@ -5,7 +5,7 @@ description: "A proposed agent action: what, with which arguments, by whom, on w {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.Action` — class, defined at `src/ctrlrun/action.py:202` +`ctrlrun.Action` — class, defined at `src/ctrlrun/action.py:204` ```python from ctrlrun import Action diff --git a/docs/reference/api/ActionDenied.mdx b/docs/reference/api/ActionDenied.mdx index b31afb3..86a9c26 100644 --- a/docs/reference/api/ActionDenied.mdx +++ b/docs/reference/api/ActionDenied.mdx @@ -5,7 +5,7 @@ description: "The action may not run. `reason` says why, e.g. `unknown_action` ( {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.ActionDenied` — class, defined at `src/ctrlrun/errors.py:29` +`ctrlrun.ActionDenied` — class, defined at `src/ctrlrun/errors.py:31` ```python from ctrlrun import ActionDenied diff --git a/docs/reference/api/AmbiguousEffect.mdx b/docs/reference/api/AmbiguousEffect.mdx index 1f83ece..a6f66bd 100644 --- a/docs/reference/api/AmbiguousEffect.mdx +++ b/docs/reference/api/AmbiguousEffect.mdx @@ -5,7 +5,7 @@ description: "The outcome of this effect is unknown; only a human may resolve it {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.AmbiguousEffect` — class, defined at `src/ctrlrun/errors.py:141` +`ctrlrun.AmbiguousEffect` — class, defined at `src/ctrlrun/errors.py:143` ```python from ctrlrun import AmbiguousEffect diff --git a/docs/reference/api/Approval.mdx b/docs/reference/api/Approval.mdx index c78d4d9..cc685a1 100644 --- a/docs/reference/api/Approval.mdx +++ b/docs/reference/api/Approval.mdx @@ -5,7 +5,7 @@ description: "A human's grant, bound to one `action_hash` (SPEC-v0.1 §4.1)." {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.Approval` — class, defined at `src/ctrlrun/approval.py:463` +`ctrlrun.Approval` — class, defined at `src/ctrlrun/approval.py:465` ```python from ctrlrun import Approval diff --git a/docs/reference/api/ApprovalAnswer.mdx b/docs/reference/api/ApprovalAnswer.mdx index e4e5189..4ffa37a 100644 --- a/docs/reference/api/ApprovalAnswer.mdx +++ b/docs/reference/api/ApprovalAnswer.mdx @@ -5,7 +5,7 @@ description: "A human's answer, and who gave it (SPEC-v0.5 §2.2, §3.4)." {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.ApprovalAnswer` — class, defined at `src/ctrlrun/adapter.py:157` +`ctrlrun.ApprovalAnswer` — class, defined at `src/ctrlrun/adapter.py:159` ```python from ctrlrun import ApprovalAnswer diff --git a/docs/reference/api/ApprovalMismatch.mdx b/docs/reference/api/ApprovalMismatch.mdx index 1885878..3f7a980 100644 --- a/docs/reference/api/ApprovalMismatch.mdx +++ b/docs/reference/api/ApprovalMismatch.mdx @@ -5,7 +5,7 @@ description: "The presented approval does not authorize this action (SPEC-v0.1 {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.ApprovalMismatch` — class, defined at `src/ctrlrun/errors.py:111` +`ctrlrun.ApprovalMismatch` — class, defined at `src/ctrlrun/errors.py:113` ```python from ctrlrun import ApprovalMismatch diff --git a/docs/reference/api/ApprovalProvider.mdx b/docs/reference/api/ApprovalProvider.mdx index 644c104..03b2214 100644 --- a/docs/reference/api/ApprovalProvider.mdx +++ b/docs/reference/api/ApprovalProvider.mdx @@ -5,7 +5,7 @@ description: "How a human is asked, and how the answer comes back (SPEC-v0.1 §4 {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.ApprovalProvider` — class, defined at `src/ctrlrun/approval.py:671` +`ctrlrun.ApprovalProvider` — class, defined at `src/ctrlrun/approval.py:673` ```python from ctrlrun import ApprovalProvider diff --git a/docs/reference/api/ApprovalRequest.mdx b/docs/reference/api/ApprovalRequest.mdx index 64e5036..8d651f1 100644 --- a/docs/reference/api/ApprovalRequest.mdx +++ b/docs/reference/api/ApprovalRequest.mdx @@ -5,7 +5,7 @@ description: "A pending question for a human: may this exact action run? (SPEC-v {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.ApprovalRequest` — class, defined at `src/ctrlrun/approval.py:392` +`ctrlrun.ApprovalRequest` — class, defined at `src/ctrlrun/approval.py:394` ```python from ctrlrun import ApprovalRequest diff --git a/docs/reference/api/ApprovalRequired.mdx b/docs/reference/api/ApprovalRequired.mdx index 8be768e..29dd777 100644 --- a/docs/reference/api/ApprovalRequired.mdx +++ b/docs/reference/api/ApprovalRequired.mdx @@ -5,7 +5,7 @@ description: "The action needs a human. `request_id` is what `ctrlrun approve` t {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.ApprovalRequired` — class, defined at `src/ctrlrun/errors.py:88` +`ctrlrun.ApprovalRequired` — class, defined at `src/ctrlrun/errors.py:90` ```python from ctrlrun import ApprovalRequired diff --git a/docs/reference/api/ApprovalTimeout.mdx b/docs/reference/api/ApprovalTimeout.mdx index 0000a96..1369e8c 100644 --- a/docs/reference/api/ApprovalTimeout.mdx +++ b/docs/reference/api/ApprovalTimeout.mdx @@ -5,7 +5,7 @@ description: "Nobody answered the approval request in time (SPEC-v0.1 §4.3)." {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.ApprovalTimeout` — class, defined at `src/ctrlrun/errors.py:103` +`ctrlrun.ApprovalTimeout` — class, defined at `src/ctrlrun/errors.py:105` ```python from ctrlrun import ApprovalTimeout diff --git a/docs/reference/api/ApproverIdentity.mdx b/docs/reference/api/ApproverIdentity.mdx index b18d06e..9a3bd73 100644 --- a/docs/reference/api/ApproverIdentity.mdx +++ b/docs/reference/api/ApproverIdentity.mdx @@ -5,7 +5,7 @@ description: "How a deployment verifies who answered an approval (SPEC-v0.8 §2. {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.ApproverIdentity` — class, defined at `src/ctrlrun/approval.py:201` +`ctrlrun.ApproverIdentity` — class, defined at `src/ctrlrun/approval.py:203` ```python from ctrlrun import ApproverIdentity diff --git a/docs/reference/api/Authority.mdx b/docs/reference/api/Authority.mdx index 838591e..5b56b53 100644 --- a/docs/reference/api/Authority.mdx +++ b/docs/reference/api/Authority.mdx @@ -5,7 +5,7 @@ description: "The `authority:` section, loaded and evaluable (SPEC-v0.3 §4)." {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.Authority` — class, defined at `src/ctrlrun/authority.py:1134` +`ctrlrun.Authority` — class, defined at `src/ctrlrun/authority.py:1231` ```python from ctrlrun import Authority diff --git a/docs/reference/api/AuthorityDenied.mdx b/docs/reference/api/AuthorityDenied.mdx index 9fd97c3..c6767f7 100644 --- a/docs/reference/api/AuthorityDenied.mdx +++ b/docs/reference/api/AuthorityDenied.mdx @@ -5,7 +5,7 @@ description: "The principal holds no grant that covers this action (SPEC-v0.3 § {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.AuthorityDenied` — class, defined at `src/ctrlrun/errors.py:40` +`ctrlrun.AuthorityDenied` — class, defined at `src/ctrlrun/errors.py:42` ```python from ctrlrun import AuthorityDenied diff --git a/docs/reference/api/AuthorityEscalation.mdx b/docs/reference/api/AuthorityEscalation.mdx index 0281954..9a54470 100644 --- a/docs/reference/api/AuthorityEscalation.mdx +++ b/docs/reference/api/AuthorityEscalation.mdx @@ -5,7 +5,7 @@ description: "A delegation that may not exist: it is not contained in its parent {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.AuthorityEscalation` — class, defined at `src/ctrlrun/errors.py:65` +`ctrlrun.AuthorityEscalation` — class, defined at `src/ctrlrun/errors.py:67` ```python from ctrlrun import AuthorityEscalation diff --git a/docs/reference/api/AuthorityResult.mdx b/docs/reference/api/AuthorityResult.mdx index efe4e1d..8b01c52 100644 --- a/docs/reference/api/AuthorityResult.mdx +++ b/docs/reference/api/AuthorityResult.mdx @@ -5,7 +5,7 @@ description: "What the authority axis decided, and which grant it decided on (§ {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.AuthorityResult` — class, defined at `src/ctrlrun/authority.py:619` +`ctrlrun.AuthorityResult` — class, defined at `src/ctrlrun/authority.py:623` ```python from ctrlrun import AuthorityResult @@ -14,7 +14,7 @@ from ctrlrun import AuthorityResult ```python class AuthorityResult - def __init__(passed: bool, reason: str, grant_id: str | None = None, delegation_id: str | None = None, depth: int = 0, dimension: str | None = None, missing_parent_id: str | None = None, expired_parent_id: str | None = None, depth_exceeded: int | None = None, cycle_at: str | None = None) + def __init__(passed: bool, reason: str, grant_id: str | None = None, delegation_id: str | None = None, depth: int = 0, hop: str | None = None, dimension: str | None = None, missing_parent_id: str | None = None, expired_parent_id: str | None = None, depth_exceeded: int | None = None, cycle_at: str | None = None) ``` What the authority axis decided, and which grant it decided on (§4.8). diff --git a/docs/reference/api/CTRLRunError.mdx b/docs/reference/api/CTRLRunError.mdx index dd146c4..4f06fcf 100644 --- a/docs/reference/api/CTRLRunError.mdx +++ b/docs/reference/api/CTRLRunError.mdx @@ -5,7 +5,7 @@ description: "Base class for every error raised by CTRLRun." {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.CTRLRunError` — class, defined at `src/ctrlrun/errors.py:4` +`ctrlrun.CTRLRunError` — class, defined at `src/ctrlrun/errors.py:6` ```python from ctrlrun import CTRLRunError diff --git a/docs/reference/api/Condition.mdx b/docs/reference/api/Condition.mdx index 78d1465..5fce005 100644 --- a/docs/reference/api/Condition.mdx +++ b/docs/reference/api/Condition.mdx @@ -5,7 +5,7 @@ description: "One `_: operand` test against an action's arguments {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.Condition` — class, defined at `src/ctrlrun/policy.py:371` +`ctrlrun.Condition` — class, defined at `src/ctrlrun/policy.py:400` ```python from ctrlrun import Condition diff --git a/docs/reference/api/Control.mdx b/docs/reference/api/Control.mdx index 5a3c7f0..fc33906 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:677` +`ctrlrun.Control` — class, defined at `src/ctrlrun/control.py:822` ```python from ctrlrun import Control @@ -14,7 +14,7 @@ from ctrlrun import Control ```python class Control - def __init__(policy: Policy, store: StateStore, approvals: ApprovalProvider | None = None, *, clock: Callable[[], datetime] = _utc_now, approval_ttl: timedelta = DEFAULT_APPROVAL_TTL, lease: timedelta = DEFAULT_LEASE, sinks: Sequence[EventSink] = (), suspend_timeout: timedelta = DEFAULT_SUSPEND_TIMEOUT, identity: IdentityProvider | None = None, authority: Authority | None = None, environment: str | None = None, approver_identity: ApproverIdentity | None = None, require_approved_policy: bool = False) + def __init__(policy: Policy, store: StateStore, approvals: ApprovalProvider | None = None, *, clock: Callable[[], datetime] = _utc_now, approval_ttl: timedelta = DEFAULT_APPROVAL_TTL, lease: timedelta = DEFAULT_LEASE, sinks: Sequence[EventSink] = (), suspend_timeout: timedelta = DEFAULT_SUSPEND_TIMEOUT, identity: IdentityProvider | None = None, authority: Authority | None = None, environment: str | None = None, approver_identity: ApproverIdentity | None = None, require_approved_policy: bool = False, upstream: str | None = None) ``` Policy, state and evidence composed around a single action (SPEC-v0.1 §8). diff --git a/docs/reference/api/Decision.mdx b/docs/reference/api/Decision.mdx index c3de473..13e9462 100644 --- a/docs/reference/api/Decision.mdx +++ b/docs/reference/api/Decision.mdx @@ -5,7 +5,7 @@ description: "What may happen to an action: exactly three outcomes in v0.1 (SPEC {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.Decision` — class, defined at `src/ctrlrun/policy.py:308` +`ctrlrun.Decision` — class, defined at `src/ctrlrun/policy.py:337` ```python from ctrlrun import Decision diff --git a/docs/reference/api/Delegation.mdx b/docs/reference/api/Delegation.mdx index dfce962..343a59e 100644 --- a/docs/reference/api/Delegation.mdx +++ b/docs/reference/api/Delegation.mdx @@ -5,7 +5,7 @@ description: "A grant created at runtime by a principal who already holds one (S {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.Delegation` — class, defined at `src/ctrlrun/authority.py:579` +`ctrlrun.Delegation` — class, defined at `src/ctrlrun/authority.py:583` ```python from ctrlrun import Delegation diff --git a/docs/reference/api/DelegationRecord.mdx b/docs/reference/api/DelegationRecord.mdx index 7c7aca2..682a49b 100644 --- a/docs/reference/api/DelegationRecord.mdx +++ b/docs/reference/api/DelegationRecord.mdx @@ -5,7 +5,7 @@ description: "One row of the `delegations` table (SPEC-v0.3 §5.2)." {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.DelegationRecord` — class, defined at `src/ctrlrun/state.py:384` +`ctrlrun.DelegationRecord` — class, defined at `src/ctrlrun/state.py:386` ```python from ctrlrun import DelegationRecord diff --git a/docs/reference/api/DuplicateEffect.mdx b/docs/reference/api/DuplicateEffect.mdx index 5aa4c66..0c335ec 100644 --- a/docs/reference/api/DuplicateEffect.mdx +++ b/docs/reference/api/DuplicateEffect.mdx @@ -5,7 +5,7 @@ description: "This logical effect already happened, or is happening now (SPEC-v0 {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.DuplicateEffect` — class, defined at `src/ctrlrun/errors.py:126` +`ctrlrun.DuplicateEffect` — class, defined at `src/ctrlrun/errors.py:128` ```python from ctrlrun import DuplicateEffect diff --git a/docs/reference/api/EffectKeyError.mdx b/docs/reference/api/EffectKeyError.mdx index f43411b..1e2f6f9 100644 --- a/docs/reference/api/EffectKeyError.mdx +++ b/docs/reference/api/EffectKeyError.mdx @@ -5,7 +5,7 @@ description: "An effect template cannot be resolved to a key (SPEC-v0.1 §5.1)." {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.EffectKeyError` — class, defined at `src/ctrlrun/errors.py:21` +`ctrlrun.EffectKeyError` — class, defined at `src/ctrlrun/errors.py:23` ```python from ctrlrun import EffectKeyError diff --git a/docs/reference/api/EffectRecord.mdx b/docs/reference/api/EffectRecord.mdx index 17bef6c..3c00265 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:184` +`ctrlrun.EffectRecord` — class, defined at `src/ctrlrun/effect.py:186` ```python from ctrlrun import EffectRecord diff --git a/docs/reference/api/EffectState.mdx b/docs/reference/api/EffectState.mdx index 209d443..3ac56c0 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:159` +`ctrlrun.EffectState` — class, defined at `src/ctrlrun/effect.py:161` ```python from ctrlrun import EffectState diff --git a/docs/reference/api/Event.mdx b/docs/reference/api/Event.mdx index 993749b..7af19f4 100644 --- a/docs/reference/api/Event.mdx +++ b/docs/reference/api/Event.mdx @@ -5,7 +5,7 @@ description: "One ordered step in the life of an action (SPEC-v0.1 §6.2)." {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.Event` — class, defined at `src/ctrlrun/receipt.py:297` +`ctrlrun.Event` — class, defined at `src/ctrlrun/receipt.py:307` ```python from ctrlrun import Event diff --git a/docs/reference/api/EventSink.mdx b/docs/reference/api/EventSink.mdx index ea24ea4..517562f 100644 --- a/docs/reference/api/EventSink.mdx +++ b/docs/reference/api/EventSink.mdx @@ -5,7 +5,7 @@ description: "Somewhere a copy of every `Event` and `Receipt` goes (SPEC-v0.2 § {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.EventSink` — class, defined at `src/ctrlrun/receipt.py:773` +`ctrlrun.EventSink` — class, defined at `src/ctrlrun/receipt.py:800` ```python from ctrlrun import EventSink diff --git a/docs/reference/api/FrameworkInterrupt.mdx b/docs/reference/api/FrameworkInterrupt.mdx index 4bcb7d0..63991c9 100644 --- a/docs/reference/api/FrameworkInterrupt.mdx +++ b/docs/reference/api/FrameworkInterrupt.mdx @@ -5,7 +5,7 @@ description: "One framework's human-in-the-loop primitive, and nothing else (SPE {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.FrameworkInterrupt` — class, defined at `src/ctrlrun/adapter.py:179` +`ctrlrun.FrameworkInterrupt` — class, defined at `src/ctrlrun/adapter.py:181` ```python from ctrlrun import FrameworkInterrupt diff --git a/docs/reference/api/Grant.mdx b/docs/reference/api/Grant.mdx index ff85c8e..bf292fc 100644 --- a/docs/reference/api/Grant.mdx +++ b/docs/reference/api/Grant.mdx @@ -5,7 +5,7 @@ description: "One permission: this subject may propose these actions, under thes {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.Grant` — class, defined at `src/ctrlrun/authority.py:416` +`ctrlrun.Grant` — class, defined at `src/ctrlrun/authority.py:433` ```python from ctrlrun import Grant diff --git a/docs/reference/api/HeaderIdentityProvider.mdx b/docs/reference/api/HeaderIdentityProvider.mdx index fbc75a1..35ac277 100644 --- a/docs/reference/api/HeaderIdentityProvider.mdx +++ b/docs/reference/api/HeaderIdentityProvider.mdx @@ -5,7 +5,7 @@ description: "The principal named by a trusted HTTP header (§3.3)." {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.HeaderIdentityProvider` — class, defined at `src/ctrlrun/identity.py:126` +`ctrlrun.HeaderIdentityProvider` — class, defined at `src/ctrlrun/identity.py:128` ```python from ctrlrun import HeaderIdentityProvider diff --git a/docs/reference/api/IdentityContext.mdx b/docs/reference/api/IdentityContext.mdx index edeccca..c9609ef 100644 --- a/docs/reference/api/IdentityContext.mdx +++ b/docs/reference/api/IdentityContext.mdx @@ -5,7 +5,7 @@ description: "What a provider is told about the call it is resolving a principal {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.IdentityContext` — class, defined at `src/ctrlrun/identity.py:35` +`ctrlrun.IdentityContext` — class, defined at `src/ctrlrun/identity.py:37` ```python from ctrlrun import IdentityContext diff --git a/docs/reference/api/IdentityError.mdx b/docs/reference/api/IdentityError.mdx index 37a929d..e3215df 100644 --- a/docs/reference/api/IdentityError.mdx +++ b/docs/reference/api/IdentityError.mdx @@ -5,7 +5,7 @@ description: "A credential was offered and rejected (SPEC-v0.3 §3.2)." {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.IdentityError` — class, defined at `src/ctrlrun/errors.py:194` +`ctrlrun.IdentityError` — class, defined at `src/ctrlrun/errors.py:196` ```python from ctrlrun import IdentityError diff --git a/docs/reference/api/IdentityProvider.mdx b/docs/reference/api/IdentityProvider.mdx index ecf7821..009e231 100644 --- a/docs/reference/api/IdentityProvider.mdx +++ b/docs/reference/api/IdentityProvider.mdx @@ -5,7 +5,7 @@ description: "Resolves the principal for one action (SPEC-v0.3 §3.1)." {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.IdentityProvider` — class, defined at `src/ctrlrun/identity.py:70` +`ctrlrun.IdentityProvider` — class, defined at `src/ctrlrun/identity.py:72` ```python from ctrlrun import IdentityProvider diff --git a/docs/reference/api/InMemoryStateStore.mdx b/docs/reference/api/InMemoryStateStore.mdx index b52a7e9..b5b2d63 100644 --- a/docs/reference/api/InMemoryStateStore.mdx +++ b/docs/reference/api/InMemoryStateStore.mdx @@ -5,7 +5,7 @@ description: "Everything held in process memory: for tests and `ctrlrun demo`." {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.InMemoryStateStore` — class, defined at `src/ctrlrun/state.py:968` +`ctrlrun.InMemoryStateStore` — class, defined at `src/ctrlrun/state.py:970` ```python from ctrlrun import InMemoryStateStore diff --git a/docs/reference/api/InterruptApprovalProvider.mdx b/docs/reference/api/InterruptApprovalProvider.mdx index c67da08..88a4f40 100644 --- a/docs/reference/api/InterruptApprovalProvider.mdx +++ b/docs/reference/api/InterruptApprovalProvider.mdx @@ -5,7 +5,7 @@ description: "An `ApprovalProvider` whose `wait()` routes through a framework's {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.InterruptApprovalProvider` — class, defined at `src/ctrlrun/adapter.py:209` +`ctrlrun.InterruptApprovalProvider` — class, defined at `src/ctrlrun/adapter.py:211` ```python from ctrlrun import InterruptApprovalProvider diff --git a/docs/reference/api/InvalidArgument.mdx b/docs/reference/api/InvalidArgument.mdx index ad8090b..e1345ba 100644 --- a/docs/reference/api/InvalidArgument.mdx +++ b/docs/reference/api/InvalidArgument.mdx @@ -5,7 +5,7 @@ description: "An argument cannot be accepted as given." {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.InvalidArgument` — class, defined at `src/ctrlrun/errors.py:8` +`ctrlrun.InvalidArgument` — class, defined at `src/ctrlrun/errors.py:10` ```python from ctrlrun import InvalidArgument diff --git a/docs/reference/api/JSONLEventSink.mdx b/docs/reference/api/JSONLEventSink.mdx index deda44e..4ea2266 100644 --- a/docs/reference/api/JSONLEventSink.mdx +++ b/docs/reference/api/JSONLEventSink.mdx @@ -5,7 +5,7 @@ description: "The JSONL half of the evidence: two append-only files in one direc {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.JSONLEventSink` — class, defined at `src/ctrlrun/receipt.py:792` +`ctrlrun.JSONLEventSink` — class, defined at `src/ctrlrun/receipt.py:819` ```python from ctrlrun import JSONLEventSink diff --git a/docs/reference/api/LocalApprovalProvider.mdx b/docs/reference/api/LocalApprovalProvider.mdx index 4331f16..bf8adb2 100644 --- a/docs/reference/api/LocalApprovalProvider.mdx +++ b/docs/reference/api/LocalApprovalProvider.mdx @@ -5,7 +5,7 @@ description: "Requests go to the StateStore; `wait()` polls it (SPEC-v0.1 §4.3) {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.LocalApprovalProvider` — class, defined at `src/ctrlrun/approval.py:800` +`ctrlrun.LocalApprovalProvider` — class, defined at `src/ctrlrun/approval.py:802` ```python from ctrlrun import LocalApprovalProvider diff --git a/docs/reference/api/MissingDependency.mdx b/docs/reference/api/MissingDependency.mdx index e99e0b6..7dc19b1 100644 --- a/docs/reference/api/MissingDependency.mdx +++ b/docs/reference/api/MissingDependency.mdx @@ -5,7 +5,7 @@ description: "An optional extra is not installed (SPEC-v0.2 §1.1, §11)." {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.MissingDependency` — class, defined at `src/ctrlrun/errors.py:228` +`ctrlrun.MissingDependency` — class, defined at `src/ctrlrun/errors.py:230` ```python from ctrlrun import MissingDependency diff --git a/docs/reference/api/NotExecuted.mdx b/docs/reference/api/NotExecuted.mdx index 5250328..f704ad2 100644 --- a/docs/reference/api/NotExecuted.mdx +++ b/docs/reference/api/NotExecuted.mdx @@ -5,7 +5,7 @@ description: "Raised by an executor to assert the remote side did nothing (SPEC- {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.NotExecuted` — class, defined at `src/ctrlrun/errors.py:157` +`ctrlrun.NotExecuted` — class, defined at `src/ctrlrun/errors.py:159` ```python from ctrlrun import NotExecuted diff --git a/docs/reference/api/PendingApproval.mdx b/docs/reference/api/PendingApproval.mdx index a8aaa3b..180cf66 100644 --- a/docs/reference/api/PendingApproval.mdx +++ b/docs/reference/api/PendingApproval.mdx @@ -5,7 +5,7 @@ description: "What the framework's interrupt is handed, and the only thing it is {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.PendingApproval` — class, defined at `src/ctrlrun/adapter.py:92` +`ctrlrun.PendingApproval` — class, defined at `src/ctrlrun/adapter.py:94` ```python from ctrlrun import PendingApproval diff --git a/docs/reference/api/Policy.mdx b/docs/reference/api/Policy.mdx index 8189ebd..d725645 100644 --- a/docs/reference/api/Policy.mdx +++ b/docs/reference/api/Policy.mdx @@ -5,7 +5,7 @@ description: "Action-level autonomy policy: which actions may run, and under whi {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.Policy` — class, defined at `src/ctrlrun/policy.py:700` +`ctrlrun.Policy` — class, defined at `src/ctrlrun/policy.py:761` ```python from ctrlrun import Policy diff --git a/docs/reference/api/PolicyError.mdx b/docs/reference/api/PolicyError.mdx index 8f88305..779f8fd 100644 --- a/docs/reference/api/PolicyError.mdx +++ b/docs/reference/api/PolicyError.mdx @@ -5,7 +5,7 @@ description: "The policy is missing, unreadable, or malformed. Raised at load ti {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.PolicyError` — class, defined at `src/ctrlrun/errors.py:17` +`ctrlrun.PolicyError` — class, defined at `src/ctrlrun/errors.py:19` ```python from ctrlrun import PolicyError diff --git a/docs/reference/api/Principal.mdx b/docs/reference/api/Principal.mdx index 8c5c6df..fc3b9a9 100644 --- a/docs/reference/api/Principal.mdx +++ b/docs/reference/api/Principal.mdx @@ -5,7 +5,7 @@ description: "Who is acting: an agent, optionally on behalf of a human." {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.Principal` — class, defined at `src/ctrlrun/action.py:163` +`ctrlrun.Principal` — class, defined at `src/ctrlrun/action.py:165` ```python from ctrlrun import Principal diff --git a/docs/reference/api/Receipt.mdx b/docs/reference/api/Receipt.mdx index 115e1b2..5cec2f9 100644 --- a/docs/reference/api/Receipt.mdx +++ b/docs/reference/api/Receipt.mdx @@ -5,7 +5,7 @@ description: "Portable evidence of one action that reached a terminal state (SPE {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.Receipt` — class, defined at `src/ctrlrun/receipt.py:389` +`ctrlrun.Receipt` — class, defined at `src/ctrlrun/receipt.py:399` ```python from ctrlrun import Receipt @@ -14,7 +14,7 @@ from ctrlrun import Receipt ```python class Receipt - def __init__(receipt_id: str, action_id: str, action: str, action_hash: str, principal: Principal, resource: str | None, arguments: Mapping[str, Any], environment: str, decision: Decision, decision_reason: str, result: ReceiptResult, started_at: datetime, finished_at: datetime, approval_id: str | None = None, approver: str | None = None, effect_key: str | None = None, attempt: int = 1, error: str | None = None, execution: ReceiptResult | None = None, would_have: _WouldHave | None = None, seq: int | None = None, prev_hash: str | None = None, policy_hash: str | None = None, policy_version: str | None = None, controls: tuple[str, ...] = (), hash: str | None = None, precondition_at_request: str | None = None, precondition_at_recheck: str | None = None, approvers: tuple[VerifiedApprover, ...] = (), authority_grant_id: str | None = None, task: str | None = None, scope_hash: str | None = None, budget_charges: tuple[Mapping[str, Any], ...] = (), schema: str = RECEIPT_SCHEMA) + def __init__(receipt_id: str, action_id: str, action: str, action_hash: str, principal: Principal, resource: str | None, arguments: Mapping[str, Any], environment: str, decision: Decision, decision_reason: str, result: ReceiptResult, started_at: datetime, finished_at: datetime, approval_id: str | None = None, approver: str | None = None, effect_key: str | None = None, attempt: int = 1, error: str | None = None, execution: ReceiptResult | None = None, would_have: _WouldHave | None = None, seq: int | None = None, prev_hash: str | None = None, policy_hash: str | None = None, policy_version: str | None = None, controls: tuple[str, ...] = (), hash: str | None = None, precondition_at_request: str | None = None, precondition_at_recheck: str | None = None, approvers: tuple[VerifiedApprover, ...] = (), authority_grant_id: str | None = None, task: str | None = None, scope_hash: str | None = None, hop: str | None = None, budget_charges: tuple[Mapping[str, Any], ...] = (), schema: str = RECEIPT_SCHEMA) ``` Portable evidence of one action that reached a terminal state (SPEC-v0.1 §6.1). diff --git a/docs/reference/api/ReconcileOutcome.mdx b/docs/reference/api/ReconcileOutcome.mdx index fe52382..17eda0b 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:54` +`ctrlrun.ReconcileOutcome` — attribute, defined at `src/ctrlrun/effect.py:56` ```python from ctrlrun import ReconcileOutcome diff --git a/docs/reference/api/SQLiteStateStore.mdx b/docs/reference/api/SQLiteStateStore.mdx index 7e68fc5..ce0af8b 100644 --- a/docs/reference/api/SQLiteStateStore.mdx +++ b/docs/reference/api/SQLiteStateStore.mdx @@ -5,7 +5,7 @@ description: "Approvals, effects and evidence in one SQLite file (ARCHITECTURE {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.SQLiteStateStore` — class, defined at `src/ctrlrun/state.py:1476` +`ctrlrun.SQLiteStateStore` — class, defined at `src/ctrlrun/state.py:1478` ```python from ctrlrun import SQLiteStateStore diff --git a/docs/reference/api/SchemaMismatch.mdx b/docs/reference/api/SchemaMismatch.mdx index 5a0b1c3..84f45c0 100644 --- a/docs/reference/api/SchemaMismatch.mdx +++ b/docs/reference/api/SchemaMismatch.mdx @@ -5,7 +5,7 @@ description: "A store met a database it does not recognise, in either direction {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.SchemaMismatch` — class, defined at `src/ctrlrun/errors.py:205` +`ctrlrun.SchemaMismatch` — class, defined at `src/ctrlrun/errors.py:207` ```python from ctrlrun import SchemaMismatch diff --git a/docs/reference/api/ScriptedApprovalProvider.mdx b/docs/reference/api/ScriptedApprovalProvider.mdx index af9eb71..8e2cac1 100644 --- a/docs/reference/api/ScriptedApprovalProvider.mdx +++ b/docs/reference/api/ScriptedApprovalProvider.mdx @@ -5,7 +5,7 @@ description: "A human replaced by a fixed script: for tests and `ctrlrun demo` ( {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.ScriptedApprovalProvider` — class, defined at `src/ctrlrun/approval.py:856` +`ctrlrun.ScriptedApprovalProvider` — class, defined at `src/ctrlrun/approval.py:858` ```python from ctrlrun import ScriptedApprovalProvider diff --git a/docs/reference/api/StateStore.mdx b/docs/reference/api/StateStore.mdx index 300c413..f18316e 100644 --- a/docs/reference/api/StateStore.mdx +++ b/docs/reference/api/StateStore.mdx @@ -5,7 +5,7 @@ description: "Durable state behind a `Control` (SPEC-v0.1 §5.3): approvals, eff {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.StateStore` — class, defined at `src/ctrlrun/state.py:634` +`ctrlrun.StateStore` — class, defined at `src/ctrlrun/state.py:636` ```python from ctrlrun import StateStore diff --git a/docs/reference/api/StaticIdentityProvider.mdx b/docs/reference/api/StaticIdentityProvider.mdx index 3e21f01..2037bc5 100644 --- a/docs/reference/api/StaticIdentityProvider.mdx +++ b/docs/reference/api/StaticIdentityProvider.mdx @@ -5,7 +5,7 @@ description: "A fixed principal, for development, tests and single-tenant demons {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.StaticIdentityProvider` — class, defined at `src/ctrlrun/identity.py:87` +`ctrlrun.StaticIdentityProvider` — class, defined at `src/ctrlrun/identity.py:89` ```python from ctrlrun import StaticIdentityProvider diff --git a/docs/reference/api/Subject.mdx b/docs/reference/api/Subject.mdx index b2c0968..1f4bd67 100644 --- a/docs/reference/api/Subject.mdx +++ b/docs/reference/api/Subject.mdx @@ -5,7 +5,7 @@ description: "Who a grant is addressed to: an agent pattern, a user pattern, or {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.Subject` — class, defined at `src/ctrlrun/authority.py:320` +`ctrlrun.Subject` — class, defined at `src/ctrlrun/authority.py:337` ```python from ctrlrun import Subject diff --git a/docs/reference/api/Suspended.mdx b/docs/reference/api/Suspended.mdx index ab9fdf1..d5d338f 100644 --- a/docs/reference/api/Suspended.mdx +++ b/docs/reference/api/Suspended.mdx @@ -5,7 +5,7 @@ description: "Raised by an executor: the remote asked for something before it wi {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.Suspended` — class, defined at `src/ctrlrun/errors.py:165` +`ctrlrun.Suspended` — class, defined at `src/ctrlrun/errors.py:167` ```python from ctrlrun import Suspended diff --git a/docs/reference/api/VerifiedApprover.mdx b/docs/reference/api/VerifiedApprover.mdx index 4a7f817..f3355e9 100644 --- a/docs/reference/api/VerifiedApprover.mdx +++ b/docs/reference/api/VerifiedApprover.mdx @@ -5,7 +5,7 @@ description: "Who answered, as the surface that took the answer verified them (S {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.VerifiedApprover` — class, defined at `src/ctrlrun/approval.py:127` +`ctrlrun.VerifiedApprover` — class, defined at `src/ctrlrun/approval.py:129` ```python from ctrlrun import VerifiedApprover diff --git a/docs/reference/api/WebhookApprovalProvider.mdx b/docs/reference/api/WebhookApprovalProvider.mdx index ac7777e..baccaf2 100644 --- a/docs/reference/api/WebhookApprovalProvider.mdx +++ b/docs/reference/api/WebhookApprovalProvider.mdx @@ -5,7 +5,7 @@ description: "Notify a human system on `APPROVAL_REQUESTED`, and let it answer ( {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.WebhookApprovalProvider` — class, defined at `src/ctrlrun/webhook.py:141` +`ctrlrun.WebhookApprovalProvider` — class, defined at `src/ctrlrun/webhook.py:143` ```python from ctrlrun import WebhookApprovalProvider diff --git a/docs/reference/api/acs-AcsControlHook.mdx b/docs/reference/api/acs-AcsControlHook.mdx index e7988ef..37a4bea 100644 --- a/docs/reference/api/acs-AcsControlHook.mdx +++ b/docs/reference/api/acs-AcsControlHook.mdx @@ -5,7 +5,7 @@ description: "Answer ACS `steps/*` hooks with CTRLRun's decisions and outcomes." {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.acs.AcsControlHook` — class, defined at `src/ctrlrun/acs.py:84` +`ctrlrun.acs.AcsControlHook` — class, defined at `src/ctrlrun/acs.py:86` ```python from ctrlrun.acs import AcsControlHook diff --git a/docs/reference/api/action_hash.mdx b/docs/reference/api/action_hash.mdx index adf9177..592f372 100644 --- a/docs/reference/api/action_hash.mdx +++ b/docs/reference/api/action_hash.mdx @@ -5,7 +5,7 @@ description: "Return the action hash used to bind approvals to an exact action ( {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.action_hash` — function, defined at `src/ctrlrun/action.py:351` +`ctrlrun.action_hash` — function, defined at `src/ctrlrun/action.py:353` ```python from ctrlrun import action_hash diff --git a/docs/reference/api/authority-Budget.mdx b/docs/reference/api/authority-Budget.mdx index 9c41dbe..6938919 100644 --- a/docs/reference/api/authority-Budget.mdx +++ b/docs/reference/api/authority-Budget.mdx @@ -5,7 +5,7 @@ description: "How much, over what, in how long (SPEC-v0.9 §2.2)." {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.authority.Budget` — class, defined at `src/ctrlrun/authority.py:366` +`ctrlrun.authority.Budget` — class, defined at `src/ctrlrun/authority.py:383` ```python from ctrlrun.authority import Budget diff --git a/docs/reference/api/banner.mdx b/docs/reference/api/banner.mdx index fd0308d..92e33ba 100644 --- a/docs/reference/api/banner.mdx +++ b/docs/reference/api/banner.mdx @@ -5,7 +5,7 @@ description: "Log SPEC-v0.3 §6.5's observe banner, once per `Control`. An adapt {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.banner` — function, defined at `src/ctrlrun/adapter.py:473` +`ctrlrun.banner` — function, defined at `src/ctrlrun/adapter.py:475` ```python from ctrlrun import banner diff --git a/docs/reference/api/canonical_bytes.mdx b/docs/reference/api/canonical_bytes.mdx index a954cdd..817893f 100644 --- a/docs/reference/api/canonical_bytes.mdx +++ b/docs/reference/api/canonical_bytes.mdx @@ -5,7 +5,7 @@ description: "The canonical form of an arbitrary mapping: UTF-8 JSON, sorted key {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.canonical_bytes` — function, defined at `src/ctrlrun/action.py:252` +`ctrlrun.canonical_bytes` — function, defined at `src/ctrlrun/action.py:254` ```python from ctrlrun import canonical_bytes diff --git a/docs/reference/api/canonicalize.mdx b/docs/reference/api/canonicalize.mdx index e2fbc06..d2d4640 100644 --- a/docs/reference/api/canonicalize.mdx +++ b/docs/reference/api/canonicalize.mdx @@ -5,7 +5,7 @@ description: "Return the canonical form of an Action: UTF-8 JSON, sorted keys, n {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.canonicalize` — function, defined at `src/ctrlrun/action.py:332` +`ctrlrun.canonicalize` — function, defined at `src/ctrlrun/action.py:334` ```python from ctrlrun import canonicalize diff --git a/docs/reference/api/conformance-run.mdx b/docs/reference/api/conformance-run.mdx index ded0281..e9b05dd 100644 --- a/docs/reference/api/conformance-run.mdx +++ b/docs/reference/api/conformance-run.mdx @@ -5,7 +5,7 @@ description: "Drive every suite through `adapter` and report what each came to ( {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.conformance.run` — function, defined at `src/ctrlrun/conformance/suites.py:1083` +`ctrlrun.conformance.run` — function, defined at `src/ctrlrun/conformance/suites.py:1085` ```python from ctrlrun.conformance import run diff --git a/docs/reference/api/conformance-store-run.mdx b/docs/reference/api/conformance-store-run.mdx index 4e4e72d..3069d62 100644 --- a/docs/reference/api/conformance-store-run.mdx +++ b/docs/reference/api/conformance-store-run.mdx @@ -5,7 +5,7 @@ description: "Drive every case against `backend` and report what each came to (S {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.conformance.store.run` — function, defined at `src/ctrlrun/conformance/store/__init__.py:53` +`ctrlrun.conformance.store.run` — function, defined at `src/ctrlrun/conformance/store/__init__.py:55` ```python from ctrlrun.conformance.store import run diff --git a/docs/reference/api/context.mdx b/docs/reference/api/context.mdx index ca76059..4cb2fa3 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:322` +`ctrlrun.context` — function, defined at `src/ctrlrun/control.py:420` ```python from ctrlrun import context diff --git a/docs/reference/api/gateway-serve.mdx b/docs/reference/api/gateway-serve.mdx index 4798cb7..03d9431 100644 --- a/docs/reference/api/gateway-serve.mdx +++ b/docs/reference/api/gateway-serve.mdx @@ -5,7 +5,7 @@ description: "Run a gateway in front of one upstream MCP server (SPEC-v0.2 §6.1 {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.gateway.serve` — function, defined at `src/ctrlrun/gateway/__init__.py:41` +`ctrlrun.gateway.serve` — function, defined at `src/ctrlrun/gateway/__init__.py:45` ```python from ctrlrun.gateway import serve diff --git a/docs/reference/api/idempotency_token.mdx b/docs/reference/api/idempotency_token.mdx index 1f7139f..413f193 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:290` +`ctrlrun.idempotency_token` — function, defined at `src/ctrlrun/control.py:388` ```python from ctrlrun import idempotency_token diff --git a/docs/reference/api/jwt_identity-JWTIdentityProvider.mdx b/docs/reference/api/jwt_identity-JWTIdentityProvider.mdx index dfb8c46..4739383 100644 --- a/docs/reference/api/jwt_identity-JWTIdentityProvider.mdx +++ b/docs/reference/api/jwt_identity-JWTIdentityProvider.mdx @@ -5,7 +5,7 @@ description: "Verify a bearer JWT and map its verified claims onto a `Principal` {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.jwt_identity.JWTIdentityProvider` — class, defined at `src/ctrlrun/jwt_identity.py:113` +`ctrlrun.jwt_identity.JWTIdentityProvider` — class, defined at `src/ctrlrun/jwt_identity.py:115` ```python from ctrlrun.jwt_identity import JWTIdentityProvider diff --git a/docs/reference/api/needs_approval.mdx b/docs/reference/api/needs_approval.mdx index 59a0523..86d5b20 100644 --- a/docs/reference/api/needs_approval.mdx +++ b/docs/reference/api/needs_approval.mdx @@ -5,7 +5,7 @@ description: "Does this call need a human? For a framework that asks before it i {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.needs_approval` — function, defined at `src/ctrlrun/adapter.py:428` +`ctrlrun.needs_approval` — function, defined at `src/ctrlrun/adapter.py:430` ```python from ctrlrun import needs_approval diff --git a/docs/reference/api/otel-OTelEventSink.mdx b/docs/reference/api/otel-OTelEventSink.mdx index 6338d0c..bd58427 100644 --- a/docs/reference/api/otel-OTelEventSink.mdx +++ b/docs/reference/api/otel-OTelEventSink.mdx @@ -5,7 +5,7 @@ description: "Export every `Event` and `Receipt` as OpenTelemetry spans (SPEC-v0 {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.otel.OTelEventSink` — class, defined at `src/ctrlrun/otel.py:45` +`ctrlrun.otel.OTelEventSink` — class, defined at `src/ctrlrun/otel.py:47` ```python from ctrlrun.otel import OTelEventSink diff --git a/docs/reference/api/parse_conditions.mdx b/docs/reference/api/parse_conditions.mdx index ef816d6..a94127e 100644 --- a/docs/reference/api/parse_conditions.mdx +++ b/docs/reference/api/parse_conditions.mdx @@ -5,7 +5,7 @@ description: "Parse a `when:`-shaped mapping into conditions, keyed by the raw c {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.parse_conditions` — function, defined at `src/ctrlrun/policy.py:1248` +`ctrlrun.parse_conditions` — function, defined at `src/ctrlrun/policy.py:1328` ```python from ctrlrun import parse_conditions diff --git a/docs/reference/api/postgres-PostgresStateStore.mdx b/docs/reference/api/postgres-PostgresStateStore.mdx index 1260b0b..9e91e77 100644 --- a/docs/reference/api/postgres-PostgresStateStore.mdx +++ b/docs/reference/api/postgres-PostgresStateStore.mdx @@ -5,7 +5,7 @@ description: "Approvals, effects and evidence in a Postgres schema (SPEC-v0.6 § {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.postgres.PostgresStateStore` — class, defined at `src/ctrlrun/postgres.py:333` +`ctrlrun.postgres.PostgresStateStore` — class, defined at `src/ctrlrun/postgres.py:335` ```python from ctrlrun.postgres import PostgresStateStore diff --git a/docs/reference/api/protect.mdx b/docs/reference/api/protect.mdx index 8e00624..261ea15 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:4576` +`ctrlrun.protect` — function, defined at `src/ctrlrun/control.py:4939` ```python from ctrlrun import protect @@ -13,7 +13,7 @@ from ctrlrun import protect ```python -def protect(name: str, *, effect: str | None = None, resource: str | None = None, wait: bool = False, lease: timedelta | None = None, reconcile: Callable[[str], ReconcileOutcome] | None = None, reconcile_eagerly: bool = False, control: Control | None = None, preconditions: Callable[[Action], Mapping[str, Any]] | None = None, task: str | None = None, scope: Callable[[Action], Mapping[str, Any]] | None = None) -> Callable[[Callable[P, R]], Callable[P, R]] +def protect(name: str, *, effect: str | None = None, resource: str | None = None, wait: bool = False, lease: timedelta | None = None, reconcile: Callable[[str], ReconcileOutcome] | None = None, reconcile_eagerly: bool = False, control: Control | None = None, preconditions: Callable[[Action], Mapping[str, Any]] | None = None, task: str | None = None, hop: str | None = None, scope: Callable[[Action], Mapping[str, Any]] | None = None) -> Callable[[Callable[P, R]], Callable[P, R]] ``` Bind a function to an action name: every call becomes a decided, recorded Action. diff --git a/docs/reference/api/state-Charge.mdx b/docs/reference/api/state-Charge.mdx index 41c4302..7f828af 100644 --- a/docs/reference/api/state-Charge.mdx +++ b/docs/reference/api/state-Charge.mdx @@ -5,7 +5,7 @@ description: "What one reservation spends against one grant's budget (SPEC-v0.9 {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.state.Charge` — class, defined at `src/ctrlrun/state.py:511` +`ctrlrun.state.Charge` — class, defined at `src/ctrlrun/state.py:513` ```python from ctrlrun.state import Charge diff --git a/docs/reference/api/state-Consumption.mdx b/docs/reference/api/state-Consumption.mdx index ce89b28..5c2d3aa 100644 --- a/docs/reference/api/state-Consumption.mdx +++ b/docs/reference/api/state-Consumption.mdx @@ -5,7 +5,7 @@ description: "One ledger row, as `consumptions()` hands it back (SPEC-v0.9 §3.3 {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.state.Consumption` — class, defined at `src/ctrlrun/state.py:551` +`ctrlrun.state.Consumption` — class, defined at `src/ctrlrun/state.py:553` ```python from ctrlrun.state import Consumption diff --git a/docs/reference/api/state-check_charges.mdx b/docs/reference/api/state-check_charges.mdx index 48369b0..352b812 100644 --- a/docs/reference/api/state-check_charges.mdx +++ b/docs/reference/api/state-check_charges.mdx @@ -5,7 +5,7 @@ description: "SPEC-v0.9 §3.3.1's predicate, in one place so three backends cann {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.state.check_charges` — function, defined at `src/ctrlrun/state.py:569` +`ctrlrun.state.check_charges` — function, defined at `src/ctrlrun/state.py:571` ```python from ctrlrun.state import check_charges diff --git a/docs/reference/api/transport-HTTPConnection.mdx b/docs/reference/api/transport-HTTPConnection.mdx index f87022f..db2c277 100644 --- a/docs/reference/api/transport-HTTPConnection.mdx +++ b/docs/reference/api/transport-HTTPConnection.mdx @@ -5,7 +5,7 @@ description: "`http.client.HTTPConnection`, plus `NotExecuted` from `connect()` {/* 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` +`ctrlrun.transport.HTTPConnection` — class, defined at `src/ctrlrun/transport.py:114` ```python from ctrlrun.transport import HTTPConnection diff --git a/docs/reference/api/transport-HTTPSConnection.mdx b/docs/reference/api/transport-HTTPSConnection.mdx index ff60641..8056f2a 100644 --- a/docs/reference/api/transport-HTTPSConnection.mdx +++ b/docs/reference/api/transport-HTTPSConnection.mdx @@ -5,7 +5,7 @@ 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` +`ctrlrun.transport.HTTPSConnection` — class, defined at `src/ctrlrun/transport.py:191` ```python from ctrlrun.transport import HTTPSConnection diff --git a/docs/reference/api/transport-Transport.mdx b/docs/reference/api/transport-Transport.mdx index 65b02ef..7dfd6b0 100644 --- a/docs/reference/api/transport-Transport.mdx +++ b/docs/reference/api/transport-Transport.mdx @@ -5,7 +5,7 @@ description: "What a transport observed, where no answer came back (SPEC-v0.2 § {/* 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` +`ctrlrun.transport.Transport` — class, defined at `src/ctrlrun/transport.py:67` ```python from ctrlrun.transport import Transport diff --git a/docs/reference/api/transport-effect_state.mdx b/docs/reference/api/transport-effect_state.mdx index a86a4f9..ef865b7 100644 --- a/docs/reference/api/transport-effect_state.mdx +++ b/docs/reference/api/transport-effect_state.mdx @@ -5,7 +5,7 @@ description: "The rule (SPEC-v0.7 §2.1): `FAILED` for `NEVER_CONNECTED`, `AMBIG {/* 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` +`ctrlrun.transport.effect_state` — function, defined at `src/ctrlrun/transport.py:95` ```python from ctrlrun.transport import effect_state diff --git a/docs/reference/api/transport-urlopen.mdx b/docs/reference/api/transport-urlopen.mdx index 13da67b..bc8d401 100644 --- a/docs/reference/api/transport-urlopen.mdx +++ b/docs/reference/api/transport-urlopen.mdx @@ -5,7 +5,7 @@ description: "`urllib.request.urlopen` for `http` and `https`, classified (SPEC- {/* 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` +`ctrlrun.transport.urlopen` — function, defined at `src/ctrlrun/transport.py:214` ```python from ctrlrun.transport import urlopen diff --git a/docs/reference/api/verify-run.mdx b/docs/reference/api/verify-run.mdx index 9a8b618..55b0eda 100644 --- a/docs/reference/api/verify-run.mdx +++ b/docs/reference/api/verify-run.mdx @@ -5,7 +5,7 @@ description: "Run the applicable guarantees against this configuration and repor {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.verify.run` — function, defined at `src/ctrlrun/verify/__init__.py:154` +`ctrlrun.verify.run` — function, defined at `src/ctrlrun/verify/__init__.py:156` ```python from ctrlrun.verify import run diff --git a/docs/reference/api/with_approval.mdx b/docs/reference/api/with_approval.mdx index de2b8cc..366818b 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:345` +`ctrlrun.with_approval` — function, defined at `src/ctrlrun/control.py:443` ```python from ctrlrun import with_approval diff --git a/docs/reference/cli.mdx b/docs/reference/cli.mdx index c9e7fba..8dfb504 100644 --- a/docs/reference/cli.mdx +++ b/docs/reference/cli.mdx @@ -166,6 +166,8 @@ Usage: ctrlrun inspect [OPTIONS] [ACTION_ID] Options: --grant TEXT Show this grant's budgets instead: consumed, held, and what holds it. + --hop TEXT Show this hop or delegation instead: who issued it, and what + each link narrowed. --json Emit one JSON object instead. --store-url TEXT The store to open. Default: $CTRLRUN_STORE_URL, else the SQLite database beside the policy (.ctrlrun/state.db, or diff --git a/docs/reference/receipt-and-event-schemas.mdx b/docs/reference/receipt-and-event-schemas.mdx index 763e647..1536a7e 100644 --- a/docs/reference/receipt-and-event-schemas.mdx +++ b/docs/reference/receipt-and-event-schemas.mdx @@ -57,8 +57,9 @@ it. A reader checking a chain compares those two. | `authority_grant_id` | `str | None` | `None` | | `task` | `str | None` | `None` | | `scope_hash` | `str | None` | `None` | +| `hop` | `str | None` | `None` | | `budget_charges` | `tuple` | `()` | -| `schema` | `str` | `'ctrlrun.receipt/v6'` | +| `schema` | `str` | `'ctrlrun.receipt/v7'` | | `_stored_document` | `collections.abc.Mapping[str, Any] | None` | `None` | ### Result diff --git a/docs/verify.md b/docs/verify.md index 4ee5e68..b8523e9 100644 --- a/docs/verify.md +++ b/docs/verify.md @@ -58,6 +58,9 @@ G23 a failing scope provider refuses PASS stripe.refund own code, which verify cannot read. The gateway and the ACS hook cannot name a provider at all (SPEC-v0.9 §5.2.2)) G24 grant refused off its task PASS head-of-support +G25 a hop narrows or it is refused PASS head-of-support +G26 a hop is named on both sides PASS head-of-support +G27 a swapped upstream is denied PASS payouts (a token is unique only as far as your effect keys are: two stores sharing a provider account must not produce the same effect-key string for different effects, and nothing diff --git a/generated/readiness.full.mdx b/generated/readiness.full.mdx index 12b6d44..52af7c0 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.9.0**, on [PyPI](https://pypi.org/project/ctrlrun/), Python 3.11 and later. -- **5,975 tests**, every version specified before it was written and every requirement mutation-tested. [Read more](/docs/how-this-is-built). -- **24 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.10.0**, on [PyPI](https://pypi.org/project/ctrlrun/), Python 3.11 and later. +- **6,048 tests**, every version specified before it was written and every requirement mutation-tested. [Read more](/docs/how-this-is-built). +- **27 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 c819159..181d0e5 100644 --- a/generated/readiness.json +++ b/generated/readiness.json @@ -1,6 +1,6 @@ { - "guarantees": 24, - "released": "0.9.0", + "guarantees": 27, + "released": "0.10.0", "soak": { "actions": 889735, "backend": "postgres", @@ -9,6 +9,6 @@ "positive_control": true, "unexplained": 0 }, - "tests": 5975, - "version": "0.9.0" + "tests": 6048, + "version": "0.10.0" } diff --git a/generated/readiness.mdx b/generated/readiness.mdx index 23741ee..682e0f3 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.9.0**, on [PyPI](https://pypi.org/project/ctrlrun/), Python 3.11 and later. -- **5,975 tests**, every version specified before it was written and every requirement mutation-tested. -- **24 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.10.0**, on [PyPI](https://pypi.org/project/ctrlrun/), Python 3.11 and later. +- **6,048 tests**, every version specified before it was written and every requirement mutation-tested. +- **27 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 9428f97..e944dae 100644 --- a/generated/readiness.readme.md +++ b/generated/readiness.readme.md @@ -1,7 +1,7 @@ -- **Version 0.9.0**, on [PyPI](https://pypi.org/project/ctrlrun/), Python 3.11 and later. -- **5,975 tests**, every version specified before it was written and every requirement mutation-tested. -- **24 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.10.0**, on [PyPI](https://pypi.org/project/ctrlrun/), Python 3.11 and later. +- **6,048 tests**, every version specified before it was written and every requirement mutation-tested. +- **27 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. From 534166221babc7c6b499fe0344c9d0c56dd70ba8 Mon Sep 17 00:00:00 2001 From: arpan Date: Mon, 14 Sep 2026 03:49:01 +0530 Subject: [PATCH 6/7] The cookbook generator emits the licence the kernel requires, and a test says so Found by running the generators for this release against the kernel worktree: nineteen files came back modified, every one of them with the two SPDX lines **removed**. The kernel's `test_every_source_file_carries_its_copyright_and_license` requires `# SPDX-FileCopyrightText` and `# SPDX-License-Identifier` at the top of every `.py` and `.sh` under `examples/`. Nineteen of those files are written by `render_cookbook.py` in this repository, which never emitted them. They are committed in the kernel because they were added by hand there, so nothing was red; the next `--write` from here would have stripped all nineteen and turned the kernel's own suite red on output this repository produced. `.yaml` is excluded because the kernel's rule names the two suffixes it checks, and the extracted policies have never carried the tags. The test asserts that direction too, so adding them to the policies is drift rather than tidiness. `render_cookbook.py` now reports `19 recipes, 0 drifted` against the kernel's committed tree, which it did not before. Mutated: the two emitting lines removed, both the new test and the existing drift test fail; restored, 24 pass. A rule in one repository that only holds because of a hand edit in the other is the shape this milestone kept finding. This is the connection, stated on the side that writes the files. --- tests/test_cookbook_pages.py | 29 +++++++++++++++++++++++++++++ tools/docs_audit/render_cookbook.py | 12 ++++++++++++ 2 files changed, 41 insertions(+) diff --git a/tests/test_cookbook_pages.py b/tests/test_cookbook_pages.py index 7be1d27..112947d 100644 --- a/tests/test_cookbook_pages.py +++ b/tests/test_cookbook_pages.py @@ -47,6 +47,35 @@ def test_every_recipe_directory_is_what_its_page_shows(): assert render_cookbook.check(render_cookbook.recipes()) == [] +def test_every_extracted_script_carries_the_licence_the_kernel_requires(): + """The kernel's `test_every_source_file_carries_its_copyright_and_license` requires two SPDX + lines at the top of every `.py` and `.sh` under `examples/`, and this generator writes + nineteen of those files. It did not emit them, so the first run after that rule landed stripped + them off all nineteen and turned the kernel's own suite red on output produced here. + + The kernel's rule and this generator are in two repositories and nothing connected them. This + is the connection, stated on the side that writes the files. The `.yaml` files are excluded + because the kernel's rule names the two suffixes it checks and the extracted policies have + never carried the tags. + """ + tags = ( + "# SPDX-FileCopyrightText: 2026 The CTRLRun contributors", + "# SPDX-License-Identifier: Apache-2.0", + ) + checked = 0 + for name, files in render_cookbook.recipes().items(): + for filename, content in files.items(): + if not filename.endswith((".py", ".sh")): + assert not content.startswith(tags[0]), ( + f"{name}/{filename}: the kernel's rule does not cover this suffix, so a tag " + "here is drift in the other direction" + ) + continue + checked += 1 + assert content.splitlines()[:2] == list(tags), f"{name}/{filename}" + assert checked >= 19, checked + + def test_a_hand_edit_to_an_extracted_file_is_drift(tmp_path, monkeypatch): monkeypatch.setattr(render_cookbook, "EXAMPLES", tmp_path) extracted = render_cookbook.recipes() diff --git a/tools/docs_audit/render_cookbook.py b/tools/docs_audit/render_cookbook.py index 1a35330..2a68cea 100644 --- a/tools/docs_audit/render_cookbook.py +++ b/tools/docs_audit/render_cookbook.py @@ -25,6 +25,16 @@ "# Extracted by CTRLRun/ctrlrun-docs tools/docs_audit/render_cookbook.py from\n" "# docs/cookbook/{name}.mdx — edit the page, never this file.\n" ) +# The kernel's `test_every_source_file_carries_its_copyright_and_license` requires these two +# lines, in this order, at the top of every `.py` and `.sh` under `examples/`. A generator that +# did not emit them stripped them off nineteen files the moment it next ran, and the kernel's own +# suite went red on output this repository produced. Not the `.yaml` files: that test checks the +# two suffixes it names, and the extracted policies have never carried the tags. +SPDX = ( + "# SPDX-FileCopyrightText: 2026 The CTRLRun contributors\n" + "# SPDX-License-Identifier: Apache-2.0\n" +) +SPDX_SUFFIXES = (".py", ".sh") def recipes() -> dict[str, dict[str, str]]: @@ -43,6 +53,8 @@ def recipes() -> dict[str, dict[str, str]]: continue name = "ctrlrun.yaml" header = HEADER.format(name=page.stem) + if name.endswith(SPDX_SUFFIXES): + header = SPDX + header files[name] = header + fence.body # A recipe is a directory only where the page carries something to run. The two adapter # recipes show a framework's own code, which the adapters CI job runs against a real From dbea7d66a93388c4b47e2f483632c8b47cc6dac2 Mon Sep 17 00:00:00 2001 From: arpan Date: Mon, 14 Sep 2026 04:06:01 +0530 Subject: [PATCH 7/7] Regenerate against the release, and four things the regeneration found MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merging `ci/docs-job-follows-the-branch` gives this branch the pairing rule, so its CI checks these pages against ctrlrun `release/0.10.0` rather than against `main`. Without it this PR is red by construction until the kernel release merges, which is the ordering the pairing exists to fix. Twenty-three conflicts were generated files, resolved by regenerating rather than by choosing a side: all seven generators against the release worktree, `19 recipes, 0 drifted`, `83 pages`, readiness re-pasted into both homes (`6,056` tests now, this branch added eight). Four findings, each from running something rather than reading it. **1. `docs/verify.md` quoted a transcript no run produces.** It carried `ctrlrun 0.9.0` in the header and `G27 ... PASS payouts` in the body at once — G27 passes on `examples/authority-escalation`, and that block is the run against `examples/authority/payments.yaml`, where it is `N/A no action entry pins an upstream`. Replaced with the real 53 lines. I resolved this conflict the wrong way first; the other branch had it right. **2. Both CI pages leaked an absolute local path, and had before this branch.** `policy /private/var/folders/gp/…/T/tmpdi6bvpqd/ctrlrun.yaml` was on `docs/guides/verify-in-ci.mdx` and `docs/cookbook/verify-in-github-actions.mdx` in `HEAD`, from an earlier refresh that pasted a temp directory into a published page. The test that guards these transcripts **skips lines starting with `policy `**, so it never saw it. Both now read `./ctrlrun.yaml`. **3. 153 stale `CLAIMS.md` citations, and the three that were blocking all of them.** The SPDX headers moved every line by two. `repoint-claims.py` is all-or-nothing and refused to write because three citations could not be resolved: `action.py:79` and `migrations.py:107`, whose real lines are `:81` and `:109` — the same +2, unresolvable only because the script's heuristic cannot anchor a bare `if isinstance(...)`. Fixed by hand; the script then re-pointed the other 147. **4. The OWASP page was missing G25, G26 and G27, and contradicted itself about `ASI04`.** The rows are added. `ASI04` is **out of scope with no guarantee mapped to it**, and the pinning slice is mapped where binding a peer and a tool schema belongs, under `ASI02` and `ASI07`. It previously opened "one slice" while the disclaimer three paragraphs up said two entries are not addressed **at all**; both cannot be true. The disclaimer now also says it was three until v0.10. Whole suite green against the release worktree. --- docs.mdx | 2 +- docs/CLAIMS.md | 166 ++++++++++----------- docs/OWASP-AGENTIC-TOP10.md | 10 +- docs/cookbook/verify-in-github-actions.mdx | 6 +- docs/guides/verify-in-ci.mdx | 8 +- docs/production/index.mdx | 2 +- docs/verify.md | 6 +- generated/readiness.full.mdx | 2 +- generated/readiness.json | 2 +- generated/readiness.mdx | 2 +- generated/readiness.readme.md | 2 +- 11 files changed, 108 insertions(+), 100 deletions(-) diff --git a/docs.mdx b/docs.mdx index 3551508..8d2f035 100644 --- a/docs.mdx +++ b/docs.mdx @@ -219,7 +219,7 @@ the framework's own interrupt, and a framework with no such primitive does not n {/* generated from the suite, pyproject and the soak (mdx) — run the generator */} - **Version 0.10.0**, on [PyPI](https://pypi.org/project/ctrlrun/), Python 3.11 and later. -- **6,048 tests**, every version specified before it was written and every requirement mutation-tested. +- **6,056 tests**, every version specified before it was written and every requirement mutation-tested. - **27 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). diff --git a/docs/CLAIMS.md b/docs/CLAIMS.md index cc3a8c5..a52d6ce 100644 --- a/docs/CLAIMS.md +++ b/docs/CLAIMS.md @@ -25,17 +25,17 @@ 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:1294` — 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:655` — passes only the action's **name and arguments** to `_ActionPolicy.evaluate` (`policy.py:655`), 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:251`) 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:248`. Exactly as approved: the approval is bound to `action_hash` and consumed with the reservation — `_authorize_and_reserve` — `state.py:1196`. Or not at all: a refusal raises before the executor — `Control.execute` — `control.py:1294`. Says so instead of guessing: only `NotExecuted` maps to `FAILED` — `_outcome` — `control.py:2172` — and everything else is `AMBIGUOUS`. A receipt: `Receipt` — `receipt.py:250`. **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` | +| "The last check before an AI agent does something it can't undo." | `Control.execute` — `control.py:1304` — 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:657` — passes only the action's **name and arguments** to `_ActionPolicy.evaluate` (`policy.py:657`), 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:253`) 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:250`. Exactly as approved: the approval is bound to `action_hash` and consumed with the reservation — `_authorize_and_reserve` — `state.py:1198`. Or not at all: a refusal raises before the executor — `Control.execute` — `control.py:1304`. Says so instead of guessing: only `NotExecuted` maps to `FAILED` — `_outcome` — `control.py:2209` — and everything else is `AMBIGUOUS`. A receipt: `Receipt` — `receipt.py:252`. **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:1476` — 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` | +| "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:1478` — 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` | ## The refund that happened twice | 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` — `_outcome` — `control.py:1301`; 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:589` — and `Control._reconciled` — `control.py:2826` | `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` — `_outcome` — `control.py:1311`; a retry against an `AMBIGUOUS` key is refused by `plan_reservation` — `effect.py:250`; the two things permitted to move the record on and nothing else — `resolve` — `cli/main.py:591` — and `Control._reconciled` — `control.py:2863` | `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,65 +50,65 @@ 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` — `_outcome` — `control.py:2172`; `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:637`; `PostgresStateStore.reserve_effect` — `postgres.py:740` | `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:1196` | `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:655` | `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:1294` | `test_T74_a_denial_leaves_no_pending_approval_request` | -| "Neither axis reads the agent's instructions" | `Policy.evaluate` — `policy.py:655` — sees the action's name and arguments; `Authority.evaluate` — `authority.py:1294` — 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:79` | `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:1196` — 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`." | `_outcome` — `control.py:2172`; `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:793`; `prev_hash`, `GENESIS_HASH` for the first — `receipt.py:125` | `test_T172_every_receipt_carries_the_hash_and_the_declared_version`, `test_T164_an_altered_receipt_is_content_altered_at_its_seq` | +| "A lost reply is `AMBIGUOUS`, never `FAILED`, and a retry against an `AMBIGUOUS` effect is refused." | Only `NotExecuted` maps to `FAILED` — `_outcome` — `control.py:2209`; `plan_reservation` — `effect.py:250` | `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:639`; `PostgresStateStore.reserve_effect` — `postgres.py:742` | `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:1198` | `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:657` | `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:1304` | `test_T74_a_denial_leaves_no_pending_approval_request` | +| "Neither axis reads the agent's instructions" | `Policy.evaluate` — `policy.py:657` — sees the action's name and arguments; `Authority.evaluate` — `authority.py:1296` — 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:81` | `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:1198` — 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`." | `_outcome` — `control.py:2209`; `NotExecuted` — `errors.py:159` | `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:795`; `prev_hash`, `GENESIS_HASH` for the first — `receipt.py:127` | `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:4902`) 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:355` | 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:377`; `with_approval` — `control.py:392`; `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:397`; `serve` — `gateway/__init__.py:41` | `test_T19_the_upstream_receives_the_canonical_arguments` | +| "You probably do not need an adapter" | Three ways in, and `@protect` (`control.py:4939`) 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:357` | 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:381`; `with_approval` — `control.py:392`; `ApprovalRequired` (`errors.py:90`) 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:42` | `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:399`; `serve` — `gateway/__init__.py:45` | `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:979`; `McpOptions` — `policy.py:600` | `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:93` | `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: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:150` | `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` | -| "an adapter never constructs one and never supplies a principal" | `needs_approval` — `adapter.py:428` — resolves the principal from the `Control` so no adapter builds an `Action` | `test_T129_no_public_callable_takes_a_principal`, `test_T129_the_module_exposes_no_way_to_construct_a_control` | -| "prevention" / "attribution" | `carries_approved_arguments` gates §3.4's rebuild in `_check_answer` — `adapter.py:246` | `test_T137b_the_readme_says_the_binding_is_attribution_and_why` | +| "they are declared in the policy" (effect and resource templates for a tool call) | `Policy.effect_template` / `resource_template` — `policy.py:981`; `McpOptions` — `policy.py:602` | `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:95` | `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:131`, 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:161` | `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:182` — 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:256` — calls `grant_approval` / `deny_approval`, and an adapter calls neither | `test_T130_each_broken_fixture_fails_the_suite_named_for_it` | +| "an adapter never constructs one and never supplies a principal" | `needs_approval` — `adapter.py:430` — resolves the principal from the `Control` so no adapter builds an `Action` | `test_T129_no_public_callable_takes_a_principal`, `test_T129_the_module_exposes_no_way_to_construct_a_control` | +| "prevention" / "attribution" | `carries_approved_arguments` gates §3.4's rebuild in `_check_answer` — `adapter.py:248` | `test_T137b_the_readme_says_the_binding_is_attribution_and_why` | | "Adapters ship on their own version line" | `adapters/*/pyproject.toml`, never in the `ctrlrun` wheel or sdist | `test_T136_the_ctrlrun_distributions_contain_no_adapter` | ## Write down what the agent may do | Claim | Code | Proof | |---|---|---| -| "cheap to undo is autonomous, anything that leaves the building needs a human, money is by amount with both ends bound" | `Decision` — `policy.py:308` — is exactly `allow`, `approve`, `deny`; rules match first-wins over `Condition` (`policy.py:399`) with the operators `eq`, `neq`, `in`, `lt`, `lte`, `gt`, `gte` — `_OPERATORS` — `policy.py:119` | `test_T6_an_action_name_is_matched_exactly`, `test_T176_the_operators_behave_as_they_do_everywhere_else` | -| "Unknown actions are denied; there is no default-allow." | `Policy.evaluate` — `policy.py:655` | `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:79` | `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:655` — 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:1286`; `Control._authority_result` — `control.py:1010` | `test_T67_a_principal_with_no_grant_is_denied` | +| "cheap to undo is autonomous, anything that leaves the building needs a human, money is by amount with both ends bound" | `Decision` — `policy.py:308` — is exactly `allow`, `approve`, `deny`; rules match first-wins over `Condition` (`policy.py:401`) with the operators `eq`, `neq`, `in`, `lt`, `lte`, `gt`, `gte` — `_OPERATORS` — `policy.py:125` | `test_T6_an_action_name_is_matched_exactly`, `test_T176_the_operators_behave_as_they_do_everywhere_else` | +| "Unknown actions are denied; there is no default-allow." | `Policy.evaluate` — `policy.py:657` | `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:81` | `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:657` — 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:1288`; `Control._authority_result` — `control.py:1114` | `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:84`), reached for reads and for actions with no effect key alike | `test_T67_an_action_the_policy_allows_outright_still_needs_a_grant` | +| "every principal needs a grant and no grant means denied" | `NO_AUTHORITY` — the fail-closed default of `Authority.evaluate` (`authority.py:86`), 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:4114`; `Authority.plan_delegation` — `authority.py:1538`; `ctrlrun delegate` — `cli/main.py:1045` | `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:984` — runs from `plan_delegation` (`authority.py:1538`) **and** from the chain walk in `Authority.evaluate` (`authority.py:1294`) | `test_t76_each_dimension_violated_alone`, `test_t77b_a_narrowed_parent_narrows_its_children` | -| "provably a subset of its parent on every dimension, at creation and again at every evaluation; a consequence budget is consumed inside the reservation's own transaction, and a rolling window bounds what may start rather than recalling what already did" | Containment as in the row above. The budget: `check_charges` — `state.py:569` — is evaluated inside `reserve_effect`'s own transaction on all three backends, and `_charges_for` — `authority.py:1393` — charges every ancestor in the chain. The window is rolling and bounds the next reserve only: `_spent` sums `[now - window, now]` and nothing reads it again after a reservation is taken | `test_T408_a_charge_and_its_reservation_are_one_transaction`, `test_T409_N_processes_racing_one_budget_spend_at_most_the_limit`, `test_T412b_every_ancestor_is_charged_through_a_real_chain`, `test_T408c_the_rolling_window_forgets` | -| "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:984`; the subject half is `_subject_contained` (`authority.py:1063`) | `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:4451` — writes one row — `revoke_delegation` — `state.py:835` 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:778`; `Control._observed` — `control.py:1678`; `_WouldHave` — `receipt.py:344`; `ReceiptResult.OBSERVED` — `receipt.py:256` | `test_T82_observe_executes_what_enforce_would_deny`, `test_T83_a_duplicate_is_recorded_and_still_runs` | +| "narrow it at runtime with `ctrlrun delegate`" | `Control.delegate` — `control.py:4151`; `Authority.plan_delegation` — `authority.py:1549`; `ctrlrun delegate` — `cli/main.py:1096` | `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:986` — runs from `plan_delegation` (`authority.py:1549`) **and** from the chain walk in `Authority.evaluate` (`authority.py:1296`) | `test_t76_each_dimension_violated_alone`, `test_t77b_a_narrowed_parent_narrows_its_children` | +| "provably a subset of its parent on every dimension, at creation and again at every evaluation; a consequence budget is consumed inside the reservation's own transaction, and a rolling window bounds what may start rather than recalling what already did" | Containment as in the row above. The budget: `check_charges` — `state.py:571` — is evaluated inside `reserve_effect`'s own transaction on all three backends, and `_charges_for` — `authority.py:1404` — charges every ancestor in the chain. The window is rolling and bounds the next reserve only: `_spent` sums `[now - window, now]` and nothing reads it again after a reservation is taken | `test_T408_a_charge_and_its_reservation_are_one_transaction`, `test_T409_N_processes_racing_one_budget_spend_at_most_the_limit`, `test_T412b_every_ancestor_is_charged_through_a_real_chain`, `test_T408c_the_rolling_window_forgets` | +| "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:986`; the subject half is `_subject_contained` (`authority.py:1065`) | `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:4488` — writes one row — `revoke_delegation` — `state.py:837` 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:778`; `Control._observed` — `control.py:1694`; `_WouldHave` — `receipt.py:346`; `ReceiptResult.OBSERVED` — `receipt.py:258` | `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:778` | `test_T84_mode_is_refused_anywhere_but_the_top_level` | -| "`ctrlrun stats` gives you the numbers" | `stats` — `cli/main.py:915`; 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:1678` | `test_T82_observe_executes_what_enforce_would_deny`, `test_T83_an_executor_that_fails_on_a_held_key_still_writes_the_record` | +| "`ctrlrun stats` gives you the numbers" | `stats` — `cli/main.py:917`; 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:1694` | `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 | Claim | Code | Proof | |---|---|---| -| "runs the kernel's own failure scenarios against the configuration in front of it" | `ctrlrun.verify.run` — `verify/__init__.py:154`; the eleven guarantees — `GUARANTEES` — `verify/guarantees.py:49`; the scenarios — `verify/scenarios.py` | `test_T100_the_authority_example_passes_every_non_authority_guarantee` (11/11), `test_T100_a_v1_document_with_no_templates_and_no_grants` | +| "runs the kernel's own failure scenarios against the configuration in front of it" | `ctrlrun.verify.run` — `verify/__init__.py:156`; the eleven guarantees — `GUARANTEES` — `verify/guarantees.py:51`; the scenarios — `verify/scenarios.py` | `test_T100_the_authority_example_passes_every_non_authority_guarantee` (11/11), `test_T100_a_v1_document_with_no_templates_and_no_grants` | | "in a scratch store, with fake executors, and no network" | One scratch store per guarantee under a temporary directory — `verify/scenarios.py`, `Engine.control`; `state_path()` is never called and `Control.from_file()` is never used | `test_T103_the_operators_store_is_byte_identical_before_and_after`, `test_T103_a_store_that_does_not_exist_is_not_created`, `test_T107_a_full_run_completes_with_no_network` | | "Your `.ctrlrun/state.db` is byte-identical before and after" | The scratch path is a `tempfile.mkdtemp` removed in a `finally` — `verify/__init__.py` | `test_T103_the_operators_store_is_byte_identical_before_and_after` (SHA-256 and `st_mtime_ns`), `test_T103_CTRLRUN_STATE_is_not_read_and_not_created` | | "Not applicable is not a pass" | `Report.applicable` is passes plus failures — `verify/report.py`; every N/A reason is a statement about the document — `verify/guarantees.py` | `test_T101_a_policy_with_no_approve_rule_makes_G1_and_G2_not_applicable`, `test_T102_a_policy_with_no_effect_templates_makes_G3_G4_and_G5_not_applicable` | @@ -133,52 +133,52 @@ keeps it honest: ## The capability matrix Rendered from `capabilities.yaml`; the six rows are the six groups of the verify -catalogue, `GUARANTEES` (`verify/guarantees.py:49`). +catalogue, `GUARANTEES` (`verify/guarantees.py:51`). | Claim | Code | Proof | |---|---|---| -| "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:665`; 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:637`, decided inside the `BEGIN IMMEDIATE` of `_authorize_and_reserve` (`state.py:1196`) 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` — `_outcome` — `control.py:2172`. 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:655` — answers `deny` for a name the document does not list. Missing or malformed policy: `Policy.from_file` — `policy.py:812` — raises `PolicyError`, and there is no `Control` without a policy. Missing principal: `_refuse_no_principal` — `control.py:4784` | `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:84`); `contained_dimension` — `authority.py:984` — runs from `plan_delegation` (`authority.py:1538`) 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:240`; `Event` — `receipt.py:306`; the store is authoritative — `append_event` — `state.py:846`; the JSONL export — `JSONLEventSink` — `receipt.py:817` | `test_T11_every_demo_receipt_carries_every_field_in_the_spec`, `test_T11_every_demo_receipt_parses_back_into_a_Receipt` | +| "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:667`; 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:639`, decided inside the `BEGIN IMMEDIATE` of `_authorize_and_reserve` (`state.py:1198`) against `effect_key TEXT PRIMARY KEY` (`migrations.py:109`; `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` — `_outcome` — `control.py:2209`. Every other exception, timeouts included, yields `AMBIGUOUS`. A retry against an `AMBIGUOUS` key is refused — `effect.py:174`, 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:657` — answers `deny` for a name the document does not list. Missing or malformed policy: `Policy.from_file` — `policy.py:814` — raises `PolicyError`, and there is no `Control` without a policy. Missing principal: `_refuse_no_principal` — `control.py:4821` | `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:86`); `contained_dimension` — `authority.py:986` — runs from `plan_delegation` (`authority.py:1549`) 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:242`; `Event` — `receipt.py:308`; the store is authoritative — `append_event` — `state.py:848`; the JSONL export — `JSONLEventSink` — `receipt.py:819` | `test_T11_every_demo_receipt_carries_every_field_in_the_spec`, `test_T11_every_demo_receipt_parses_back_into_a_Receipt` | ## What it guarantees | Claim | Code | Proof | |---|---|---| -| "On SQLite that is `BEGIN IMMEDIATE`" | `_authorize_and_reserve` — `state.py:1196` | `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:740` — `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:740` — 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:248` (refuse retry on `AMBIGUOUS`) and `_outcome` — `control.py:2172` (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:931`; `_resolve_lost_update` — `postgres.py:1557`; only `NotExecuted` maps to `FAILED` — `_outcome` — `control.py:2172` | `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:140` | `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:172`; who resolved it — `resolved_by` — `effect.py:208`; `resolve` — `cli/main.py:589` | `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:2826`; `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` | +| "On SQLite that is `BEGIN IMMEDIATE`" | `_authorize_and_reserve` — `state.py:1198` | `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:742` — `INSERT … ON CONFLICT DO NOTHING` against `effect_key TEXT PRIMARY KEY COLLATE "C"` (`migrations.py:109`) | `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:742` — and every other method implement `v0.1 §5.3`'s frozen protocol; the decisions stay in `plan_reservation` (`effect.py:250`) | `test_T154_postgres_passes_the_store_conformance_suite` | +| "graded by the suite written for SQLite" | `ctrlrun.conformance.store.run` — `conformance/store/__init__.py:55` | `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:250` (refuse retry on `AMBIGUOUS`) and `_outcome` — `control.py:2209` (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:933`; `_resolve_lost_update` — `postgres.py:1559`; only `NotExecuted` maps to `FAILED` — `_outcome` — `control.py:2209` | `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:142` | `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:174`; who resolved it — `resolved_by` — `effect.py:210`; `resolve` — `cli/main.py:591` | `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:2863`; `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:655`; `Policy.from_file` — `policy.py:812`; `_refuse_no_principal` — `control.py:4784`; `_authorize_and_reserve` — `state.py:1196` | `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:127` | `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:1286`; `contained_dimension` — `authority.py:984`; `Control.revoke` — `control.py:4451` | `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:208`; 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` | +| "Unknown action, missing policy, malformed policy, missing principal, missing or mismatched approval and inconsistent state are all `deny`." | `Policy.evaluate` — `policy.py:657`; `Policy.from_file` — `policy.py:814`; `_refuse_no_principal` — `control.py:4821`; `_authorize_and_reserve` — `state.py:1198` | `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:129` | `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:1288`; `contained_dimension` — `authority.py:986`; `Control.revoke` — `control.py:4488` | `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:210`; 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:793`, over `_canonical_policy` — `policy.py:1041`; carried into the receipt by `_record` — `control.py:4675` | `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:795`, over `_canonical_policy` — `policy.py:1043`; carried into the receipt by `_record` — `control.py:4712` | `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:783` | `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:1294`; `_spend_unneeded_approval` — `control.py:2766` | `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:533`; `prev_hash` — `receipt.py:452`; `GENESIS_HASH` — `receipt.py:125`; `put_receipt` takes the head row's lock first — `postgres.py:2092` | `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:935`; the six names — `CHAIN_BREAKS` — `receipt.py:872` | `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:633`, called from both stores' constructors; `HEAD` — `migrations.py:414` | `test_T147_a_v05_database_migrates_and_keeps_every_row`, `test_T150_reopening_does_not_rerun` | -| "An older binary against a newer schema refuses immediately" | `_refuse` — `migrations.py:559`; `SchemaMismatch` — `errors.py` | `test_T148_an_older_binary_refuses_a_newer_database`, `test_T148_no_other_table_is_read_before_the_refusal` | +| "the approval is re-checked against the policy in force at execution" | `Control.execute` — `control.py:1304`; `_spend_unneeded_approval` — `control.py:2803` | `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:535`; `prev_hash` — `receipt.py:454`; `GENESIS_HASH` — `receipt.py:127`; `put_receipt` takes the head row's lock first — `postgres.py:2094` | `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:937`; the six names — `CHAIN_BREAKS` — `receipt.py:874` | `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:635`, called from both stores' constructors; `HEAD` — `migrations.py:416` | `test_T147_a_v05_database_migrates_and_keeps_every_row`, `test_T150_reopening_does_not_rerun` | +| "An older binary against a newer schema refuses immediately" | `_refuse` — `migrations.py:561`; `SchemaMismatch` — `errors.py` | `test_T148_an_older_binary_refuses_a_newer_database`, `test_T148_no_other_table_is_read_before_the_refusal` | | "Releases carry PyPI provenance attestations from GitHub Actions" | `.github/workflows/publish.yml` — `pypa/gh-action-pypi-publish` pinned at v1.14.2, which generates and uploads PEP 740 attestations by default since v1.11.0 (its release notes, read 2026-09-06), with no `attestations: false`; the `pypi` job's only permission is `id-token: write` | `test_the_publish_workflow_attests_through_trusted_publishing`, `test_every_action_is_pinned_to_a_commit` | -| "`ctrlrun approve`, `deny`, `resolve`, `inspect`, `receipts` and `stats` work from the shell against any store" | `approve` — `cli/main.py:379`; `receipts` — `cli/main.py:457`; `effects` — `cli/main.py:531`; `resolve` — `cli/main.py:589`; `inspect` — `cli/main.py:633`; `stats` — `cli/main.py:915`; every one takes `--store-url` (SPEC-v0.6 §9.4) | `test_T10_resolve_failed_permits_a_retry`, `test_T18_inspect_json_emits_the_inspection_schema`, `test_T86_stats_counts_what_observe_mode_recorded`, `test_verify_chain_reads_a_postgres_store_through_store_url` | -| "`WebhookApprovalProvider` sends an approval request to a webhook, such as Slack, and takes the answer back through the same grant calls" | `WebhookApprovalProvider` — `webhook.py:141` — one signed POST on `APPROVAL_REQUESTED`; the inbound answer lands through `grant_approval` / `deny_approval` like the CLI's | `test_T27_the_outbound_post_carries_a_signature_over_the_exact_bytes_sent`, `test_T27_the_payload_carries_what_the_spec_names` | -| "one OpenTelemetry span per action, one span event per step" | `OTelEventSink` — `otel.py:45` | `test_T29_one_action_produces_one_span_named_for_the_action`, `test_T29_every_event_becomes_a_span_event_named_by_its_type` | -| "argument values stay out of it unless you ask for them" | `OTelEventSink(arguments=...)` — `otel.py:45` | `test_T29_argument_values_are_not_attributes_by_default` | -| "Receipts in a `ctrlrun.policy/v4` document can cite the `controls:` an action satisfies" | `PolicyControl` — `policy.py:360`; `Receipt` — `receipt.py:398` — carries `controls`; attribution only, never a decision | `test_T175_the_receipt_carries_the_union_of_the_action_and_the_matched_rule`, `test_T175_a_control_is_attribution_and_changes_no_decision` | -| "a rule can condition on the `data:` labels present in an action's arguments" | `DataLabel` — `policy.py:627`; `Policy.data_scope` — `policy.py:647`; `data_scope_in` in `v0.1 §3.2`'s grammar with no new operator | `test_T176_the_derived_set_is_the_labels_of_the_arguments_actually_supplied`, `test_T176_the_derived_set_drives_a_decision` | +| "`ctrlrun approve`, `deny`, `resolve`, `inspect`, `receipts` and `stats` work from the shell against any store" | `approve` — `cli/main.py:381`; `receipts` — `cli/main.py:459`; `effects` — `cli/main.py:533`; `resolve` — `cli/main.py:591`; `inspect` — `cli/main.py:635`; `stats` — `cli/main.py:917`; every one takes `--store-url` (SPEC-v0.6 §9.4) | `test_T10_resolve_failed_permits_a_retry`, `test_T18_inspect_json_emits_the_inspection_schema`, `test_T86_stats_counts_what_observe_mode_recorded`, `test_verify_chain_reads_a_postgres_store_through_store_url` | +| "`WebhookApprovalProvider` sends an approval request to a webhook, such as Slack, and takes the answer back through the same grant calls" | `WebhookApprovalProvider` — `webhook.py:143` — one signed POST on `APPROVAL_REQUESTED`; the inbound answer lands through `grant_approval` / `deny_approval` like the CLI's | `test_T27_the_outbound_post_carries_a_signature_over_the_exact_bytes_sent`, `test_T27_the_payload_carries_what_the_spec_names` | +| "one OpenTelemetry span per action, one span event per step" | `OTelEventSink` — `otel.py:47` | `test_T29_one_action_produces_one_span_named_for_the_action`, `test_T29_every_event_becomes_a_span_event_named_by_its_type` | +| "argument values stay out of it unless you ask for them" | `OTelEventSink(arguments=...)` — `otel.py:47` | `test_T29_argument_values_are_not_attributes_by_default` | +| "Receipts in a `ctrlrun.policy/v4` document can cite the `controls:` an action satisfies" | `PolicyControl` — `policy.py:362`; `Receipt` — `receipt.py:400` — carries `controls`; attribution only, never a decision | `test_T175_the_receipt_carries_the_union_of_the_action_and_the_matched_rule`, `test_T175_a_control_is_attribution_and_changes_no_decision` | +| "a rule can condition on the `data:` labels present in an action's arguments" | `DataLabel` — `policy.py:629`; `Policy.data_scope` — `policy.py:649`; `data_scope_in` in `v0.1 §3.2`'s grammar with no new operator | `test_T176_the_derived_set_is_the_labels_of_the_arguments_actually_supplied`, `test_T176_the_derived_set_drives_a_decision` | ## What it can't, stated as limits @@ -190,7 +190,7 @@ The README also makes negative claims. They matter as much as the positive ones. | "CTRLRun is not a transaction manager: it rolls nothing back" | There is no compensation, saga or rollback code path in the package; an `AMBIGUOUS` effect is resolved by a human or a reconcile hook and never undone — `RECONCILED_STATES` — `effect.py` | | "The receipt chain detects alteration, and alteration is not authorship." | n/a — a disclaimer, and the scan that keeps it one: `test_T180_the_release_documents_do_not_blur_alteration_and_authorship` | | "erasing the end of the log costs two statements" | No code — this is what the chain does **not** cover, and it is asserted rather than argued: `test_erasing_a_suffix_and_rewinding_the_head_is_two_statements_and_undetected` | -| "CTRLRun does not detect prompt injection" | No code — and that is the point. Nothing in the package reads the agent's instructions: `Policy.evaluate` takes the action's name and arguments (`policy.py:655`) and `Authority` matches a grant against the action, so neither axis has the prompt to inspect. The README's problem table claims containment of the consequence, and this row is the sentence that stops it being read as detection. | `test_T6_an_action_name_is_matched_exactly`, `test_a_condition_naming_an_action_field_is_refused_at_load` | +| "CTRLRun does not detect prompt injection" | No code — and that is the point. Nothing in the package reads the agent's instructions: `Policy.evaluate` takes the action's name and arguments (`policy.py:657`) and `Authority` matches a grant against the action, so neither axis has the prompt to inspect. The README's problem table claims containment of the consequence, and this row is the sentence that stops it being read as detection. | `test_T6_an_action_name_is_matched_exactly`, `test_a_condition_naming_an_action_field_is_refused_at_load` | | "`ctrlrun verify` cannot see your executors" | `docs/verify.md`, "What it does not mean"; `THREAT_MODEL.md`, "Known v0.4 limitations" | | "`ctrlrun scan` … reports the consequential call sites and policy entries CTRLRun is **not** covering" and "has no score, no percentage and no badge" | `ctrlrun/scan/` reads the tree with `ast` and never imports it, resolves no principal, evaluates no policy and opens no store (SPEC-scan §9.2); the limits sentence is emitted on every run including a clean one, and no percentage is computed anywhere | `test_T194_scan_never_imports_the_tree_it_reads`, `test_T205_scan_resolves_no_principal_evaluates_no_policy_and_opens_no_store`, `test_T203_the_limits_sentence_is_in_every_run_including_a_clean_one` | | "`ctrlrun mcp-operator` … It authenticates who answered and records it; it does not check that they were entitled to." | the write tools refuse without a principal and attribute the answer to the verified one; there is no entitlement check, and `docs/SPEC-mcp-operator.md` §10 says so | `test_T184_approve_refuses_without_a_principal`, `test_T184_approve_succeeds_with_one_and_is_attributed`, `test_T183_there_is_no_flag_that_permits_a_remote_bind` | @@ -201,10 +201,10 @@ The README also makes negative claims. They matter as much as the positive ones. | Claim | Code | Proof | |---|---|---| | "the same `StateStore` protocol, extended by nothing, graded by the suite written for SQLite rather than one written for it" | `PostgresStateStore` — `postgres.py` — satisfies `StateStore` and adds no method (SPEC-v0.6 §9.1); `ctrlrun.conformance.store.SUITES` is the SQLite suite, run against both | `test_T141_the_shipped_backends_pass`, `test_T154_postgres_passes_the_store_conformance_suite` | -| "automatic at open and forward-only, with no flag that opens a database un-migrated. An older binary against a newer schema refuses immediately." | `migrate` — `migrations.py:633` — called from both stores' constructors; `_refuse` — `migrations.py:559` — raises `SchemaMismatch` on a newer `user_version` | `test_T147_a_v05_database_migrates_and_keeps_every_row`, `test_T148_an_older_binary_refuses_a_newer_database`, `test_T152b_no_flag_opens_a_database_without_migrating` | -| "an edit, a deletion from the middle or a reordering is detected and named by `seq`" | `verify_chain` — `receipt.py:450` — and the six break names in `CHAIN_BREAKS` | `test_T164_an_altered_receipt_is_content_altered_at_its_seq`, `test_T164_reordering_two_receipts_is_detected_either_way`, `test_the_verify_chain_flag_reports_a_break_by_seq_and_by_name` | +| "automatic at open and forward-only, with no flag that opens a database un-migrated. An older binary against a newer schema refuses immediately." | `migrate` — `migrations.py:635` — called from both stores' constructors; `_refuse` — `migrations.py:561` — raises `SchemaMismatch` on a newer `user_version` | `test_T147_a_v05_database_migrates_and_keeps_every_row`, `test_T148_an_older_binary_refuses_a_newer_database`, `test_T152b_no_flag_opens_a_database_without_migrating` | +| "an edit, a deletion from the middle or a reordering is detected and named by `seq`" | `verify_chain` — `receipt.py:452` — and the six break names in `CHAIN_BREAKS` | `test_T164_an_altered_receipt_is_content_altered_at_its_seq`, `test_T164_reordering_two_receipts_is_detected_either_way`, `test_the_verify_chain_flag_reports_a_break_by_seq_and_by_name` | | "It detects **alteration**, which is not authorship: receipts are not signed." | No signing code, and a release scan keeps the vocabulary out | `test_T180_the_release_documents_do_not_blur_alteration_and_authorship` | -| "every receipt records the policy that decided it, so a receipt from six months ago says what the rules were" | `Policy.policy_hash` — `policy.py:793` — over the parsed decision inputs, recorded on the receipt | `test_T172_every_receipt_carries_the_hash_and_the_declared_version`, `test_T171_any_decision_input_changes_the_hash`, `test_T171_the_declared_version_alone_does_not_change_the_hash` | +| "every receipt records the policy that decided it, so a receipt from six months ago says what the rules were" | `Policy.policy_hash` — `policy.py:795` — over the parsed decision inputs, recorded on the receipt | `test_T172_every_receipt_carries_the_hash_and_the_declared_version`, `test_T171_any_decision_input_changes_the_hash`, `test_T171_the_declared_version_alone_does_not_change_the_hash` | ## The docs site: Home and Concepts @@ -220,7 +220,7 @@ restating the code; the ones that are new to the site carry their own code and p | `get-started/install` | "importing `ctrlrun` imports nothing from an extra" | `test_T30_a_subprocess_importing_ctrlrun_pulls_in_no_module_from_an_extra` | | `get-started/install` | "raises `MissingDependency` with the install command in the message" | `test_a_missing_extra_raises_MissingDependency_naming_the_install_command` | | `get-started/quickstart` | every block on the page, and the outputs shown | the blocks are `runnable` and pass `tools/docs_audit/snippets.py` in one temporary directory, in order; the outputs are pasted from one run of the same blocks | -| `concepts/action-and-hash` | "The action hash is the SHA-256 of that canonical form"; sorted keys, no whitespace, UTF-8, `float` rejected; `action_id` excluded | `canonicalize` / `action_hash` — `action.py`; `float` refused — `action.py:79`; `test_T7_canonical_form_is_exactly_the_specified_serialization`, `test_T7_nested_dicts_are_sorted_recursively`, `test_T60_claims_do_not_change_the_action_hash` | +| `concepts/action-and-hash` | "The action hash is the SHA-256 of that canonical form"; sorted keys, no whitespace, UTF-8, `float` rejected; `action_id` excluded | `canonicalize` / `action_hash` — `action.py`; `float` refused — `action.py:81`; `test_T7_canonical_form_is_exactly_the_specified_serialization`, `test_T7_nested_dicts_are_sorted_recursively`, `test_T60_claims_do_not_change_the_action_hash` | | `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 | @@ -228,8 +228,8 @@ restating the code; the ones that are new to the site carry their own code and p | `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:4727` | -| `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` | +| `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:4764` | +| `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:31`, `DuplicateEffect` — `errors.py:143`, `AmbiguousEffect` — `errors.py:143`, and `test_a_policy_deny_is_denied_the_same_way_as_an_unknown_action` | ## The docs site: Production @@ -239,7 +239,7 @@ sentence that rots quietly. | Page | Claim | Proved by | |---|---|---| -| `production/index` | the readiness block — version, test count, guarantee count, the two stores, the soak, the chain, the licence | rendered by `tools/docs_audit/render_readiness.py` from `pyproject.toml`, `pytest --collect-only`, the `GUARANTEES` catalogue — `verify/guarantees.py:49` — and `research/soak/results/`; `test_the_readiness_block_is_the_generators_in_every_place_it_appears` asserts the same block in the README, the docs home and this page, and `test_the_readiness_block_refuses_a_shrunken_suite_and_accepts_a_grown_one` makes the count a floor | +| `production/index` | the readiness block — version, test count, guarantee count, the two stores, the soak, the chain, the licence | rendered by `tools/docs_audit/render_readiness.py` from `pyproject.toml`, `pytest --collect-only`, the `GUARANTEES` catalogue — `verify/guarantees.py:51` — and `research/soak/results/`; `test_the_readiness_block_is_the_generators_in_every_place_it_appears` asserts the same block in the README, the docs home and this page, and `test_the_readiness_block_refuses_a_shrunken_suite_and_accepts_a_grown_one` makes the count a floor | | `production/index` | the **Not yet** list: no external security audit, no third-party review of the kernel, no sector packs | stated rather than measured, because nothing in a repository can measure an absence. A fourth line — *no soak of the length the roadmap asks for* — was **derived** from the published run until `SPEC-v0.6.md` §8.1 removed the duration from the criterion on 2026-09-07, which removed the thing being derived; the run's own duration is still printed on the soak line above the list. The list lives inside the generated block so it cannot be scrolled past. `test_the_not_yet_list_is_inside_the_block_and_not_below_it`, `test_the_not_yet_list_is_the_constant_and_derives_nothing_from_the_soak` and `test_the_readiness_block_does_not_report_the_soak_as_an_unmet_gate` assert all of it; removing a stated line is its own pull request with the row that makes the new sentence true | | `production/index` | "SQLite is the default and it is production-grade on one host… Postgres is for many hosts" | the header row above; `test_the_first_line_of_the_section_says_which_store_and_why` asserts the order, because Postgres first would tell a reader with one host something false | | `production/how-reservation-works` | the two rows: an exception before `COMMIT` is a failed write; one during it is unknown and is re-read | SPEC-v0.6 §4.3 Tables A, A1 and A2; `test_T155_a_connection_killed_during_commit_is_resolved_by_the_re_read`, `test_T155e_a_commit_the_server_never_received_re_issues_the_update`, `test_T155c_the_re_read_identity_check_is_not_an_action_id_match`, `test_T156_a_failed_re_read_refuses_to_proceed`; `test_the_two_rows_of_the_lost_commit_are_not_merged` asserts the page keeps them apart | diff --git a/docs/OWASP-AGENTIC-TOP10.md b/docs/OWASP-AGENTIC-TOP10.md index 3e6dfd6..4cd1bb2 100644 --- a/docs/OWASP-AGENTIC-TOP10.md +++ b/docs/OWASP-AGENTIC-TOP10.md @@ -6,8 +6,9 @@ sidebarTitle: "OWASP Agentic Top 10" This is a **reading** of somebody else's taxonomy against the guarantees CTRLRun tests. It is not a compliance claim, a conformance claim, a certification, or a statement that CTRLRun -covers the OWASP Top 10 for Agentic Applications. Three of the ten entries are not addressed -by CTRLRun at all, and they are listed by name below. +covers the OWASP Top 10 for Agentic Applications. Two of the ten entries are not addressed +by CTRLRun at all, and they are listed by name below. It was three until v0.10, which put +authority across an agent hop and moved `ASI07` into the partial half. Every row maps a guarantee to an entry, and every guarantee is backed by a passing acceptance test — so each row points at code and at a test. A row whose test disappears is a row that @@ -87,6 +88,9 @@ mechanism, not the entry. | **G22** held budget refuses next reserve | A grant may carry a consequence budget: a metric, a limit and a rolling window. The sum is consumed **when the effect is reserved, in the same transaction**, and an action that would take it past the limit is refused `budget_exhausted` with an `ACTION_DENIED` event and a `DENIED` receipt. Every grant in the delegation chain is charged, so a child cannot spend its parent's budget over again. | `ASI08:2026` (partly), `ASI01:2026` (partly), `ASI10:2026` (partly) | Until v0.9 the authority model bounded **one action** and never an aggregate: `amount_lte: 5000` says nothing about the thousand actions that each pass it, so an agent acting entirely within its permissions could empty an account one permitted refund at a time. Three limits stated wherever the feature is described. A budget **cannot recall an action already in flight**: the window rolls forward and a reservation taken a second before it rolls is committed regardless. An `AMBIGUOUS` effect **holds** its charge until a human resolves it, which is deliberate and which means an agent that can manufacture ambiguity can exhaust a budget it cannot spend, a denial of service against its own principal that is fail-closed. And a budget counts a **metric an operator named** — an action argument — not a consequence: nothing here ranks, scores or classifies what an action means. | | **G23** a failing scope provider refuses | Where a deployment configures one, a scope provider answers *is this record this principal's?* and the kernel matches the answer against the action's resource, strictly **before** the reservation. Two distinct refusals, never conflated: `scope_unavailable` when the provider raises, answers the wrong shape, or answers something the canonicalizer refuses; `out_of_scope` when it answered and the record is not covered. Only the **hash** of the answer reaches the receipt. | `ASI06:2026` (partly), `ASI01:2026` (partly), `ASI02:2026` (partly) | This is the bite on an identifier an attacker chose, which the `ASI06` row below said nothing had: a grant permits `records.read` on `customer:*`, and until v0.9 nothing had an opinion about *whose* record `customer:90210` is. Two limits. **A scope provider is worth what its source is worth**: it is the operator's own code answering from the operator's own system of record, and a poisoned source answers wrongly with the kernel none the wiser. And whether a deployment configures one is a fact about its code, which `ctrlrun verify` cannot read: verify grades the mechanism against a provider it supplies. The gateway and the ACS hook cannot name a provider at all. | | **G24** grant refused off its task | A grant may name `tasks:`, the unit of work the authority is for, attenuated by the same `child ⊆ parent` rule as actions, resources and environments. An action bound to a task the grant does not cover is refused by reason and not by exception type. A grant that names no `tasks:` authorises any task, so every grant written before v0.9 keeps its meaning. | `ASI01:2026` (partly), `ASI03:2026` (partly), `ASI10:2026` (partly) | **Task binding limits blast radius; it does not detect a hijack.** The task id is supplied by the caller, so an agent that has been talked into a different goal will often still be operating inside the task it was legitimately given, and the kernel cannot tell a hijacked run from an honest one. What it stops is a credential or a delegation issued for one unit of work being spent on another, which is the shape a stolen grant most often takes. The task never enters the action hash, so binding one changes no existing receipt. | +| **G25** a hop narrows or it is refused | Authority handed to a second agent is a **subset** of what the first agent held, on every dimension, checked when the hop is created and again at every evaluation under it. An action proposed under a hop is decided against **that hop's grant alone**, with no fallback to anything else the receiving agent holds, so a hop can only ever narrow. | `ASI07:2026` (partly), `ASI03:2026` (partly), `ASI10:2026` (partly) | Before v0.10 a second agent acted under its own grants and the first agent's limits were a convention. Delegation existed but stopped at the process. What this does **not** do is compel a receiving agent to present the hop it was given: an agent that holds a root grant of its own can act under that instead, and the deployment rule that closes it is that an agent which only ever acts on handed-over work holds no root grant. `ctrlrun scan` names the principals that do. | +| **G26** a hop is named on both sides | The receipt of an action taken under a hop names the hop, and so does the record of the hop's creation, so an action and the delegation that authorised it are joined from either end without inference. | `ASI07:2026` (partly), `ASI10:2026` (partly), `ASI03:2026` (partly) | Attribution across a hop was reconstruction before this: a reader had to match timestamps and principals and hope. It is evidence, not prevention, and it is on the receipt rather than in a log that can be rotated away. A hop created *inside* an action is not named on its creator's receipt; the `DELEGATION_CREATED` event carries the `action_id` and holds the join instead. | +| **G27** a swapped upstream is denied | An action entry may pin the upstream it authorises, by the SHA-256 of the server's leaf certificate or by the hash of a tool's advertised schema. A server that is not the pinned one, or a tool whose schema moved under an approved action name, is refused `upstream_mismatch`; an upstream nothing observed is refused `upstream_unverified` and never admitted. | `ASI02:2026` (partly), `ASI07:2026` (partly) | An approved action name is a name, and until v0.10 nothing checked that the thing answering to it was the thing that was approved. Enforced by `ctrlrun gateway`, the surface that holds the connection: at startup, at the decision, and at the TLS handshake. In-process there is no upstream to observe, so a pinned action refuses on every call, which is fail-closed and is why `ctrlrun verify` skips a pinned action unless a scenario asks for it by name. This is one slice of a supply chain and not the category: nothing here inspects a package, a model, a build or a signature chain. | --- @@ -96,7 +100,7 @@ The half that makes the table above credible. One honest sentence each; nothing | Entry | Title | Why not | |---|---|---| -| `ASI04:2026` | Agentic Supply Chain Vulnerabilities | **One slice, and the category stays out of scope.** Since 0.10.0 a policy entry may pin the upstream it authorises, by the SHA-256 of the server's leaf certificate or by the hash of a tool's advertised schema, so a swapped MCP server behind the same name, or a tool whose schema moved under an approved action name, is a `DENY`. That is enforced by `ctrlrun gateway`, which is the surface that holds the connection: at startup, at the decision, and at the TLS handshake, where the pinned certificates are the connection's only trust anchors. **Everything else about supply chain remains out of scope**: CTRLRun still never inspects a package, a model, a build, a registry or a signature chain, it decides actions, and a poisoned dependency still reaches it as an ordinary caller. In-process there is no upstream to observe and a pinned action is refused on every call. | +| `ASI04:2026` | Agentic Supply Chain Vulnerabilities | **Out of scope, and no guarantee maps to it.** CTRLRun never inspects a package, a model, a build, a registry or a signature chain; it decides actions, and a poisoned dependency reaches it as an ordinary caller. What is easy to mistake for coverage, and is not: since 0.10.0 a policy entry may pin the upstream it authorises, by the SHA-256 of the server's leaf certificate or by the hash of a tool's advertised schema, so a swapped MCP server behind the same name, or a tool whose schema moved under an approved action name, is a `DENY`. **That is G27, and G27 is mapped under `ASI02` and `ASI07`, where binding a peer and a tool schema belongs** — not here. It is one property about one connection, and a supply chain is everything upstream of it. That is enforced by `ctrlrun gateway`, which is the surface that holds the connection: at startup, at the decision, and at the TLS handshake, where the pinned certificates are the connection's only trust anchors. In-process there is no upstream to observe and a pinned action is refused on every call. | | `ASI05:2026` | Unexpected Code Execution | Out of scope. Nothing here sandboxes an interpreter or constrains what a process may run. CTRLRun sits between an agent and one remote effect, not between an agent and its own runtime. | | `ASI07:2026` | Insecure Inter-Agent Communication | **Partly, since 0.10.0, and the part is authority rather than the channel.** Authority now propagates across an agent hop: the envelope a second agent receives is a subset of the one the first agent held on every dimension, checked at the hop and again at every evaluation, and an action proposed under a hop is decided against that hop's grant **alone**, with no fallback to anything else the receiving agent holds. A consumption charges the issuer and every ancestor, so a hop spends the issuer's budget rather than creating a second root. G25 grades the narrowing, G26 that both ends of a hop name it. **What it does not do**: CTRLRun defines no wire format, secures no channel, and authenticates no peer, so message integrity, transport security and agent identity are the deployment's, exactly as `ASI06`'s row says of identity. It cannot compel a receiving agent to present the hop it was given, and a receiver whose store cannot read the chain is refused rather than trusted. No A2A conformance claim. | diff --git a/docs/cookbook/verify-in-github-actions.mdx b/docs/cookbook/verify-in-github-actions.mdx index 7c764c0..f22f8a2 100644 --- a/docs/cookbook/verify-in-github-actions.mdx +++ b/docs/cookbook/verify-in-github-actions.mdx @@ -62,10 +62,11 @@ 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.9.0, catalogue ctrlrun.guarantees/v6 -policy /private/var/folders/gp/jrq5dccs6xldv1jw839n7dsw0000gn/T/tmpmax85d9e/ctrlrun.yaml (ctrlrun.policy/v2, mode: enforce) +CTRLRun verify — ctrlrun 0.10.0, catalogue ctrlrun.guarantees/v6 +policy ./ctrlrun.yaml (ctrlrun.policy/v2, mode: enforce) authority none store sqlite, scratch (created and destroyed for this run) + G1 mutated approval refused PASS k8s.delete_namespace G2 replayed approval refused PASS k8s.delete_namespace G3 duplicate effect refused PASS k8s.delete_namespace @@ -108,6 +109,7 @@ G27 a swapped upstream is denied N/A no action entry pins an upstream two stores sharing a provider account must not produce the same effect-key string for different effects, and nothing here can check that) + 15/15 declared guarantees pass. 12 not applicable: G8, G9, G13, G15, G17, G19, G22, G23, G24, G25, G26, G27. ``` diff --git a/docs/guides/verify-in-ci.mdx b/docs/guides/verify-in-ci.mdx index 2ae0649..fe657fe 100644 --- a/docs/guides/verify-in-ci.mdx +++ b/docs/guides/verify-in-ci.mdx @@ -32,10 +32,11 @@ guarantees pass. ``` ```text - CTRLRun verify — ctrlrun 0.9.0, catalogue ctrlrun.guarantees/v6 - policy /private/var/folders/gp/jrq5dccs6xldv1jw839n7dsw0000gn/T/tmpdi6bvpqd/ctrlrun.yaml (ctrlrun.policy/v2, mode: enforce) + CTRLRun verify — ctrlrun 0.10.0, catalogue ctrlrun.guarantees/v6 + policy ./ctrlrun.yaml (ctrlrun.policy/v2, mode: enforce) authority none store sqlite, scratch (created and destroyed for this run) + G1 mutated approval refused PASS k8s.delete_namespace G2 replayed approval refused PASS k8s.delete_namespace G3 duplicate effect refused PASS k8s.delete_namespace @@ -78,8 +79,9 @@ 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) + 15/15 declared guarantees pass. 12 not applicable: G8, G9, G13, G15, G17, G19, G22, G23, G24, G25, G26, G27. -``` + ``` 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 diff --git a/docs/production/index.mdx b/docs/production/index.mdx index 68d4322..b8143b6 100644 --- a/docs/production/index.mdx +++ b/docs/production/index.mdx @@ -28,7 +28,7 @@ need. `test_the_first_line_of_the_section_says_which_store_and_why` asserts the {/* generated from the suite, pyproject and the soak (full) — run the generator */} - **Version 0.10.0**, on [PyPI](https://pypi.org/project/ctrlrun/), Python 3.11 and later. -- **6,048 tests**, every version specified before it was written and every requirement mutation-tested. [Read more](/docs/how-this-is-built). +- **6,056 tests**, every version specified before it was written and every requirement mutation-tested. [Read more](/docs/how-this-is-built). - **27 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). diff --git a/docs/verify.md b/docs/verify.md index b8523e9..47de744 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.9.0, catalogue ctrlrun.guarantees/v5 +CTRLRun verify — ctrlrun 0.10.0, catalogue ctrlrun.guarantees/v6 policy examples/authority/payments.yaml (ctrlrun.policy/v7, mode: enforce) authority same document, 3 grants store sqlite, scratch (created and destroyed for this run) @@ -60,13 +60,13 @@ G23 a failing scope provider refuses PASS stripe.refund G24 grant refused off its task PASS head-of-support G25 a hop narrows or it is refused PASS head-of-support G26 a hop is named on both sides PASS head-of-support -G27 a swapped upstream is denied PASS payouts +G27 a swapped upstream is denied N/A no action entry pins an upstream (a token is unique only as far as your effect keys are: two stores sharing a provider account must not produce the same effect-key string for different effects, and nothing here can check that) -22/22 declared guarantees pass. 2 not applicable: G13, G15. +24/24 declared guarantees pass. 3 not applicable: G13, G15, G27. ``` It reads the policy document — `$CTRLRUN_CONFIG`, else `./ctrlrun.yaml` — and the authority diff --git a/generated/readiness.full.mdx b/generated/readiness.full.mdx index 52af7c0..436a156 100644 --- a/generated/readiness.full.mdx +++ b/generated/readiness.full.mdx @@ -1,6 +1,6 @@ {/* generated from the suite, pyproject and the soak (full) — run the generator */} - **Version 0.10.0**, on [PyPI](https://pypi.org/project/ctrlrun/), Python 3.11 and later. -- **6,048 tests**, every version specified before it was written and every requirement mutation-tested. [Read more](/docs/how-this-is-built). +- **6,056 tests**, every version specified before it was written and every requirement mutation-tested. [Read more](/docs/how-this-is-built). - **27 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). diff --git a/generated/readiness.json b/generated/readiness.json index 181d0e5..94f8ea7 100644 --- a/generated/readiness.json +++ b/generated/readiness.json @@ -9,6 +9,6 @@ "positive_control": true, "unexplained": 0 }, - "tests": 6048, + "tests": 6056, "version": "0.10.0" } diff --git a/generated/readiness.mdx b/generated/readiness.mdx index 682e0f3..8f5e316 100644 --- a/generated/readiness.mdx +++ b/generated/readiness.mdx @@ -1,6 +1,6 @@ {/* generated from the suite, pyproject and the soak (mdx) — run the generator */} - **Version 0.10.0**, on [PyPI](https://pypi.org/project/ctrlrun/), Python 3.11 and later. -- **6,048 tests**, every version specified before it was written and every requirement mutation-tested. +- **6,056 tests**, every version specified before it was written and every requirement mutation-tested. - **27 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). diff --git a/generated/readiness.readme.md b/generated/readiness.readme.md index e944dae..c22b71a 100644 --- a/generated/readiness.readme.md +++ b/generated/readiness.readme.md @@ -1,6 +1,6 @@ - **Version 0.10.0**, on [PyPI](https://pypi.org/project/ctrlrun/), Python 3.11 and later. -- **6,048 tests**, every version specified before it was written and every requirement mutation-tested. +- **6,056 tests**, every version specified before it was written and every requirement mutation-tested. - **27 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).