diff --git a/docs.json b/docs.json index 8ca9d9f..9e77e46 100644 --- a/docs.json +++ b/docs.json @@ -301,6 +301,7 @@ "docs/reference/api/conformance-store-run", "docs/reference/api/context", "docs/reference/api/gateway-serve", + "docs/reference/api/idempotency_token", "docs/reference/api/jwt_identity-JWTIdentityProvider", "docs/reference/api/needs_approval", "docs/reference/api/otel-OTelEventSink", diff --git a/docs.mdx b/docs.mdx index e8c21f3..d1097ec 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.6.1**, on [PyPI](https://pypi.org/project/ctrlrun/), Python 3.11 and later. -- **4,513 tests**, every version specified before it was written and every requirement mutation-tested. -- **12 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. +- **4,999 tests**, every version specified before it was written and every requirement mutation-tested. +- **15 guarantees you can check in your own setup**, with `ctrlrun verify` against your policy, on your store's backend, in a scratch store it creates. - **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 d93cd9b..f0e18f5 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:565` — 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:495` — passes only the action's **name and arguments** to `_ActionPolicy.evaluate` (`policy.py:495`), 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:150`) 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:163`. Exactly as approved: the approval is bound to `action_hash` and consumed with the reservation — `_authorize_and_reserve` — `state.py:929`. Or not at all: a refusal raises before the executor — `Control.execute` — `control.py:565`. Says so instead of guessing: only `NotExecuted` maps to `FAILED` — `control.py:1090` — and everything else is `AMBIGUOUS`. A receipt: `Receipt` — `receipt.py:106`. **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:752` — resolves the principal, evaluates authority and policy, consumes the approval and reserves the effect key **before** the executor runs; nothing in the wrapper calls the function first | `test_T1_a_blind_retry_is_refused_and_never_reaches_the_remote`, `test_T3_the_fake_remote_is_called_exactly_once` | +| "Autonomy belongs to the action, not the agent." | `Policy.evaluate(action)` — `policy.py:534` — passes only the action's **name and arguments** to `_ActionPolicy.evaluate` (`policy.py:534`), whose signature has no principal in it. A rule cannot read who is acting even by accident. `agent_eq` and `user_eq` are refused at load by `RESERVED_ARGUMENTS` (`policy.py:186`) rather than silently matching nothing. | `test_T6_an_action_name_is_matched_exactly`, `test_a_condition_naming_an_action_field_is_refused_at_load` | +| "A consequential action happens at most once, exactly as approved, and leaves a receipt — and when the outcome is unknown, CTRLRun says so instead of guessing." | At most once: `plan_reservation` — `effect.py:170`. Exactly as approved: the approval is bound to `action_hash` and consumed with the reservation — `_authorize_and_reserve` — `state.py:929`. Or not at all: a refusal raises before the executor — `Control.execute` — `control.py:752`. Says so instead of guessing: only `NotExecuted` maps to `FAILED` — `control.py:1416` — and everything else is `AMBIGUOUS`. A receipt: `Receipt` — `receipt.py:150`. **This sentence read *happens once … or not at all* until 0.6**, a two-way disjunction that excluded the third outcome the product exists for: a lost reply is neither, and the README's own first section says so. | `test_T3_exactly_one_agent_reserves_and_seven_are_blocked`, `test_T2_a_mutated_action_presenting_the_approval_raises_ApprovalMismatch`, `test_T1_a_lost_response_leaves_the_effect_ambiguous`, `test_T11_every_demo_receipt_carries_every_field_in_the_spec` | | "A 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:1103` — 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:1113` — which is a write lock on the file and holds across OS processes. Postgres: `PostgresStateStore` over `UNIQUE(effect_key)` with `INSERT … ON CONFLICT DO NOTHING` and checked row counts (SPEC-v0.6 §4.2), the same `StateStore` protocol, extended by nothing | `test_T3_exactly_one_agent_reserves_and_seven_are_blocked` (8 OS processes, both backends), `test_T141_the_shipped_backends_pass`, `test_T154_postgres_passes_the_store_conformance_suite` | ## 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` — `control.py:1090`; a retry against an `AMBIGUOUS` key is refused by `plan_reservation` — `effect.py:163`; the two things permitted to move the record on and nothing else — `resolve` — `cli/main.py:518` — and `Control._reconciled` — `control.py:1522` | `test_T1_a_lost_response_leaves_the_effect_ambiguous`, `test_T1_a_blind_retry_is_refused_and_never_reaches_the_remote`, `test_T160_there_is_no_reaper`, `test_T13_a_hook_answering_not_executed_moves_the_record_to_failed` | +| "A lost reply is `AMBIGUOUS`, never `FAILED`, and a retry against an `AMBIGUOUS` effect is refused — until a human, or a `reconcile` hook, says what happened." | Only `NotExecuted` maps to `FAILED` — `control.py:1416`; a retry against an `AMBIGUOUS` key is refused by `plan_reservation` — `effect.py:170`; the two things permitted to move the record on and nothing else — `resolve` — `cli/main.py:518` — and `Control._reconciled` — `control.py:1913` | `test_T1_a_lost_response_leaves_the_effect_ambiguous`, `test_T1_a_blind_retry_is_refused_and_never_reaches_the_remote`, `test_T160_there_is_no_reaper`, `test_T13_a_hook_answering_not_executed_moves_the_record_to_failed` | | "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,28 +50,28 @@ by its quoted claim, and `tests/test_docs_audit.py` fails if a named row is not | Claim | Code | Proof | |---|---|---| -| "A lost reply is `AMBIGUOUS`, never `FAILED`, and a retry against an `AMBIGUOUS` effect is refused." | Only `NotExecuted` maps to `FAILED` — `control.py:1090`; `plan_reservation` — `effect.py:163` | `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:502`; `PostgresStateStore.reserve_effect` — `postgres.py:674` | `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:929` | `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:495` | `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:565` | `test_T74_a_denial_leaves_no_pending_approval_request` | -| "Neither axis reads the agent's instructions" | `Policy.evaluate` — `policy.py:495` — sees the action's name and arguments; `Authority.evaluate` — `authority.py:806` — sees the action and the principal; neither is handed a prompt, a message or a tool result | `test_T6_an_action_name_is_matched_exactly`, `test_T67_a_principal_with_no_grant_is_denied` | +| "A lost reply is `AMBIGUOUS`, never `FAILED`, and a retry against an `AMBIGUOUS` effect is refused." | Only `NotExecuted` maps to `FAILED` — `control.py:1416`; `plan_reservation` — `effect.py:170` | `test_T1_a_lost_response_leaves_the_effect_ambiguous`, `test_T1_a_blind_retry_is_refused_and_never_reaches_the_remote` | +| "reserved atomically across processes and hosts; one worker wins" | `reserve_effect` — `state.py:510`; `PostgresStateStore.reserve_effect` — `postgres.py:720` | `test_T3_exactly_one_agent_reserves_and_seven_are_blocked`, `test_T154_postgres_passes_the_store_conformance_suite` | +| "bound to the hash of the exact action a human saw, used once, and refused for anything else" | `_authorize_and_reserve` — `state.py:939` | `test_T2_a_mutated_action_presenting_the_approval_raises_ApprovalMismatch`, `test_T4_replaying_the_approval_raises_ApprovalMismatch_with_reason_consumed` | +| "An action the policy does not list is denied" | `Policy.evaluate` — `policy.py:534` | `test_T6_unknown_action_is_denied_with_reason_unknown_action` | +| "Authority first ... then policy" / "authority first" | `Control.execute` evaluates authority before policy and a denial appends `AUTHORITY_DENIED` and never `POLICY_EVALUATED` — `control.py:752` | `test_T74_a_denial_leaves_no_pending_approval_request` | +| "Neither axis reads the agent's instructions" | `Policy.evaluate` — `policy.py:534` — sees the action's name and arguments; `Authority.evaluate` — `authority.py:806` — sees the action and the principal; neither is handed a prompt, a message or a tool result | `test_T6_an_action_name_is_matched_exactly`, `test_T67_a_principal_with_no_grant_is_denied` | | "canonical arguments (sorted keys, no floats) ... Its SHA-256 is the action hash" | `canonicalize` / `action_hash` — `action.py`; `float` refused at any depth — `action.py:71` | `test_T7_canonical_form_is_exactly_the_specified_serialization`, `test_T7_nested_dicts_are_sorted_recursively` | -| "The approval is single-use, expires, and matches nothing but that exact action." | `_authorize_and_reserve` — `state.py:929` — checks expiry at consumption | `test_T5_expiry_is_checked_at_consumption_not_only_at_grant`, `test_T4_replaying_the_approval_raises_ApprovalMismatch_with_reason_consumed` | -| "Only `NotExecuted`, raised by you, means `FAILED`." | `control.py:1090`; `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:633`; `prev_hash`, `GENESIS_HASH` for the first — `receipt.py:41` | `test_T172_every_receipt_carries_the_hash_and_the_declared_version`, `test_T164_an_altered_receipt_is_content_altered_at_its_seq` | +| "The approval is single-use, expires, and matches nothing but that exact action." | `_authorize_and_reserve` — `state.py:939` — checks expiry at consumption | `test_T5_expiry_is_checked_at_consumption_not_only_at_grant`, `test_T4_replaying_the_approval_raises_ApprovalMismatch_with_reason_consumed` | +| "Only `NotExecuted`, raised by you, means `FAILED`." | `control.py:1416`; `NotExecuted` — `errors.py:157` | `test_T1_a_lost_response_leaves_the_effect_ambiguous` | +| "the hash of the policy that decided it, chained to the receipt before it" | `Policy.policy_hash` — `policy.py:672`; `prev_hash`, `GENESIS_HASH` for the first — `receipt.py:78` | `test_T172_every_receipt_carries_the_hash_and_the_declared_version`, `test_T164_an_altered_receipt_is_content_altered_at_its_seq` | ## Three ways to use it | Claim | Code | Proof | |---|---|---| -| "You probably do not need an adapter" | Three ways in, and `@protect` (`control.py:2125`) 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:3021`) covers this process while the gateway covers MCP — an adapter buys only the interrupt | `test_T139_the_adapter_section_says_when_you_do_not_need_one_up_front` | | "`ctrlrun init` writes a starter" | `init` — `cli/main.py:340` | CI's `package` job runs `ctrlrun init` from the wheel and asserts `ctrlrun.yaml` exists | -| "The human runs `ctrlrun approve ` and the agent calls again inside `ctrlrun.with_approval(request_id)`" | `approve` — `cli/main.py:364`; `with_approval` — `control.py:178`; `ApprovalRequired` (`errors.py:88`) carries `request_id` | `test_T2_a_mutated_action_presenting_the_approval_raises_ApprovalMismatch` (the granted path first), `test_T4_replaying_the_approval_raises_ApprovalMismatch_with_reason_consumed` | +| "The human runs `ctrlrun approve ` and the agent calls again inside `ctrlrun.with_approval(request_id)`" | `approve` — `cli/main.py:364`; `with_approval` — `control.py:260`; `ApprovalRequired` (`errors.py:88`) carries `request_id` | `test_T2_a_mutated_action_presenting_the_approval_raises_ApprovalMismatch` (the granted path first), `test_T4_replaying_the_approval_raises_ApprovalMismatch_with_reason_consumed` | | "No agent changes" | `INTERCEPTED_METHOD` is `tools/call` and every other method is relayed unchanged — `gateway/mcp.py:40` | `test_a_non_intercepted_method_is_relayed_with_no_ctrlrun_outcome` | | "Point the MCP client at the gateway instead of at the tool server" | `Gateway.handle` — `gateway/server.py:385`; `serve` — `gateway/__init__.py:41` | `test_T19_the_upstream_receives_the_canonical_arguments` | | "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:729`; `McpOptions` — `policy.py:460` | `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:774`; `McpOptions` — `policy.py:496` | `test_T16_a_v2_document_loads_and_exposes_its_templates`, `test_T16_a_decorator_and_a_policy_template_produce_the_same_action_hash` | | "Everything but `tools/call` is relayed untouched" | `parse_request(...).intercept` — `gateway/mcp.py:84` | `test_every_other_method_is_relayed_not_intercepted` | | "A lost response over the wire blocks the retry exactly as it does in process" | `classify` — `gateway/outcome.py:144`, translated into v0.1 §5.5's own vocabulary by the gateway's executor | `test_T23_the_identical_call_sent_again_is_refused_and_the_upstream_called_once` | | "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` | @@ -85,23 +85,23 @@ 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:234` — is exactly `allow`, `approve`, `deny`; rules match first-wins over `Condition` (`policy.py:298`) with the operators `eq`, `neq`, `in`, `lt`, `lte`, `gt`, `gte` — `_OPERATORS` — `policy.py:83` | `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:495` | `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:234` — is exactly `allow`, `approve`, `deny`; rules match first-wins over `Condition` (`policy.py:334`) with the operators `eq`, `neq`, `in`, `lt`, `lte`, `gt`, `gte` — `_OPERATORS` — `policy.py:106` | `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:534` | `test_T6_unknown_action_is_denied_with_reason_unknown_action` | | "Amounts are integer minor units; floats are rejected outright" | `float` refused at any depth — `action.py:71` | `test_T7_canonical_form_is_exactly_the_specified_serialization` | -| "The policy cannot see who is asking — deliberately, since v0.1" | `Policy.evaluate` still takes only the action's name and arguments; `RESERVED_ARGUMENTS` — `policy.py:150` — refuses `agent_eq` and every other principal-addressing condition at load, in a document of **every** schema version | `test_T74b_a_reserved_name_in_a_policy_rule_is_a_load_error`, `test_T74b_a_reserved_name_in_a_grant_constraint_is_a_load_error` | -| "the second axis, `authority:`" | `Authority.evaluate` — `authority.py:798`; `Control._authority_result` — `control.py:431` | `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:534` — refuses `agent_eq` and every other principal-addressing condition at load, in a document of **every** schema version | `test_T74b_a_reserved_name_in_a_policy_rule_is_a_load_error`, `test_T74b_a_reserved_name_in_a_grant_constraint_is_a_load_error` | +| "the second axis, `authority:`" | `Authority.evaluate` — `authority.py:798`; `Control._authority_result` — `control.py:618` | `test_T67_a_principal_with_no_grant_is_denied` | | "opt-in, and then fail-closed" | `_optional_authority` returns `None` for a document with no section — `control.py`; `Control.authority is None` is v0.2 behaviour exactly | `test_T66_a_document_with_no_authority_section_leaves_control_authority_none`, `test_T66_no_authority_event_is_appended_without_a_section`, and T66's session-wide guard in `tests/conftest.py` | | "every principal needs a grant and no grant means denied" | `NO_AUTHORITY` — the fail-closed default of `Authority.evaluate` (`authority.py:64`), reached for reads and for actions with no effect key alike | `test_T67_an_action_the_policy_allows_outright_still_needs_a_grant` | | "A grant carries no `decision:`" | `_GRANT_KEYS` — `authority.py` — is a closed set that does not contain `decision` | `test_T73b_grant_refuses_what_the_loader_refuses` | | "combine as the **stricter of the two**" | `Control.evaluate` returns the combined result — `control.py`; a denial on either axis is a denial | `test_T70_the_stricter_of_the_two_wins` | -| "narrow it at runtime with `ctrlrun delegate`" | `Control.delegate` — `control.py:1700`; `Authority.plan_delegation` — `authority.py:877`; `ctrlrun delegate` — `cli/main.py:898` | `test_t75_the_delegation_authorizes_an_action_within_its_limits` | +| "narrow it at runtime with `ctrlrun delegate`" | `Control.delegate` — `control.py:2591`; `Authority.plan_delegation` — `authority.py:877`; `ctrlrun delegate` — `cli/main.py:898` | `test_t75_the_delegation_authorizes_an_action_within_its_limits` | | "provably a subset of its parent on every dimension, at creation and again at every evaluation" | `contained_dimension` — `authority.py:603` — runs from `plan_delegation` (`authority.py:877`) **and** from the chain walk in `Authority.evaluate` (`authority.py:806`) | `test_t76_each_dimension_violated_alone`, `test_t77b_a_narrowed_parent_narrows_its_children` | | "omitting a dimension the parent constrains is rejected rather than inherited" | `contained_dimension` treats an absent child dimension as unconstrained and therefore wider — `authority.py:603`; the subject half is `_subject_contained` (`authority.py:634`) | `test_t81_omission_is_not_unlimited`, `test_T73b_a_subject_addressed_to_every_principal_is_refused`, `test_t76_each_dimension_violated_alone` | -| "`ctrlrun revoke` cuts a chain of any depth with one write" | `Control.revoke` — `control.py:1716` — writes one row — `revoke_delegation` — `state.py:651` 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:618`; `Control._observed` — `control.py:762`; `_WouldHave` — `receipt.py:200`; `ReceiptResult.OBSERVED` — `receipt.py:112` | `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:618` | `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:2607` — writes one row — `revoke_delegation` — `state.py:659` and visits no children; every evaluation walks to the root | `test_t78_a_revoked_parent_denies_its_grandchild`, `test_put_delegation_is_never_an_upsert` | +| "`mode: observe` … records what *would* have been blocked, without blocking anything" | `_parse_mode` — `policy.py:657`; `Control._observed` — `control.py:1036`; `_WouldHave` — `receipt.py:244`; `ReceiptResult.OBSERVED` — `receipt.py:156` | `test_T82_observe_executes_what_enforce_would_deny`, `test_T83_a_duplicate_is_recorded_and_still_runs` | +| "One top-level line" | `mode:` is refused anywhere but the top level — `reject_nested_mode`, `policy.py:657` | `test_T84_mode_is_refused_anywhere_but_the_top_level` | | "`ctrlrun stats` gives you the numbers" | `stats` — `cli/main.py:727`; counted from `would_have.blocked_reason` and nothing else | `test_T86_stats_counts_what_observe_mode_recorded`, `test_T86_stats_reaches_no_network` | -| "It is not a dry run: it executes" | `_observed` runs the executor on every path, including the ones enforce mode would have refused — `control.py:762` | `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:1036` | `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 @@ -136,48 +136,48 @@ catalogue, `GUARANTEES` (`verify/guarantees.py:41`). | 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:515`; 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:502`, decided inside the `BEGIN IMMEDIATE` of `_authorize_and_reserve` (`state.py:929`) against `effect_key TEXT PRIMARY KEY` (`migrations.py:107`; `COLLATE "C"` on Postgres, §4.4) | `test_T3_exactly_one_agent_reserves_and_seven_are_blocked` (8 OS processes, both backends), `test_T3_the_fake_remote_is_called_exactly_once` | -| "An unknown outcome is AMBIGUOUS, never FAILED, and blocks a blind retry." | Only `NotExecuted` maps to `FAILED` — `control.py:1090`. Every other exception, timeouts included, yields `AMBIGUOUS`. A retry against an `AMBIGUOUS` key is refused — `effect.py:163`, 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:495` — answers `deny` for a name the document does not list. Missing or malformed policy: `Policy.from_file` — `policy.py:652` — raises `PolicyError`, and there is no `Control` without a policy. Missing principal: `_refuse_no_principal` — `control.py:1888` | `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 approval is bound to the exact action; a mutated or replayed one is refused." | `action_hash` — `action.py`; the approval record stores it and `_authorize_and_reserve` compares it — `state.py:523`; single use is the `granted → consumed` transition in the same `BEGIN IMMEDIATE` | `test_T2_a_mutated_action_presenting_the_approval_raises_ApprovalMismatch`, `test_T4_replaying_the_approval_raises_ApprovalMismatch_with_reason_consumed`, `test_T5_expiry_is_checked_at_consumption_not_only_at_grant` | +| "One logical effect happens at most once, across threads, processes and hosts." | `reserve_effect` — `state.py:510`, decided inside the `BEGIN IMMEDIATE` of `_authorize_and_reserve` (`state.py:939`) against `effect_key TEXT PRIMARY KEY` (`migrations.py:107`; `COLLATE "C"` on Postgres, §4.4) | `test_T3_exactly_one_agent_reserves_and_seven_are_blocked` (8 OS processes, both backends), `test_T3_the_fake_remote_is_called_exactly_once` | +| "An unknown outcome is AMBIGUOUS, never FAILED, and blocks a blind retry." | Only `NotExecuted` maps to `FAILED` — `control.py:1416`. Every other exception, timeouts included, yields `AMBIGUOUS`. A retry against an `AMBIGUOUS` key is refused — `effect.py:94`, the one place `plan_reservation` decides it for every store | `test_T1_a_blind_retry_writes_a_blocked_receipt`, `test_T1_the_ambiguous_record_survives_the_blocked_retry`, `test_T1_a_lost_response_leaves_the_effect_ambiguous` | +| "An unknown action, a missing policy or a missing principal is denied." | Unknown action: `Policy.evaluate` — `policy.py:534` — answers `deny` for a name the document does not list. Missing or malformed policy: `Policy.from_file` — `policy.py:691` — raises `PolicyError`, and there is no `Control` without a policy. Missing principal: `_refuse_no_principal` — `control.py:2903` | `test_T6_unknown_action_raises_ActionDenied_with_reason_unknown_action`, `test_missing_policy_file_is_a_policy_error`, `test_malformed_policy_document_is_a_policy_error`, `test_T62_a_declining_provider_with_no_context_is_no_principal` | | "With authority on, every principal needs a grant, and delegation cannot widen one." | `NO_AUTHORITY` — the fail-closed default of `Authority.evaluate` (`authority.py:64`); `contained_dimension` — `authority.py:603` — runs from `plan_delegation` (`authority.py:877`) and from the chain walk in `Authority.evaluate` | `test_T67_a_principal_with_no_grant_is_denied`, `test_t76_each_dimension_violated_alone` | -| "Every executed action leaves a portable JSON receipt" | `ReceiptResult` — `receipt.py:96`; `Event` — `receipt.py:162`; the store is authoritative — `append_event` — `state.py:662`; the JSONL export — `JSONLEventSink` — `receipt.py:438` | `test_T11_every_demo_receipt_carries_every_field_in_the_spec`, `test_T11_every_demo_receipt_parses_back_into_a_Receipt` | +| "Every executed action leaves a portable JSON receipt" | `ReceiptResult` — `receipt.py:140`; `Event` — `receipt.py:206`; the store is authoritative — `append_event` — `state.py:670`; the JSONL export — `JSONLEventSink` — `receipt.py:583` | `test_T11_every_demo_receipt_carries_every_field_in_the_spec`, `test_T11_every_demo_receipt_parses_back_into_a_Receipt` | ## What it guarantees | Claim | Code | Proof | |---|---|---| -| "On SQLite that is `BEGIN IMMEDIATE`" | `_authorize_and_reserve` — `state.py:929` | `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:674` — `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:674` — and every other method implement `v0.1 §5.3`'s frozen protocol; the decisions stay in `plan_reservation` (`effect.py:163`) | `test_T154_postgres_passes_the_store_conformance_suite` | +| "On SQLite that is `BEGIN IMMEDIATE`" | `_authorize_and_reserve` — `state.py:939` | `test_T3_exactly_one_agent_reserves_and_seven_are_blocked` | +| "a unique index on the effect key and compare-and-set updates whose row counts are checked" | `reserve_effect` — `postgres.py:720` — `INSERT … ON CONFLICT DO NOTHING` against `effect_key TEXT PRIMARY KEY COLLATE "C"` (`migrations.py:107`) | `test_T3_exactly_one_agent_reserves_and_seven_are_blocked` (8 OS processes, both backends) | +| "Same `StateStore` protocol, extended by nothing" | `PostgresStateStore.reserve_effect` — `postgres.py:720` — and every other method implement `v0.1 §5.3`'s frozen protocol; the decisions stay in `plan_reservation` (`effect.py:170`) | `test_T154_postgres_passes_the_store_conformance_suite` | | "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:163` (refuse retry on `AMBIGUOUS`) and `control.py:1090` (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:816`; `_resolve_lost_update` — `postgres.py:1167`; only `NotExecuted` maps to `FAILED` — `control.py:1090` | `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:128` | `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:63`; who resolved it — `resolved_by` — `effect.py:63`; `resolve` — `cli/main.py:518` | `test_T159_ambiguous_survives_a_restart_and_still_refuses_a_blind_retry`, `test_T160_there_is_no_reaper`, `test_T161_a_human_resolution_records_who` | -| "the only thing besides a human permitted to move a record out of `AMBIGUOUS`" | `Control._reconciled` — `control.py:1522`; `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` | +| "It will not *knowingly* execute the same logical effect twice, and will never treat an unknown outcome as a failure." | `plan_reservation` — `effect.py:170` (refuse retry on `AMBIGUOUS`) and `control.py:1416` (only `NotExecuted` → `FAILED`) | `test_T1_a_blind_retry_is_refused_and_never_reaches_the_remote`, `test_T1_a_lost_response_leaves_the_effect_ambiguous` | +| "a lost connection during `COMMIT` ... are `AMBIGUOUS`" | `_resolve_lost_insert` — `postgres.py:872`; `_resolve_lost_update` — `postgres.py:1350`; only `NotExecuted` maps to `FAILED` — `control.py:1416` | `test_T155_a_connection_killed_during_commit_is_resolved_by_the_re_read`, `test_T155_no_effect_is_ever_recorded_failed_by_a_lost_commit` | +| "the store re-reads the row to find out which" | The six branches, named and logged — `A2_LANDED` — `postgres.py:131` | `test_T155b_a_landed_commit_on_a_transition_is_seen_as_landed`, `test_T155d_a_commit_the_server_never_received_retries_the_insert` | +| "A crashed worker's effect stays `AMBIGUOUS` until a human runs `ctrlrun resolve` or a `reconcile` hook asks the remote what happened" | An expired lease is `AMBIGUOUS` and nothing sweeps it — `LEASE_EXPIRED` — `effect.py:94`; who resolved it — `resolved_by` — `effect.py:130`; `resolve` — `cli/main.py:518` | `test_T159_ambiguous_survives_a_restart_and_still_refuses_a_blind_retry`, `test_T160_there_is_no_reaper`, `test_T161_a_human_resolution_records_who` | +| "the only thing besides a human permitted to move a record out of `AMBIGUOUS`" | `Control._reconciled` — `control.py:1913`; `RECONCILED_STATES` — `effect.py` | `test_T13_a_hook_answering_not_executed_moves_the_record_to_failed`, `test_T14_a_hook_answering_committed_refuses_the_retry_as_a_duplicate` | | "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:495`; `Policy.from_file` — `policy.py:652`; `_refuse_no_principal` — `control.py:2007`; `_authorize_and_reserve` — `state.py:929` | `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:87` | `test_T84_mode_is_refused_anywhere_but_the_top_level`, `test_T101b_zero_applicable_guarantees_is_not_a_pass` | -| "With `authority:` on, every principal needs a grant, delegation cannot widen one, and `ctrlrun revoke` cuts a chain with one write." | `Authority.evaluate` — `authority.py:798`; `contained_dimension` — `authority.py:603`; `Control.revoke` — `control.py:1716` | `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:534`; `Policy.from_file` — `policy.py:691`; `_refuse_no_principal` — `control.py:2903`; `_authorize_and_reserve` — `state.py:939` | `test_T6_unknown_action_raises_ActionDenied_with_reason_unknown_action`, `test_malformed_policy_document_is_a_policy_error`, `test_T62_a_declining_provider_with_no_context_is_no_principal`, `test_T2_a_mutated_action_presenting_the_approval_raises_ApprovalMismatch` | +| "No flag makes a consequential action permissive by default" | There is no such option on `Control`, on `@protect`, on the CLI or in the policy schema's closed key sets — `_TOP_LEVEL_KEYS` — `policy.py:110` | `test_T84_mode_is_refused_anywhere_but_the_top_level`, `test_T101b_zero_applicable_guarantees_is_not_a_pass` | +| "With `authority:` on, every principal needs a grant, delegation cannot widen one, and `ctrlrun revoke` cuts a chain with one write." | `Authority.evaluate` — `authority.py:798`; `contained_dimension` — `authority.py:603`; `Control.revoke` — `control.py:2607` | `test_T67_a_principal_with_no_grant_is_denied`, `test_t76_each_dimension_violated_alone`, `test_t78_a_revoked_parent_denies_its_grandchild` | | "verifies a bearer token against a JWKS or a pinned key" | `JWTIdentityProvider._verified` — `jwt_identity.py:175`; the algorithm comes from the configured list and never from the token | `test_T88_a_valid_token_becomes_a_principal`, `test_T89_every_invalid_token_is_refused_by_cause` | | "maps the verified claims onto a principal" | `_principal` — `jwt_identity.py` — copies only the claims named in `claim_names` | `test_T88_only_the_named_claims_reach_the_principal` | | "`pip install \"ctrlrun[identity]\"`" | `identity = ["pyjwt[crypto]>=2.8"]` in `pyproject.toml`; imported lazily by `_jwt()` — `jwt_identity.py` | `test_T92_constructing_without_the_extra_names_the_install_command`, `test_T92_importing_ctrlrun_pulls_in_no_jwt_module` | | "CTRLRun issues no credential and defines no identity format" | There is no minting, signing or issuing code path in the package: `jwt_identity.py` calls `decode` and never `encode` | `test_the_package_never_encodes_a_token` | -| "every receipt records which policy decided it" | `Policy.policy_hash` — `policy.py:633`, over `_canonical_policy` — `policy.py:768`; carried into the receipt by `_record` — `control.py:1921` | `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:623` | `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:565`; `_spend_unneeded_approval` — `control.py:1462` | `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:313`; `prev_hash` — `receipt.py:41`; `GENESIS_HASH` — `receipt.py:41`; `put_receipt` takes the head row's lock first — `postgres.py:1640` | `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:551`; the six names — `CHAIN_BREAKS` — `receipt.py:488` | `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:525`, called from both stores' constructors; `HEAD` — `migrations.py:306` | `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:451`; `SchemaMismatch` — `errors.py` | `test_T148_an_older_binary_refuses_a_newer_database`, `test_T148_no_other_table_is_read_before_the_refusal` | +| "every receipt records which policy decided it" | `Policy.policy_hash` — `policy.py:672`, over `_canonical_policy` — `policy.py:824`; carried into the receipt by `_record` — `control.py:2812` | `test_T172_every_receipt_carries_the_hash_and_the_declared_version`, `test_T172_two_policies_sharing_a_version_string_are_told_apart_by_the_hash` | +| "the policy's declared `version:` and a hash of its canonical content" | `version:` is recorded and never authoritative; `policy_hash` is what tells two documents apart — `policy.py:662` | `test_T171_the_declared_version_alone_does_not_change_the_hash`, `test_T171_comments_key_order_and_whitespace_do_not_change_the_hash` | +| "the approval is re-checked against the policy in force at execution" | `Control.execute` — `control.py:752`; `_spend_unneeded_approval` — `control.py:1853` | `test_T173_the_DENY_row_refuses_and_leaves_the_approval_granted`, `test_T173_the_ALLOW_row_invalidates_the_approval_it_did_not_need` | +| "Each receipt carries the hash of the one before it" | `Receipt.chain_hash` — `receipt.py:393`; `prev_hash` — `receipt.py:352`; `GENESIS_HASH` — `receipt.py:78`; `put_receipt` takes the head row's lock first — `postgres.py:1838` | `test_T164_an_altered_receipt_is_content_altered_at_its_seq`, `test_T164_reordering_two_receipts_is_detected_either_way` | +| "`ctrlrun receipts --verify-chain` reports it by `seq`" | `verify_chain` — `receipt.py:701`; the six names — `CHAIN_BREAKS` — `receipt.py:638` | `test_the_verify_chain_flag_reports_a_break_by_seq_and_by_name`, `test_verify_chain_reads_a_postgres_store_through_store_url` | +| "migrations are automatic at open, forward-only" | `migrate` — `migrations.py:544`, called from both stores' constructors; `HEAD` — `migrations.py:325` | `test_T147_a_v05_database_migrates_and_keeps_every_row`, `test_T150_reopening_does_not_rerun` | +| "An older binary against a newer schema refuses immediately" | `_refuse` — `migrations.py:470`; `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:366`; `receipts` — `cli/main.py:426`; `effects` — `cli/main.py:500`; `resolve` — `cli/main.py:518`; `inspect` — `cli/main.py:554`; `stats` — `cli/main.py:727`; 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:420`; `Receipt` — `receipt.py:245` — 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:403`; `Policy.data_scope` — `policy.py:487`; `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:420`; `Receipt` — `receipt.py:298` — 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:521`; `Policy.data_scope` — `policy.py:526`; `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 @@ -189,7 +189,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:495`) 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:534`) 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` | @@ -200,10 +200,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:525` — called from both stores' constructors; `_refuse` — `migrations.py:451` — 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:292` — 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:544` — called from both stores' constructors; `_refuse` — `migrations.py:470` — 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:350` — 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:633` — 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:672` — 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 @@ -223,11 +223,11 @@ restating the code; the ones that are new to the site carry their own code and p | `concepts/decisions` | three decisions, first match wins, unknown denied, principal-addressing conditions refused at load | the "Write down what the agent may do" rows above | | `concepts/approval-binding` | A1–A4, the mismatch leaving the approval granted, one core provider writing every grant | the matrix row "An approval is bound to the exact action…", the "Three ways to use it" adapter rows, and `test_T2_a_mutated_action_leaves_the_approval_granted` | | `concepts/approval-binding` | the `DENY` and `ALLOW` rows when the policy changed between grant and consumption | "the approval is re-checked against the policy in force at execution" above | -| `concepts/effect-keys` | reservation atomic across threads, processes and hosts; an expired lease is `AMBIGUOUS`, never free; `COMMITTED` refuses, `FAILED` permits, `AMBIGUOUS` refuses a blind retry | the matrix row "One logical effect happens at most once…"; `LEASE_EXPIRED` — `effect.py:63`; `test_T160_an_expired_lease_frees_nothing_and_no_read_transitions_it`, `test_T8_a_failed_attempt_permits_a_retry_that_commits` | +| `concepts/effect-keys` | reservation atomic across threads, processes and hosts; an expired lease is `AMBIGUOUS`, never free; `COMMITTED` refuses, `FAILED` permits, `AMBIGUOUS` refuses a blind retry | the matrix row "One logical effect happens at most once…"; `LEASE_EXPIRED` — `effect.py:94`; `test_T160_an_expired_lease_frees_nothing_and_no_read_transitions_it`, `test_T8_a_failed_attempt_permits_a_retry_that_commits` | | `concepts/outcomes-and-ambiguous` | the outcome table; only a human or a reconcile hook moves a record on, and only in the direction the answer points; nothing sweeps; a lost `COMMIT` on Postgres is `AMBIGUOUS` | the matrix row "An unknown outcome is AMBIGUOUS…", the reconciliation rows, "A crashed worker's effect stays `AMBIGUOUS`…" and the Postgres rows above; `test_T160_there_is_no_reaper` | | `concepts/receipts-and-evidence` | the receipt's fields, the JSONL sink, the policy hash and version, the chain and what it does not prove | the matrix row "Every executed action leaves a portable JSON receipt", the receipt-chain and policy-versioning rows above, and `test_T11_every_demo_receipt_carries_every_field_in_the_spec` | | `concepts/authority-and-delegation` | opt-in then fail-closed, no `decision:` on a grant, stricter of the two, containment at creation and at every evaluation, omission rejected, one-write revocation, identity consumed | the authority rows under "Write down what the agent may do" and "What it guarantees" above | -| `concepts/observe-mode` | executes, records `would_have`, one top-level line, counted by `ctrlrun stats`, never asks a human | the observe-mode rows above; `_observed` — `control.py:1958` | +| `concepts/observe-mode` | executes, records `would_have`, one top-level line, counted by `ctrlrun stats`, never asks a human | the observe-mode rows above; `_observed` — `control.py:2850` | | `concepts/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 70a88c6..7ec22f1 100644 --- a/docs/OWASP-AGENTIC-TOP10.md +++ b/docs/OWASP-AGENTIC-TOP10.md @@ -75,6 +75,9 @@ mechanism, not the entry. | **G10** unknown exception is ambiguous | `NotExecuted` is the only outcome that means "the remote did nothing". Everything else, timeouts included, is `AMBIGUOUS`. | `ASI08:2026` | The mapping from an executor's exception to an outcome is asymmetric on purpose: a timeout is not a failure, so a framework's retry-on-error cannot be the thing that decides whether money moved twice. | | **G11** an altered receipt is detected | Each receipt carries the hash of the one before it. Altering, deleting or reordering one breaks the chain, and the break is reported by name — `content_altered`, `hash_missing`, `link_broken`, `missing`, `head_mismatch`, `unchained` — and by `seq`. | `ASI09:2026` (partly) | The evidence an operator reads after an incident is the thing an attacker who got that far has the most reason to edit. This does not stop them: it makes **changing what a receipt says, while keeping the receipts after it**, cost a rewrite of all of them plus the head, rather than one statement. What it does not close is the end of the log — erasing a suffix, or appending to it, each cost two statements and are undetected, because the head is a row in the same database and not an external anchor. v0.6 has no anchor and claims none. It is **not** a signature and says nothing about who wrote the log; somebody who can rewrite every row including the head recomputes the chain and it verifies, and `THREAT_MODEL.md` still lists a malicious administrator as out of scope. | | **G13** clock divergence is named | The kernel measures how far this host's clock disagrees with the store's, on a store that has a clock of its own, and reports the divergence as `CLOCK_SKEW_DETECTED` when it passes the configured threshold. It changes no decision: every lease is still compared against the application clock exactly as before. | `ASI08:2026` (partly) | Once the store is shared, each host brings its own clock, and the failure is fail-closed and therefore quiet. A host running ahead sees a live lease as expired and marks `AMBIGUOUS` a record whose real holder is mid-flight and about to succeed; a host running behind refuses for longer than it should. Neither says why. What this closes is the silence, not the skew: an operator reading a receipt learns that two clocks disagreed and by how much, so the response to the first failure is not itself the second one. It does not synchronize anything, and a skew below the threshold is not reported. | +| **G14** token changes across a renewal | `ctrlrun.idempotency_token()` answers inside an executor with a token derived from `(effect_key, attempt)`: stable within one attempt, including across a resume, and different after a renewal. Send it to a provider as its idempotency key. | `ASI08:2026` (partly) | A provider handed the effect key alone would answer the one retry the kernel permits, permitted *because the executor proved nothing happened*, with the cached failure of the attempt that failed. A token that moves with the attempt keeps a provider's cache from becoming a second source of stale outcomes. What it is for is reconciliation, a deterministic handle to ask a provider what became of an attempt whose outcome is unknown; it does not make a retry safe, and after `AMBIGUOUS` the kernel still refuses one. It is unique only as far as the operator's effect keys are, and nothing here checks two stores sharing a provider account. | +| **G15** renewal past the ceiling refused | An action entry may declare `max_attempts`; above it the executor is not called, the record is released as `FAILED`, a `blocked` receipt names the ceiling, and `ActionDenied(reason="attempt_ceiling")` is raised. The refused attempt number is spent. | `ASI08:2026` (partly), `ASI10:2026` (partly) | Without a ceiling a renewal after `FAILED` is unbounded, so an agent that keeps proposing an action that keeps failing keeps dispatching, and one human yes on an `APPROVE` action bought unlimited dispatches. The ceiling is the operator's, not the kernel's: an entry that declares none renews exactly as before, and the decision is taken on the attempt number the store assigned, after the reservation, so two callers cannot both pass a read taken before reserving. It bounds attempts on one effect key, not what an agent does across many. | +| **G16** a moved fingerprint is refused | Under `APPROVE`, a `preconditions=` provider's answer is kept as a `sha256:` fingerprint on the request; on the presenting pass it is computed again, strictly before the store call that consumes the approval, and a difference is refused with `ApprovalMismatch(reason="precondition_changed")`, reserving nothing and leaving the approval granted. | `ASI09:2026` (partly), `ASI01:2026` (partly) | A human's yes was given against the world as it was; this refuses the action where the state the approval depended on has moved since. It **narrows** the window between approval and execution and does not close it: the comparison is a network call outside the atomic reservation write, and a change that lands between the comparison and the reservation is not refused. What the provider looks at is in the operator's code, which verify does not read; verify grades the check with a provider of its own. | --- diff --git a/docs/cookbook/verify-in-github-actions.mdx b/docs/cookbook/verify-in-github-actions.mdx index 516dc86..689f6be 100644 --- a/docs/cookbook/verify-in-github-actions.mdx +++ b/docs/cookbook/verify-in-github-actions.mdx @@ -66,7 +66,6 @@ CTRLRun verify — ctrlrun 0.6.1, catalogue ctrlrun.guarantees/v3 policy /home/runner/work/agent/agent/ctrlrun.yaml (ctrlrun.policy/v2, mode: enforce) authority none store sqlite, scratch (created and destroyed for this run) - G1 mutated approval refused PASS k8s.delete_namespace G2 replayed approval refused PASS k8s.delete_namespace G3 duplicate effect refused PASS k8s.delete_namespace @@ -79,8 +78,19 @@ G9 delegation cannot escalate N/A no authority section G10 unknown exception is ambiguous PASS k8s.delete_namespace G11 an altered receipt is detected PASS k8s.delete_namespace G13 clock divergence is named N/A the store verify was given reads only the application's clock, so there is no second clock to diverge from; pass --store-url postgresql://… to grade this - -9/9 declared guarantees pass. 3 not applicable: G8, G9, G13. +G14 token changes across a renewal PASS k8s.delete_namespace (attempt 1 and its renewal carry different tokens) +G15 renewal past the ceiling refused N/A no action verify can drive to allow or approve declares both `effect:` and `max_attempts` +G16 a moved fingerprint is refused PASS k8s.delete_namespace + (verify supplies its own precondition provider; whether + your @protect declares one is in your code, which verify + does not read. The gateway and the ACS hook cannot name a + provider at all, and refuse an approval that carries a + fingerprint) + (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) +11/11 declared guarantees pass. 4 not applicable: G8, G9, G13, G15. ``` The first line is on stderr, from G7's own scenario driving an action with no principal — the @@ -89,9 +99,9 @@ the first action that fits each scenario in alphabetical order; which one appear about it. The `policy` line is the resolved absolute path, so yours will differ. -Two guarantees are not applicable because the policy has no `authority:` section; they are -listed with the reason and excluded from the denominator. Green means nothing that could be -checked was wrong. +Four guarantees are not applicable: no `authority:` section (G8, G9), no second clock in a +scratch store (G13), no `max_attempts` to refuse past (G15). Each is listed with its reason and +excluded from the denominator. Green means nothing that could be checked was wrong. ## The receipt diff --git a/docs/guides/verify-in-ci.mdx b/docs/guides/verify-in-ci.mdx index e647c63..50cbb76 100644 --- a/docs/guides/verify-in-ci.mdx +++ b/docs/guides/verify-in-ci.mdx @@ -36,7 +36,6 @@ guarantees pass. policy /home/you/agent/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 @@ -49,19 +48,30 @@ guarantees pass. G10 unknown exception is ambiguous PASS k8s.delete_namespace G11 an altered receipt is detected PASS k8s.delete_namespace G13 clock divergence is named N/A the store verify was given reads only the application's clock, so there is no second clock to diverge from; pass --store-url postgresql://… to grade this - - 9/9 declared guarantees pass. 3 not applicable: G8, G9, G13. + G14 token changes across a renewal PASS k8s.delete_namespace (attempt 1 and its renewal carry different tokens) + G15 renewal past the ceiling refused N/A no action verify can drive to allow or approve declares both `effect:` and `max_attempts` + G16 a moved fingerprint is refused PASS k8s.delete_namespace + (verify supplies its own precondition provider; whether + your @protect declares one is in your code, which verify + does not read. The gateway and the ACS hook cannot name a + provider at all, and refuse an approval that carries a + fingerprint) + (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) + 11/11 declared guarantees pass. 4 not applicable: G8, G9, G13, G15. ``` - Two things about that output. The first line is on **stderr**, from G7's own scenario: it - drives an action with no principal and the refusal logs, which is the guarantee passing and - not a problem. And every row names `k8s.delete_namespace` rather than the refund — verify - exercises one action per guarantee and takes the **first that fits, in alphabetical order**, - so which of your actions appears is not a judgement about it. + The first line is on **stderr**, from G7's own scenario: an action with no principal is + refused and the refusal logs, which is the guarantee passing. Every row names + `k8s.delete_namespace` because verify takes the **first action that fits, in alphabetical + order**, not because of anything about the refund. - Two guarantees are not applicable: this policy has no `authority:` section, so nothing - about grants can be exercised. They are reported with the reason, excluded from the - denominator and listed separately. Never `11/11`, and no flag folds them in. + Four guarantees are not applicable: no `authority:` section (G8, G9), no second clock in a + scratch store (G13), no `max_attempts` to refuse past (G15). Each is reported with its + reason, excluded from the denominator and listed separately. Never `15/15`, and no flag + folds them in. @@ -122,10 +132,9 @@ acted is invisible to it. The badge does not mean secure, safe, compliant, certi - Exit 2, `mode: observe`: verify refuses an observed configuration, because observe mode executes what enforce mode would refuse and nothing can be proved about it. - Exit 2, *nothing was checked and nothing is claimed*: **no** guarantee was applicable, so - `0/0` — which is never a pass. In practice that means an `actions:` map with nothing in it. A - policy with one `allow` action and no `approve` rule, no `effect:` template and no grants is - not this case: it exits 0 with `4/4` and seven not applicable, because the fail-closed and - unknown-outcome guarantees still have something to exercise. + `0/0` — which is never a pass. In practice that means an `actions:` map with nothing in it; + a policy with one `allow` action still exercises the fail-closed and unknown-outcome + guarantees. - Exit 1: a guarantee failed. The report names the scenario and the action; that is a bug report, not a configuration problem. diff --git a/docs/production/index.mdx b/docs/production/index.mdx index fd07d58..bc58e3d 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.6.1**, on [PyPI](https://pypi.org/project/ctrlrun/), Python 3.11 and later. -- **4,513 tests**, every version specified before it was written and every requirement mutation-tested. [Read more](/docs/how-this-is-built). -- **12 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). +- **4,999 tests**, every version specified before it was written and every requirement mutation-tested. [Read more](/docs/how-this-is-built). +- **15 guarantees you can check in your own setup**, with `ctrlrun verify` against your policy, on your store's backend, in a scratch store it creates. [Read more](/docs/security/verify-guarantees). - **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/Approval.mdx b/docs/reference/api/Approval.mdx index c4a5cd0..b1cbbf3 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:111` +`ctrlrun.Approval` — class, defined at `src/ctrlrun/approval.py:125` ```python from ctrlrun import Approval diff --git a/docs/reference/api/ApprovalProvider.mdx b/docs/reference/api/ApprovalProvider.mdx index aa83627..883d1e1 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:304` +`ctrlrun.ApprovalProvider` — class, defined at `src/ctrlrun/approval.py:318` ```python from ctrlrun import ApprovalProvider diff --git a/docs/reference/api/ApprovalRequest.mdx b/docs/reference/api/ApprovalRequest.mdx index 3ae214f..0a57d6d 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:78` +`ctrlrun.ApprovalRequest` — class, defined at `src/ctrlrun/approval.py:79` ```python from ctrlrun import ApprovalRequest @@ -14,7 +14,7 @@ from ctrlrun import ApprovalRequest ```python class ApprovalRequest - def __init__(request_id: str, action_hash: str, action: Action, created_at: datetime, expires_at: datetime, policy_hash: str | None = None) + def __init__(request_id: str, action_hash: str, action: Action, created_at: datetime, expires_at: datetime, policy_hash: str | None = None, precondition_fingerprint: str | None = None) ``` A pending question for a human: may this exact action run? (SPEC-v0.1 §4.1) diff --git a/docs/reference/api/Condition.mdx b/docs/reference/api/Condition.mdx index 6a3d693..1265fd0 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:297` +`ctrlrun.Condition` — class, defined at `src/ctrlrun/policy.py:333` ```python from ctrlrun import Condition diff --git a/docs/reference/api/Control.mdx b/docs/reference/api/Control.mdx index db1bbae..449d9b7 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:280` +`ctrlrun.Control` — class, defined at `src/ctrlrun/control.py:457` ```python from ctrlrun import Control diff --git a/docs/reference/api/Decision.mdx b/docs/reference/api/Decision.mdx index aec0b55..18ada64 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:234` +`ctrlrun.Decision` — class, defined at `src/ctrlrun/policy.py:270` ```python from ctrlrun import Decision diff --git a/docs/reference/api/DelegationRecord.mdx b/docs/reference/api/DelegationRecord.mdx index 59367df..628ab91 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:372` +`ctrlrun.DelegationRecord` — class, defined at `src/ctrlrun/state.py:380` ```python from ctrlrun import DelegationRecord diff --git a/docs/reference/api/EffectRecord.mdx b/docs/reference/api/EffectRecord.mdx index 86c432a..c0f7d69 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:99` +`ctrlrun.EffectRecord` — class, defined at `src/ctrlrun/effect.py:106` ```python from ctrlrun import EffectRecord diff --git a/docs/reference/api/EffectState.mdx b/docs/reference/api/EffectState.mdx index 8f277ec..559ca45 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:74` +`ctrlrun.EffectState` — class, defined at `src/ctrlrun/effect.py:81` ```python from ctrlrun import EffectState diff --git a/docs/reference/api/Event.mdx b/docs/reference/api/Event.mdx index 59aac5d..35981f6 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:161` +`ctrlrun.Event` — class, defined at `src/ctrlrun/receipt.py:205` ```python from ctrlrun import Event diff --git a/docs/reference/api/EventSink.mdx b/docs/reference/api/EventSink.mdx index cbad7cf..277cd12 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:419` +`ctrlrun.EventSink` — class, defined at `src/ctrlrun/receipt.py:564` ```python from ctrlrun import EventSink diff --git a/docs/reference/api/InMemoryStateStore.mdx b/docs/reference/api/InMemoryStateStore.mdx index 2bf8880..535b5cd 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:728` +`ctrlrun.InMemoryStateStore` — class, defined at `src/ctrlrun/state.py:736` ```python from ctrlrun import InMemoryStateStore diff --git a/docs/reference/api/JSONLEventSink.mdx b/docs/reference/api/JSONLEventSink.mdx index fbe303c..970b1e1 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:438` +`ctrlrun.JSONLEventSink` — class, defined at `src/ctrlrun/receipt.py:583` ```python from ctrlrun import JSONLEventSink diff --git a/docs/reference/api/LocalApprovalProvider.mdx b/docs/reference/api/LocalApprovalProvider.mdx index 6b644c5..7eb815c 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:369` +`ctrlrun.LocalApprovalProvider` — class, defined at `src/ctrlrun/approval.py:423` ```python from ctrlrun import LocalApprovalProvider diff --git a/docs/reference/api/Policy.mdx b/docs/reference/api/Policy.mdx index 84e1e42..1e6db84 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:599` +`ctrlrun.Policy` — class, defined at `src/ctrlrun/policy.py:638` ```python from ctrlrun import Policy diff --git a/docs/reference/api/Receipt.mdx b/docs/reference/api/Receipt.mdx index d781369..4f780ea 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:253` +`ctrlrun.Receipt` — class, defined at `src/ctrlrun/receipt.py:297` ```python from ctrlrun import Receipt @@ -14,11 +14,24 @@ 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) + 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, schema: str = RECEIPT_SCHEMA) ``` Portable evidence of one action that reached a terminal state (SPEC-v0.1 §6.1). +`ctrlrun.receipt/v4` (SPEC-v0.7 §6.11) adds `precondition_at_request` and +`precondition_at_recheck`: the fingerprint the approval was requested with, and the one +computed on the presenting pass, each `None` where there was none. Hashes only, never the +state they were computed from. On a refusal they say which side moved or was missing; on a +committed action they are equal, and the receipt records that the world was compared +before the reservation. That comparison narrows the window between a human's decision and +the effect and does not close it (§6.7). + +`schema` is the schema the receipt is written under. A receipt read from a store keeps the +one it was written with, renders under that schema's label and keys, and is hashed as the +document it was read from, so a `v3` receipt a released 0.6 wrote still rehashes to its +stored hash under a `v4` binary. + ## Next - [Python API index](/docs/reference/api/index). diff --git a/docs/reference/api/ReconcileOutcome.mdx b/docs/reference/api/ReconcileOutcome.mdx index 38a1776..d731a39 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:45` +`ctrlrun.ReconcileOutcome` — attribute, defined at `src/ctrlrun/effect.py:52` ```python from ctrlrun import ReconcileOutcome diff --git a/docs/reference/api/SQLiteStateStore.mdx b/docs/reference/api/SQLiteStateStore.mdx index 30f5bbb..ee638e7 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:1103` +`ctrlrun.SQLiteStateStore` — class, defined at `src/ctrlrun/state.py:1113` ```python from ctrlrun import SQLiteStateStore diff --git a/docs/reference/api/ScriptedApprovalProvider.mdx b/docs/reference/api/ScriptedApprovalProvider.mdx index d998e9e..8ea1a3c 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:425` +`ctrlrun.ScriptedApprovalProvider` — class, defined at `src/ctrlrun/approval.py:479` ```python from ctrlrun import ScriptedApprovalProvider diff --git a/docs/reference/api/StateStore.mdx b/docs/reference/api/StateStore.mdx index 9d9ba69..023c2db 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:499` +`ctrlrun.StateStore` — class, defined at `src/ctrlrun/state.py:507` ```python from ctrlrun import StateStore diff --git a/docs/reference/api/context.mdx b/docs/reference/api/context.mdx index 305a4c2..3a4020e 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:154` +`ctrlrun.context` — function, defined at `src/ctrlrun/control.py:236` ```python from ctrlrun import context diff --git a/docs/reference/api/idempotency_token.mdx b/docs/reference/api/idempotency_token.mdx new file mode 100644 index 0000000..f02de15 --- /dev/null +++ b/docs/reference/api/idempotency_token.mdx @@ -0,0 +1,43 @@ +--- +title: "idempotency_token" +description: "The provider idempotency token for the attempt this executor is running (SPEC-v0.7 §4)." +--- + +{/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} + +`ctrlrun.idempotency_token` — function, defined at `src/ctrlrun/control.py:204` + +```python +from ctrlrun import idempotency_token +``` + + +```python +def idempotency_token() -> str +``` + +The provider idempotency token for the attempt this executor is running (SPEC-v0.7 §4). + +Send it to the provider as its idempotency key. It is +`ctrlrun.effect.idempotency_token_for(effect_key, attempt)` for the attempt that holds the +reservation, so it is stable across a `Control.resume` of the same attempt and **different +after a renewal**: a token stable across v0.1 §5.4's renewal would have the provider answer +the one retry the kernel permits with the cached failure of the attempt that failed (§4.1). + +What it is for is reconciliation: a deterministic handle to ask the provider what became of +an attempt whose outcome is unknown, by a key the provider already indexes (§4.7). It does +not make a retry safe, and after an `AMBIGUOUS` outcome the kernel still refuses one. + +Nothing is stored: the token is a pure function of two fields every receipt of an attempt +that ran already carries, so a receipt re-derives it with `idempotency_token_for` and a +`reconcile` hook reads the attempt off the record (§4.5). + +**Outside an executor this raises `InvalidArgument`**, because a token invented outside an +attempt identifies nothing. So does an executor whose action has no effect key, an +observe-mode attempt whose reservation was refused, and a thread the executor started +without copying its context: a missing value is refused rather than guessed (§4.3). + +## Next + +- [Python API index](/docs/reference/api/index). +- [Get started](/docs/get-started/quickstart) · [Why](/docs/why). diff --git a/docs/reference/api/index.mdx b/docs/reference/api/index.mdx index a31bfcd..44624fc 100644 --- a/docs/reference/api/index.mdx +++ b/docs/reference/api/index.mdx @@ -69,6 +69,7 @@ a name with no docstring fails a test, so every page has one. | [`ctrlrun.canonical_bytes`](/docs/reference/api/canonical_bytes) | function | The canonical form of an arbitrary mapping: UTF-8 JSON, sorted keys, no whitespace. | | [`ctrlrun.canonicalize`](/docs/reference/api/canonicalize) | function | Return the canonical form of an Action: UTF-8 JSON, sorted keys, no whitespace. | | [`ctrlrun.context`](/docs/reference/api/context) | function | Bind the principal for calls made inside the block. | +| [`ctrlrun.idempotency_token`](/docs/reference/api/idempotency_token) | function | The provider idempotency token for the attempt this executor is running (SPEC-v0.7 §4). | | [`ctrlrun.needs_approval`](/docs/reference/api/needs_approval) | function | Does this call need a human? For a framework that asks before it invokes (SPEC-v0.5 §3.5). | | [`ctrlrun.parse_conditions`](/docs/reference/api/parse_conditions) | function | Parse a `when:`-shaped mapping into conditions, keyed by the raw condition key. | | [`ctrlrun.protect`](/docs/reference/api/protect) | function | Bind a function to an action name: every call becomes a decided, recorded Action. | diff --git a/docs/reference/api/parse_conditions.mdx b/docs/reference/api/parse_conditions.mdx index 78d13d7..d670a32 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:1004` +`ctrlrun.parse_conditions` — function, defined at `src/ctrlrun/policy.py:1061` ```python from ctrlrun import parse_conditions diff --git a/docs/reference/api/postgres-PostgresStateStore.mdx b/docs/reference/api/postgres-PostgresStateStore.mdx index c5b268a..ec6fac0 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:272` +`ctrlrun.postgres.PostgresStateStore` — class, defined at `src/ctrlrun/postgres.py:317` ```python from ctrlrun.postgres import PostgresStateStore diff --git a/docs/reference/api/protect.mdx b/docs/reference/api/protect.mdx index 06cb18a..4edd261 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:2125` +`ctrlrun.protect` — function, defined at `src/ctrlrun/control.py:3021` ```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) -> 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) -> Callable[[Callable[P, R]], Callable[P, R]] ``` Bind a function to an action name: every call becomes a decided, recorded Action. @@ -32,6 +32,12 @@ means what it always meant: past the lease the effect is `AMBIGUOUS`, never rele (SPEC-v0.2 §2). With `reconcile_eagerly`, it also runs immediately after this call produces an `AMBIGUOUS` outcome, rather than only when one blocks a later attempt. +`preconditions` reads the state an approval depends on, and is `Control.execute`'s keyword +(SPEC-v0.7 §6.2): called with the `Action` when the approval is requested and again on the +presenting pass, before the store call that consumes it, with a refusal where the two +fingerprints differ or only one exists. The recheck narrows the window a human's approval +leaves open; it does not close it (§6.7). Not callable is refused here, at decoration time. + ## Next - [Python API index](/docs/reference/api/index). diff --git a/docs/reference/api/with_approval.mdx b/docs/reference/api/with_approval.mdx index ec4c2a8..a0660b6 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:177` +`ctrlrun.with_approval` — function, defined at `src/ctrlrun/control.py:259` ```python from ctrlrun import with_approval diff --git a/docs/reference/policy-yaml.mdx b/docs/reference/policy-yaml.mdx index 5bc9197..446d659 100644 --- a/docs/reference/policy-yaml.mdx +++ b/docs/reference/policy-yaml.mdx @@ -1,6 +1,6 @@ --- title: "Policy YAML reference" -description: "Every key of ctrlrun.yaml, from ctrlrun.policy/v1 to v4: schema, actions, decision, rules, when conditions and their seven operators." +description: "Every key of ctrlrun.yaml, from ctrlrun.policy/v1 to v5: schema, actions, decision, rules, max_attempts, when conditions and their seven operators." --- `ctrlrun.yaml` is one document: a `schema`, an `actions` map, and from `ctrlrun.policy/v3` the @@ -13,7 +13,7 @@ every key and operator the loader accepts. | Key | Type | Since | Default when omitted | Notes | |---|---|---|---|---| -| `schema` | string | v1 | **error**: a document with no schema is an unknown schema, never "assume v1" | one of `ctrlrun.policy/v1`, `ctrlrun.policy/v2`, `ctrlrun.policy/v3`, `ctrlrun.policy/v4`; each accepts the keys below marked with its version or earlier, and refuses the rest | +| `schema` | string | v1 | **error**: a document with no schema is an unknown schema, never "assume v1" | one of `ctrlrun.policy/v1`, `ctrlrun.policy/v2`, `ctrlrun.policy/v3`, `ctrlrun.policy/v4`, `ctrlrun.policy/v5`; each accepts the keys below marked with its version or earlier, and refuses the rest | | `actions` | map of action name → entry | v1 | **error** | the whole list of what an agent may do; an action not in it is denied | | `mode` | `enforce` or `observe` | v3 | `enforce` | top level only; refused anywhere else | | `environment` | string | v3 | `production`, unless `$CTRLRUN_ENVIRONMENT` or the gateway's `--environment` says otherwise | part of every action's hash and of authority matching; never read off the wire | @@ -61,6 +61,7 @@ Exactly one of `decision` and `rules`, plus the optional keys. | `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 | | `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 | +| `max_attempts` | integer, at least 1 | v5 | no ceiling: a renewal after `FAILED` is unbounded, exactly as before v5 | the most **attempts** that may execute on one effect key, the first included: `3` is the first attempt and two renewals. Above it the executor is not called, the record is released as `FAILED`, a `blocked` receipt names the ceiling, and `ActionDenied(reason="attempt_ceiling")` is raised; the refused number is spent. `0`, a negative, a bool, a float, a string and a mapping are each a load error naming the key, the action and the line. An attempt is not an executor invocation: a suspended executor's resumes all run on one attempt. Inside the policy hash | ## A rule @@ -129,6 +130,7 @@ reader would have silently misread is one it refuses instead. | `ctrlrun.policy/v2` | `effect`, `resource`, `mcp` on an entry | run a gateway tool with no reservation | | `ctrlrun.policy/v3` | `mode`, `environment`, `authority` at the top | enforce an observed deployment, or run with no authority check | | `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 | 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/reference/receipt-and-event-schemas.mdx b/docs/reference/receipt-and-event-schemas.mdx index 1aa4629..a589422 100644 --- a/docs/reference/receipt-and-event-schemas.mdx +++ b/docs/reference/receipt-and-event-schemas.mdx @@ -51,6 +51,10 @@ it. A reader checking a chain compares those two. | `policy_version` | `str | None` | `None` | | `controls` | `tuple` | `()` | | `hash` | `str | None` | `None` | +| `precondition_at_request` | `str | None` | `None` | +| `precondition_at_recheck` | `str | None` | `None` | +| `schema` | `str` | `'ctrlrun.receipt/v4'` | +| `_stored_document` | `collections.abc.Mapping[str, Any] | None` | `None` | ### Result diff --git a/docs/verify.md b/docs/verify.md index 3f222a3..8037298 100644 --- a/docs/verify.md +++ b/docs/verify.md @@ -31,8 +31,20 @@ G9 delegation cannot escalate PASS head-of-support (6 of 6 dimensions) G10 unknown exception is ambiguous PASS stripe.refund G11 an altered receipt is detected PASS stripe.refund G13 clock divergence is named N/A the store verify was given reads only the application's clock, so there is no second clock to diverge from; pass --store-url postgresql://… to grade this - -11/11 declared guarantees pass. 1 not applicable: G13. +G14 token changes across a renewal PASS stripe.refund (attempt 1 and its renewal carry different tokens) +G15 renewal past the ceiling refused N/A no action verify can drive to allow or approve declares both `effect:` and `max_attempts` +G16 a moved fingerprint is refused PASS stripe.refund + (verify supplies its own precondition provider; whether + your @protect declares one is in your code, which verify + does not read. The gateway and the ACS hook cannot name a + provider at all, and refuse an approval that carries a + fingerprint) + (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) + +13/13 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 c3a2881..98a9d3d 100644 --- a/generated/readiness.full.mdx +++ b/generated/readiness.full.mdx @@ -1,7 +1,7 @@ {/* generated from the suite, pyproject and the soak (full) — run the generator */} - **Version 0.6.1**, on [PyPI](https://pypi.org/project/ctrlrun/), Python 3.11 and later. -- **4,513 tests**, every version specified before it was written and every requirement mutation-tested. [Read more](/docs/how-this-is-built). -- **12 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). +- **4,999 tests**, every version specified before it was written and every requirement mutation-tested. [Read more](/docs/how-this-is-built). +- **15 guarantees you can check in your own setup**, with `ctrlrun verify` against your policy, on your store's backend, in a scratch store it creates. [Read more](/docs/security/verify-guarantees). - **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 876381e..4d3d968 100644 --- a/generated/readiness.json +++ b/generated/readiness.json @@ -1,5 +1,5 @@ { - "guarantees": 12, + "guarantees": 15, "released": "0.6.1", "soak": { "actions": 889735, @@ -9,6 +9,6 @@ "positive_control": true, "unexplained": 0 }, - "tests": 4513, + "tests": 4999, "version": "0.6.1" } diff --git a/generated/readiness.mdx b/generated/readiness.mdx index fbec7ea..4fd96f9 100644 --- a/generated/readiness.mdx +++ b/generated/readiness.mdx @@ -1,7 +1,7 @@ {/* generated from the suite, pyproject and the soak (mdx) — run the generator */} - **Version 0.6.1**, on [PyPI](https://pypi.org/project/ctrlrun/), Python 3.11 and later. -- **4,513 tests**, every version specified before it was written and every requirement mutation-tested. -- **12 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. +- **4,999 tests**, every version specified before it was written and every requirement mutation-tested. +- **15 guarantees you can check in your own setup**, with `ctrlrun verify` against your policy, on your store's backend, in a scratch store it creates. - **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 e2f87b0..36a0723 100644 --- a/generated/readiness.readme.md +++ b/generated/readiness.readme.md @@ -1,7 +1,7 @@ - **Version 0.6.1**, on [PyPI](https://pypi.org/project/ctrlrun/), Python 3.11 and later. -- **4,513 tests**, every version specified before it was written and every requirement mutation-tested. -- **12 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. +- **4,999 tests**, every version specified before it was written and every requirement mutation-tested. +- **15 guarantees you can check in your own setup**, with `ctrlrun verify` against your policy, on your store's backend, in a scratch store it creates. - **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_release_documents.py b/tests/test_release_documents.py index 229a338..3f8228e 100644 --- a/tests/test_release_documents.py +++ b/tests/test_release_documents.py @@ -129,6 +129,8 @@ def _load(name: str) -> set[str]: "one `Control` each. `SPEC-v0.1.md` §8's frozen signature is amended in the same change, as the", # noqa: E501 "- **`WebhookApprovalProvider`** — core, over stdlib `urllib.request`. One signed POST on", "`APPROVAL_REQUESTED`; the gateway serves the signed inbound grant/deny at", + # v0.7 item 3: "the executor signature" is a function's parameters, not a receipt. + "re-derives it and a `reconcile` hook reads the attempt off the record. The executor signature is", # noqa: E501 ), "docs/THREAT_MODEL.md": ( "| A forged or tampered token | `JWTIdentityProvider` verifies the signature against a JWKS or a pinned key, with the algorithm taken from its own allow-list and never from the token (RFC 8725 §3.1) |", # noqa: E501