scripts/repoint-claims.py re-derives them for the commits in between — it refuses rather than guessing when a symbol has no definition, because a row pointing at a docstring that happens to contain the right word makes the guard green and the claim false. test_the_claims_table_line_numbers_point_at_what_they_name resolves every one against the line it cites.
The rows follow the README’s sections. docs/capabilities.yaml names one row per capability
by its quoted claim, and tests/test_docs_audit.py fails if a named row is not here.
The header
The last check before an AI agent does something it can’t undo. Autonomy belongs to the action, not the agent. Every consequential action happens once, exactly as approved, or not at all — and leaves a receipt.
| Claim | Code | Proof |
|---|---|---|
| ”The last check before an AI agent does something it can’t undo.” | Control.execute — control.py:542 — 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:678 — passes only the action’s name and arguments to _ActionPolicy.evaluate (policy.py:495), whose signature has no principal in it. A rule cannot read who is acting even by accident. agent_eq and user_eq are refused at load by RESERVED_ARGUMENTS (policy.py:150) rather than silently matching nothing. | test_T6_an_action_name_is_matched_exactly, test_a_condition_naming_an_action_field_is_refused_at_load |
| ”Every consequential action happens once, exactly as approved, or not at all — and leaves a receipt.” | Once: plan_reservation — effect.py:163. Exactly as approved: the approval is bound to action_hash and consumed with the reservation — _authorize_and_reserve — state.py:780. Or not at all: a refusal raises before the executor — Control.execute — control.py:542. A receipt: Receipt — receipt.py:245 | test_T3_exactly_one_agent_reserves_and_seven_are_blocked, test_T2_a_mutated_action_presenting_the_approval_raises_ApprovalMismatch, test_T11_every_demo_receipt_carries_every_field_in_the_spec |
The capability matrix
Rendered fromdocs/capabilities.yaml; the six rows are the six groups of the verify
catalogue, GUARANTEES (verify/guarantees.py:39).
| 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:780; 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 executes once, across threads, processes and hosts.” | reserve_effect — state.py:353, decided inside the BEGIN IMMEDIATE of _authorize_and_reserve (state.py:780) against effect_key TEXT PRIMARY KEY (migrations.py:107; COLLATE "C" on Postgres, §4.4) | test_T3_exactly_one_agent_reserves_and_seven_are_blocked (8 OS processes, both backends), test_T3_the_fake_remote_is_called_exactly_once |
| ”An unknown outcome is AMBIGUOUS, never FAILED, and blocks a blind retry.” | Only NotExecuted maps to FAILED — control.py:1036. Every other exception, timeouts included, yields AMBIGUOUS. A retry against an AMBIGUOUS key is refused — effect.py:163, the one place plan_reservation decides it for every store | test_T1_a_blind_retry_writes_a_blocked_receipt, test_T1_the_ambiguous_record_survives_the_blocked_retry, test_T1_a_lost_response_leaves_the_effect_ambiguous |
| ”An unknown action, a missing policy or a missing principal is denied.” | Unknown action: Policy.evaluate — policy.py:678 — answers deny for a name the document does not list. Missing or malformed policy: Policy.from_file — policy.py:578 — raises PolicyError, and there is no Control without a policy. Missing principal: _refuse_no_principal — control.py:1849 | 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:807); contained_dimension — authority.py:604 — runs from plan_delegation (authority.py:878) 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:429; Event — receipt.py:429; the store is authoritative — append_event — state.py:513; the JSONL export — JSONLEventSink — receipt.py:429 | test_T11_every_demo_receipt_carries_every_field_in_the_spec, test_T11_every_demo_receipt_parses_back_into_a_Receipt |
The problem, and how it works
| Claim | Code | Proof |
|---|---|---|
”A lost reply is AMBIGUOUS, never FAILED, and a retry against an AMBIGUOUS effect is refused.” | Only NotExecuted maps to FAILED — control.py:1036; plan_reservation — effect.py:163 | test_T1_a_lost_response_leaves_the_effect_ambiguous, test_T1_a_blind_retry_is_refused_and_never_reaches_the_remote |
| ”reserved atomically across processes and hosts; one worker wins” | reserve_effect — state.py:353; PostgresStateStore.reserve_effect — postgres.py:528 | 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:780 | 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:678 | 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:542 | test_T74_a_denial_leaves_no_pending_approval_request |
| ”Neither axis reads the agent’s instructions” | Policy.evaluate — policy.py:678 — sees the action’s name and arguments; Authority.evaluate — authority.py:807 — 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:47 | 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:780 — checks expiry at consumption | test_T5_expiry_is_checked_at_consumption_not_only_at_grant, test_T4_replaying_the_approval_raises_ApprovalMismatch_with_reason_consumed |
”Only NotExecuted, raised by you, means FAILED.” | control.py:1036; NotExecuted — errors.py:157 | test_T1_a_lost_response_leaves_the_effect_ambiguous |
| ”the hash of the policy that decided it, chained to the receipt before it” | Policy.policy_hash — policy.py:559; prev_hash, GENESIS_HASH for the first — receipt.py:284 | test_T172_every_receipt_carries_the_hash_and_the_declared_version, test_T164_an_altered_receipt_is_content_altered_at_its_seq |
Three ways to use it
| Claim | Code | Proof |
|---|---|---|
| ”You probably do not need an adapter” | Three ways in, and @protect (control.py:1967) 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:282 | CI’s package job runs ctrlrun init from the wheel and asserts ctrlrun.yaml exists |
”The human runs ctrlrun approve <request id> and the agent calls again inside ctrlrun.with_approval(request_id)” | approve — cli/main.py:306; with_approval — control.py:164; ApprovalRequired (errors.py:88) carries request_id | test_T2_a_mutated_action_presenting_the_approval_raises_ApprovalMismatch (the granted path first), test_T4_replaying_the_approval_raises_ApprovalMismatch_with_reason_consumed |
| ”No agent changes” | INTERCEPTED_METHOD is tools/call and every other method is relayed unchanged — gateway/mcp.py:40 | test_a_non_intercepted_method_is_relayed_with_no_ctrlrun_outcome |
| ”Point the MCP client at the gateway instead of at the tool server” | Gateway.handle — gateway/server.py:421; serve — gateway/__init__.py:40 | test_T19_the_upstream_receives_the_canonical_arguments |
”Tools become actions named mcp.<alias>.<tool>” | Gateway._intercept — gateway/server.py:459 | test_T19_the_action_is_named_for_the_alias_and_the_tool |
| ”they are declared in the policy” (effect and resource templates for a tool call) | Policy.effect_template / resource_template — policy.py:668; McpOptions — policy.py:668 | test_T16_a_v2_document_loads_and_exposes_its_templates, test_T16_a_decorator_and_a_policy_template_produce_the_same_action_hash |
”Everything but tools/call is relayed untouched” | parse_request(...).intercept — gateway/mcp.py:84 | test_every_other_method_is_relayed_not_intercepted |
| ”A lost response over the wire blocks the retry exactly as it does in process” | classify — gateway/outcome.py:144, translated into v0.1 §5.5’s own vocabulary by the gateway’s executor | test_T23_the_identical_call_sent_again_is_refused_and_the_upstream_called_once |
”the gateway prints, on the line that starts it, every action in your policy that has no effect: template” | _announce — gateway/__init__.py:145 | test_the_startup_block_names_the_environment_identity_and_authority, test_the_startup_block_says_so_when_there_is_no_authority_section |
”route an approve decision through the framework’s own interrupt” | FrameworkInterrupt — adapter.py:180 — is a Protocol with one method returning a value; it holds no state and writes nothing | test_T135b_the_adapter_reuses_the_sdks_primitive_and_reimplements_nothing |
”one core provider writes the grant through the same calls ctrlrun approve makes” / “There is never a second place to say yes” | InterruptApprovalProvider.wait — adapter.py:254 — calls grant_approval / deny_approval, and an adapter calls neither | test_T130_each_broken_fixture_fails_the_suite_named_for_it |
| ”an adapter never constructs one and never supplies a principal” | needs_approval — adapter.py:408 — resolves the principal from the Control so no adapter builds an Action | test_T129_no_public_callable_takes_a_principal, test_T129_the_module_exposes_no_way_to_construct_a_control |
| ”prevention” / “attribution” | carries_approved_arguments gates §3.4’s rebuild in _check_answer — adapter.py:320 | test_T137b_the_readme_says_the_binding_is_attribution_and_why |
| ”Adapters ship on their own version line” | adapters/*/pyproject.toml, never in the ctrlrun wheel or sdist | test_T136_the_ctrlrun_distributions_contain_no_adapter |
Write down what the agent may do
| Claim | Code | Proof |
|---|---|---|
| ”cheap to undo is autonomous, anything that leaves the building needs a human, money is by amount with both ends bound” | Decision — policy.py:234 — is exactly allow, approve, deny; rules match first-wins over Condition (policy.py:298) with the operators eq, neq, in, lt, lte, gt, gte — _OPERATORS — policy.py:83 | test_T6_an_action_name_is_matched_exactly, test_T176_the_operators_behave_as_they_do_everywhere_else |
| ”Unknown actions are denied; there is no default-allow.” | Policy.evaluate — policy.py:678 | test_T6_unknown_action_is_denied_with_reason_unknown_action |
| ”Amounts are integer minor units; floats are rejected outright” | float refused at any depth — action.py:47 | test_T7_canonical_form_is_exactly_the_specified_serialization |
| ”The policy cannot see who is asking — deliberately, since v0.1” | Policy.evaluate still takes only the action’s name and arguments; RESERVED_ARGUMENTS — policy.py:150 — refuses agent_eq and every other principal-addressing condition at load, in a document of every schema version | test_T74b_a_reserved_name_in_a_policy_rule_is_a_load_error, test_T74b_a_reserved_name_in_a_grant_constraint_is_a_load_error |
”the second axis, authority:” | Authority.evaluate — authority.py:807; Control._authority_result — control.py:408 | test_T67_a_principal_with_no_grant_is_denied |
| ”opt-in, and then fail-closed” | _optional_authority returns None for a document with no section — control.py; Control.authority is None is v0.2 behaviour exactly | test_T66_a_document_with_no_authority_section_leaves_control_authority_none, test_T66_no_authority_event_is_appended_without_a_section, and T66’s session-wide guard in tests/conftest.py |
| ”every principal needs a grant and no grant means denied” | NO_AUTHORITY — the fail-closed default of Authority.evaluate (authority.py:807), 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:1634; Authority.plan_delegation — authority.py:878; ctrlrun delegate — cli/main.py:945 | 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:604 — runs from plan_delegation (authority.py:878) and from the chain walk in Authority.evaluate (authority.py:807) | test_t76_each_dimension_violated_alone, test_t77b_a_narrowed_parent_narrows_its_children |
| ”omitting a dimension the parent constrains is rejected rather than inherited” | contained_dimension treats an absent child dimension as unconstrained and therefore wider — authority.py:604; the subject half is _subject_contained (authority.py:635) | 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:1650 — writes one row — revoke_delegation — state.py:1166 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:895; Control._observed — control.py:738; _WouldHave — receipt.py:191; ReceiptResult.OBSERVED — receipt.py:191 | 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:914 | test_T84_mode_is_refused_anywhere_but_the_top_level |
”ctrlrun stats gives you the numbers” | stats — cli/main.py:710; 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:738 | test_T82_observe_executes_what_enforce_would_deny, test_T83_an_executor_that_fails_on_a_held_key_still_writes_the_record |
Prove it holds in your setup
| Claim | Code | Proof |
|---|---|---|
| ”runs the kernel’s own failure scenarios against the configuration in front of it” | ctrlrun.verify.run — verify/__init__.py:112; the eleven guarantees — GUARANTEES — verify/guarantees.py:39; the scenarios — verify/scenarios.py | test_T100_the_authority_example_passes_every_non_authority_guarantee (11/11), test_T100_a_v1_document_with_no_templates_and_no_grants |
| ”in a scratch store, with fake executors, and no network” | One scratch store per guarantee under a temporary directory — verify/scenarios.py, Engine.control; state_path() is never called and Control.from_file() is never used | test_T103_the_operators_store_is_byte_identical_before_and_after, test_T103_a_store_that_does_not_exist_is_not_created, test_T107_a_full_run_completes_with_no_network |
”Your .ctrlrun/state.db is byte-identical before and after” | The scratch path is a tempfile.mkdtemp removed in a finally — verify/__init__.py | test_T103_the_operators_store_is_byte_identical_before_and_after (SHA-256 and st_mtime_ns), test_T103_CTRLRUN_STATE_is_not_read_and_not_created |
| ”Not applicable is not a pass” | Report.applicable is passes plus failures — verify/report.py; every N/A reason is a statement about the document — verify/guarantees.py | test_T101_a_policy_with_no_approve_rule_makes_G1_and_G2_not_applicable, test_T102_a_policy_with_no_effect_templates_makes_G3_G4_and_G5_not_applicable |
”6/6 (5 not applicable), never 11/11” | Report.summary_line — the N/A ids are a separate sentence, never a parenthesis inside the fraction | test_T113_the_summary_is_the_last_line_and_names_the_not_applicable_ids (asserts 11/11 appears nowhere in an N/A run) |
| “There is no flag that folds one into the count” | There is no such parameter on run() (§9.1 freezes the signature) and no such option on the CLI | test_T101b_zero_applicable_guarantees_is_not_a_pass — 0/0 exits 2 |
| The two quoted reports | Both are real runs; the first is asserted line by line against run(examples/authority/payments.yaml) and against docs/verify.md’s copy | test_the_readme_quotes_the_real_verify_output, test_the_readme_and_the_verify_page_quote_the_same_report |
| ”means the declared guarantees pass” | badge_from_document — verify/report.py; the phrase is the first sentence under docs/verify.md#what-the-badge-means | test_T119_the_rendered_badge_text_is_exactly_CTRLRun_verified_N_over_M, test_T119_the_link_target_carries_the_exact_phrase |
| ”It does not mean secure, safe, compliant, certified or audited” | Those words appear in docs/verify.md only inside the sentence that refuses them, and nowhere in the badge, the summary, action.yml or the workflow | test_T119_no_claim_uses_the_forbidden_vocabulary, test_T119_the_action_and_the_workflow_make_no_forbidden_claim |
| ”There is a GitHub Action” | action.yml at the repository root — composite, one verify run, summary and badge rendered from its JSON | test_T118_the_action_is_a_composite_action_at_the_repository_root, and CI’s own verify job against both example configurations |
| ”verify has no flag that relaxes a check” | No argument and no environment variable changes what verify builds — SPEC-v0.4 §3.9 | test_T101b_zero_applicable_guarantees_is_not_a_pass, test_T107_a_full_run_completes_with_no_network |
| Not claimed | Why | Where the limit is asserted |
|---|---|---|
| That verify checks the operator’s executors | It never calls the function behind @protect and never imports the module it lives in | docs/verify.md, “What it does not mean”; THREAT_MODEL.md, “Known v0.4 limitations” |
| That a green badge means the configuration is a good one | The guarantees are about the kernel doing what it says under that configuration | test_T119_no_claim_uses_the_forbidden_vocabulary |
| That a guarantee reported N/A was checked | It was not, and the reason is on the line | test_T113_every_not_applicable_line_carries_its_reason |
| That a partial run means anything about the whole | --only writes no badge at all | test_T120_a_partial_run_writes_no_badge |
What it guarantees
| Claim | Code | Proof |
|---|---|---|
”On SQLite that is BEGIN IMMEDIATE” | _authorize_and_reserve — state.py:780 | 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:528 — INSERT … ON CONFLICT DO NOTHING against effect_key TEXT PRIMARY KEY COLLATE "C" (migrations.py:107) | test_T3_exactly_one_agent_reserves_and_seven_are_blocked (8 OS processes, both backends) |
“Same StateStore protocol, extended by nothing” | PostgresStateStore.reserve_effect — postgres.py:528 — and every other method implement v0.1 §5.3’s frozen protocol; the decisions stay in plan_reservation (effect.py:163) | test_T154_postgres_passes_the_store_conformance_suite |
| ”graded by the suite written for SQLite” | ctrlrun.conformance.store.run — conformance/store/__init__.py:52 | test_T140_every_fixture_fails_the_suite_named_for_it |
| ”It will not knowingly execute the same logical effect twice, and will never treat an unknown outcome as a failure.” | plan_reservation — effect.py:163 (refuse retry on AMBIGUOUS) and control.py:1036 (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:648; _resolve_lost_update — postgres.py:995; only NotExecuted maps to FAILED — control.py:1036 | 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:127 | test_T155b_a_landed_commit_on_a_transition_is_seen_as_landed, test_T155d_a_commit_the_server_never_received_retries_the_insert |
”A crashed worker’s effect stays AMBIGUOUS until a human runs ctrlrun resolve or a reconcile hook asks the remote what happened” | An expired lease is AMBIGUOUS and nothing sweeps it — LEASE_EXPIRED — effect.py:63; who resolved it — resolved_by — effect.py:63; resolve — cli/main.py:460 | 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:1456; 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:678; Policy.from_file — policy.py:578; _refuse_no_principal — control.py:1849; _authorize_and_reserve — state.py:780 | test_T6_unknown_action_raises_ActionDenied_with_reason_unknown_action, test_malformed_policy_document_is_a_policy_error, test_T62_a_declining_provider_with_no_context_is_no_principal, test_T2_a_mutated_action_presenting_the_approval_raises_ApprovalMismatch |
| ”No flag makes a consequential action permissive by default” | There is no such option on Control, on @protect, on the CLI or in the policy schema’s closed key sets — _TOP_LEVEL_KEYS — policy.py:87 | test_T84_mode_is_refused_anywhere_but_the_top_level, test_T101b_zero_applicable_guarantees_is_not_a_pass |
”With authority: on, every principal needs a grant, delegation cannot widen one, and ctrlrun revoke cuts a chain with one write.” | Authority.evaluate — authority.py:807; contained_dimension — authority.py:604; Control.revoke — control.py:1650 | test_T67_a_principal_with_no_grant_is_denied, test_t76_each_dimension_violated_alone, test_t78_a_revoked_parent_denies_its_grandchild |
| ”verifies a bearer token against a JWKS or a pinned key” | JWTIdentityProvider._verified — jwt_identity.py:175; the algorithm comes from the configured list and never from the token | test_T88_a_valid_token_becomes_a_principal, test_T89_every_invalid_token_is_refused_by_cause |
| ”maps the verified claims onto a principal” | _principal — jwt_identity.py — copies only the claims named in claim_names | test_T88_only_the_named_claims_reach_the_principal |
”pip install \"ctrlrun[identity]\"” | identity = ["pyjwt[crypto]>=2.8"] in pyproject.toml; imported lazily by _jwt() — jwt_identity.py | test_T92_constructing_without_the_extra_names_the_install_command, test_T92_importing_ctrlrun_pulls_in_no_jwt_module |
| ”CTRLRun issues no credential and defines no identity format” | There is no minting, signing or issuing code path in the package: jwt_identity.py calls decode and never encode | test_the_package_never_encodes_a_token |
| ”every receipt records which policy decided it” | Policy.policy_hash — policy.py:559, over _canonical_policy — policy.py:698; carried into the receipt by policy_in_force — control.py:1595 | 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:559 | 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” | policy_in_force — control.py:1595 | 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:284; prev_hash — receipt.py:41; GENESIS_HASH — receipt.py:41; put_receipt takes the head row’s lock first — postgres.py:1464 | 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:479; the six names — CHAIN_BREAKS — receipt.py:479 | test_the_verify_chain_flag_reports_a_break_by_seq_and_by_name, test_verify_chain_reads_a_postgres_store_through_store_url |
| ”migrations are automatic at open, forward-only” | migrate — migrations.py:525, called from both stores’ constructors; HEAD — migrations.py:306 | test_T147_a_v05_database_migrates_and_keeps_every_row, test_T150_reopening_does_not_rerun |
| ”An older binary against a newer schema refuses immediately” | _refuse — migrations.py:451; SchemaMismatch — errors.py | test_T148_an_older_binary_refuses_a_newer_database, test_T148_no_other_table_is_read_before_the_refusal |
| ”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:306; receipts — cli/main.py:368; effects — cli/main.py:442; resolve — cli/main.py:460; inspect — cli/main.py:494; stats — cli/main.py:710; 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:135 — one signed POST on APPROVAL_REQUESTED; the inbound answer lands through grant_approval / deny_approval like the CLI’s | test_T27_the_outbound_post_carries_a_signature_over_the_exact_bytes_sent, test_T27_the_payload_carries_what_the_spec_names |
| ”one OpenTelemetry span per action, one span event per step” | OTelEventSink — otel.py:45 | test_T29_one_action_produces_one_span_named_for_the_action, test_T29_every_event_becomes_a_span_event_named_by_its_type |
| ”argument values stay out of it unless you ask for them” | OTelEventSink(arguments=...) — otel.py:45 | test_T29_argument_values_are_not_attributes_by_default |
”Receipts in a ctrlrun.policy/v4 document can cite the controls: an action satisfies” | PolicyControl — policy.py:420; Receipt — receipt.py:245 — carries controls; attribution only, never a decision | test_T175_the_receipt_carries_the_union_of_the_action_and_the_matched_rule, test_T175_a_control_is_attribution_and_changes_no_decision |
”a rule can condition on the data: labels present in an action’s arguments” | DataLabel — policy.py:403; Policy.data_scope — policy.py:654; data_scope_in in v0.1 §3.2’s grammar with no new operator | test_T176_the_derived_set_is_the_labels_of_the_arguments_actually_supplied, test_T176_the_derived_set_drives_a_decision |
What it can’t, stated as limits
The README also makes negative claims. They matter as much as the positive ones.| Claim | Where it holds |
|---|---|
| ”CTRLRun cannot guarantee exactly-once execution against external systems it doesn’t control.” | Stated, not implemented — see THREAT_MODEL.md, “Out of scope”. CTRLRun never asserts what a remote did; only NotExecuted, raised by the executor, claims that. |
| ”CTRLRun is not a transaction manager: it rolls nothing back” | There is no compensation, saga or rollback code path in the package; an AMBIGUOUS effect is resolved by a human or a reconcile hook and never undone — RECONCILED_STATES — effect.py |
| ”The receipt chain detects alteration, and alteration is not authorship.” | n/a — a disclaimer, and the scan that keeps it one: test_T180_the_release_documents_do_not_blur_alteration_and_authorship |
| ”erasing the end of the log costs two statements” | No code — this is what the chain does not cover, and it is asserted rather than argued: test_erasing_a_suffix_and_rewinding_the_head_is_two_statements_and_undetected |
”ctrlrun verify cannot see your executors” | docs/verify.md, “What it does not mean”; THREAT_MODEL.md, “Known v0.4 limitations" |
| "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 |
The docs site: Home and Concepts
Every claim on the docs site’s Home page and Concepts pages, mapped the same way. Most of them are the README’s claims in a second place, so the rows point at the rows above rather than restating the code; the ones that are new to the site carry their own code and proof.| Page | Claim | Proved by |
|---|---|---|
index | the hero, the promise, the demo transcript and the capability grid | the header rows above; the grid is the generator’s output for docs/capabilities.yaml, checked by test_the_generated_copies_match_the_generator |
index | ”This site is an MCP server” | Mintlify hosts one at /mcp for every site (its documentation, read 2026-09-06); the URL is the site’s and changes with the domain, and test_the_home_page_carries_the_fixed_copy_and_the_generated_grid asserts the configuration line is present |
get-started/install | ”installs the kernel and exactly two dependencies, pyyaml and click” | test_core_declares_only_pyyaml_and_click, test_the_core_dependencies_have_not_grown |
get-started/install | ”importing ctrlrun imports nothing from an extra” | test_T30_a_subprocess_importing_ctrlrun_pulls_in_no_module_from_an_extra |
get-started/install | ”raises MissingDependency with the install command in the message” | test_a_missing_extra_raises_MissingDependency_naming_the_install_command |
get-started/quickstart | every block on the page, and the outputs shown | the blocks are runnable and pass tools/docs_audit/snippets.py in one temporary directory, in order; the outputs are pasted from one run of the same blocks |
concepts/action-and-hash | ”The action hash is the SHA-256 of that canonical form”; sorted keys, no whitespace, UTF-8, float rejected; action_id excluded | canonicalize / action_hash — action.py; float refused — action.py:47; test_T7_canonical_form_is_exactly_the_specified_serialization, test_T7_nested_dicts_are_sorted_recursively, test_T60_claims_do_not_change_the_action_hash |
concepts/decisions | three decisions, first match wins, unknown denied, principal-addressing conditions refused at load | the “Write down what the agent may do” rows above |
concepts/approval-binding | A1–A4, the mismatch leaving the approval granted, one core provider writing every grant | the matrix row “An approval is bound to the exact action…”, the “Three ways to use it” adapter rows, and test_T2_a_mutated_action_leaves_the_approval_granted |
concepts/approval-binding | the DENY and ALLOW rows when the policy changed between grant and consumption | ”the approval is re-checked against the policy in force at execution” above |
concepts/effect-keys | reservation atomic across threads, processes and hosts; an expired lease is AMBIGUOUS, never free; COMMITTED refuses, FAILED permits, AMBIGUOUS refuses a blind retry | the matrix row “One logical effect executes once…”; LEASE_EXPIRED — effect.py:63; test_T160_an_expired_lease_frees_nothing_and_no_read_transitions_it, test_T8_a_failed_attempt_permits_a_retry_that_commits |
concepts/outcomes-and-ambiguous | the outcome table; only a human or a reconcile hook moves a record on, and only in the direction the answer points; nothing sweeps; a lost COMMIT on Postgres is AMBIGUOUS | the matrix row “An unknown outcome is AMBIGUOUS…”, the reconciliation rows, “A crashed worker’s effect stays AMBIGUOUS…” and the Postgres rows above; test_T160_there_is_no_reaper |
concepts/receipts-and-evidence | the receipt’s fields, the JSONL sink, the policy hash and version, the chain and what it does not prove | the matrix row “Every executed action leaves a portable JSON receipt”, the receipt-chain and policy-versioning rows above, and test_T11_every_demo_receipt_carries_every_field_in_the_spec |
concepts/authority-and-delegation | opt-in then fail-closed, no decision: on a grant, stricter of the two, containment at creation and at every evaluation, omission rejected, one-write revocation, identity consumed | the authority rows under “Write down what the agent may do” and “What it guarantees” above |
concepts/observe-mode | executes, records would_have, one top-level line, counted by ctrlrun stats, never asks a human | the observe-mode rows above; _observed — control.py:738 |
concepts/fail-closed | the refusal table, one exception per row | the matrix row “An unknown action, a missing policy or a missing principal is denied.”, ActionDenied — errors.py:29, DuplicateEffect — errors.py:126, AmbiguousEffect — errors.py:141, and test_a_policy_deny_is_denied_the_same_way_as_an_unknown_action |
Demo output
The README quotesctrlrun demo verbatim.
test_the_readme_demo_section_quotes_the_demo_output_verbatim runs the demo and asserts every
line it prints appears in the README, masking only the generated approval and delegation ids.
The animation at the top of the README ends on lines docs/assets/demo.expected.txt lists, and
tests/test_readme_assets.py asserts each is a line the demo prints and the README quotes.
How these line numbers are kept honest
They are not, automatically — a citation is prose, and prose drifts. Every row above was re-derived against the tree at the tag named at the top of this file by reading the line each one names. The v0.6 pass moved fifteen of them, and none for an interesting reason: the v0.1 and v0.2 rows were written against v0.3.0 and the files have grown since. The two that had drifted semantically were fixed in the previous pass and still point where their sentences say — theBEGIN IMMEDIATE citation at the reservation path rather than grant_approval’s, and the
“no principal” claim at the public Policy.evaluate rather than the private
_ActionPolicy.evaluate. One row moved between files: “blocks duplicate execution attempts”
cited state.py for the AMBIGUOUS refusal, which now lives in effect.py’s
plan_reservation, decided once for both stores.
If you are regenerating this file, re-derive every row. Do not carry one forward on trust.
And from v0.5, you do not have to take that on trust either.
test_the_claims_table_line_numbers_point_at_what_they_name resolves every file.py:NNN in
this document against the line it cites and fails if the symbol the cell names is not on it.
It found nine stale references the first time it ran, four of which pointed at a string
literal, a comment or the middle of another function. The instruction above had been followed
by hand at three releases and the table had drifted anyway, which is the argument for the test
rather than against the instruction.