diff --git a/docs.mdx b/docs.mdx index 01463cf..8e702a7 100644 --- a/docs.mdx +++ b/docs.mdx @@ -218,8 +218,8 @@ the framework's own interrupt, and a framework with no such primitive does not n ## Where it stands {/* generated from the suite, pyproject and the soak (mdx) — run the generator */} -- **Version 0.10.0**, on [PyPI](https://pypi.org/project/ctrlrun/), Python 3.11 and later, tested on 3.11 to 3.14. -- **6,201 tests**, every version specified before it was written and every requirement mutation-tested. +- **Version 0.11.0**, on [PyPI](https://pypi.org/project/ctrlrun/), Python 3.11 and later, tested on 3.11 to 3.14. +- **6,221 tests**, every version specified before it was written and every requirement mutation-tested. - **32 guarantees you can check in your own setup**, with `ctrlrun verify` against your policy, on your store's backend, in a scratch store it creates. - **One host: a file.** SQLite, no server, no ops. **Many hosts: Postgres**, the same guarantees, graded by the same suite. - **Soaked for 20m 0s on postgres**: 889,735 actions, 0 unattributed ambiguous outcomes, positive control fired. Nothing here establishes what only accumulates over days. [What it does not establish](https://ctrlrun.dev/docs/production/soak). diff --git a/docs/CLAIMS.md b/docs/CLAIMS.md index a7634f5..a7f25c2 100644 --- a/docs/CLAIMS.md +++ b/docs/CLAIMS.md @@ -27,7 +27,7 @@ by its quoted claim, and `tests/test_docs_audit.py` fails if a named row is not |---|---|---| | "The last check before an AI agent does something it can't undo." | `Control.execute` — `control.py:1318` — resolves the principal, evaluates authority and policy, consumes the approval and reserves the effect key **before** the executor runs; nothing in the wrapper calls the function first | `test_T1_a_blind_retry_is_refused_and_never_reaches_the_remote`, `test_T3_the_fake_remote_is_called_exactly_once` | | "Autonomy belongs to the action, not the agent." | `Policy.evaluate(action)` — `policy.py:657` — passes only the action's **name and arguments** to `_ActionPolicy.evaluate` (`policy.py:657`), whose signature has no principal in it. A rule cannot read who is acting even by accident. `agent_eq` and `user_eq` are refused at load by `RESERVED_ARGUMENTS` (`policy.py:253`) rather than silently matching nothing. | `test_T6_an_action_name_is_matched_exactly`, `test_a_condition_naming_an_action_field_is_refused_at_load` | -| "A consequential action happens at most once, exactly as approved, and leaves a receipt — and when the outcome is unknown, CTRLRun says so instead of guessing." | At most once: `plan_reservation` — `effect.py:250`. Exactly as approved: the approval is bound to `action_hash` and consumed with the reservation — `_authorize_and_reserve` — `state.py:1341`. Or not at all: a refusal raises before the executor — `Control.execute` — `control.py:1318`. Says so instead of guessing: only `NotExecuted` maps to `FAILED` — `_outcome` — `control.py:2223` — and everything else is `AMBIGUOUS`. A receipt: `Receipt` — `receipt.py:252`. **This sentence read *happens once … or not at all* until 0.6**, a two-way disjunction that excluded the third outcome the product exists for: a lost reply is neither, and the README's own first section says so. | `test_T3_exactly_one_agent_reserves_and_seven_are_blocked`, `test_T2_a_mutated_action_presenting_the_approval_raises_ApprovalMismatch`, `test_T1_a_lost_response_leaves_the_effect_ambiguous`, `test_T11_every_demo_receipt_carries_every_field_in_the_spec` | +| "A consequential action happens at most once, exactly as approved, and leaves a receipt — and when the outcome is unknown, CTRLRun says so instead of guessing." | At most once: `plan_reservation` — `effect.py:250`. Exactly as approved: the approval is bound to `action_hash` and consumed with the reservation — `_authorize_and_reserve` — `state.py:1341`. Or not at all: a refusal raises before the executor — `Control.execute` — `control.py:1318`. Says so instead of guessing: only `NotExecuted` maps to `FAILED` — `_outcome` — `control.py:2223` — and everything else is `AMBIGUOUS`. A receipt: `Receipt` — `receipt.py:256`. **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:1535` — which is a write lock on the file and holds across OS processes. Postgres: `PostgresStateStore` over `UNIQUE(effect_key)` with `INSERT … ON CONFLICT DO NOTHING` and checked row counts (SPEC-v0.6 §4.2), the same `StateStore` protocol, extended by nothing | `test_T3_exactly_one_agent_reserves_and_seven_are_blocked` (8 OS processes, both backends), `test_T141_the_shipped_backends_pass`, `test_T154_postgres_passes_the_store_conformance_suite` | @@ -35,7 +35,7 @@ by its quoted claim, and `tests/test_docs_audit.py` fails if a named row is not | Claim | Code | Proof | |---|---|---| -| "A lost reply is `AMBIGUOUS`, never `FAILED`, and a retry against an `AMBIGUOUS` effect is refused — until a human, or a `reconcile` hook, says what happened." | Only `NotExecuted` maps to `FAILED` — `_outcome` — `control.py:1325`; a retry against an `AMBIGUOUS` key is refused by `plan_reservation` — `effect.py:250`; the two things permitted to move the record on and nothing else — `resolve` — `cli/main.py:995` — and `Control._reconciled` — `control.py:2877` | `test_T1_a_lost_response_leaves_the_effect_ambiguous`, `test_T1_a_blind_retry_is_refused_and_never_reaches_the_remote`, `test_T160_there_is_no_reaper`, `test_T13_a_hook_answering_not_executed_moves_the_record_to_failed` | +| "A lost reply is `AMBIGUOUS`, never `FAILED`, and a retry against an `AMBIGUOUS` effect is refused — until a human, or a `reconcile` hook, says what happened." | Only `NotExecuted` maps to `FAILED` — `_outcome` — `control.py:1325`; a retry against an `AMBIGUOUS` key is refused by `plan_reservation` — `effect.py:250`; the two things permitted to move the record on and nothing else — `resolve` — `cli/main.py:1024` — and `Control._reconciled` — `control.py:2877` | `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 @@ -53,13 +53,13 @@ by its quoted claim, and `tests/test_docs_audit.py` fails if a named row is not | "A lost reply is `AMBIGUOUS`, never `FAILED`, and a retry against an `AMBIGUOUS` effect is refused." | Only `NotExecuted` maps to `FAILED` — `_outcome` — `control.py:2223`; `plan_reservation` — `effect.py:250` | `test_T1_a_lost_response_leaves_the_effect_ambiguous`, `test_T1_a_blind_retry_is_refused_and_never_reaches_the_remote` | | "reserved atomically across processes and hosts; one worker wins" | `reserve_effect` — `state.py:643`; `PostgresStateStore.reserve_effect` — `postgres.py:769` | `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:1341` | `test_T2_a_mutated_action_presenting_the_approval_raises_ApprovalMismatch`, `test_T4_replaying_the_approval_raises_ApprovalMismatch_with_reason_consumed` | -| "An action the policy does not list is denied" | `Policy.evaluate` — `policy.py:657` | `test_T6_unknown_action_is_denied_with_reason_unknown_action` | +| "An action the policy does not list is denied" | `Policy.evaluate` — `policy.py:646` | `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:1318` | `test_T74_a_denial_leaves_no_pending_approval_request` | -| "Neither axis reads the agent's instructions" | `Policy.evaluate` — `policy.py:657` — sees the action's name and arguments; `Authority.evaluate` — `authority.py:1296` — sees the action and the principal; neither is handed a prompt, a message or a tool result | `test_T6_an_action_name_is_matched_exactly`, `test_T67_a_principal_with_no_grant_is_denied` | +| "Neither axis reads the agent's instructions" | `Policy.evaluate` — `policy.py:646` — sees the action's name and arguments; `Authority.evaluate` — `authority.py:1296` — sees the action and the principal; neither is handed a prompt, a message or a tool result | `test_T6_an_action_name_is_matched_exactly`, `test_T67_a_principal_with_no_grant_is_denied` | | "canonical arguments (sorted keys, no floats) ... Its SHA-256 is the action hash" | `canonicalize` / `action_hash` — `action.py`; `float` refused at any depth — `action.py:81` | `test_T7_canonical_form_is_exactly_the_specified_serialization`, `test_T7_nested_dicts_are_sorted_recursively` | | "The approval is single-use, expires, and matches nothing but that exact action." | `_authorize_and_reserve` — `state.py:1341` — checks expiry at consumption | `test_T5_expiry_is_checked_at_consumption_not_only_at_grant`, `test_T4_replaying_the_approval_raises_ApprovalMismatch_with_reason_consumed` | | "Only `NotExecuted`, raised by you, means `FAILED`." | `_outcome` — `control.py:2223`; `NotExecuted` — `errors.py:159` | `test_T1_a_lost_response_leaves_the_effect_ambiguous` | -| "the hash of the policy that decided it, chained to the receipt before it" | `Policy.policy_hash` — `policy.py:795`; `prev_hash`, `GENESIS_HASH` for the first — `receipt.py:127` | `test_T172_every_receipt_carries_the_hash_and_the_declared_version`, `test_T164_an_altered_receipt_is_content_altered_at_its_seq` | +| "the hash of the policy that decided it, chained to the receipt before it" | `Policy.policy_hash` — `policy.py:784`; `prev_hash`, `GENESIS_HASH` for the first — `receipt.py:131` | `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 @@ -71,7 +71,7 @@ by its quoted claim, and `tests/test_docs_audit.py` fails if a named row is not | "No agent changes" | `INTERCEPTED_METHOD` is `tools/call` and every other method is relayed unchanged — `gateway/mcp.py:42` | `test_a_non_intercepted_method_is_relayed_with_no_ctrlrun_outcome` | | "Point the MCP client at the gateway instead of at the tool server" | `Gateway.handle` — `gateway/server.py:399`; `serve` — `gateway/__init__.py:45` | `test_T19_the_upstream_receives_the_canonical_arguments` | | "Tools become actions named `mcp..`" | `Gateway._intercept` — `gateway/server.py:442` | `test_T19_the_action_is_named_for_the_alias_and_the_tool` | -| "they are declared in the policy" (effect and resource templates for a tool call) | `Policy.effect_template` / `resource_template` — `policy.py:981`; `McpOptions` — `policy.py:602` | `test_T16_a_v2_document_loads_and_exposes_its_templates`, `test_T16_a_decorator_and_a_policy_template_produce_the_same_action_hash` | +| "they are declared in the policy" (effect and resource templates for a tool call) | `Policy.effect_template` / `resource_template` — `policy.py:970`; `McpOptions` — `policy.py:591` | `test_T16_a_v2_document_loads_and_exposes_its_templates`, `test_T16_a_decorator_and_a_policy_template_produce_the_same_action_hash` | | "Everything but `tools/call` is relayed untouched" | `parse_request(...).intercept` — `gateway/mcp.py:95` | `test_every_other_method_is_relayed_not_intercepted` | | "A lost response over the wire blocks the retry exactly as it does in process" | `classify` — `gateway/outcome.py:131`, translated into v0.1 §5.5's own vocabulary by the gateway's executor | `test_T23_the_identical_call_sent_again_is_refused_and_the_upstream_called_once` | | "the gateway prints, on the line that starts it, every action in your policy that has no `effect:` template" | `_announce` — `gateway/__init__.py:161` | `test_the_startup_block_names_the_environment_identity_and_authority`, `test_the_startup_block_says_so_when_there_is_no_authority_section` | @@ -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:308` — is exactly `allow`, `approve`, `deny`; rules match first-wins over `Condition` (`policy.py:401`) with the operators `eq`, `neq`, `in`, `lt`, `lte`, `gt`, `gte` — `_OPERATORS` — `policy.py:125` | `test_T6_an_action_name_is_matched_exactly`, `test_T176_the_operators_behave_as_they_do_everywhere_else` | -| "Unknown actions are denied; there is no default-allow." | `Policy.evaluate` — `policy.py:657` | `test_T6_unknown_action_is_denied_with_reason_unknown_action` | +| "cheap to undo is autonomous, anything that leaves the building needs a human, money is by amount with both ends bound" | `Decision` — `policy.py:308` — is exactly `allow`, `approve`, `deny`; rules match first-wins over `Condition` (`policy.py:390`) with the operators `eq`, `neq`, `in`, `lt`, `lte`, `gt`, `gte` — `_OPERATORS` — `policy.py:131` | `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:646` | `test_T6_unknown_action_is_denied_with_reason_unknown_action` | | "Amounts are integer minor units; floats are rejected outright" | `float` refused at any depth — `action.py:81` | `test_T7_canonical_form_is_exactly_the_specified_serialization` | -| "The policy cannot see who is asking — deliberately, since v0.1" | `Policy.evaluate` still takes only the action's name and arguments; `RESERVED_ARGUMENTS` — `policy.py:657` — refuses `agent_eq` and every other principal-addressing condition at load, in a document of **every** schema version | `test_T74b_a_reserved_name_in_a_policy_rule_is_a_load_error`, `test_T74b_a_reserved_name_in_a_grant_constraint_is_a_load_error` | +| "The 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:646` — refuses `agent_eq` and every other principal-addressing condition at load, in a document of **every** schema version | `test_T74b_a_reserved_name_in_a_policy_rule_is_a_load_error`, `test_T74b_a_reserved_name_in_a_grant_constraint_is_a_load_error` | | "the second axis, `authority:`" | `Authority.evaluate` — `authority.py:1288`; `Control._authority_result` — `control.py:1128` | `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:86`), reached for reads and for actions with no effect key alike | `test_T67_an_action_the_policy_allows_outright_still_needs_a_grant` | | "A grant carries no `decision:`" | `_GRANT_KEYS` — `authority.py` — is a closed set that does not contain `decision` | `test_T73b_grant_refuses_what_the_loader_refuses` | | "combine as the **stricter of the two**" | `Control.evaluate` returns the combined result — `control.py`; a denial on either axis is a denial | `test_T70_the_stricter_of_the_two_wins` | -| "narrow it at runtime with `ctrlrun delegate`" | `Control.delegate` — `control.py:4165`; `Authority.plan_delegation` — `authority.py:1549`; `ctrlrun delegate` — `cli/main.py:1522` | `test_t75_the_delegation_authorizes_an_action_within_its_limits` | +| "narrow it at runtime with `ctrlrun delegate`" | `Control.delegate` — `control.py:4165`; `Authority.plan_delegation` — `authority.py:1549`; `ctrlrun delegate` — `cli/main.py:1551` | `test_t75_the_delegation_authorizes_an_action_within_its_limits` | | "provably a subset of its parent on every dimension, at creation and again at every evaluation" | `contained_dimension` — `authority.py:986` — runs from `plan_delegation` (`authority.py:1549`) **and** from the chain walk in `Authority.evaluate` (`authority.py:1296`) | `test_t76_each_dimension_violated_alone`, `test_t77b_a_narrowed_parent_narrows_its_children` | | "provably a subset of its parent on every dimension, at creation and again at every evaluation; a consequence budget is consumed inside the reservation's own transaction, and a rolling window bounds what may start rather than recalling what already did" | Containment as in the row above. The budget: `check_charges` — `state.py:575` — is evaluated inside `reserve_effect`'s own transaction on all three backends, and `_charges_for` — `authority.py:1404` — charges every ancestor in the chain. The window is rolling and bounds the next reserve only: `_spent` sums `[now - window, now]` and nothing reads it again after a reservation is taken | `test_T408_a_charge_and_its_reservation_are_one_transaction`, `test_T409_N_processes_racing_one_budget_spend_at_most_the_limit`, `test_T412b_every_ancestor_is_charged_through_a_real_chain`, `test_T408c_the_rolling_window_forgets` | | "omitting a dimension the parent constrains is rejected rather than inherited" | `contained_dimension` treats an absent child dimension as unconstrained and therefore wider — `authority.py:986`; the subject half is `_subject_contained` (`authority.py:1065`) | `test_t81_omission_is_not_unlimited`, `test_T73b_a_subject_addressed_to_every_principal_is_refused`, `test_t76_each_dimension_violated_alone` | | "`ctrlrun revoke` cuts a chain of any depth with one write" | `Control.revoke` — `control.py:4505` — writes one row — `revoke_delegation` — `state.py:841` and visits no children; every evaluation walks to the root | `test_t78_a_revoked_parent_denies_its_grandchild`, `test_put_delegation_is_never_an_upsert` | -| "`mode: observe` … records what *would* have been blocked, without blocking anything" | `_parse_mode` — `policy.py:778`; `Control._observed` — `control.py:1708`; `_WouldHave` — `receipt.py:346`; `ReceiptResult.OBSERVED` — `receipt.py:258` | `test_T82_observe_executes_what_enforce_would_deny`, `test_T83_a_duplicate_is_recorded_and_still_runs` | -| "One top-level line" | `mode:` is refused anywhere but the top level — `reject_nested_mode`, `policy.py:778` | `test_T84_mode_is_refused_anywhere_but_the_top_level` | -| "`ctrlrun stats` gives you the numbers" | `stats` — `cli/main.py:1326`; counted from `would_have.blocked_reason` and nothing else | `test_T86_stats_counts_what_observe_mode_recorded`, `test_T86_stats_reaches_no_network` | +| "`mode: observe` … records what *would* have been blocked, without blocking anything" | `_parse_mode` — `policy.py:769`; `Control._observed` — `control.py:1708`; `_WouldHave` — `receipt.py:350`; `ReceiptResult.OBSERVED` — `receipt.py:262` | `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:769` | `test_T84_mode_is_refused_anywhere_but_the_top_level` | +| "`ctrlrun stats` gives you the numbers" | `stats` — `cli/main.py:1355`; 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:1708` | `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 @@ -140,9 +140,9 @@ catalogue, `GUARANTEES` (`verify/guarantees.py:55`). | "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:671`; 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:643`, decided inside the `BEGIN IMMEDIATE` of `_authorize_and_reserve` (`state.py:1341`) against `effect_key TEXT PRIMARY KEY` (`migrations.py:109`; `COLLATE "C"` on Postgres, §4.4) | `test_T3_exactly_one_agent_reserves_and_seven_are_blocked` (8 OS processes, both backends), `test_T3_the_fake_remote_is_called_exactly_once` | | "An unknown outcome is AMBIGUOUS, never FAILED, and blocks a blind retry." | Only `NotExecuted` maps to `FAILED` — `_outcome` — `control.py:2223`. Every other exception, timeouts included, yields `AMBIGUOUS`. A retry against an `AMBIGUOUS` key is refused — `effect.py:174`, the one place `plan_reservation` decides it for every store | `test_T1_a_blind_retry_writes_a_blocked_receipt`, `test_T1_the_ambiguous_record_survives_the_blocked_retry`, `test_T1_a_lost_response_leaves_the_effect_ambiguous` | -| "An unknown action, a missing policy or a missing principal is denied." | Unknown action: `Policy.evaluate` — `policy.py:657` — answers `deny` for a name the document does not list. Missing or malformed policy: `Policy.from_file` — `policy.py:814` — raises `PolicyError`, and there is no `Control` without a policy. Missing principal: `_refuse_no_principal` — `control.py:4851` | `test_T6_unknown_action_raises_ActionDenied_with_reason_unknown_action`, `test_missing_policy_file_is_a_policy_error`, `test_malformed_policy_document_is_a_policy_error`, `test_T62_a_declining_provider_with_no_context_is_no_principal` | +| "An unknown action, a missing policy or a missing principal is denied." | Unknown action: `Policy.evaluate` — `policy.py:646` — answers `deny` for a name the document does not list. Missing or malformed policy: `Policy.from_file` — `policy.py:814` — raises `PolicyError`, and there is no `Control` without a policy. Missing principal: `_refuse_no_principal` — `control.py:4851` | `test_T6_unknown_action_raises_ActionDenied_with_reason_unknown_action`, `test_missing_policy_file_is_a_policy_error`, `test_malformed_policy_document_is_a_policy_error`, `test_T62_a_declining_provider_with_no_context_is_no_principal` | | "With authority on, every principal needs a grant, and delegation cannot widen one." | `NO_AUTHORITY` — the fail-closed default of `Authority.evaluate` (`authority.py:86`); `contained_dimension` — `authority.py:986` — runs from `plan_delegation` (`authority.py:1549`) and from the chain walk in `Authority.evaluate` | `test_T67_a_principal_with_no_grant_is_denied`, `test_t76_each_dimension_violated_alone` | -| "Every executed action leaves a portable JSON receipt" | `ReceiptResult` — `receipt.py:242`; `Event` — `receipt.py:308`; the store is authoritative — `append_event` — `state.py:852`; the JSONL export — `JSONLEventSink` — `receipt.py:934` | `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:246`; `Event` — `receipt.py:312`; the store is authoritative — `append_event` — `state.py:852`; the JSONL export — `JSONLEventSink` — `receipt.py:938` | `test_T11_every_demo_receipt_carries_every_field_in_the_spec`, `test_T11_every_demo_receipt_parses_back_into_a_Receipt` | ## What it guarantees @@ -155,30 +155,30 @@ catalogue, `GUARANTEES` (`verify/guarantees.py:55`). | "It will not *knowingly* execute the same logical effect twice, and will never treat an unknown outcome as a failure." | `plan_reservation` — `effect.py:250` (refuse retry on `AMBIGUOUS`) and `_outcome` — `control.py:2223` (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:960`; `_resolve_lost_update` — `postgres.py:1586`; only `NotExecuted` maps to `FAILED` — `_outcome` — `control.py:2223` | `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:154` | `test_T155b_a_landed_commit_on_a_transition_is_seen_as_landed`, `test_T155d_a_commit_the_server_never_received_retries_the_insert` | -| "A crashed worker's effect stays `AMBIGUOUS` until a human runs `ctrlrun resolve` or a `reconcile` hook asks the remote what happened" | An expired lease is `AMBIGUOUS` and nothing sweeps it — `LEASE_EXPIRED` — `effect.py:174`; who resolved it — `resolved_by` — `effect.py:210`; `resolve` — `cli/main.py:995` | `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` | +| "A crashed worker's effect stays `AMBIGUOUS` until a human runs `ctrlrun resolve` or a `reconcile` hook asks the remote what happened" | An expired lease is `AMBIGUOUS` and nothing sweeps it — `LEASE_EXPIRED` — `effect.py:174`; who resolved it — `resolved_by` — `effect.py:210`; `resolve` — `cli/main.py:1024` | `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:2877`; `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:657`; `Policy.from_file` — `policy.py:814`; `_refuse_no_principal` — `control.py:4851`; `_authorize_and_reserve` — `state.py:1341` | `test_T6_unknown_action_raises_ActionDenied_with_reason_unknown_action`, `test_malformed_policy_document_is_a_policy_error`, `test_T62_a_declining_provider_with_no_context_is_no_principal`, `test_T2_a_mutated_action_presenting_the_approval_raises_ApprovalMismatch` | -| "No flag makes a consequential action permissive by default" | There is no such option on `Control`, on `@protect`, on the CLI or in the policy schema's closed key sets — `_TOP_LEVEL_KEYS` — `policy.py:129` | `test_T84_mode_is_refused_anywhere_but_the_top_level`, `test_T101b_zero_applicable_guarantees_is_not_a_pass` | +| "Unknown action, missing policy, malformed policy, missing principal, missing or mismatched approval and inconsistent state are all `deny`." | `Policy.evaluate` — `policy.py:646`; `Policy.from_file` — `policy.py:803`; `_refuse_no_principal` — `control.py:4851`; `_authorize_and_reserve` — `state.py:1341` | `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:135` | `test_T84_mode_is_refused_anywhere_but_the_top_level`, `test_T101b_zero_applicable_guarantees_is_not_a_pass` | | "With `authority:` on, every principal needs a grant, delegation cannot widen one, and `ctrlrun revoke` cuts a chain with one write." | `Authority.evaluate` — `authority.py:1288`; `contained_dimension` — `authority.py:986`; `Control.revoke` — `control.py:4505` | `test_T67_a_principal_with_no_grant_is_denied`, `test_t76_each_dimension_violated_alone`, `test_t78_a_revoked_parent_denies_its_grandchild` | -| "verifies a bearer token against a JWKS or a pinned key" | `JWTIdentityProvider._verified` — `jwt_identity.py:210`; the algorithm comes from the configured list and never from the token | `test_T88_a_valid_token_becomes_a_principal`, `test_T89_every_invalid_token_is_refused_by_cause` | +| "verifies a bearer token against a JWKS or a pinned key" | `JWTIdentityProvider._verified` — `jwt_identity.py:215`; 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:795`, over `_canonical_policy` — `policy.py:1043`; carried into the receipt by `_record` — `control.py:4742` | `test_T172_every_receipt_carries_the_hash_and_the_declared_version`, `test_T172_two_policies_sharing_a_version_string_are_told_apart_by_the_hash` | -| "the policy's declared `version:` and a hash of its canonical content" | `version:` is recorded and never authoritative; `policy_hash` is what tells two documents apart — `policy.py:783` | `test_T171_the_declared_version_alone_does_not_change_the_hash`, `test_T171_comments_key_order_and_whitespace_do_not_change_the_hash` | +| "every receipt records which policy decided it" | `Policy.policy_hash` — `policy.py:784`, over `_canonical_policy` — `policy.py:1032`; carried into the receipt by `_record` — `control.py:4742` | `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:774` | `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:1318`; `_spend_unneeded_approval` — `control.py:2817` | `test_T173_the_DENY_row_refuses_and_leaves_the_approval_granted`, `test_T173_the_ALLOW_row_invalidates_the_approval_it_did_not_need` | -| "Each receipt carries the hash of the one before it" | `Receipt.chain_hash` — `receipt.py:535`; `prev_hash` — `receipt.py:454`; `GENESIS_HASH` — `receipt.py:127`; `put_receipt` takes the head row's lock first — `postgres.py:2121` | `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:1052`; the six names — `CHAIN_BREAKS` — `receipt.py:989` | `test_the_verify_chain_flag_reports_a_break_by_seq_and_by_name`, `test_verify_chain_reads_a_postgres_store_through_store_url` | +| "Each receipt carries the hash of the one before it" | `Receipt.chain_hash` — `receipt.py:539`; `prev_hash` — `receipt.py:458`; `GENESIS_HASH` — `receipt.py:131`; `put_receipt` takes the head row's lock first — `postgres.py:2121` | `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:1056`; the six names — `CHAIN_BREAKS` — `receipt.py:993` | `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:723`, called from both stores' constructors; `HEAD` — `migrations.py:504` | `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:649`; `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:395`; `receipts` — `cli/main.py:473`; `effects` — `cli/main.py:937`; `resolve` — `cli/main.py:995`; `inspect` — `cli/main.py:1039`; `stats` — `cli/main.py:1074`; every one takes `--store-url` (SPEC-v0.6 §9.4) | `test_T10_resolve_failed_permits_a_retry`, `test_T18_inspect_json_emits_the_inspection_schema`, `test_T86_stats_counts_what_observe_mode_recorded`, `test_verify_chain_reads_a_postgres_store_through_store_url` | +| "`ctrlrun approve`, `deny`, `resolve`, `inspect`, `receipts` and `stats` work from the shell against any store" | `approve` — `cli/main.py:395`; `receipts` — `cli/main.py:473`; `effects` — `cli/main.py:966`; `resolve` — `cli/main.py:1024`; `inspect` — `cli/main.py:1068`; `stats` — `cli/main.py:1355`; every one takes `--store-url` (SPEC-v0.6 §9.4) | `test_T10_resolve_failed_permits_a_retry`, `test_T18_inspect_json_emits_the_inspection_schema`, `test_T86_stats_counts_what_observe_mode_recorded`, `test_verify_chain_reads_a_postgres_store_through_store_url` | | "`WebhookApprovalProvider` sends an approval request to a webhook, such as Slack, and takes the answer back through the same grant calls" | `WebhookApprovalProvider` — `webhook.py:143` — one signed POST on `APPROVAL_REQUESTED`; the inbound answer lands through `grant_approval` / `deny_approval` like the CLI's | `test_T27_the_outbound_post_carries_a_signature_over_the_exact_bytes_sent`, `test_T27_the_payload_carries_what_the_spec_names` | | "one OpenTelemetry span per action, one span event per step" | `OTelEventSink` — `otel.py:47` | `test_T29_one_action_produces_one_span_named_for_the_action`, `test_T29_every_event_becomes_a_span_event_named_by_its_type` | | "argument values stay out of it unless you ask for them" | `OTelEventSink(arguments=...)` — `otel.py:47` | `test_T29_argument_values_are_not_attributes_by_default` | -| "Receipts in a `ctrlrun.policy/v4` document can cite the `controls:` an action satisfies" | `PolicyControl` — `policy.py:362`; `Receipt` — `receipt.py:400` — carries `controls`; attribution only, never a decision | `test_T175_the_receipt_carries_the_union_of_the_action_and_the_matched_rule`, `test_T175_a_control_is_attribution_and_changes_no_decision` | -| "a rule can condition on the `data:` labels present in an action's arguments" | `DataLabel` — `policy.py:629`; `Policy.data_scope` — `policy.py:649`; `data_scope_in` in `v0.1 §3.2`'s grammar with no new operator | `test_T176_the_derived_set_is_the_labels_of_the_arguments_actually_supplied`, `test_T176_the_derived_set_drives_a_decision` | +| "Receipts in a `ctrlrun.policy/v4` document can cite the `controls:` an action satisfies" | `PolicyControl` — `policy.py:351`; `Receipt` — `receipt.py:404` — 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:618`; `Policy.data_scope` — `policy.py:638`; `data_scope_in` in `v0.1 §3.2`'s grammar with no new operator | `test_T176_the_derived_set_is_the_labels_of_the_arguments_actually_supplied`, `test_T176_the_derived_set_drives_a_decision` | ## What it can't, stated as limits @@ -190,7 +190,7 @@ The README also makes negative claims. They matter as much as the positive ones. | "CTRLRun is not a transaction manager: it rolls nothing back" | There is no compensation, saga or rollback code path in the package; an `AMBIGUOUS` effect is resolved by a human or a reconcile hook and never undone — `RECONCILED_STATES` — `effect.py` | | "The receipt chain detects alteration, and alteration is not authorship." | n/a — a disclaimer, and the scan that keeps it one: `test_T180_the_release_documents_do_not_blur_alteration_and_authorship` | | "erasing the end of the log costs two statements" | No code — this is what the chain does **not** cover, and it is asserted rather than argued: `test_erasing_a_suffix_and_rewinding_the_head_is_two_statements_and_undetected` | -| "CTRLRun does not detect prompt injection" | No code — and that is the point. Nothing in the package reads the agent's instructions: `Policy.evaluate` takes the action's name and arguments (`policy.py:657`) and `Authority` matches a grant against the action, so neither axis has the prompt to inspect. The README's problem table claims containment of the consequence, and this row is the sentence that stops it being read as detection. | `test_T6_an_action_name_is_matched_exactly`, `test_a_condition_naming_an_action_field_is_refused_at_load` | +| "CTRLRun 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:646`) 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. **`--coverage` opens a store and still computes none**: `ctrlrun/coverage.py` reports a list with a reason per entry, carries no `score`, `percentage` or `ratio` field, and does not move the exit code (SPEC-v0.11 §7, rule 4) | `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`, `test_T560_the_report_is_a_list_and_never_a_score`, `test_T563_the_coverage_flag_does_not_move_the_exit_code` | | "`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` | @@ -202,9 +202,9 @@ The README also makes negative claims. They matter as much as the positive ones. |---|---|---| | "the same `StateStore` protocol, extended by nothing, graded by the suite written for SQLite rather than one written for it" | `PostgresStateStore` — `postgres.py` — satisfies `StateStore` and adds no method (SPEC-v0.6 §9.1); `ctrlrun.conformance.store.SUITES` is the SQLite suite, run against both | `test_T141_the_shipped_backends_pass`, `test_T154_postgres_passes_the_store_conformance_suite` | | "automatic at open and forward-only, with no flag that opens a database un-migrated. An older binary against a newer schema refuses immediately." | `migrate` — `migrations.py:723` — called from both stores' constructors; `_refuse` — `migrations.py:649` — raises `SchemaMismatch` on a newer `user_version` | `test_T147_a_v05_database_migrates_and_keeps_every_row`, `test_T148_an_older_binary_refuses_a_newer_database`, `test_T152b_no_flag_opens_a_database_without_migrating` | -| "an edit, a deletion from the middle or a reordering is detected and named by `seq`" | `verify_chain` — `receipt.py:452` — and the six break names in `CHAIN_BREAKS` | `test_T164_an_altered_receipt_is_content_altered_at_its_seq`, `test_T164_reordering_two_receipts_is_detected_either_way`, `test_the_verify_chain_flag_reports_a_break_by_seq_and_by_name` | +| "an edit, a deletion from the middle or a reordering is detected and named by `seq`" | `verify_chain` — `receipt.py:456` — 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:795` — over the parsed decision inputs, recorded on the receipt | `test_T172_every_receipt_carries_the_hash_and_the_declared_version`, `test_T171_any_decision_input_changes_the_hash`, `test_T171_the_declared_version_alone_does_not_change_the_hash` | +| "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:784` — 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 diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 3cc19e8..64bc161 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -371,7 +371,7 @@ raises. The upgrade was checked against the **released** 0.9.0 from PyPI rather Standards: A2A, as code. No conformance claim. -## v0.11 — Evidence +## v0.11 — Evidence · shipped 2026-09-14 One question: can the record be trusted after the fact, and kept? @@ -384,6 +384,43 @@ One question: can the record be trusted after the fact, and kept? **Does not close.** Authorship. An anchor proves the log existed in this form at that time; it does not prove who wrote it, and a malicious administrator who rewrites everything before the next anchor is still out of scope. Signed receipts stay off the roadmap for the reason `SPEC-v0.6.md` §11 gives. +Exit criteria met: `ctrlrun.guarantees/v7` with `G28` to `G32` each grading `PASS` and each grading +the same under `--only` as in a full run; `examples/anchored-chain` exercises an anchor and prints +what one does **not** prove; a chain written by the **released** 0.6.1, 0.7.0, 0.8.0, 0.9.0 and +0.10.0 wheels verifies end to end across five receipt schema versions; a prune across a checkpoint +verifies and anchors that checkpoint before deleting anything; a held range refuses to prune; and +two prunes racing under the multi-process standard against Postgres leave no break the store did not +already have. The upgrade was checked against the **released** 0.10.0 from PyPI rather than a +fixture: 0.11.0 migrates the store, the chain verifies across the boundary, and 0.10.0 then refuses +it with `SchemaMismatch` rather than corrupting it. + +**Reconciled against what shipped**, because three sentences above were written before the code +existed and two of them were wrong. + +- **The anchor detects truncation and NOT append**, and the bullet above said "erased or appended" + until 2026-09-14. A forged receipt lands at head + 1, above every anchored `seq`, so nothing stops + reproducing and the next anchor freezes it like any other. `T531` runs a forged append and + requires both reports to stay clean, so the limit is a tested property rather than a sentence + somebody has to remember. +- **Enforcement coverage does not come "from events already written."** `ACTION_PROPOSED` carries an + `action_hash` and nothing that maps it back to a name. It comes from receipts, which every decided + action leaves, **a denial included** -- so an action that is always denied counts as exercised. +- **`ctrlrun scan --coverage` is the surface**, and `docs/CONTROL-MAPPING.md` is still not written. + The v0.11 line cited it in the present tense; roadmap line 136 says it is written only when a + design partner asks, and none has. +- **Retention shipped twice.** The build order made an independent review required for the prune, + *the one not to skip*, and the merge did not wait for it. The review found seven defects, the + first of which made a forged checkpoint launderable with one row in the store's own `anchors` + table, and the fixes went in as a second pull request. `SPEC-v0.11.md` §13.4 records all seven + and the rule this milestone adds: **a required review is a merge gate, not a step in the item.** + +Three surfaces this milestone amends rather than adds to, each named because an amendment to a +frozen surface is not a patch: `StateStore` gains anchor, checkpoint and hold methods +(`SPEC-v0.6.md` §9.2), `StateStore.receipts()` may now hand back an `UnreadableReceipt` instead of +raising, and `verify_chain` seeds from a checkpoint where a store has one. **`CHAIN_BREAKS` is +unchanged and stays closed at six**: the anchor has its own set, because putting its kinds in +`CHAIN_BREAKS` would fail `G11`'s control with `control failed` on every anchoring deployment. + Do not build: a SIEM · dashboards over receipts · a receipt query language · export formats beyond JSON and OTel. Exit: **the truncation case** that `SPEC-v0.6.md` §6.4 lists as undetected now detects, with the anchor as the positive control and the two-statement attack as the scenario (append is **not** in this criterion, and §2.4 of `SPEC-v0.11.md` says why); a prune across a checkpoint verifies, and the prune's checkpoint is anchored before anything is deleted; a held range refuses to prune; a chain written across **five** receipt schema versions verifies end to end, and the checkpoint receipt of a prune carries the version current when it was written. diff --git a/docs/cookbook/verify-in-github-actions.mdx b/docs/cookbook/verify-in-github-actions.mdx index 8116a4f..a1dd3be 100644 --- a/docs/cookbook/verify-in-github-actions.mdx +++ b/docs/cookbook/verify-in-github-actions.mdx @@ -62,7 +62,7 @@ by tag where you want a ref nobody can move. The agent sees nothing; this is the operator's check. The build sees: ```text -CTRLRun verify — ctrlrun 0.10.0, catalogue ctrlrun.guarantees/v7 +CTRLRun verify — ctrlrun 0.11.0, catalogue ctrlrun.guarantees/v7 policy /Users/arpanghoshal/ctrlrun-project/wt/v11-i3/examples/cookbook/verify-in-github-actions/ctrlrun.yaml (ctrlrun.policy/v2, mode: enforce) authority none store sqlite, scratch (created and destroyed for this run) diff --git a/docs/guides/verify-in-ci.mdx b/docs/guides/verify-in-ci.mdx index 7f2b17c..52348eb 100644 --- a/docs/guides/verify-in-ci.mdx +++ b/docs/guides/verify-in-ci.mdx @@ -32,7 +32,7 @@ guarantees pass. ``` ```text - CTRLRun verify — ctrlrun 0.10.0, catalogue ctrlrun.guarantees/v7 + CTRLRun verify — ctrlrun 0.11.0, catalogue ctrlrun.guarantees/v7 policy /Users/arpanghoshal/ctrlrun-project/wt/v11-i3/examples/cookbook/verify-in-github-actions/ctrlrun.yaml (ctrlrun.policy/v2, mode: enforce) authority none store sqlite, scratch (created and destroyed for this run) diff --git a/docs/production/index.mdx b/docs/production/index.mdx index 6bb76fb..c4355fc 100644 --- a/docs/production/index.mdx +++ b/docs/production/index.mdx @@ -27,8 +27,8 @@ need. `test_the_first_line_of_the_section_says_which_store_and_why` asserts the ## Where it stands {/* generated from the suite, pyproject and the soak (full) — run the generator */} -- **Version 0.10.0**, on [PyPI](https://pypi.org/project/ctrlrun/), Python 3.11 and later, tested on 3.11 to 3.14. -- **6,201 tests**, every version specified before it was written and every requirement mutation-tested. [Read more](/docs/how-this-is-built). +- **Version 0.11.0**, on [PyPI](https://pypi.org/project/ctrlrun/), Python 3.11 and later, tested on 3.11 to 3.14. +- **6,221 tests**, every version specified before it was written and every requirement mutation-tested. [Read more](/docs/how-this-is-built). - **32 guarantees you can check in your own setup**, with `ctrlrun verify` against your policy, on your store's backend, in a scratch store it creates. [Read more](/docs/security/verify-guarantees). - **One host: a file.** SQLite, no server, no ops. **Many hosts: Postgres**, the same guarantees, graded by the same suite. [Read more](/docs/production/postgres). - **Soaked for 20m 0s on postgres**: 889,735 actions, 0 unattributed ambiguous outcomes, positive control fired. Nothing here establishes what only accumulates over days. [Read more](/docs/production/soak). diff --git a/docs/production/operations.mdx b/docs/production/operations.mdx index 32dabae..3d29d7c 100644 --- a/docs/production/operations.mdx +++ b/docs/production/operations.mdx @@ -49,11 +49,24 @@ and there will not be one. - **A reporting role still needs `CREATE` on the schema.** Opening the store migrates it, so a read-only role cannot run `--verify-chain` — it is refused before a receipt is read, naming the missing privilege. Read-only is for your own queries against the tables, not for the CLI. +- **Anchor on an interval you choose, and say what it is.** `ctrlrun anchor --provider ...` + records the chain's head outside the database, which is what makes a truncation detectable at + all: the head that would catch one is otherwise a row in the same database. The window you are + exposed to is `(last anchored seq, current head]`, so the interval **is** the claim, and it is + the number to quote rather than any sentence about tamper-evidence. See + [anchoring](/docs/production/anchoring). +- **Prune with a window, not with a `DELETE`.** `ctrlrun prune` removes a prefix and leaves a + checkpoint the reader seeds from; deleting receipts yourself is detected as a break, by design. + It refuses rather than warns, and there is no `--force`. See + [retention](/docs/production/retention). ## What this does not do - **There is nothing to run.** No agent, no sidecar, no scheduler. Every command above is a - one-shot read of the store. + one-shot read of the store, and the two that write — `anchor` and `prune` — run when **your** + scheduler runs them. Nothing sweeps, nothing reaps, and **no hold expires**: a hold that lapsed + on a timer would release evidence on a schedule nobody reviewed, so a person places it and a + person ends it. - **It does not retry for you.** A proven non-execution leaves the key retryable — the next attempt is admitted rather than refused — and nothing performs that attempt but your code. Every other outcome refuses one. @@ -64,11 +77,13 @@ and there will not be one. **Verified by** `T160` and `T177d` — an expired lease frees nothing, and displaying it changes nothing — `T161` for the two authorities that can move an unknown outcome and what each is -recorded as, and `T177c` for the CLI surface these commands come from, asserted against the -command list so a new one cannot appear unnoticed. +recorded as, `T545c` for the hold that no timer lifts, and `T177c` for the CLI surface these +commands come from, asserted against the command list so a new one cannot appear unnoticed. ## Next - [Recovery after a crash](/docs/production/recovery): why nothing sweeps. +- [Anchoring](/docs/production/anchoring) and [retention](/docs/production/retention): the two + things this milestone gives an operator to schedule. - [Reconcile automatically](/docs/guides/reconcile-automatically): the hook that answers without a person. - [Get started](/docs/get-started/quickstart) · [Why](/docs/why). diff --git a/docs/production/retention.mdx b/docs/production/retention.mdx index 563793e..2269fdd 100644 --- a/docs/production/retention.mdx +++ b/docs/production/retention.mdx @@ -67,19 +67,32 @@ reviewed, so a person places it and a person ends it. - **It does not run on a schedule.** Nothing sweeps, nothing reaps, and no hold expires. You run the command, or your scheduler does. - **It does not make a checkpoint unforgeable.** Anyone who can insert receipts can write a - checkpoint row. What narrows that is the anchor: a prune must anchor its checkpoint before - deleting, and an erasure with no anchored checkpoint behind it reports `anchor_broken`. + checkpoint row, and anyone who can write that row can write a row in the local `anchors` table + beside it. What narrows both is the provider: a checkpoint supersedes the receipts below it + only when **the provider still returns an anchor**, and only when that anchor's `(seq, hash)` + is the pair the checkpoint claims. A local row the provider does not confirm buys nothing, and + an erasure with no anchored checkpoint behind it reports `anchor_broken`. - **It does not decide what you may delete.** Retention is not in your policy document, deliberately: a policy key would make pruning subject to `require_approved_policy`, and a deployment that had not approved its current policy could then never prune. What authorises a prune is shell access to the store, which policy does not mediate. Put a human in front of the command, where you already are for every other destructive operation on your database. +## What the prune itself leaves behind + +Two receipts, not one, and they are distinguishable: the first records the request (`--through`, +`--older-than`, `--reason`) as `proposed`, and the second records what became of it as +`completed` or `refused`. A prune that was refused and a prune that succeeded therefore do not +leave the same bytes, which is the only reason the record of a refusal is worth keeping. + **Verified by** `T540` for the chain verifying across the gap, with a naive prefix delete as its negative control; `T543` for a store that already had a break; `T545` for a hold; `T546b` for the -budget window, where pruning a `COMMITTED` row inside it manufactures authority; `T547b` for a -forged checkpoint with no anchor behind it; and `T549`, which races two prunes in separate OS -processes against a real Postgres server. +budget window, where pruning a `COMMITTED` row inside it manufactures authority; `T550` and +`T559` for a checkpoint the provider does not confirm, including a forged local `anchors` row +that names the checkpoint's own pair; `T552` for a `--through` above the head; `T554` for a +checkpoint asserting a pair that never existed; `T555` for the two receipts; `T551` and `T558` +for the prune holding its lock across every write on SQLite and on Postgres; and `T549`, which +races two prunes in separate OS processes against a real Postgres server. ## Next diff --git a/docs/reference/api/Condition.mdx b/docs/reference/api/Condition.mdx index 5fce005..cf1dcf3 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:400` +`ctrlrun.Condition` — class, defined at `src/ctrlrun/policy.py:389` ```python from ctrlrun import Condition diff --git a/docs/reference/api/Decision.mdx b/docs/reference/api/Decision.mdx index 13e9462..050121a 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:337` +`ctrlrun.Decision` — class, defined at `src/ctrlrun/decision.py:41` ```python from ctrlrun import Decision diff --git a/docs/reference/api/Event.mdx b/docs/reference/api/Event.mdx index 7af19f4..e7cbb3d 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:307` +`ctrlrun.Event` — class, defined at `src/ctrlrun/receipt.py:311` ```python from ctrlrun import Event diff --git a/docs/reference/api/EventSink.mdx b/docs/reference/api/EventSink.mdx index df54bd9..709a9f9 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:915` +`ctrlrun.EventSink` — class, defined at `src/ctrlrun/receipt.py:919` ```python from ctrlrun import EventSink diff --git a/docs/reference/api/JSONLEventSink.mdx b/docs/reference/api/JSONLEventSink.mdx index 0e3e899..17ea899 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:934` +`ctrlrun.JSONLEventSink` — class, defined at `src/ctrlrun/receipt.py:938` ```python from ctrlrun import JSONLEventSink diff --git a/docs/reference/api/Policy.mdx b/docs/reference/api/Policy.mdx index d725645..2d1d2f6 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:761` +`ctrlrun.Policy` — class, defined at `src/ctrlrun/policy.py:750` ```python from ctrlrun import Policy diff --git a/docs/reference/api/Receipt.mdx b/docs/reference/api/Receipt.mdx index 5cec2f9..b2f7f1f 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:399` +`ctrlrun.Receipt` — class, defined at `src/ctrlrun/receipt.py:403` ```python from ctrlrun import Receipt diff --git a/docs/reference/api/jwt_identity-JWTIdentityProvider.mdx b/docs/reference/api/jwt_identity-JWTIdentityProvider.mdx index 4739383..1a62416 100644 --- a/docs/reference/api/jwt_identity-JWTIdentityProvider.mdx +++ b/docs/reference/api/jwt_identity-JWTIdentityProvider.mdx @@ -5,7 +5,7 @@ description: "Verify a bearer JWT and map its verified claims onto a `Principal` {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.jwt_identity.JWTIdentityProvider` — class, defined at `src/ctrlrun/jwt_identity.py:115` +`ctrlrun.jwt_identity.JWTIdentityProvider` — class, defined at `src/ctrlrun/jwt_identity.py:120` ```python from ctrlrun.jwt_identity import JWTIdentityProvider diff --git a/docs/reference/api/parse_conditions.mdx b/docs/reference/api/parse_conditions.mdx index a94127e..31316cb 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:1328` +`ctrlrun.parse_conditions` — function, defined at `src/ctrlrun/policy.py:1317` ```python from ctrlrun import parse_conditions diff --git a/docs/verify.md b/docs/verify.md index b10b59c..1da34ff 100644 --- a/docs/verify.md +++ b/docs/verify.md @@ -14,7 +14,7 @@ what could not be tested at all. ```console $ ctrlrun verify -CTRLRun verify — ctrlrun 0.10.0, catalogue ctrlrun.guarantees/v7 +CTRLRun verify — ctrlrun 0.11.0, catalogue ctrlrun.guarantees/v7 policy examples/authority/payments.yaml (ctrlrun.policy/v7, mode: enforce) authority same document, 3 grants store sqlite, scratch (created and destroyed for this run) diff --git a/generated/readiness.full.mdx b/generated/readiness.full.mdx index 144e7cf..b14d7bf 100644 --- a/generated/readiness.full.mdx +++ b/generated/readiness.full.mdx @@ -1,6 +1,6 @@ {/* generated from the suite, pyproject and the soak (full) — run the generator */} -- **Version 0.10.0**, on [PyPI](https://pypi.org/project/ctrlrun/), Python 3.11 and later, tested on 3.11 to 3.14. -- **6,201 tests**, every version specified before it was written and every requirement mutation-tested. [Read more](/docs/how-this-is-built). +- **Version 0.11.0**, on [PyPI](https://pypi.org/project/ctrlrun/), Python 3.11 and later, tested on 3.11 to 3.14. +- **6,221 tests**, every version specified before it was written and every requirement mutation-tested. [Read more](/docs/how-this-is-built). - **32 guarantees you can check in your own setup**, with `ctrlrun verify` against your policy, on your store's backend, in a scratch store it creates. [Read more](/docs/security/verify-guarantees). - **One host: a file.** SQLite, no server, no ops. **Many hosts: Postgres**, the same guarantees, graded by the same suite. [Read more](/docs/production/postgres). - **Soaked for 20m 0s on postgres**: 889,735 actions, 0 unattributed ambiguous outcomes, positive control fired. Nothing here establishes what only accumulates over days. [Read more](/docs/production/soak). diff --git a/generated/readiness.json b/generated/readiness.json index 96c0544..012afe1 100644 --- a/generated/readiness.json +++ b/generated/readiness.json @@ -9,7 +9,7 @@ "3.14" ] }, - "released": "0.10.0", + "released": "0.11.0", "soak": { "actions": 889735, "backend": "postgres", @@ -18,6 +18,6 @@ "positive_control": true, "unexplained": 0 }, - "tests": 6201, - "version": "0.10.0" + "tests": 6221, + "version": "0.11.0" } diff --git a/generated/readiness.mdx b/generated/readiness.mdx index 552c0be..322708b 100644 --- a/generated/readiness.mdx +++ b/generated/readiness.mdx @@ -1,6 +1,6 @@ {/* generated from the suite, pyproject and the soak (mdx) — run the generator */} -- **Version 0.10.0**, on [PyPI](https://pypi.org/project/ctrlrun/), Python 3.11 and later, tested on 3.11 to 3.14. -- **6,201 tests**, every version specified before it was written and every requirement mutation-tested. +- **Version 0.11.0**, on [PyPI](https://pypi.org/project/ctrlrun/), Python 3.11 and later, tested on 3.11 to 3.14. +- **6,221 tests**, every version specified before it was written and every requirement mutation-tested. - **32 guarantees you can check in your own setup**, with `ctrlrun verify` against your policy, on your store's backend, in a scratch store it creates. - **One host: a file.** SQLite, no server, no ops. **Many hosts: Postgres**, the same guarantees, graded by the same suite. - **Soaked for 20m 0s on postgres**: 889,735 actions, 0 unattributed ambiguous outcomes, positive control fired. Nothing here establishes what only accumulates over days. [What it does not establish](https://ctrlrun.dev/docs/production/soak). diff --git a/generated/readiness.readme.md b/generated/readiness.readme.md index 7e2f5f3..ba89b4b 100644 --- a/generated/readiness.readme.md +++ b/generated/readiness.readme.md @@ -1,6 +1,6 @@ -- **Version 0.10.0**, on [PyPI](https://pypi.org/project/ctrlrun/), Python 3.11 and later, tested on 3.11 to 3.14. -- **6,201 tests**, every version specified before it was written and every requirement mutation-tested. +- **Version 0.11.0**, on [PyPI](https://pypi.org/project/ctrlrun/), Python 3.11 and later, tested on 3.11 to 3.14. +- **6,221 tests**, every version specified before it was written and every requirement mutation-tested. - **32 guarantees you can check in your own setup**, with `ctrlrun verify` against your policy, on your store's backend, in a scratch store it creates. - **One host: a file.** SQLite, no server, no ops. **Many hosts: Postgres**, the same guarantees, graded by the same suite. - **Soaked for 20m 0s on postgres**: 889,735 actions, 0 unattributed ambiguous outcomes, positive control fired. Nothing here establishes what only accumulates over days. [What it does not establish](https://ctrlrun.dev/docs/production/soak). diff --git a/tests/test_release_documents.py b/tests/test_release_documents.py index 61ddb0a..0a2db31 100644 --- a/tests/test_release_documents.py +++ b/tests/test_release_documents.py @@ -93,7 +93,12 @@ def _load(name: str) -> set[str]: # not do". Both halves survived the cut, which is the half §6.4 cares about: the chain # detects alteration, and the same sentence says alteration is not authorship. "README.md": ( - "is detected. They are not signed: alteration is not authorship. The badge above means the", + # v0.11 rewrapped this paragraph: the chain detects alteration, and the sentence beside + # it now names the two things it does **not** detect, a truncation at the end and a + # forged append, and says `ctrlrun anchor` is what closes them. Only this line carries a + # word the scan looks for, and the list must not carry lines that do not: T180's staleness + # check fails on an entry matching nothing, which is how a softened allow-list is caught. + "signed: alteration is not authorship. The badge above means the", ), "CHANGELOG.md": ( '- **`docs/docs/ROADMAP.md`\'s v0.6 bullet said "receipt integrity (hash chain / signatures)", and the', # noqa: E501