From a6220fb153ac1642720299fd4eeefc71c3e98dd6 Mon Sep 17 00:00:00 2001 From: arpan Date: Mon, 14 Sep 2026 22:19:10 +0530 Subject: [PATCH 1/2] Retention: its own page, the OWASP rows, and postgres.md's claim closed postgres.md has said since v0.6 that this library has no retention policy, in the same breath as the reason one is hard. That is the claim item 3 pays. The retention page states two things the ROADMAP line did not: the ledger rule is settlement and then a window rather than 'un-released', and the window is supplied on the command line rather than derived, because a store that resolved a grant's budgets would be reading the policy. Signed-off-by: arpan --- SEO.md | 1 + docs.json | 6 ++ docs.mdx | 4 +- docs/CLAIMS.md | 46 +++++----- docs/OWASP-AGENTIC-TOP10.md | 3 + docs/ROADMAP.md | 2 +- docs/cookbook/verify-in-github-actions.mdx | 7 +- docs/guides/verify-in-ci.mdx | 7 +- docs/postgres.md | 20 +++-- docs/production/index.mdx | 4 +- docs/production/retention.mdx | 88 +++++++++++++++++++ docs/reference/api/DelegationRecord.mdx | 2 +- docs/reference/api/InMemoryStateStore.mdx | 2 +- docs/reference/api/SQLiteStateStore.mdx | 2 +- docs/reference/api/StateStore.mdx | 8 +- .../api/postgres-PostgresStateStore.mdx | 2 +- docs/reference/api/state-Charge.mdx | 2 +- docs/reference/api/state-Consumption.mdx | 2 +- docs/reference/api/state-check_charges.mdx | 2 +- docs/reference/cli.mdx | 62 +++++++++++++ docs/verify.md | 5 +- generated/readiness.full.mdx | 4 +- generated/readiness.json | 4 +- generated/readiness.mdx | 4 +- generated/readiness.readme.md | 4 +- tests/test_docs_production.py | 2 + 26 files changed, 240 insertions(+), 55 deletions(-) create mode 100644 docs/production/retention.mdx diff --git a/SEO.md b/SEO.md index 9c48515..f3e5c78 100644 --- a/SEO.md +++ b/SEO.md @@ -50,6 +50,7 @@ that page's frontmatter, never here. | `docs/production/recovery` | agent crashed mid action | A restarted process repairs nothing and cannot know the holder is dead. | | `docs/production/receipt-integrity` | verify receipt chain | Run ctrlrun receipts --verify-chain and read the six names it can report. | | `docs/production/anchoring` | anchor receipt chain outside database | Anchor the chain's head where your database's writer cannot reach it, and what that does not prove. | +| `docs/production/retention` | delete old receipts hash chain | Prune a prefix of the receipt chain and still verify across the gap, or be refused. | | `docs/production/soak` | ctrlrun soak test results | One published run, its measured duration, and the exit criterion it does not meet. | | `docs/production/operations` | ctrlrun monitoring | Watch how many effects are sitting in an unknown outcome that nobody has answered. | | `docs/mcp/overview` | MCP gateway human approval | CTRLRun works with MCP in four ways. | diff --git a/docs.json b/docs.json index d1cb573..2a3554c 100644 --- a/docs.json +++ b/docs.json @@ -82,6 +82,7 @@ "docs/production/recovery", "docs/production/receipt-integrity", "docs/production/anchoring", + "docs/production/retention", "docs/production/soak", "docs/production/operations" ] @@ -491,6 +492,11 @@ "destination": "/docs/production/anchoring", "permanent": true }, + { + "source": "/production/retention", + "destination": "/docs/production/retention", + "permanent": true + }, { "source": "/production/soak", "destination": "/docs/production/soak", diff --git a/docs.mdx b/docs.mdx index 8353ae4..25a4be0 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.10.0**, on [PyPI](https://pypi.org/project/ctrlrun/), Python 3.11 and later, tested on 3.11 to 3.14. -- **6,158 tests**, every version specified before it was written and every requirement mutation-tested. -- **29 guarantees you can check in your own setup**, with `ctrlrun verify` against your policy, on your store's backend, in a scratch store it creates. +- **6,192 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). - **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 c6f2bcf..d904b2e 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:1255`. 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:252`. **This sentence read *happens once … or not at all* until 0.6**, a two-way disjunction that excluded the third outcome the product exists for: a lost reply is neither, and the README's own first section says so. | `test_T3_exactly_one_agent_reserves_and_seven_are_blocked`, `test_T2_a_mutated_action_presenting_the_approval_raises_ApprovalMismatch`, `test_T1_a_lost_response_leaves_the_effect_ambiguous`, `test_T11_every_demo_receipt_carries_every_field_in_the_spec` | | "A Python library that sits between the decision to act and the call that acts." | `@protect` — `control.py` — wraps the callable that acts, and `Control.execute` runs every check before invoking it. The category noun was on `docs.mdx` and in `pyproject.toml`'s `description` and nowhere in the README until 0.6, so a reader had to infer what CTRLRun **is** from three slogans. | `test_the_header_carries_the_fixed_copy_and_the_five_badges`, `test_T1_a_blind_retry_is_refused_and_never_reaches_the_remote` | | "Runs in production on a single file, or on Postgres across hosts" | SQLite: `SQLiteStateStore` reserves inside the `BEGIN IMMEDIATE` of `_authorize_and_reserve` — `state.py: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:743` — 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: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` | | "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 @@ -51,13 +51,13 @@ by its quoted claim, and `tests/test_docs_audit.py` fails if a named row is not | Claim | Code | Proof | |---|---|---| | "A lost reply is `AMBIGUOUS`, never `FAILED`, and a retry against an `AMBIGUOUS` effect is refused." | Only `NotExecuted` maps to `FAILED` — `_outcome` — `control.py: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:641`; `PostgresStateStore.reserve_effect` — `postgres.py:752` | `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:1255` | `test_T2_a_mutated_action_presenting_the_approval_raises_ApprovalMismatch`, `test_T4_replaying_the_approval_raises_ApprovalMismatch_with_reason_consumed` | +| "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` | | "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` | | "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:1255` — 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` | +| "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` | @@ -66,8 +66,8 @@ by its quoted claim, and `tests/test_docs_audit.py` fails if a named row is not | Claim | Code | Proof | |---|---|---| | "You probably do not need an adapter" | Three ways in, and `@protect` (`control.py:4969`) 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:367` | 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:391`; `with_approval` — `control.py:446`; `ApprovalRequired` (`errors.py:90`) carries `request_id` | `test_T2_a_mutated_action_presenting_the_approval_raises_ApprovalMismatch` (the granted path first), `test_T4_replaying_the_approval_raises_ApprovalMismatch_with_reason_consumed` | +| "`ctrlrun init` writes a starter" | `init` — `cli/main.py:371` | 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:395`; `with_approval` — `control.py:446`; `ApprovalRequired` (`errors.py:90`) carries `request_id` | `test_T2_a_mutated_action_presenting_the_approval_raises_ApprovalMismatch` (the granted path first), `test_T4_replaying_the_approval_raises_ApprovalMismatch_with_reason_consumed` | | "No agent changes" | `INTERCEPTED_METHOD` is `tools/call` and every other method is relayed unchanged — `gateway/mcp.py:42` | `test_a_non_intercepted_method_is_relayed_with_no_ctrlrun_outcome` | | "Point the MCP client at the gateway instead of at the tool server" | `Gateway.handle` — `gateway/server.py:399`; `serve` — `gateway/__init__.py:45` | `test_T19_the_upstream_receives_the_canonical_arguments` | | "Tools become actions named `mcp..`" | `Gateway._intercept` — `gateway/server.py:442` | `test_T19_the_action_is_named_for_the_alias_and_the_tool` | @@ -94,14 +94,14 @@ by its quoted claim, and `tests/test_docs_audit.py` fails if a named row is not | "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:1270` | `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:1522` | `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:573` — 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` | +| "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:839` 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` | +| "`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:1074`; counted from `would_have.blocked_reason` and nothing else | `test_T86_stats_counts_what_observe_mode_recorded`, `test_T86_stats_reaches_no_network` | +| "`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` | | "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 @@ -137,28 +137,28 @@ catalogue, `GUARANTEES` (`verify/guarantees.py:55`). | 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:669`; 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:641`, decided inside the `BEGIN IMMEDIATE` of `_authorize_and_reserve` (`state.py:1255`) 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 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` | | "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:850`; 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: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` | ## What it guarantees | Claim | Code | Proof | |---|---|---| -| "On SQLite that is `BEGIN IMMEDIATE`" | `_authorize_and_reserve` — `state.py:1255` | `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:752` — `INSERT … ON CONFLICT DO NOTHING` against `effect_key TEXT PRIMARY KEY COLLATE "C"` (`migrations.py:109`) | `test_T3_exactly_one_agent_reserves_and_seven_are_blocked` (8 OS processes, both backends) | -| "Same `StateStore` protocol, extended by nothing" | `PostgresStateStore.reserve_effect` — `postgres.py:752` — and every other method implement `v0.1 §5.3`'s frozen protocol; the decisions stay in `plan_reservation` (`effect.py:250`) | `test_T154_postgres_passes_the_store_conformance_suite` | +| "On SQLite that is `BEGIN IMMEDIATE`" | `_authorize_and_reserve` — `state.py:1341` | `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:769` — `INSERT … ON CONFLICT DO NOTHING` against `effect_key TEXT PRIMARY KEY COLLATE "C"` (`migrations.py:109`) | `test_T3_exactly_one_agent_reserves_and_seven_are_blocked` (8 OS processes, both backends) | +| "Same `StateStore` protocol, extended by nothing" | `PostgresStateStore.reserve_effect` — `postgres.py:769` — and every other method implement `v0.1 §5.3`'s frozen protocol; the decisions stay in `plan_reservation` (`effect.py:250`) | `test_T154_postgres_passes_the_store_conformance_suite` | | "graded by the suite written for SQLite" | `ctrlrun.conformance.store.run` — `conformance/store/__init__.py:55` | `test_T140_every_fixture_fails_the_suite_named_for_it` | | "It will not *knowingly* execute the same logical effect twice, and will never treat an unknown outcome as a failure." | `plan_reservation` — `effect.py:250` (refuse retry on `AMBIGUOUS`) and `_outcome` — `control.py: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:943`; `_resolve_lost_update` — `postgres.py:1569`; 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:152` | `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:743` | `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 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` | | "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:1255` | `test_T6_unknown_action_raises_ActionDenied_with_reason_unknown_action`, `test_malformed_policy_document_is_a_policy_error`, `test_T62_a_declining_provider_with_no_context_is_no_principal`, `test_T2_a_mutated_action_presenting_the_approval_raises_ApprovalMismatch` | +| "Unknown action, missing policy, malformed policy, missing principal, missing or mismatched approval and inconsistent state are all `deny`." | `Policy.evaluate` — `policy.py: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` | | "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` | @@ -168,12 +168,12 @@ catalogue, `GUARANTEES` (`verify/guarantees.py:55`). | "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` | | "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:2104` | `test_T164_an_altered_receipt_is_content_altered_at_its_seq`, `test_T164_reordering_two_receipts_is_detected_either_way` | +| "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` | | "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:391`; `receipts` — `cli/main.py:469`; `effects` — `cli/main.py:685`; `resolve` — `cli/main.py:743`; `inspect` — `cli/main.py:787`; `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: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` | | "`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` | diff --git a/docs/OWASP-AGENTIC-TOP10.md b/docs/OWASP-AGENTIC-TOP10.md index 0698274..2fc177e 100644 --- a/docs/OWASP-AGENTIC-TOP10.md +++ b/docs/OWASP-AGENTIC-TOP10.md @@ -92,6 +92,9 @@ mechanism, not the entry. | **G26** a hop is named on both sides | The receipt of an action taken under a hop names the hop, and so does the record of the hop's creation, so an action and the delegation that authorised it are joined from either end without inference. | `ASI07:2026` (partly), `ASI10:2026` (partly), `ASI03:2026` (partly) | Attribution across a hop was reconstruction before this: a reader had to match timestamps and principals and hope. It is evidence, not prevention, and it is on the receipt rather than in a log that can be rotated away. A hop created *inside* an action is not named on its creator's receipt; the `DELEGATION_CREATED` event carries the `action_id` and holds the join instead. | | **G27** a swapped upstream is denied | An action entry may pin the upstream it authorises, by the SHA-256 of the server's leaf certificate or by the hash of a tool's advertised schema. A server that is not the pinned one, or a tool whose schema moved under an approved action name, is refused `upstream_mismatch`; an upstream nothing observed is refused `upstream_unverified` and never admitted. | `ASI02:2026` (partly), `ASI07:2026` (partly) | An approved action name is a name, and until v0.10 nothing checked that the thing answering to it was the thing that was approved. Enforced by `ctrlrun gateway`, the surface that holds the connection: at startup, at the decision, and at the TLS handshake. In-process there is no upstream to observe, so a pinned action refuses on every call, which is fail-closed and is why `ctrlrun verify` skips a pinned action unless a scenario asks for it by name. This is one slice of a supply chain and not the category: nothing here inspects a package, a model, a build or a signature chain. | | **G28** truncation past an anchor fails | The chain's head is a row in the same database, so erasing the end of the log and updating that row is two statements and the chain reports itself intact. An anchor records the pair the head holds (`seq` and the hash at it) through a provider **you** supply, outside the store, and anything at or below an anchored `seq` can then no longer be removed or altered without the anchored pair failing to reproduce. Reported as `anchor_broken`, `anchor_missing` or `anchor_repudiated`, in the anchor's own report. | `ASI09:2026` (partly) | **An anchor freezes a prefix, and the limits are the point.** An **append is not detected**: a forged receipt lands above every anchored `seq`, so nothing stops reproducing and the next anchor freezes it like any other. Receipts written and erased entirely between two anchors are not detected either. It is **not a signature** and says nothing about who wrote the log, and an administrator who rewrites everything before the next anchor is still out of scope. The window you are exposed to is `(last anchored seq, current head]`, and its size is your choice of interval: that is the number to tune, and the number to quote instead of any sentence about tamper-evidence. CTRLRun ships **no** anchor provider, because an RFC 3161 client is a network client; the anchor is worth exactly what the record you point it at is worth, and one in the same directory as the database is worth nothing. | +| **G29** a prune adds no new chain break | Receipts accumulate, and deleting them breaks the chain by design. `ctrlrun prune` removes a **prefix** and leaves a checkpoint the reader seeds from, so the chain verifies **across** the gap. It refuses rather than warns: a prune that would leave a `(kind, seq)` pair the store did not already report, one through the head, one moving the checkpoint backwards, or one deleting a budget ledger row whose charge is still held. There is no `--force`. | `ASI09:2026` (partly) | Retention and evidence pull against each other, and the honest answer is that a prune is the only operation here that **destroys** evidence: what it deletes is gone. What this makes true is that the deletion is bounded and visible rather than silent, and that a deletion nobody can verify around is **refused** rather than completed with a warning. The rule is a **delta** and not a promise that the chain verifies afterwards: a store carrying `unchained` rows from before v0.6 can still be pruned, because the alternative is retention being impossible on exactly the oldest stores. It does not prune events, approvals, delegations or continuations, and nothing runs on a schedule. | +| **G30** a held range refuses to prune | `ctrlrun hold place` names a range of receipts and a reason, and any prune overlapping it is refused with the hold named. | `ASI09:2026` (partly) | The case this is for is a legal hold arriving in the middle of a retention schedule, and the failure it prevents is a scheduled job quietly deleting what somebody has just been told to keep. **A hold has no expiry**: one that lapsed on a timer would release evidence on a schedule nobody reviewed, so a person places it and a person ends it. What it does not do is stop anyone with database access from deleting rows directly; it binds `ctrlrun prune`, not `DELETE`. | +| **G32** an honest prune keeps anchors | A prune anchors its checkpoint **before** it deletes anything, and an anchor at or below an anchored checkpoint is then **superseded** rather than broken. | `ASI09:2026` (partly) | Without this the two features cancel: anchoring hourly and pruning at ninety days, every anchor older than the retention window would be permanently `anchor_broken`, and a deployment would have to choose between pruning and a permanent tamper signal. The half that stops *superseded* becoming a hole is that the checkpoint must itself be anchored, through the provider, which is outside the store: an attacker who erases a prefix and writes a checkpoint to explain it has to leave a record of the prune in the operator's own anchor history. **A prune stays visible even though the receipts are gone**, which is the whole of what retention owes evidence. | | **G31** five receipt schemas verify | One receipt chain can hold every schema version a store has been written under, and `ctrlrun verify` walks it end to end, hash by hash, **each row hashed by the rule its own version wrote**. A store kept since v0.6 holds five: `v3`, `v4`, `v5`, `v6`, `v7`. A receipt whose schema label this binary does not know is **named** and is not reported as a break. | `ASI09:2026` (partly) | The receipt chain has been checkable since v0.6, but nothing proved it stayed checkable **across an upgrade**, which is the only interesting case: a chain that verifies on the day it is written and stops verifying two releases later is evidence with a shelf life nobody stated. The proof is built from the released wheels rather than from fixtures, because a fixture is this build's opinion of what 0.6 wrote. What it does not close is everything `G11` does not close: this is about whether the record can still be **read and recomputed** years later, not about who wrote it, and an administrator who rewrites every row including the head is still out of scope. Nor does it make a future version's fields intelligible: an unknown label is named so an operator knows which row this binary could not fully interpret, which is a different sentence from *this row was tampered with*. | --- diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 2b4ec01..43bb38d 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -376,7 +376,7 @@ Standards: A2A, as code. No conformance claim. One question: can the record be trusted after the fact, and kept? - **An external anchor for the receipt chain.** The chain detects alteration and says on every page that it does not detect truncation or append — both measured at two statements, undetected, because the head is a row in the same database. v0.11 anchors the head outside the database at an interval (an RFC 3161 timestamp, or an equivalent the operator supplies) so that **anything at or below an anchored `seq` can no longer be removed or altered** without the anchored pair failing to reproduce. An anchor freezes a prefix: **an append is not detected**, because an appended row lands above every anchored `seq`, and nor is a receipt created and destroyed entirely between two anchors. This sentence said "erased or appended" until 2026-09-14, when `SPEC-v0.11.md`'s review ran the cases; §2.4 there is the table, and the named kinds are the anchor's own, not the six chain-break kinds. No keys of its own: it consumes a timestamp and issues nothing, which is why it is here and signing is not. **Built by item 2 on 2026-09-14**, with `G28` grading it and `ctrlrun anchor` running it. CTRLRun ships **no** provider: an RFC 3161 client is a network client, so the operator supplies four calls (`make`, `check`, `latest`, `since`) and `examples/anchored-chain/` shows the smallest one that works. `since()` is the call a review added and the reason the design holds: with `make` and `check` alone, the record of *which* anchors exist lived in CTRLRun's own table, so deleting the newest row there left the older anchor reproducing and the truncation invisible, at a cost of one more statement. -- **Retention and legal hold.** There is no retention policy today and `docs/postgres.md` says so, in the same breath as the reason one is hard to write: deleting receipts from the middle or the end of the chain is detected as a break by design. v0.11 pays that debt: a chain-preserving prune that leaves a checkpoint receipt verifiable across the gap, and a hold that refuses to prune, both recorded as receipts themselves. **v0.9 adds a second growing table and states the invariant rather than the command**: the budget ledger only grows, and `SPEC-v0.9.md` §7.3 says that rows older than the longest window on any budget of a grant cannot affect a future decision, so somebody else's archiving is safe. One caveat travels with it, because the invariant is about decisions and not about evidence: an `AMBIGUOUS` effect older than that window still **holds** a charge the operator surfaces display, so an archiver on a live ledger excludes un-released rows. `ctrlrun stats` reports the row count so the growth is visible before it matters. +- **Retention and legal hold.** There is no retention policy today and `docs/postgres.md` says so, in the same breath as the reason one is hard to write: deleting receipts from the middle or the end of the chain is detected as a break by design. v0.11 pays that debt: a chain-preserving prune that leaves a checkpoint receipt verifiable across the gap, and a hold that refuses to prune, both recorded as receipts themselves. **v0.9 adds a second growing table and states the invariant rather than the command**: the budget ledger only grows, and `SPEC-v0.9.md` §7.3 says that rows older than the longest window on any budget of a grant cannot affect a future decision, so somebody else's archiving is safe. One caveat travels with it, because the invariant is about decisions and not about evidence: an `AMBIGUOUS` effect older than that window still **holds** a charge the operator surfaces display, so an archiver on a live ledger excludes un-released rows. `ctrlrun stats` reports the row count so the growth is visible before it matters. **Built by item 3 on 2026-09-14**, as `ctrlrun prune` and `ctrlrun hold`, with `G29`, `G30` and `G32` grading it. Two things the build settled that the line above did not say. The ledger rule is **settlement and then a window**, not "un-released": `COMMITTED` holds permanently and only `FAILED` releases, so "un-released" would have been almost every row forever, and a `COMMITTED` row is prunable only **outside** §7.3's window, because pruning one inside it hands back authority nobody granted. And the window is **supplied** on the command line rather than derived: a ledger row carries no window and no limit, those travel on `Charge` from the authority document, and a store that resolved them would be reading the policy. - **Enforcement coverage.** From events already written: policy entries never exercised, gateway tools never routed, `@protect` actions never seen. The runtime half of `ctrlrun scan`, under the same rule — a clean result is not a verdict, no score, no percentage, no badge. - **One chain, several receipt schemas.** `ctrlrun.receipt/v7` is the schema today, and the rule since `SPEC-v0.3.md` §12.2 is that every reader upgrades before any writer switches, so an older receipt on disk still parses. v0.8 (the verified approver; the grant id under break-glass), v0.9 (budget consumption) and v0.10 (the hop) each add fields and each bump the version, so a chain kept from v0.6 across them holds **five receipt schema versions**: `v3`, which 0.6 wrote, `v4`, which v0.7 added, and `v5`, `v6` and `v7` after it. This sentence has now gone stale twice and is corrected here rather than quietly both times. It said *three shapes* and named `v3` as the schema today, before v0.7's precondition fields bumped it; v0.7's release pass fixed that and left *four* and `v4`, which v0.10's hop field made wrong again. A count of versions in a document is a number that goes stale at every release, which is the argument for reading `receipt.py`'s constants instead. And nothing yet proved that `verify` walks it end to end, hash by hash, each receipt hashed by the rule its own version wrote. **v0.11's item 4 proves it and `G31` grades it, since 2026-09-14.** No new field: the version string already existed. What is new is the proof, and the rule that a receipt whose version the binary does not know is *named* and not reported as a break — the same distinction v0.6 §3.2 draws for a `schema_version` row the binary does not know. **The proof is built from the released wheels rather than from fixtures** (`scripts/five_schema_chain.py`): five environments, `pip install ctrlrun==0.6.1`, `0.7.0`, `0.8.0`, `0.9.0`, `0.10.0`, one store, then this build verifies across the whole thing, because a fixture is only this build's opinion of what 0.6 wrote. One thing that proof got wrong first is worth keeping: run with `PYTHONPATH=src`, the variable is inherited by every child, so all five "released wheels" imported the build under test and the run reported **one** schema version while looking exactly like a pass. The script now strips it and checks, per release, that the interpreter ran from that release's own environment. Added 2026-09-10, proved 2026-09-14. diff --git a/docs/cookbook/verify-in-github-actions.mdx b/docs/cookbook/verify-in-github-actions.mdx index b308b0e..8116a4f 100644 --- a/docs/cookbook/verify-in-github-actions.mdx +++ b/docs/cookbook/verify-in-github-actions.mdx @@ -63,7 +63,7 @@ The agent sees nothing; this is the operator's check. The build sees: ```text CTRLRun verify — ctrlrun 0.10.0, catalogue ctrlrun.guarantees/v7 -policy /Users/arpanghoshal/ctrlrun-project/wt/v11-i2/examples/cookbook/verify-in-github-actions/ctrlrun.yaml (ctrlrun.policy/v2, mode: enforce) +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) @@ -106,13 +106,16 @@ G25 a hop narrows or it is refused N/A no authority section G26 a hop is named on both sides N/A no authority section G27 a swapped upstream is denied N/A no action entry pins an upstream G28 truncation past an anchor fails PASS k8s.delete_namespace +G29 a prune adds no new chain break PASS k8s.delete_namespace +G30 a held range refuses to prune PASS k8s.delete_namespace G31 five receipt schemas verify PASS k8s.delete_namespace +G32 an honest prune keeps anchors PASS k8s.delete_namespace (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) -17/17 declared guarantees pass. 12 not applicable: G8, G9, G13, G15, G17, G19, G22, G23, G24, G25, G26, G27. +20/20 declared guarantees pass. 12 not applicable: G8, G9, G13, G15, G17, G19, G22, G23, G24, G25, G26, G27. ``` The first line is on stderr, from G7's own scenario driving an action with no principal — the diff --git a/docs/guides/verify-in-ci.mdx b/docs/guides/verify-in-ci.mdx index 62f04ed..7f2b17c 100644 --- a/docs/guides/verify-in-ci.mdx +++ b/docs/guides/verify-in-ci.mdx @@ -33,7 +33,7 @@ guarantees pass. ```text CTRLRun verify — ctrlrun 0.10.0, catalogue ctrlrun.guarantees/v7 - policy /Users/arpanghoshal/ctrlrun-project/wt/v11-i2/examples/cookbook/verify-in-github-actions/ctrlrun.yaml (ctrlrun.policy/v2, mode: enforce) + 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) @@ -76,13 +76,16 @@ guarantees pass. G26 a hop is named on both sides N/A no authority section G27 a swapped upstream is denied N/A no action entry pins an upstream G28 truncation past an anchor fails PASS k8s.delete_namespace + G29 a prune adds no new chain break PASS k8s.delete_namespace + G30 a held range refuses to prune PASS k8s.delete_namespace G31 five receipt schemas verify PASS k8s.delete_namespace + G32 an honest prune keeps anchors PASS k8s.delete_namespace (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) - 17/17 declared guarantees pass. 12 not applicable: G8, G9, G13, G15, G17, G19, G22, G23, G24, G25, G26, G27. + 20/20 declared guarantees pass. 12 not applicable: G8, G9, G13, G15, G17, G19, G22, G23, G24, G25, G26, G27. ``` The first line is on **stderr**, from G7's own scenario: an action with no principal is diff --git a/docs/postgres.md b/docs/postgres.md index 3453f0a..61bc0e3 100644 --- a/docs/postgres.md +++ b/docs/postgres.md @@ -277,12 +277,20 @@ them is true: accepts the break window — `--verify-chain` names the `seq` range, so it is legible — or truncates. Receipts written before the chain existed at all report `unchained`, which is a different and documented case. -- **It does not prune, roll or retain.** `effects`, `events`, `receipts`, `approvals`, - `delegations` and `continuations` grow without bound. Deciding what may be deleted is a - retention policy and this library does not have one; note that deleting receipts from the middle - or the end of the chain is detected as a break by design, so a retention job needs to be written - with that in mind, and `ctrlrun receipts --verify-chain` will report the boundary as a break - because it cannot know the deletion was deliberate. +- **It does not roll, and it prunes only receipts.** `effects`, `events`, `approvals`, + `delegations` and `continuations` still grow without bound, and nothing runs in the background + to trim them. + + **Receipts are the exception since 0.11**: `ctrlrun prune --through ` deletes a **prefix** + of the receipt chain and leaves a checkpoint the reader seeds from, so + `ctrlrun receipts --verify-chain` verifies **across** the gap rather than reporting the + boundary as a break. It takes a prefix and nothing else, because deleting from the middle or + the end is detected as a break by design and that is the feature rather than an obstacle. + + It **refuses** rather than warning: a prune that would leave the chain reporting a break it did + not already report, one through the head, one overlapping a `ctrlrun hold`, and one that would + delete a budget ledger row whose charge is still held. There is no `--force`. See + [retention](/docs/production/retention). - **It does not create the schema, the database, the user or the grants.** - **It does not pool, discover a primary, retry a failed connection, or fail over.** - **It does not sweep expired leases**, and nothing runs in the background at all. diff --git a/docs/production/index.mdx b/docs/production/index.mdx index 4a3f255..ba65c9e 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.10.0**, on [PyPI](https://pypi.org/project/ctrlrun/), Python 3.11 and later, tested on 3.11 to 3.14. -- **6,158 tests**, every version specified before it was written and every requirement mutation-tested. [Read more](/docs/how-this-is-built). -- **29 guarantees you can check in your own setup**, with `ctrlrun verify` against your policy, on your store's backend, in a scratch store it creates. [Read more](/docs/security/verify-guarantees). +- **6,192 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). - **Each receipt carries the hash of the one before it**, so an alteration is detected and named. [Read more](/docs/production/receipt-integrity). diff --git a/docs/production/retention.mdx b/docs/production/retention.mdx new file mode 100644 index 0000000..563793e --- /dev/null +++ b/docs/production/retention.mdx @@ -0,0 +1,88 @@ +--- +title: "Retention: pruning the receipt chain" +description: "Delete old receipts without breaking the chain: ctrlrun prune leaves a checkpoint the reader verifies across, and refuses rather than warns." +--- + +Receipts accumulate. Deleting them is the problem: each receipt carries the hash of the one +before it, so a deletion from the middle or the end **is detected as a break**, and that is the +feature rather than an obstacle. A retention job that simply deleted would be manufacturing the +attack the chain exists to catch. + +`ctrlrun prune` removes a **prefix** and leaves a **checkpoint** behind. The chain reader seeds +from that checkpoint, so it verifies across the gap instead of reporting it. + +## Running it + +```bash +ctrlrun prune \ + --through 40000 \ + --older-than 90d \ + --provider yourpkg.anchors:provider \ + --by ops@example.com \ + --reason "90-day retention" +``` + +`--older-than` is **your** number, and the one to use is the longest window on any budget of any +grant. A ledger row carries no window and no limit; those live in your authority document, and a +store that resolved them would be reading your policy. So you supply it and the kernel checks +the rule. + +`--provider` is the same [anchor provider](/docs/production/anchoring) you already use. A prune +**anchors its checkpoint before it deletes anything**, so the prune stays visible in your +provider's own record even after the receipts are gone. + +## It refuses, it does not warn + +There is no `--force`, no `--allow-gap` and no setting that admits a break the prune caused. + +| Refused | Because | +|---|---| +| it would leave a break the store did not already report | that is destroying evidence and calling it retention | +| it would take the chain's head | nothing chained would be left for the head to name | +| the checkpoint would move backwards | that is the second of two racing prunes | +| it overlaps a hold | the refusal names the hold and its reason | +| a ledger row's effect is `AMBIGUOUS`, `RESERVED` or `EXECUTING` | its charge is still held, and deleting it hands back authority nobody granted | +| a `COMMITTED` ledger row is newer than `--older-than` | a committed charge is never released, so it still counts against a budget | + +A prune **already-broken** store is allowed, as long as the prune adds nothing: a store carrying +`unchained` rows from before v0.6 can still be pruned, because those rows can never be inside a +prefix and the alternative would make retention impossible on exactly the oldest stores. + +## Holds + +```bash +ctrlrun hold place --id matter-2026-11 --from-seq 1 --to-seq 90000 \ + --reason "litigation" --by legal@example.com +ctrlrun hold list +ctrlrun hold release --id matter-2026-11 --by legal@example.com +``` + +**A hold has no expiry.** One that lapsed on a timer would release evidence on a schedule nobody +reviewed, so a person places it and a person ends it. + +## What this does not do + +- **It does not prune anything but receipts and the ledger rows tied to them.** Events, + approvals, delegations and continuations still grow without bound. +- **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`. +- **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. + +**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. + +## Next + +- [Anchoring](/docs/production/anchoring): the checkpoint's other half. +- [Receipt integrity](/docs/production/receipt-integrity): the chain, and the six names it reports. +- [Get started](/docs/get-started/quickstart) · [Why](/docs/why). diff --git a/docs/reference/api/DelegationRecord.mdx b/docs/reference/api/DelegationRecord.mdx index 6437814..83be55b 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:388` +`ctrlrun.DelegationRecord` — class, defined at `src/ctrlrun/state.py:390` ```python from ctrlrun import DelegationRecord diff --git a/docs/reference/api/InMemoryStateStore.mdx b/docs/reference/api/InMemoryStateStore.mdx index f66b5b9..2664bdd 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:1009` +`ctrlrun.InMemoryStateStore` — class, defined at `src/ctrlrun/state.py:1053` ```python from ctrlrun import InMemoryStateStore diff --git a/docs/reference/api/SQLiteStateStore.mdx b/docs/reference/api/SQLiteStateStore.mdx index c8bff6c..f040418 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:1535` +`ctrlrun.SQLiteStateStore` — class, defined at `src/ctrlrun/state.py:1621` ```python from ctrlrun import SQLiteStateStore diff --git a/docs/reference/api/StateStore.mdx b/docs/reference/api/StateStore.mdx index 13838ac..33728bb 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:638` +`ctrlrun.StateStore` — class, defined at `src/ctrlrun/state.py:640` ```python from ctrlrun import StateStore @@ -42,6 +42,12 @@ class StateStore(ApprovalStore, Protocol) def put_anchor(anchor: Anchor) -> None def anchors() -> tuple[Anchor, ...] def checkpoint() -> tuple[int, str] | None + def put_checkpoint(checkpoint: Checkpoint) -> None + def put_hold(hold: Hold) -> None + def holds() -> tuple[Hold, ...] + def release_hold(hold_id: str, *, by: str, at: datetime) -> None + def pruning() -> AbstractContextManager[None] + def delete_prefix(through: int, effect_keys: Sequence[str]) -> tuple[int, int] def events() -> tuple[Event, ...] def receipts() -> tuple[Receipt | UnreadableReceipt, ...] def close() -> None diff --git a/docs/reference/api/postgres-PostgresStateStore.mdx b/docs/reference/api/postgres-PostgresStateStore.mdx index d8eb8a3..09afd1c 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:345` +`ctrlrun.postgres.PostgresStateStore` — class, defined at `src/ctrlrun/postgres.py:347` ```python from ctrlrun.postgres import PostgresStateStore diff --git a/docs/reference/api/state-Charge.mdx b/docs/reference/api/state-Charge.mdx index a6f39ae..0126fc4 100644 --- a/docs/reference/api/state-Charge.mdx +++ b/docs/reference/api/state-Charge.mdx @@ -5,7 +5,7 @@ description: "What one reservation spends against one grant's budget (SPEC-v0.9 {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.state.Charge` — class, defined at `src/ctrlrun/state.py:515` +`ctrlrun.state.Charge` — class, defined at `src/ctrlrun/state.py:517` ```python from ctrlrun.state import Charge diff --git a/docs/reference/api/state-Consumption.mdx b/docs/reference/api/state-Consumption.mdx index 77fa872..9410cb0 100644 --- a/docs/reference/api/state-Consumption.mdx +++ b/docs/reference/api/state-Consumption.mdx @@ -5,7 +5,7 @@ description: "One ledger row, as `consumptions()` hands it back (SPEC-v0.9 §3.3 {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.state.Consumption` — class, defined at `src/ctrlrun/state.py:555` +`ctrlrun.state.Consumption` — class, defined at `src/ctrlrun/state.py:557` ```python from ctrlrun.state import Consumption diff --git a/docs/reference/api/state-check_charges.mdx b/docs/reference/api/state-check_charges.mdx index 67c3329..2dba42a 100644 --- a/docs/reference/api/state-check_charges.mdx +++ b/docs/reference/api/state-check_charges.mdx @@ -5,7 +5,7 @@ description: "SPEC-v0.9 §3.3.1's predicate, in one place so three backends cann {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.state.check_charges` — function, defined at `src/ctrlrun/state.py:573` +`ctrlrun.state.check_charges` — function, defined at `src/ctrlrun/state.py:575` ```python from ctrlrun.state import check_charges diff --git a/docs/reference/cli.mdx b/docs/reference/cli.mdx index 7e65e23..9966c4d 100644 --- a/docs/reference/cli.mdx +++ b/docs/reference/cli.mdx @@ -29,10 +29,12 @@ Commands: deny Refuse a pending approval request. effects Show the logical effects this store knows about. gateway Front an MCP server, applying this directory's policy to... + hold Refuse to prune a range of receipts, until a person says... init Write a starter ctrlrun.yaml and create .ctrlrun/. inspect Show one action's whole history: proposal, decision,... mcp-operator Answer approvals from an MCP client, over loopback... policy Propose a policy change, or replay one against what already... + prune Delete receipts from the start of the chain, leaving it... receipts Show the receipts this store holds. resolve Say what actually happened to an effect with an unknown... revoke Revoke a delegation, and with it every delegation beneath it. @@ -146,6 +148,66 @@ Options: --help Show this message and exit. ``` +## ctrlrun prune + +```text +Usage: ctrlrun prune [OPTIONS] + + Delete receipts from the start of the chain, leaving it verifiable across the + gap. + + **This is the only command in this library that destroys evidence.** It takes + a prefix, never a suffix and never a middle: a suffix is the attack the anchor + exists to catch, a middle is a gap by construction, and only moving the + chain's start can leave a chain anybody can still verify. + + It refuses rather than warns. A prune that would leave the chain reporting a + break it did not already report is refused with the seq named; so is one + overlapping a hold, one through the head, one moving the checkpoint backwards, + and one that would delete a ledger row whose charge is still held. There is no + --force and there is not going to be one. + + It anchors its checkpoint **before** it deletes anything, so a prune stays + visible in your anchor provider's own record even though the receipts are + gone. + +Options: + --through SEQ Delete receipts through this seq. [required] + --older-than DURATION Refuse any COMMITTED ledger row newer than this (e.g. + 90d). Use the longest window on any budget of any + grant. [required] + --provider MODULE:ATTR Your anchor provider. The prune anchors its checkpoint + before deleting anything. [required] + --by WHO Who is running this prune. [required] + --reason TEXT Why. It goes in the receipt. [required] + --json Print the result as JSON. + --store-url TEXT The store to open. Default: $CTRLRUN_STORE_URL, else + the SQLite database beside the policy + (.ctrlrun/state.db, or wherever $CTRLRUN_STATE + points). + --help Show this message and exit. +``` + +## ctrlrun hold + +```text +Usage: ctrlrun hold [OPTIONS] COMMAND [ARGS]... + + Refuse to prune a range of receipts, until a person says otherwise. + + There is no expiry. A hold that lapsed on a timer would release evidence on a + schedule nobody reviewed, which is the rule SPEC-v0.9 §4 already states about + a budget hold. + +Options: + --help Show this message and exit. + +Commands: + list Show every hold this store knows about, live or released. + place Place a hold. + release End a hold. +``` + ## ctrlrun effects ```text diff --git a/docs/verify.md b/docs/verify.md index 170ad64..b10b59c 100644 --- a/docs/verify.md +++ b/docs/verify.md @@ -62,13 +62,16 @@ G25 a hop narrows or it is refused PASS head-of-support G26 a hop is named on both sides PASS head-of-support G27 a swapped upstream is denied N/A no action entry pins an upstream G28 truncation past an anchor fails PASS stripe.refund +G29 a prune adds no new chain break PASS stripe.refund +G30 a held range refuses to prune PASS stripe.refund G31 five receipt schemas verify PASS stripe.refund +G32 an honest prune keeps anchors PASS stripe.refund (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) -26/26 declared guarantees pass. 3 not applicable: G13, G15, G27. +29/29 declared guarantees pass. 3 not applicable: G13, G15, G27. ``` It reads the policy document — `$CTRLRUN_CONFIG`, else `./ctrlrun.yaml` — and the authority diff --git a/generated/readiness.full.mdx b/generated/readiness.full.mdx index e4f03f3..d486864 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.10.0**, on [PyPI](https://pypi.org/project/ctrlrun/), Python 3.11 and later, tested on 3.11 to 3.14. -- **6,158 tests**, every version specified before it was written and every requirement mutation-tested. [Read more](/docs/how-this-is-built). -- **29 guarantees you can check in your own setup**, with `ctrlrun verify` against your policy, on your store's backend, in a scratch store it creates. [Read more](/docs/security/verify-guarantees). +- **6,192 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). - **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 9ac78fa..0c41773 100644 --- a/generated/readiness.json +++ b/generated/readiness.json @@ -1,5 +1,5 @@ { - "guarantees": 29, + "guarantees": 32, "python": { "floor": "3.11", "tested": [ @@ -18,6 +18,6 @@ "positive_control": true, "unexplained": 0 }, - "tests": 6158, + "tests": 6192, "version": "0.10.0" } diff --git a/generated/readiness.mdx b/generated/readiness.mdx index ce46e0a..240d82b 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.10.0**, on [PyPI](https://pypi.org/project/ctrlrun/), Python 3.11 and later, tested on 3.11 to 3.14. -- **6,158 tests**, every version specified before it was written and every requirement mutation-tested. -- **29 guarantees you can check in your own setup**, with `ctrlrun verify` against your policy, on your store's backend, in a scratch store it creates. +- **6,192 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). - **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 dc404c0..855d1ac 100644 --- a/generated/readiness.readme.md +++ b/generated/readiness.readme.md @@ -1,7 +1,7 @@ - **Version 0.10.0**, on [PyPI](https://pypi.org/project/ctrlrun/), Python 3.11 and later, tested on 3.11 to 3.14. -- **6,158 tests**, every version specified before it was written and every requirement mutation-tested. -- **29 guarantees you can check in your own setup**, with `ctrlrun verify` against your policy, on your store's backend, in a scratch store it creates. +- **6,192 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). - **Each receipt carries the hash of the one before it**, so an alteration is detected and named. diff --git a/tests/test_docs_production.py b/tests/test_docs_production.py index 69d88d3..d226198 100644 --- a/tests/test_docs_production.py +++ b/tests/test_docs_production.py @@ -136,6 +136,8 @@ def test_the_section_exists_and_has_a_page_for_each_thing_that_breaks(): # "what to run" page on a 900-word budget, and the anchor's limits need as much room as # its claim, which is what the budget exists to force a decision about. "anchoring", + # SPEC-v0.11 §4. + "retention", "soak", "operations", } From d4fe6585f378a511eaad73feeff29adb8be34131 Mon Sep 17 00:00:00 2001 From: arpan Date: Mon, 14 Sep 2026 22:35:54 +0530 Subject: [PATCH 2/2] Enforcement coverage: the claims, and a correction the build earned ROADMAP's v0.11 line said 'from events already written', and the action name is NOT on the event: ACTION_PROPOSED carries an action_hash and nothing that maps it back. The answer comes from receipts, which every decided action leaves, a denial included, which is why an action that is always denied counts as exercised. CLAIMS.md's scan row said 'no score, no percentage and no badge'. --coverage opens a store and still computes none, and the row now says so with the tests that pin it. Signed-off-by: arpan --- docs.mdx | 2 +- docs/CLAIMS.md | 2 +- docs/OWASP-SOLUTIONS-LANDSCAPE.md | 4 ++-- docs/ROADMAP.md | 2 +- docs/production/index.mdx | 2 +- docs/reference/cli.mdx | 12 ++++++++++++ generated/readiness.full.mdx | 2 +- generated/readiness.json | 2 +- generated/readiness.mdx | 2 +- generated/readiness.readme.md | 2 +- 10 files changed, 22 insertions(+), 10 deletions(-) diff --git a/docs.mdx b/docs.mdx index 25a4be0..01463cf 100644 --- a/docs.mdx +++ b/docs.mdx @@ -219,7 +219,7 @@ the framework's own interrupt, and a framework with no such primitive does not n {/* generated from the suite, pyproject and the soak (mdx) — run the generator */} - **Version 0.10.0**, on [PyPI](https://pypi.org/project/ctrlrun/), Python 3.11 and later, tested on 3.11 to 3.14. -- **6,192 tests**, every version specified before it was written and every requirement mutation-tested. +- **6,201 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 d904b2e..a7634f5 100644 --- a/docs/CLAIMS.md +++ b/docs/CLAIMS.md @@ -192,7 +192,7 @@ The README also makes negative claims. They matter as much as the positive ones. | "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 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 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` | | "it makes no claim about any standard" | No standards vocabulary outside a sentence that negates it, in the README, in a docstring or in CLI output: `test_T139_the_readme_makes_no_conformance_claim`, and `tools/docs_audit/lint.py` on every document | diff --git a/docs/OWASP-SOLUTIONS-LANDSCAPE.md b/docs/OWASP-SOLUTIONS-LANDSCAPE.md index c6d9faf..f4e3c14 100644 --- a/docs/OWASP-SOLUTIONS-LANDSCAPE.md +++ b/docs/OWASP-SOLUTIONS-LANDSCAPE.md @@ -55,7 +55,7 @@ stages from that one point rather than covering each in its own right. | Stage | Status | What CTRLRun has there | Since | |---|---|---|---| | Scope & Plan | Partly | A published threat model of the execution boundary ([THREAT_MODEL](/docs/THREAT_MODEL)), and a policy document that *is* the plan for what an agent may do. Nothing that models *your* agent for you. | v0.1 | -| Develop & Experiment | Yes | `@protect` on any function in the process; `ctrlrun scan` reports the consequential call sites a policy does not cover. | v0.1, scan since v0.6 | +| Develop & Experiment | Yes | `@protect` on any function in the process; `ctrlrun scan` reports the consequential call sites a policy does not cover, and `ctrlrun scan --coverage` reports what a store shows was declared and never exercised. Both are lists with reasons, not scores. | v0.1, scan since v0.6, coverage since v0.11 | | Augment & Fine Tune Data | No | CTRLRun never touches training data, models or memory. | none | | Test & Evaluate | Yes | `ctrlrun verify` runs the kernel's own failure scenarios against your configuration and reports pass, fail or **not applicable** per guarantee ([verify](/docs/verify)). | v0.4 | | Release | Yes | The `ctrlrun verify` GitHub Action and badge as a release gate; the badge means *declared guarantees pass*, never "this agent is secure by inspection". | v0.4 | @@ -88,7 +88,7 @@ example is theirs and is not a claim that CTRLRun uses it. | Checkbox | Status | What it means here | Since | |---|---|---|---| -| Perform SAST/DAST on agent planning code, tool wrappers, & plugin interfaces | Partly | `ctrlrun scan` reads a Python tree and reports the consequential call sites and policy entries CTRLRun is *not* covering. It is a coverage scanner, not a vulnerability scanner, and its report says what it misses by construction on every run. | v0.6 | +| Perform SAST/DAST on agent planning code, tool wrappers, & plugin interfaces | Partly | `ctrlrun scan` reads a Python tree and reports the consequential call sites and policy entries CTRLRun is *not* covering, and since v0.11 `--coverage` adds the runtime half: what the store shows was declared and never exercised. It is a coverage scanner, not a vulnerability scanner, and its report says what it misses by construction on every run. **Neither half produces a number**: a policy entry nothing exercised may be correctly unused, and saying otherwise would be grading the operator's document. | v0.6, v0.11 | | Harden agent loop logic against infinite loops, unsafe function routing, & unauthorized self-modification | Partly | A retry loop cannot turn one intended effect into several (`G3`, `G5`), an unknown action is refused (`G6`), and renewal after `FAILED` has an operator-set ceiling (`G15`). Nothing here inspects loop logic or prevents self-modification. | v0.1, v0.7 | | Validate connector (e.g., MCP) contracts (input/output schemas & permissions) | Partly | The gateway maps every MCP tool call onto a policy decision, and a policy entry may pin the hash of an upstream's advertised tool schema so a schema that moved under an approved name is a `deny`. It does not validate schemas in general. | v0.2, v0.10 | | Implement policy enforcement hooks in Frameworks (e.g. LangGraph, CrewAI, Others) | Yes | `@protect` for anything in-process; the adapter contract with OpenAI Agents SDK and LangGraph reference adapters, each routing an `approve` through the framework's own interrupt; the OWASP Agent Control Standard adapter ([ACS](/docs/ACS)). | v0.1, v0.2, v0.5 | diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 43bb38d..3cc19e8 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -377,7 +377,7 @@ One question: can the record be trusted after the fact, and kept? - **An external anchor for the receipt chain.** The chain detects alteration and says on every page that it does not detect truncation or append — both measured at two statements, undetected, because the head is a row in the same database. v0.11 anchors the head outside the database at an interval (an RFC 3161 timestamp, or an equivalent the operator supplies) so that **anything at or below an anchored `seq` can no longer be removed or altered** without the anchored pair failing to reproduce. An anchor freezes a prefix: **an append is not detected**, because an appended row lands above every anchored `seq`, and nor is a receipt created and destroyed entirely between two anchors. This sentence said "erased or appended" until 2026-09-14, when `SPEC-v0.11.md`'s review ran the cases; §2.4 there is the table, and the named kinds are the anchor's own, not the six chain-break kinds. No keys of its own: it consumes a timestamp and issues nothing, which is why it is here and signing is not. **Built by item 2 on 2026-09-14**, with `G28` grading it and `ctrlrun anchor` running it. CTRLRun ships **no** provider: an RFC 3161 client is a network client, so the operator supplies four calls (`make`, `check`, `latest`, `since`) and `examples/anchored-chain/` shows the smallest one that works. `since()` is the call a review added and the reason the design holds: with `make` and `check` alone, the record of *which* anchors exist lived in CTRLRun's own table, so deleting the newest row there left the older anchor reproducing and the truncation invisible, at a cost of one more statement. - **Retention and legal hold.** There is no retention policy today and `docs/postgres.md` says so, in the same breath as the reason one is hard to write: deleting receipts from the middle or the end of the chain is detected as a break by design. v0.11 pays that debt: a chain-preserving prune that leaves a checkpoint receipt verifiable across the gap, and a hold that refuses to prune, both recorded as receipts themselves. **v0.9 adds a second growing table and states the invariant rather than the command**: the budget ledger only grows, and `SPEC-v0.9.md` §7.3 says that rows older than the longest window on any budget of a grant cannot affect a future decision, so somebody else's archiving is safe. One caveat travels with it, because the invariant is about decisions and not about evidence: an `AMBIGUOUS` effect older than that window still **holds** a charge the operator surfaces display, so an archiver on a live ledger excludes un-released rows. `ctrlrun stats` reports the row count so the growth is visible before it matters. **Built by item 3 on 2026-09-14**, as `ctrlrun prune` and `ctrlrun hold`, with `G29`, `G30` and `G32` grading it. Two things the build settled that the line above did not say. The ledger rule is **settlement and then a window**, not "un-released": `COMMITTED` holds permanently and only `FAILED` releases, so "un-released" would have been almost every row forever, and a `COMMITTED` row is prunable only **outside** §7.3's window, because pruning one inside it hands back authority nobody granted. And the window is **supplied** on the command line rather than derived: a ledger row carries no window and no limit, those travel on `Charge` from the authority document, and a store that resolved them would be reading the policy. -- **Enforcement coverage.** From events already written: policy entries never exercised, gateway tools never routed, `@protect` actions never seen. The runtime half of `ctrlrun scan`, under the same rule — a clean result is not a verdict, no score, no percentage, no badge. +- **Enforcement coverage.** From what is already written: policy entries never exercised, gateway tools never routed, `@protect` actions never seen. The runtime half of `ctrlrun scan`, under the same rule — a clean result is not a verdict, no score, no percentage, no badge. **Built by item 5 on 2026-09-14** as `ctrlrun scan --coverage`. One correction the build earned: this line said *from events already written*, and the action name is **not on the event**. `ACTION_PROPOSED` carries an `action_hash` and nothing that maps it back to a name, so the answer comes from receipts, which every action that reached a decision leaves — **a denial included**, which is why an action that is always denied counts as exercised rather than as a gap. No new event type and no new column either way, which is what §7 made the test of whether the question was the right one. It does not move the exit code: a number that ranked a deployment would be the verdict this rule forbids, wearing a shell's clothes. - **One chain, several receipt schemas.** `ctrlrun.receipt/v7` is the schema today, and the rule since `SPEC-v0.3.md` §12.2 is that every reader upgrades before any writer switches, so an older receipt on disk still parses. v0.8 (the verified approver; the grant id under break-glass), v0.9 (budget consumption) and v0.10 (the hop) each add fields and each bump the version, so a chain kept from v0.6 across them holds **five receipt schema versions**: `v3`, which 0.6 wrote, `v4`, which v0.7 added, and `v5`, `v6` and `v7` after it. This sentence has now gone stale twice and is corrected here rather than quietly both times. It said *three shapes* and named `v3` as the schema today, before v0.7's precondition fields bumped it; v0.7's release pass fixed that and left *four* and `v4`, which v0.10's hop field made wrong again. A count of versions in a document is a number that goes stale at every release, which is the argument for reading `receipt.py`'s constants instead. And nothing yet proved that `verify` walks it end to end, hash by hash, each receipt hashed by the rule its own version wrote. **v0.11's item 4 proves it and `G31` grades it, since 2026-09-14.** No new field: the version string already existed. What is new is the proof, and the rule that a receipt whose version the binary does not know is *named* and not reported as a break — the same distinction v0.6 §3.2 draws for a `schema_version` row the binary does not know. **The proof is built from the released wheels rather than from fixtures** (`scripts/five_schema_chain.py`): five environments, `pip install ctrlrun==0.6.1`, `0.7.0`, `0.8.0`, `0.9.0`, `0.10.0`, one store, then this build verifies across the whole thing, because a fixture is only this build's opinion of what 0.6 wrote. One thing that proof got wrong first is worth keeping: run with `PYTHONPATH=src`, the variable is inherited by every child, so all five "released wheels" imported the build under test and the run reported **one** schema version while looking exactly like a pass. The script now strips it and checks, per release, that the interpreter ran from that release's own environment. Added 2026-09-10, proved 2026-09-14. - **A malformed value in a receipt row blinded every reader of the chain, and one `UPDATE` was enough. Closed by v0.11's item 1 on 2026-09-14.** Found while building v0.7's item 5, deferred there with a written decision, and named here because it is the evidence surface and this is the evidence milestone. A receipt whose *schema label* is unknown, and a receipt carrying an *added key*, were each already reported at their `seq` and left every other row readable. A malformed **value** of a key the schema declares was not: a float among a receipt's `controls` raised out of `Receipt.from_dict`, so `ctrlrun receipts`, `receipts --verify-chain`, `ctrlrun inspect`, `ctrlrun stats` and the operator MCP server's `receipts` and `stats` tools all stopped together, and a single tampered row hid the whole document rather than naming itself. 0.6.1 behaved the same way and v0.7 neither introduced nor widened it. **The fix is `SPEC-v0.7.md` §12.5's second candidate**, a reader that reports per row: `StateStore.receipts()` hands back a `ctrlrun.receipt.UnreadableReceipt` for a row it cannot construct, naming its `seq` and the type of what refused it. `CHAIN_BREAKS` did **not** grow, and §12.5's first candidate is declined with a reason in `SPEC-v0.11.md` §5.1: `content_altered` already names a document that cannot be canonicalized, so a second name would be two names for one break. **Two corrections this entry earned by being implemented.** It listed `G11` among the readers that stop; `ctrlrun verify` grades `G11` against a scratch store it creates and fills itself, which no `UPDATE` reaches, so `G11` was never blinded by an operator's tampered row. And it omitted the operator MCP server, which is a *network* surface: the same one statement took out the remote console as well as the terminal. Added 2026-09-12, closed 2026-09-14. diff --git a/docs/production/index.mdx b/docs/production/index.mdx index ba65c9e..6bb76fb 100644 --- a/docs/production/index.mdx +++ b/docs/production/index.mdx @@ -28,7 +28,7 @@ need. `test_the_first_line_of_the_section_says_which_store_and_why` asserts the {/* generated from the suite, pyproject and the soak (full) — run the generator */} - **Version 0.10.0**, on [PyPI](https://pypi.org/project/ctrlrun/), Python 3.11 and later, tested on 3.11 to 3.14. -- **6,192 tests**, every version specified before it was written and every requirement mutation-tested. [Read more](/docs/how-this-is-built). +- **6,201 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/reference/cli.mdx b/docs/reference/cli.mdx index 9966c4d..6c8c391 100644 --- a/docs/reference/cli.mdx +++ b/docs/reference/cli.mdx @@ -548,6 +548,13 @@ Usage: ctrlrun scan [OPTIONS] It is a finder and not a proof. Every run prints what it could not look at, and a clean scan means nothing was found where it looked. + With --coverage it also reads a store and reports what this deployment + declared and has never exercised. **That half is a list and not a score**: no + percentage, no ratio, no badge. A policy entry nothing exercised may be + correctly unused, and it says so. It does not move the exit code, for the same + reason: a number that ranked a deployment would be a verdict on the operator's + document, which this tool does not give. + Exit codes: 0 nothing was found; 1 something was, including a suppressed finding or a call whose name could not be resolved; 2 the scan could not run. @@ -559,6 +566,11 @@ Options: --vocabulary TEXT A file of verbs, one per line, replacing the built-in list. With no value, print the list in force and exit. --json Emit one ctrlrun.scan/v1 document. + --coverage Also report what this store has never exercised (SPEC-v0.11 + §7). Opens the store. + --store-url TEXT The store to open. Default: $CTRLRUN_STORE_URL, else the + SQLite database beside the policy (.ctrlrun/state.db, or + wherever $CTRLRUN_STATE points). --help Show this message and exit. ``` diff --git a/generated/readiness.full.mdx b/generated/readiness.full.mdx index d486864..144e7cf 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,192 tests**, every version specified before it was written and every requirement mutation-tested. [Read more](/docs/how-this-is-built). +- **6,201 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 0c41773..96c0544 100644 --- a/generated/readiness.json +++ b/generated/readiness.json @@ -18,6 +18,6 @@ "positive_control": true, "unexplained": 0 }, - "tests": 6192, + "tests": 6201, "version": "0.10.0" } diff --git a/generated/readiness.mdx b/generated/readiness.mdx index 240d82b..552c0be 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,192 tests**, every version specified before it was written and every requirement mutation-tested. +- **6,201 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 855d1ac..7e2f5f3 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,192 tests**, every version specified before it was written and every requirement mutation-tested. +- **6,201 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).