From 86136dec8778611ba409f43096825d72c8ba88bd Mon Sep 17 00:00:00 2001 From: arpan Date: Sun, 13 Sep 2026 11:46:06 +0530 Subject: [PATCH 1/6] Regenerate every generator against the 0.9.0 kernel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Version 0.9.0, 24 guarantees, and the CLI reference for ctrlrun inspect --grant. Four public names had no reference page. SPEC-v0.9 §10 freezes Budget, Charge, Consumption and check_charges and puts three of them in ctrlrun.state deliberately, beside StateStore, because they are the vocabulary of the store protocol. That keeps them out of ctrlrun.__all__, which is what render_api reads, so the reference documented reserve_effect(charges=...) while saying nothing about what a Charge is or what consumptions() hands back. EXTRA_NAMES is the list for exactly this case and they are in it now: 79 pages become 83. The readiness block was counting a suite two thirds installed. It counts both checkouts, and neither had its optional dependencies here: the kernel without the two adapters collects 4,296 rather than 4,220, and the docs repo without griffe and the otel SDK collects far fewer. The published number is the size of the suite standing behind the guarantees, so it is taken with everything installed: 4,296 plus 1,686, 5,982. docs/production/index.mdx also carried a duplicated tail with a stray second end-marker, so the page rendered its Not yet list twice. The generator reads to the FIRST end-marker, which is why its own --check never saw the orphan. Signed-off-by: arpan --- docs.json | 4 ++ docs/production/index.mdx | 13 ++--- docs/reference/api/Authority.mdx | 2 +- docs/reference/api/AuthorityResult.mdx | 2 +- docs/reference/api/Condition.mdx | 2 +- docs/reference/api/Control.mdx | 2 +- docs/reference/api/Decision.mdx | 2 +- docs/reference/api/Delegation.mdx | 2 +- docs/reference/api/Event.mdx | 2 +- docs/reference/api/EventSink.mdx | 2 +- docs/reference/api/Grant.mdx | 4 +- docs/reference/api/InMemoryStateStore.mdx | 2 +- docs/reference/api/JSONLEventSink.mdx | 2 +- docs/reference/api/Policy.mdx | 2 +- docs/reference/api/Receipt.mdx | 4 +- docs/reference/api/SQLiteStateStore.mdx | 2 +- docs/reference/api/StateStore.mdx | 7 +-- docs/reference/api/Subject.mdx | 2 +- docs/reference/api/authority-Budget.mdx | 34 +++++++++++++ docs/reference/api/context.mdx | 2 +- docs/reference/api/idempotency_token.mdx | 2 +- docs/reference/api/index.mdx | 4 ++ docs/reference/api/parse_conditions.mdx | 2 +- .../api/postgres-PostgresStateStore.mdx | 2 +- docs/reference/api/protect.mdx | 4 +- docs/reference/api/state-Charge.mdx | 31 ++++++++++++ docs/reference/api/state-Consumption.mdx | 29 +++++++++++ docs/reference/api/state-check_charges.mdx | 50 +++++++++++++++++++ docs/reference/api/with_approval.mdx | 2 +- docs/reference/cli.mdx | 16 +++++- docs/reference/receipt-and-event-schemas.mdx | 5 +- generated/readiness.full.mdx | 6 +-- generated/readiness.json | 8 +-- generated/readiness.mdx | 6 +-- generated/readiness.readme.md | 6 +-- tools/docs_audit/render_api.py | 12 +++++ 36 files changed, 227 insertions(+), 52 deletions(-) create mode 100644 docs/reference/api/authority-Budget.mdx create mode 100644 docs/reference/api/state-Charge.mdx create mode 100644 docs/reference/api/state-Consumption.mdx create mode 100644 docs/reference/api/state-check_charges.mdx diff --git a/docs.json b/docs.json index e9f96f7..5a82f85 100644 --- a/docs.json +++ b/docs.json @@ -296,6 +296,7 @@ "docs/reference/api/WebhookApprovalProvider", "docs/reference/api/acs-AcsControlHook", "docs/reference/api/action_hash", + "docs/reference/api/authority-Budget", "docs/reference/api/banner", "docs/reference/api/canonical_bytes", "docs/reference/api/canonicalize", @@ -310,6 +311,9 @@ "docs/reference/api/parse_conditions", "docs/reference/api/postgres-PostgresStateStore", "docs/reference/api/protect", + "docs/reference/api/state-Charge", + "docs/reference/api/state-Consumption", + "docs/reference/api/state-check_charges", "docs/reference/api/transport-HTTPConnection", "docs/reference/api/transport-HTTPSConnection", "docs/reference/api/transport-Transport", diff --git a/docs/production/index.mdx b/docs/production/index.mdx index bc40177..b0b4572 100644 --- a/docs/production/index.mdx +++ b/docs/production/index.mdx @@ -27,9 +27,9 @@ need. `test_the_first_line_of_the_section_says_which_store_and_why` asserts the ## Where it stands {/* generated from the suite, pyproject and the soak (full) — run the generator */} -- **Version 0.8.0**, on [PyPI](https://pypi.org/project/ctrlrun/), Python 3.11 and later. -- **5,653 tests**, every version specified before it was written and every requirement mutation-tested. [Read more](/docs/how-this-is-built). -- **21 guarantees you can check in your own setup**, with `ctrlrun verify` against your policy, on your store's backend, in a scratch store it creates. [Read more](/docs/security/verify-guarantees). +- **Version 0.9.0**, on [PyPI](https://pypi.org/project/ctrlrun/), Python 3.11 and later. +- **5,982 tests**, every version specified before it was written and every requirement mutation-tested. [Read more](/docs/how-this-is-built). +- **24 guarantees you can check in your own setup**, with `ctrlrun verify` against your policy, on your store's backend, in a scratch store it creates. [Read more](/docs/security/verify-guarantees). - **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). @@ -42,13 +42,6 @@ need. `test_the_first_line_of_the_section_says_which_store_and_why` asserts the - No sector packs. (the policy templates are starting points, not a product) {/* end generated */} -**Not yet:** - -- No external security audit. (optional, and no release waits for one) -- No third-party review of the kernel. (every review so far was run inside this project) -- No sector packs. (the policy templates are starting points, not a product) -{/* end generated */} - ## The shape of a deployment ``` diff --git a/docs/reference/api/Authority.mdx b/docs/reference/api/Authority.mdx index 13fbd4a..838591e 100644 --- a/docs/reference/api/Authority.mdx +++ b/docs/reference/api/Authority.mdx @@ -5,7 +5,7 @@ description: "The `authority:` section, loaded and evaluable (SPEC-v0.3 §4)." {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.Authority` — class, defined at `src/ctrlrun/authority.py:819` +`ctrlrun.Authority` — class, defined at `src/ctrlrun/authority.py:1134` ```python from ctrlrun import Authority diff --git a/docs/reference/api/AuthorityResult.mdx b/docs/reference/api/AuthorityResult.mdx index 6d57ed8..efe4e1d 100644 --- a/docs/reference/api/AuthorityResult.mdx +++ b/docs/reference/api/AuthorityResult.mdx @@ -5,7 +5,7 @@ description: "What the authority axis decided, and which grant it decided on (§ {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.AuthorityResult` — class, defined at `src/ctrlrun/authority.py:475` +`ctrlrun.AuthorityResult` — class, defined at `src/ctrlrun/authority.py:619` ```python from ctrlrun import AuthorityResult diff --git a/docs/reference/api/Condition.mdx b/docs/reference/api/Condition.mdx index 66d9ccd..78d1465 100644 --- a/docs/reference/api/Condition.mdx +++ b/docs/reference/api/Condition.mdx @@ -5,7 +5,7 @@ description: "One `_: operand` test against an action's arguments {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.Condition` — class, defined at `src/ctrlrun/policy.py:365` +`ctrlrun.Condition` — class, defined at `src/ctrlrun/policy.py:371` ```python from ctrlrun import Condition diff --git a/docs/reference/api/Control.mdx b/docs/reference/api/Control.mdx index 7ee0f35..7887045 100644 --- a/docs/reference/api/Control.mdx +++ b/docs/reference/api/Control.mdx @@ -5,7 +5,7 @@ description: "Policy, state and evidence composed around a single action (SPEC-v {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.Control` — class, defined at `src/ctrlrun/control.py:527` +`ctrlrun.Control` — class, defined at `src/ctrlrun/control.py:636` ```python from ctrlrun import Control diff --git a/docs/reference/api/Decision.mdx b/docs/reference/api/Decision.mdx index b181bb5..c3de473 100644 --- a/docs/reference/api/Decision.mdx +++ b/docs/reference/api/Decision.mdx @@ -5,7 +5,7 @@ description: "What may happen to an action: exactly three outcomes in v0.1 (SPEC {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.Decision` — class, defined at `src/ctrlrun/policy.py:302` +`ctrlrun.Decision` — class, defined at `src/ctrlrun/policy.py:308` ```python from ctrlrun import Decision diff --git a/docs/reference/api/Delegation.mdx b/docs/reference/api/Delegation.mdx index 252888d..dfce962 100644 --- a/docs/reference/api/Delegation.mdx +++ b/docs/reference/api/Delegation.mdx @@ -5,7 +5,7 @@ description: "A grant created at runtime by a principal who already holds one (S {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.Delegation` — class, defined at `src/ctrlrun/authority.py:435` +`ctrlrun.Delegation` — class, defined at `src/ctrlrun/authority.py:579` ```python from ctrlrun import Delegation diff --git a/docs/reference/api/Event.mdx b/docs/reference/api/Event.mdx index 101ec08..993749b 100644 --- a/docs/reference/api/Event.mdx +++ b/docs/reference/api/Event.mdx @@ -5,7 +5,7 @@ description: "One ordered step in the life of an action (SPEC-v0.1 §6.2)." {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.Event` — class, defined at `src/ctrlrun/receipt.py:280` +`ctrlrun.Event` — class, defined at `src/ctrlrun/receipt.py:297` ```python from ctrlrun import Event diff --git a/docs/reference/api/EventSink.mdx b/docs/reference/api/EventSink.mdx index 3c1c3c7..dc187c4 100644 --- a/docs/reference/api/EventSink.mdx +++ b/docs/reference/api/EventSink.mdx @@ -5,7 +5,7 @@ description: "Somewhere a copy of every `Event` and `Receipt` goes (SPEC-v0.2 § {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.EventSink` — class, defined at `src/ctrlrun/receipt.py:703` +`ctrlrun.EventSink` — class, defined at `src/ctrlrun/receipt.py:767` ```python from ctrlrun import EventSink diff --git a/docs/reference/api/Grant.mdx b/docs/reference/api/Grant.mdx index e9b14fe..ff85c8e 100644 --- a/docs/reference/api/Grant.mdx +++ b/docs/reference/api/Grant.mdx @@ -5,7 +5,7 @@ description: "One permission: this subject may propose these actions, under thes {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.Grant` — class, defined at `src/ctrlrun/authority.py:317` +`ctrlrun.Grant` — class, defined at `src/ctrlrun/authority.py:416` ```python from ctrlrun import Grant @@ -14,7 +14,7 @@ from ctrlrun import Grant ```python class Grant - def __init__(id: str, subject: Subject, actions: tuple[str, ...] = (), resources: tuple[str, ...] | None = None, constraints: Mapping[str, Condition] = (lambda: NO_CONSTRAINTS)(), environments: tuple[str, ...] | None = None, expires_at: datetime | None = None, delegable: bool = False) + def __init__(id: str, subject: Subject, actions: tuple[str, ...] = (), resources: tuple[str, ...] | None = None, constraints: Mapping[str, Condition] = (lambda: NO_CONSTRAINTS)(), environments: tuple[str, ...] | None = None, expires_at: datetime | None = None, delegable: bool = False, tasks: tuple[str, ...] | None = None, budgets: tuple[Budget, ...] | None = None) ``` One permission: this subject may propose these actions, under these limits (§4.2). diff --git a/docs/reference/api/InMemoryStateStore.mdx b/docs/reference/api/InMemoryStateStore.mdx index 37eafd4..b52a7e9 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:796` +`ctrlrun.InMemoryStateStore` — class, defined at `src/ctrlrun/state.py:968` ```python from ctrlrun import InMemoryStateStore diff --git a/docs/reference/api/JSONLEventSink.mdx b/docs/reference/api/JSONLEventSink.mdx index 371b528..a2a200b 100644 --- a/docs/reference/api/JSONLEventSink.mdx +++ b/docs/reference/api/JSONLEventSink.mdx @@ -5,7 +5,7 @@ description: "The JSONL half of the evidence: two append-only files in one direc {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.JSONLEventSink` — class, defined at `src/ctrlrun/receipt.py:722` +`ctrlrun.JSONLEventSink` — class, defined at `src/ctrlrun/receipt.py:786` ```python from ctrlrun import JSONLEventSink diff --git a/docs/reference/api/Policy.mdx b/docs/reference/api/Policy.mdx index b196369..8189ebd 100644 --- a/docs/reference/api/Policy.mdx +++ b/docs/reference/api/Policy.mdx @@ -5,7 +5,7 @@ description: "Action-level autonomy policy: which actions may run, and under whi {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.Policy` — class, defined at `src/ctrlrun/policy.py:694` +`ctrlrun.Policy` — class, defined at `src/ctrlrun/policy.py:700` ```python from ctrlrun import Policy diff --git a/docs/reference/api/Receipt.mdx b/docs/reference/api/Receipt.mdx index 9f8829f..115e1b2 100644 --- a/docs/reference/api/Receipt.mdx +++ b/docs/reference/api/Receipt.mdx @@ -5,7 +5,7 @@ description: "Portable evidence of one action that reached a terminal state (SPE {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.Receipt` — class, defined at `src/ctrlrun/receipt.py:372` +`ctrlrun.Receipt` — class, defined at `src/ctrlrun/receipt.py:389` ```python from ctrlrun import Receipt @@ -14,7 +14,7 @@ from ctrlrun import Receipt ```python class Receipt - def __init__(receipt_id: str, action_id: str, action: str, action_hash: str, principal: Principal, resource: str | None, arguments: Mapping[str, Any], environment: str, decision: Decision, decision_reason: str, result: ReceiptResult, started_at: datetime, finished_at: datetime, approval_id: str | None = None, approver: str | None = None, effect_key: str | None = None, attempt: int = 1, error: str | None = None, execution: ReceiptResult | None = None, would_have: _WouldHave | None = None, seq: int | None = None, prev_hash: str | None = None, policy_hash: str | None = None, policy_version: str | None = None, controls: tuple[str, ...] = (), hash: str | None = None, precondition_at_request: str | None = None, precondition_at_recheck: str | None = None, approvers: tuple[VerifiedApprover, ...] = (), authority_grant_id: str | None = None, schema: str = RECEIPT_SCHEMA) + def __init__(receipt_id: str, action_id: str, action: str, action_hash: str, principal: Principal, resource: str | None, arguments: Mapping[str, Any], environment: str, decision: Decision, decision_reason: str, result: ReceiptResult, started_at: datetime, finished_at: datetime, approval_id: str | None = None, approver: str | None = None, effect_key: str | None = None, attempt: int = 1, error: str | None = None, execution: ReceiptResult | None = None, would_have: _WouldHave | None = None, seq: int | None = None, prev_hash: str | None = None, policy_hash: str | None = None, policy_version: str | None = None, controls: tuple[str, ...] = (), hash: str | None = None, precondition_at_request: str | None = None, precondition_at_recheck: str | None = None, approvers: tuple[VerifiedApprover, ...] = (), authority_grant_id: str | None = None, task: str | None = None, scope_hash: str | None = None, budget_charges: tuple[Mapping[str, Any], ...] = (), schema: str = RECEIPT_SCHEMA) ``` Portable evidence of one action that reached a terminal state (SPEC-v0.1 §6.1). diff --git a/docs/reference/api/SQLiteStateStore.mdx b/docs/reference/api/SQLiteStateStore.mdx index 4dbeae6..7e68fc5 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:1188` +`ctrlrun.SQLiteStateStore` — class, defined at `src/ctrlrun/state.py:1476` ```python from ctrlrun import SQLiteStateStore diff --git a/docs/reference/api/StateStore.mdx b/docs/reference/api/StateStore.mdx index 26e6077..300c413 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:511` +`ctrlrun.StateStore` — class, defined at `src/ctrlrun/state.py:634` ```python from ctrlrun import StateStore @@ -14,8 +14,9 @@ from ctrlrun import StateStore ```python class StateStore(ApprovalStore, Protocol) - def reserve_effect(effect_key: str, action_id: str, lease: timedelta = DEFAULT_LEASE) -> Reservation - def consume_approval_and_reserve(approval_id: str, action_hash: str, effect_key: str, action_id: str, lease: timedelta = DEFAULT_LEASE) -> tuple[Approval, Reservation] + def reserve_effect(effect_key: str, action_id: str, lease: timedelta = DEFAULT_LEASE, charges: tuple[Charge, ...] = ()) -> Reservation + def consume_approval_and_reserve(approval_id: str, action_hash: str, effect_key: str, action_id: str, lease: timedelta = DEFAULT_LEASE, charges: tuple[Charge, ...] = ()) -> tuple[Approval, Reservation] + def consumptions(*, grant_id: str | None = None, metric: str | None = None, since: datetime | None = None, effect_key: str | None = None) -> tuple[Consumption, ...] def begin_execution(effect_key: str, action_id: str) -> None def commit_effect(effect_key: str, action_id: str, result: Any) -> None def fail_effect(effect_key: str, action_id: str, error: str) -> None diff --git a/docs/reference/api/Subject.mdx b/docs/reference/api/Subject.mdx index 5fc9442..b2c0968 100644 --- a/docs/reference/api/Subject.mdx +++ b/docs/reference/api/Subject.mdx @@ -5,7 +5,7 @@ description: "Who a grant is addressed to: an agent pattern, a user pattern, or {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.Subject` — class, defined at `src/ctrlrun/authority.py:282` +`ctrlrun.Subject` — class, defined at `src/ctrlrun/authority.py:320` ```python from ctrlrun import Subject diff --git a/docs/reference/api/authority-Budget.mdx b/docs/reference/api/authority-Budget.mdx new file mode 100644 index 0000000..9c41dbe --- /dev/null +++ b/docs/reference/api/authority-Budget.mdx @@ -0,0 +1,34 @@ +--- +title: "Budget" +description: "How much, over what, in how long (SPEC-v0.9 §2.2)." +--- + +{/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} + +`ctrlrun.authority.Budget` — class, defined at `src/ctrlrun/authority.py:366` + +```python +from ctrlrun.authority import Budget +``` + + +```python +class Budget + def __init__(metric: str, limit: int, window: timedelta) +``` + +How much, over what, in how long (SPEC-v0.9 §2.2). + +A metric names where the number comes from, a limit is what the sum may reach, and a window +is what the sum is taken over. The kernel **does not know what any metric means**: there is no +branch on a metric name anywhere, no ranking of two metrics, and no default limit for one the +kernel thinks it recognises (§2.3, and §12 carries it as a do-not-build line). + +Validated here as well as in the loader, on `Grant.__post_init__`'s rule: `Control.delegate` +takes a `Grant` built in Python, and §2.6's containment relation is undefined on a budget +whose window is negative or whose limit is a string. + +## Next + +- [Python API index](/docs/reference/api/index). +- [Get started](/docs/get-started/quickstart) · [Why](/docs/why). diff --git a/docs/reference/api/context.mdx b/docs/reference/api/context.mdx index b5b0857..ca76059 100644 --- a/docs/reference/api/context.mdx +++ b/docs/reference/api/context.mdx @@ -5,7 +5,7 @@ description: "Bind the principal for calls made inside the block." {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.context` — function, defined at `src/ctrlrun/control.py:296` +`ctrlrun.context` — function, defined at `src/ctrlrun/control.py:322` ```python from ctrlrun import context diff --git a/docs/reference/api/idempotency_token.mdx b/docs/reference/api/idempotency_token.mdx index e4ebf8c..1f7139f 100644 --- a/docs/reference/api/idempotency_token.mdx +++ b/docs/reference/api/idempotency_token.mdx @@ -5,7 +5,7 @@ description: "The provider idempotency token for the attempt this executor is ru {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.idempotency_token` — function, defined at `src/ctrlrun/control.py:264` +`ctrlrun.idempotency_token` — function, defined at `src/ctrlrun/control.py:290` ```python from ctrlrun import idempotency_token diff --git a/docs/reference/api/index.mdx b/docs/reference/api/index.mdx index 9030883..898bace 100644 --- a/docs/reference/api/index.mdx +++ b/docs/reference/api/index.mdx @@ -89,6 +89,10 @@ a name with no docstring fails a test, so every page has one. | [`ctrlrun.verify.run`](/docs/reference/api/verify-run) | function | Run the applicable guarantees against this configuration and report (§9.1). | | [`ctrlrun.conformance.run`](/docs/reference/api/conformance-run) | function | Drive every suite through `adapter` and report what each came to (SPEC-v0.5 §5). | | [`ctrlrun.conformance.store.run`](/docs/reference/api/conformance-store-run) | function | Drive every case against `backend` and report what each came to (SPEC-v0.6 §2). | +| [`ctrlrun.state.Charge`](/docs/reference/api/state-Charge) | class | What one reservation spends against one grant's budget (SPEC-v0.9 §3.3.1). | +| [`ctrlrun.state.Consumption`](/docs/reference/api/state-Consumption) | class | One ledger row, as `consumptions()` hands it back (SPEC-v0.9 §3.3.3, §10). | +| [`ctrlrun.state.check_charges`](/docs/reference/api/state-check_charges) | function | SPEC-v0.9 §3.3.1's predicate, in one place so three backends cannot drift on it. | +| [`ctrlrun.authority.Budget`](/docs/reference/api/authority-Budget) | class | How much, over what, in how long (SPEC-v0.9 §2.2). | ## Next diff --git a/docs/reference/api/parse_conditions.mdx b/docs/reference/api/parse_conditions.mdx index 82c6a78..ef816d6 100644 --- a/docs/reference/api/parse_conditions.mdx +++ b/docs/reference/api/parse_conditions.mdx @@ -5,7 +5,7 @@ description: "Parse a `when:`-shaped mapping into conditions, keyed by the raw c {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.parse_conditions` — function, defined at `src/ctrlrun/policy.py:1193` +`ctrlrun.parse_conditions` — function, defined at `src/ctrlrun/policy.py:1248` ```python from ctrlrun import parse_conditions diff --git a/docs/reference/api/postgres-PostgresStateStore.mdx b/docs/reference/api/postgres-PostgresStateStore.mdx index 18b95f8..1260b0b 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:330` +`ctrlrun.postgres.PostgresStateStore` — class, defined at `src/ctrlrun/postgres.py:333` ```python from ctrlrun.postgres import PostgresStateStore diff --git a/docs/reference/api/protect.mdx b/docs/reference/api/protect.mdx index 4d88f69..df40f63 100644 --- a/docs/reference/api/protect.mdx +++ b/docs/reference/api/protect.mdx @@ -5,7 +5,7 @@ description: "Bind a function to an action name: every call becomes a decided, r {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.protect` — function, defined at `src/ctrlrun/control.py:3856` +`ctrlrun.protect` — function, defined at `src/ctrlrun/control.py:4444` ```python from ctrlrun import protect @@ -13,7 +13,7 @@ from ctrlrun import protect ```python -def protect(name: str, *, effect: str | None = None, resource: str | None = None, wait: bool = False, lease: timedelta | None = None, reconcile: Callable[[str], ReconcileOutcome] | None = None, reconcile_eagerly: bool = False, control: Control | None = None, preconditions: Callable[[Action], Mapping[str, Any]] | None = None) -> Callable[[Callable[P, R]], Callable[P, R]] +def protect(name: str, *, effect: str | None = None, resource: str | None = None, wait: bool = False, lease: timedelta | None = None, reconcile: Callable[[str], ReconcileOutcome] | None = None, reconcile_eagerly: bool = False, control: Control | None = None, preconditions: Callable[[Action], Mapping[str, Any]] | None = None, task: str | None = None, scope: Callable[[Action], Mapping[str, Any]] | None = None) -> Callable[[Callable[P, R]], Callable[P, R]] ``` Bind a function to an action name: every call becomes a decided, recorded Action. diff --git a/docs/reference/api/state-Charge.mdx b/docs/reference/api/state-Charge.mdx new file mode 100644 index 0000000..41c4302 --- /dev/null +++ b/docs/reference/api/state-Charge.mdx @@ -0,0 +1,31 @@ +--- +title: "Charge" +description: "What one reservation spends against one grant's budget (SPEC-v0.9 §3.3.1)." +--- + +{/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} + +`ctrlrun.state.Charge` — class, defined at `src/ctrlrun/state.py:511` + +```python +from ctrlrun.state import Charge +``` + + +```python +class Charge + def __init__(grant_id: str, metric: str, amount: int, limit: int, window: timedelta) +``` + +What one reservation spends against one grant's budget (SPEC-v0.9 §3.3.1). + +**It carries the whole predicate**, not just the amount, and that is the decision §3.3.1 +argues: `limit` and `window` travel with the charge rather than being looked up, because a +store that resolved a grant's budgets would be reading the policy, and `ARCHITECTURE.md` §6 +has `state.py` not knowing about `policy.py`. The store evaluates one arithmetic predicate it +was handed, over rows it owns. + +## Next + +- [Python API index](/docs/reference/api/index). +- [Get started](/docs/get-started/quickstart) · [Why](/docs/why). diff --git a/docs/reference/api/state-Consumption.mdx b/docs/reference/api/state-Consumption.mdx new file mode 100644 index 0000000..ce89b28 --- /dev/null +++ b/docs/reference/api/state-Consumption.mdx @@ -0,0 +1,29 @@ +--- +title: "Consumption" +description: "One ledger row, as `consumptions()` hands it back (SPEC-v0.9 §3.3.3, §10)." +--- + +{/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} + +`ctrlrun.state.Consumption` — class, defined at `src/ctrlrun/state.py:551` + +```python +from ctrlrun.state import Consumption +``` + + +```python +class Consumption + def __init__(grant_id: str, metric: str, amount: int, effect_key: str, attempt: int, consumed_at: datetime, released_at: datetime | None = None) +``` + +One ledger row, as `consumptions()` hands it back (SPEC-v0.9 §3.3.3, §10). + +`released_at` is `None` while the charge is held. Whether it is held **and why** is §7.2's +question, and the answer is a join through `get_effect` on `effect_key` rather than a column +here: the ledger deliberately has no state machine of its own (§4.1). + +## Next + +- [Python API index](/docs/reference/api/index). +- [Get started](/docs/get-started/quickstart) · [Why](/docs/why). diff --git a/docs/reference/api/state-check_charges.mdx b/docs/reference/api/state-check_charges.mdx new file mode 100644 index 0000000..48369b0 --- /dev/null +++ b/docs/reference/api/state-check_charges.mdx @@ -0,0 +1,50 @@ +--- +title: "check_charges" +description: "SPEC-v0.9 §3.3.1's predicate, in one place so three backends cannot drift on it." +--- + +{/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} + +`ctrlrun.state.check_charges` — function, defined at `src/ctrlrun/state.py:569` + +```python +from ctrlrun.state import check_charges +``` + + +```python +def check_charges(charges: tuple[Charge, ...], spent: Callable[[Charge], int]) -> None +``` + +SPEC-v0.9 §3.3.1's predicate, in one place so three backends cannot drift on it. + +`spent` is the store's own sum of un-released rows for this charge's `(grant_id, metric)` +over `[now - window, now]`. The comparison is **inclusive**, matching §2.2's "what the sum may +reach": a `limit: 0` grant therefore permits an action only if its metric value is 0, and +§3.3.1 records that a zero limit does not stop a grant, since it permits unboundedly many +zero-valued actions. + +Pure, like `plan_reservation` and for the same reason: every store decides here rather than +each deciding for itself, so the arithmetic is one function a test can reach directly. + +**Every charge is evaluated, including several on one `(grant_id, metric)`.** That is §2.2's +own motivating shape: a grant with two budgets on `amount`, 100,000 a day and 500,000 a month, +is "the first thing an operator asks for", and it arrives here as two charges differing only +in `limit` and `window`. Both predicates run; §3.4's key then writes **one** row, which is +right, because it is one spend measured against two windows. + +**What is refused is two charges on one `(grant_id, metric)` carrying different amounts.** +A charge is invisible to its sibling here (each is compared against the *stored* sum), and +§3.4's key carries no window, so differing amounts would collapse to whichever row landed +first and the ledger would under-record the spend. Nothing legitimate produces that: the +amount comes from the action's own metric value, so two budgets on one metric always agree, +and §2.7's per-ancestor charges are distinct grants. + +An earlier version refused **any** duplicate pair, which made §2.2's shape die at execute +with no receipt: the loader accepted the document, observe mode reported it clean, and +`ctrlrun verify` could not grade it. An independent review found it. + +## Next + +- [Python API index](/docs/reference/api/index). +- [Get started](/docs/get-started/quickstart) · [Why](/docs/why). diff --git a/docs/reference/api/with_approval.mdx b/docs/reference/api/with_approval.mdx index 3570ab6..de2b8cc 100644 --- a/docs/reference/api/with_approval.mdx +++ b/docs/reference/api/with_approval.mdx @@ -5,7 +5,7 @@ description: "Present a granted approval to the calls made inside the block (SPE {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.with_approval` — function, defined at `src/ctrlrun/control.py:319` +`ctrlrun.with_approval` — function, defined at `src/ctrlrun/control.py:345` ```python from ctrlrun import with_approval diff --git a/docs/reference/cli.mdx b/docs/reference/cli.mdx index 832b30f..c9e7fba 100644 --- a/docs/reference/cli.mdx +++ b/docs/reference/cli.mdx @@ -117,6 +117,10 @@ Usage: ctrlrun effects [OPTIONS] Show the logical effects this store knows about. + An effect that still **holds** part of a budget says so (SPEC-v0.9 §7.2): + `--state ambiguous` is how an operator finds what is pinning a grant, and the + hold is the reason it matters. + Options: --state [new|reserved|executing|committed|failed|ambiguous] Show only effects in this state. @@ -146,12 +150,22 @@ Options: ## ctrlrun inspect ```text -Usage: ctrlrun inspect [OPTIONS] ACTION_ID +Usage: ctrlrun inspect [OPTIONS] [ACTION_ID] Show one action's whole history: proposal, decision, approval, effect, receipt. + With `--grant`, show that grant's budgets instead: how much of each is + consumed over its rolling window, how much of that is **held** by effects that + have not committed, and which effect holds each part (SPEC-v0.9 §7.2). + + The third number is the one that matters at 3am. A budget that refuses while + it looks nowhere near its limit is almost always one unresolved effect: + `ctrlrun resolve` clears it. + Options: + --grant TEXT Show this grant's budgets instead: consumed, held, and what + holds it. --json Emit one JSON object instead. --store-url TEXT The store to open. Default: $CTRLRUN_STORE_URL, else the SQLite database beside the policy (.ctrlrun/state.db, or diff --git a/docs/reference/receipt-and-event-schemas.mdx b/docs/reference/receipt-and-event-schemas.mdx index f64640c..763e647 100644 --- a/docs/reference/receipt-and-event-schemas.mdx +++ b/docs/reference/receipt-and-event-schemas.mdx @@ -55,7 +55,10 @@ it. A reader checking a chain compares those two. | `precondition_at_recheck` | `str | None` | `None` | | `approvers` | `tuple` | `()` | | `authority_grant_id` | `str | None` | `None` | -| `schema` | `str` | `'ctrlrun.receipt/v5'` | +| `task` | `str | None` | `None` | +| `scope_hash` | `str | None` | `None` | +| `budget_charges` | `tuple` | `()` | +| `schema` | `str` | `'ctrlrun.receipt/v6'` | | `_stored_document` | `collections.abc.Mapping[str, Any] | None` | `None` | ### Result diff --git a/generated/readiness.full.mdx b/generated/readiness.full.mdx index 6b1c46c..8031993 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.8.0**, on [PyPI](https://pypi.org/project/ctrlrun/), Python 3.11 and later. -- **5,653 tests**, every version specified before it was written and every requirement mutation-tested. [Read more](/docs/how-this-is-built). -- **21 guarantees you can check in your own setup**, with `ctrlrun verify` against your policy, on your store's backend, in a scratch store it creates. [Read more](/docs/security/verify-guarantees). +- **Version 0.9.0**, on [PyPI](https://pypi.org/project/ctrlrun/), Python 3.11 and later. +- **5,982 tests**, every version specified before it was written and every requirement mutation-tested. [Read more](/docs/how-this-is-built). +- **24 guarantees you can check in your own setup**, with `ctrlrun verify` against your policy, on your store's backend, in a scratch store it creates. [Read more](/docs/security/verify-guarantees). - **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 f274e24..e322f9e 100644 --- a/generated/readiness.json +++ b/generated/readiness.json @@ -1,6 +1,6 @@ { - "guarantees": 21, - "released": "0.8.0", + "guarantees": 24, + "released": "0.9.0", "soak": { "actions": 889735, "backend": "postgres", @@ -9,6 +9,6 @@ "positive_control": true, "unexplained": 0 }, - "tests": 5653, - "version": "0.8.0" + "tests": 5982, + "version": "0.9.0" } diff --git a/generated/readiness.mdx b/generated/readiness.mdx index 27be898..0f51664 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.8.0**, on [PyPI](https://pypi.org/project/ctrlrun/), Python 3.11 and later. -- **5,653 tests**, every version specified before it was written and every requirement mutation-tested. -- **21 guarantees you can check in your own setup**, with `ctrlrun verify` against your policy, on your store's backend, in a scratch store it creates. +- **Version 0.9.0**, on [PyPI](https://pypi.org/project/ctrlrun/), Python 3.11 and later. +- **5,982 tests**, every version specified before it was written and every requirement mutation-tested. +- **24 guarantees you can check in your own setup**, with `ctrlrun verify` against your policy, on your store's backend, in a scratch store it creates. - **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 a32f584..a27001b 100644 --- a/generated/readiness.readme.md +++ b/generated/readiness.readme.md @@ -1,7 +1,7 @@ -- **Version 0.8.0**, on [PyPI](https://pypi.org/project/ctrlrun/), Python 3.11 and later. -- **5,653 tests**, every version specified before it was written and every requirement mutation-tested. -- **21 guarantees you can check in your own setup**, with `ctrlrun verify` against your policy, on your store's backend, in a scratch store it creates. +- **Version 0.9.0**, on [PyPI](https://pypi.org/project/ctrlrun/), Python 3.11 and later. +- **5,982 tests**, every version specified before it was written and every requirement mutation-tested. +- **24 guarantees you can check in your own setup**, with `ctrlrun verify` against your policy, on your store's backend, in a scratch store it creates. - **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/tools/docs_audit/render_api.py b/tools/docs_audit/render_api.py index 6406382..77741ed 100644 --- a/tools/docs_audit/render_api.py +++ b/tools/docs_audit/render_api.py @@ -49,6 +49,18 @@ ("ctrlrun.verify", "run"), ("ctrlrun.conformance", "run"), ("ctrlrun.conformance.store", "run"), + # SPEC-v0.9 §10 freezes these four and puts them in `ctrlrun.state` deliberately, "beside + # `StateStore` itself, ... they are the vocabulary of the store protocol, and a third-party + # backend already imports `StateStore` from that module". So they are public and frozen and + # not in `ctrlrun.__all__`, which is exactly what this list is for. Without them the v0.9 + # reference named `StateStore.reserve_effect(charges=...)` and documented neither what a + # `Charge` is nor what `consumptions()` hands back. + ("ctrlrun.state", "Charge"), + ("ctrlrun.state", "Consumption"), + ("ctrlrun.state", "check_charges"), + # `Budget` is the one of the four that lives in `ctrlrun.authority`, because it is a field of + # a `Grant` before it is anything the store sees. + ("ctrlrun.authority", "Budget"), ) From d673ae7b183300adb99bf81ff2303e0222e7e20b Mon Sep 17 00:00:00 2001 From: arpan Date: Sun, 13 Sep 2026 11:55:25 +0530 Subject: [PATCH 2/6] v0.9 in the prose: budgets, scopes, task binding, and three OWASP rows The authority reference names tasks: and budgets:, with the budget table and the four rules an operator has to know: consumed on reserve inside the same transaction, released only on FAILED, ambiguity is not a refund, and a delegation's window may not be shorter than its parent's. The verify transcripts are re-taken from what verify actually prints, so the quoted reports show G22, G23 and G24 rather than a v4 catalogue. All three PASS on the shipped example. The OWASP mapping gains a row each, with the limits stated beside them: a budget cannot recall an action already in flight, a scope provider is worth what its source is worth, and task binding limits blast radius without detecting a hijack. The ASI06 row said nothing bites on an identifier an attacker chose, which G23 now does, and it says so without claiming the category. My first draft of those rows cited ASI04 and ASI05, which are package provenance and interpreter sandboxing. A consequence budget bears on neither, and the test that keeps the three-uncovered sentence true caught it. docs.mdx carried the same stale readiness block and duplicated tail as the production page, and none of the three generators could see it: they pass a glob of docs/** plus README.md, which does not reach a file called docs.mdx beside that directory. Its block sat at version 0.8.0 through a whole milestone. The glob is widened to the root-level patterns SITE_PATTERNS already declares, and the first --check after widening found it. Signed-off-by: arpan --- docs.mdx | 13 +-- docs/CLAIMS.md | 130 ++++++++++----------- docs/OWASP-AGENTIC-TOP10.md | 5 +- docs/cookbook/verify-in-github-actions.mdx | 9 +- docs/guides/verify-in-ci.mdx | 11 +- docs/reference/authority-yaml.mdx | 36 ++++++ docs/verify.md | 15 ++- tools/docs_audit/render_badges.py | 7 +- tools/docs_audit/render_capabilities.py | 7 +- tools/docs_audit/render_readiness.py | 7 +- 10 files changed, 150 insertions(+), 90 deletions(-) diff --git a/docs.mdx b/docs.mdx index 137cf56..1c39141 100644 --- a/docs.mdx +++ b/docs.mdx @@ -218,9 +218,9 @@ the framework's own interrupt, and a framework with no such primitive does not n ## Where it stands {/* generated from the suite, pyproject and the soak (mdx) — run the generator */} -- **Version 0.8.0**, on [PyPI](https://pypi.org/project/ctrlrun/), Python 3.11 and later. -- **5,653 tests**, every version specified before it was written and every requirement mutation-tested. -- **21 guarantees you can check in your own setup**, with `ctrlrun verify` against your policy, on your store's backend, in a scratch store it creates. +- **Version 0.9.0**, on [PyPI](https://pypi.org/project/ctrlrun/), Python 3.11 and later. +- **5,982 tests**, every version specified before it was written and every requirement mutation-tested. +- **24 guarantees you can check in your own setup**, with `ctrlrun verify` against your policy, on your store's backend, in a scratch store it creates. - **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. @@ -233,13 +233,6 @@ the framework's own interrupt, and a framework with no such primitive does not n - No sector packs. (the policy templates are starting points, not a product) {/* end generated */} -**Not yet:** - -- No external security audit. (optional, and no release waits for one) -- No third-party review of the kernel. (every review so far was run inside this project) -- No sector packs. (the policy templates are starting points, not a product) -{/* end generated */} - ## Start here diff --git a/docs/CLAIMS.md b/docs/CLAIMS.md index 9e65ffa..e7c4c19 100644 --- a/docs/CLAIMS.md +++ b/docs/CLAIMS.md @@ -25,17 +25,17 @@ by its quoted claim, and `tests/test_docs_audit.py` fails if a named row is not | Claim | Code | Proof | |---|---|---| -| "The last check before an AI agent does something it can't undo." | `Control.execute` — `control.py:954` — 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:590` — passes only the action's **name and arguments** to `_ActionPolicy.evaluate` (`policy.py:590`), 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:218`) rather than silently matching nothing. | `test_T6_an_action_name_is_matched_exactly`, `test_a_condition_naming_an_action_field_is_refused_at_load` | -| "A consequential action happens at most once, exactly as approved, and leaves a receipt — and when the outcome is unknown, CTRLRun says so instead of guessing." | At most once: `plan_reservation` — `effect.py:248`. Exactly as approved: the approval is bound to `action_hash` and consumed with the reservation — `_authorize_and_reserve` — `state.py:929`. Or not at all: a refusal raises before the executor — `Control.execute` — `control.py:954`. Says so instead of guessing: only `NotExecuted` maps to `FAILED` — `control.py:1550` — and everything else is `AMBIGUOUS`. A receipt: `Receipt` — `receipt.py:225`. **This sentence read *happens once … or not at all* until 0.6**, a two-way disjunction that excluded the third outcome the product exists for: a lost reply is neither, and the README's own first section says so. | `test_T3_exactly_one_agent_reserves_and_seven_are_blocked`, `test_T2_a_mutated_action_presenting_the_approval_raises_ApprovalMismatch`, `test_T1_a_lost_response_leaves_the_effect_ambiguous`, `test_T11_every_demo_receipt_carries_every_field_in_the_spec` | +| "The last check before an AI agent does something it can't undo." | `Control.execute` — `control.py:1086` — resolves the principal, evaluates authority and policy, consumes the approval and reserves the effect key **before** the executor runs; nothing in the wrapper calls the function first | `test_T1_a_blind_retry_is_refused_and_never_reaches_the_remote`, `test_T3_the_fake_remote_is_called_exactly_once` | +| "Autonomy belongs to the action, not the agent." | `Policy.evaluate(action)` — `policy.py:596` — passes only the action's **name and arguments** to `_ActionPolicy.evaluate` (`policy.py:596`), whose signature has no principal in it. A rule cannot read who is acting even by accident. `agent_eq` and `user_eq` are refused at load by `RESERVED_ARGUMENTS` (`policy.py:224`) rather than silently matching nothing. | `test_T6_an_action_name_is_matched_exactly`, `test_a_condition_naming_an_action_field_is_refused_at_load` | +| "A consequential action happens at most once, exactly as approved, and leaves a receipt — and when the outcome is unknown, CTRLRun says so instead of guessing." | At most once: `plan_reservation` — `effect.py:248`. Exactly as approved: the approval is bound to `action_hash` and consumed with the reservation — `_authorize_and_reserve` — `state.py:1196`. Or not at all: a refusal raises before the executor — `Control.execute` — `control.py:1086`. Says so instead of guessing: only `NotExecuted` maps to `FAILED` — `_outcome` — `control.py:1877` — and everything else is `AMBIGUOUS`. A receipt: `Receipt` — `receipt.py:242`. **This sentence read *happens once … or not at all* until 0.6**, a two-way disjunction that excluded the third outcome the product exists for: a lost reply is neither, and the README's own first section says so. | `test_T3_exactly_one_agent_reserves_and_seven_are_blocked`, `test_T2_a_mutated_action_presenting_the_approval_raises_ApprovalMismatch`, `test_T1_a_lost_response_leaves_the_effect_ambiguous`, `test_T11_every_demo_receipt_carries_every_field_in_the_spec` | | "A 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:1188` — which is a write lock on the file and holds across OS processes. Postgres: `PostgresStateStore` over `UNIQUE(effect_key)` with `INSERT … ON CONFLICT DO NOTHING` and checked row counts (SPEC-v0.6 §4.2), the same `StateStore` protocol, extended by nothing | `test_T3_exactly_one_agent_reserves_and_seven_are_blocked` (8 OS processes, both backends), `test_T141_the_shipped_backends_pass`, `test_T154_postgres_passes_the_store_conformance_suite` | +| "Runs in production on a single file, or on Postgres across hosts" | SQLite: `SQLiteStateStore` reserves inside the `BEGIN IMMEDIATE` of `_authorize_and_reserve` — `state.py:1476` — which is a write lock on the file and holds across OS processes. Postgres: `PostgresStateStore` over `UNIQUE(effect_key)` with `INSERT … ON CONFLICT DO NOTHING` and checked row counts (SPEC-v0.6 §4.2), the same `StateStore` protocol, extended by nothing | `test_T3_exactly_one_agent_reserves_and_seven_are_blocked` (8 OS processes, both backends), `test_T141_the_shipped_backends_pass`, `test_T154_postgres_passes_the_store_conformance_suite` | ## The refund that happened twice | Claim | Code | Proof | |---|---|---| -| "A lost reply is `AMBIGUOUS`, never `FAILED`, and a retry against an `AMBIGUOUS` effect is refused — until a human, or a `reconcile` hook, says what happened." | Only `NotExecuted` maps to `FAILED` — `control.py:1550`; a retry against an `AMBIGUOUS` key is refused by `plan_reservation` — `effect.py:248`; the two things permitted to move the record on and nothing else — `resolve` — `cli/main.py:540` — and `Control._reconciled` — `control.py:2233` | `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:1877`; a retry against an `AMBIGUOUS` key is refused by `plan_reservation` — `effect.py:248`; the two things permitted to move the record on and nothing else — `resolve` — `cli/main.py:587` — and `Control._reconciled` — `control.py:2512` | `test_T1_a_lost_response_leaves_the_effect_ambiguous`, `test_T1_a_blind_retry_is_refused_and_never_reaches_the_remote`, `test_T160_there_is_no_reaper`, `test_T13_a_hook_answering_not_executed_moves_the_record_to_failed` | | "The customer is refunded twice, and nothing in the stack noticed." — said of a stack without CTRLRun; the demo runs the same sequence with it, and counts the calls the remote received | `ctrlrun demo` scenario 1, which retries against a fake remote that counts its calls and prints the count | `test_T3_the_fake_remote_is_called_exactly_once`, `test_T1_a_blind_retry_is_refused_and_never_reaches_the_remote` | ## Protect your first action @@ -50,28 +50,28 @@ by its quoted claim, and `tests/test_docs_audit.py` fails if a named row is not | Claim | Code | Proof | |---|---|---| -| "A lost reply is `AMBIGUOUS`, never `FAILED`, and a retry against an `AMBIGUOUS` effect is refused." | Only `NotExecuted` maps to `FAILED` — `control.py:1550`; `plan_reservation` — `effect.py:248` | `test_T1_a_lost_response_leaves_the_effect_ambiguous`, `test_T1_a_blind_retry_is_refused_and_never_reaches_the_remote` | -| "reserved atomically across processes and hosts; one worker wins" | `reserve_effect` — `state.py:514`; `PostgresStateStore.reserve_effect` — `postgres.py:737` | `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:1014` | `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:590` | `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:954` | `test_T74_a_denial_leaves_no_pending_approval_request` | -| "Neither axis reads the agent's instructions" | `Policy.evaluate` — `policy.py:590` — sees the action's name and arguments; `Authority.evaluate` — `authority.py:884` — sees the action and the principal; neither is handed a prompt, a message or a tool result | `test_T6_an_action_name_is_matched_exactly`, `test_T67_a_principal_with_no_grant_is_denied` | +| "A lost reply is `AMBIGUOUS`, never `FAILED`, and a retry against an `AMBIGUOUS` effect is refused." | Only `NotExecuted` maps to `FAILED` — `_outcome` — `control.py:1877`; `plan_reservation` — `effect.py:248` | `test_T1_a_lost_response_leaves_the_effect_ambiguous`, `test_T1_a_blind_retry_is_refused_and_never_reaches_the_remote` | +| "reserved atomically across processes and hosts; one worker wins" | `reserve_effect` — `state.py:637`; `PostgresStateStore.reserve_effect` — `postgres.py:740` | `test_T3_exactly_one_agent_reserves_and_seven_are_blocked`, `test_T154_postgres_passes_the_store_conformance_suite` | +| "bound to the hash of the exact action a human saw, used once, and refused for anything else" | `_authorize_and_reserve` — `state.py:1196` | `test_T2_a_mutated_action_presenting_the_approval_raises_ApprovalMismatch`, `test_T4_replaying_the_approval_raises_ApprovalMismatch_with_reason_consumed` | +| "An action the policy does not list is denied" | `Policy.evaluate` — `policy.py:596` | `test_T6_unknown_action_is_denied_with_reason_unknown_action` | +| "Authority first ... then policy" / "authority first" | `Control.execute` evaluates authority before policy and a denial appends `AUTHORITY_DENIED` and never `POLICY_EVALUATED` — `control.py:1086` | `test_T74_a_denial_leaves_no_pending_approval_request` | +| "Neither axis reads the agent's instructions" | `Policy.evaluate` — `policy.py:596` — sees the action's name and arguments; `Authority.evaluate` — `authority.py:1199` — sees the action and the principal; neither is handed a prompt, a message or a tool result | `test_T6_an_action_name_is_matched_exactly`, `test_T67_a_principal_with_no_grant_is_denied` | | "canonical arguments (sorted keys, no floats) ... Its SHA-256 is the action hash" | `canonicalize` / `action_hash` — `action.py`; `float` refused at any depth — `action.py:79` | `test_T7_canonical_form_is_exactly_the_specified_serialization`, `test_T7_nested_dicts_are_sorted_recursively` | -| "The approval is single-use, expires, and matches nothing but that exact action." | `_authorize_and_reserve` — `state.py:1014` — 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:1550`; `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:728`; `prev_hash`, `GENESIS_HASH` for the first — `receipt.py:100` | `test_T172_every_receipt_carries_the_hash_and_the_declared_version`, `test_T164_an_altered_receipt_is_content_altered_at_its_seq` | +| "The approval is single-use, expires, and matches nothing but that exact action." | `_authorize_and_reserve` — `state.py:1196` — checks expiry at consumption | `test_T5_expiry_is_checked_at_consumption_not_only_at_grant`, `test_T4_replaying_the_approval_raises_ApprovalMismatch_with_reason_consumed` | +| "Only `NotExecuted`, raised by you, means `FAILED`." | `_outcome` — `control.py:1877`; `NotExecuted` — `errors.py:157` | `test_T1_a_lost_response_leaves_the_effect_ambiguous` | +| "the hash of the policy that decided it, chained to the receipt before it" | `Policy.policy_hash` — `policy.py:734`; `prev_hash`, `GENESIS_HASH` for the first — `receipt.py:117` | `test_T172_every_receipt_carries_the_hash_and_the_declared_version`, `test_T164_an_altered_receipt_is_content_altered_at_its_seq` | ## Three ways to use it | Claim | Code | Proof | |---|---|---| -| "You probably do not need an adapter" | Three ways in, and `@protect` (`control.py:3856`) covers this process while the gateway covers MCP — an adapter buys only the interrupt | `test_T139_the_adapter_section_says_when_you_do_not_need_one_up_front` | -| "`ctrlrun init` writes a starter" | `init` — `cli/main.py:340` | CI's `package` job runs `ctrlrun init` from the wheel and asserts `ctrlrun.yaml` exists | -| "The human runs `ctrlrun approve ` and the agent calls again inside `ctrlrun.with_approval(request_id)`" | `approve` — `cli/main.py:370`; `with_approval` — `control.py:320`; `ApprovalRequired` (`errors.py:88`) carries `request_id` | `test_T2_a_mutated_action_presenting_the_approval_raises_ApprovalMismatch` (the granted path first), `test_T4_replaying_the_approval_raises_ApprovalMismatch_with_reason_consumed` | +| "You probably do not need an adapter" | Three ways in, and `@protect` (`control.py:4444`) covers this process while the gateway covers MCP — an adapter buys only the interrupt | `test_T139_the_adapter_section_says_when_you_do_not_need_one_up_front` | +| "`ctrlrun init` writes a starter" | `init` — `cli/main.py:353` | CI's `package` job runs `ctrlrun init` from the wheel and asserts `ctrlrun.yaml` exists | +| "The human runs `ctrlrun approve ` and the agent calls again inside `ctrlrun.with_approval(request_id)`" | `approve` — `cli/main.py:377`; `with_approval` — `control.py:346`; `ApprovalRequired` (`errors.py:88`) carries `request_id` | `test_T2_a_mutated_action_presenting_the_approval_raises_ApprovalMismatch` (the granted path first), `test_T4_replaying_the_approval_raises_ApprovalMismatch_with_reason_consumed` | | "No agent changes" | `INTERCEPTED_METHOD` is `tools/call` and every other method is relayed unchanged — `gateway/mcp.py:40` | `test_a_non_intercepted_method_is_relayed_with_no_ctrlrun_outcome` | | "Point the MCP client at the gateway instead of at the tool server" | `Gateway.handle` — `gateway/server.py:388`; `serve` — `gateway/__init__.py:41` | `test_T19_the_upstream_receives_the_canonical_arguments` | | "Tools become actions named `mcp..`" | `Gateway._intercept` — `gateway/server.py:442` | `test_T19_the_action_is_named_for_the_alias_and_the_tool` | -| "they are declared in the policy" (effect and resource templates for a tool call) | `Policy.effect_template` / `resource_template` — `policy.py:894`; `McpOptions` — `policy.py:537` | `test_T16_a_v2_document_loads_and_exposes_its_templates`, `test_T16_a_decorator_and_a_policy_template_produce_the_same_action_hash` | +| "they are declared in the policy" (effect and resource templates for a tool call) | `Policy.effect_template` / `resource_template` — `policy.py:901`; `McpOptions` — `policy.py:543` | `test_T16_a_v2_document_loads_and_exposes_its_templates`, `test_T16_a_decorator_and_a_policy_template_produce_the_same_action_hash` | | "Everything but `tools/call` is relayed untouched" | `parse_request(...).intercept` — `gateway/mcp.py:84` | `test_every_other_method_is_relayed_not_intercepted` | | "A lost response over the wire blocks the retry exactly as it does in process" | `classify` — `gateway/outcome.py:129`, translated into v0.1 §5.5's own vocabulary by the gateway's executor | `test_T23_the_identical_call_sent_again_is_refused_and_the_upstream_called_once` | | "the gateway prints, on the line that starts it, every action in your policy that has no `effect:` template" | `_announce` — `gateway/__init__.py:146` | `test_the_startup_block_names_the_environment_identity_and_authority`, `test_the_startup_block_says_so_when_there_is_no_authority_section` | @@ -85,29 +85,29 @@ by its quoted claim, and `tests/test_docs_audit.py` fails if a named row is not | Claim | Code | Proof | |---|---|---| -| "cheap to undo is autonomous, anything that leaves the building needs a human, money is by amount with both ends bound" | `Decision` — `policy.py:302` — is exactly `allow`, `approve`, `deny`; rules match first-wins over `Condition` (`policy.py:366`) with the operators `eq`, `neq`, `in`, `lt`, `lte`, `gt`, `gte` — `_OPERATORS` — `policy.py:113` | `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:590` | `test_T6_unknown_action_is_denied_with_reason_unknown_action` | +| "cheap to undo is autonomous, anything that leaves the building needs a human, money is by amount with both ends bound" | `Decision` — `policy.py:308` — is exactly `allow`, `approve`, `deny`; rules match first-wins over `Condition` (`policy.py:372`) with the operators `eq`, `neq`, `in`, `lt`, `lte`, `gt`, `gte` — `_OPERATORS` — `policy.py:119` | `test_T6_an_action_name_is_matched_exactly`, `test_T176_the_operators_behave_as_they_do_everywhere_else` | +| "Unknown actions are denied; there is no default-allow." | `Policy.evaluate` — `policy.py:596` | `test_T6_unknown_action_is_denied_with_reason_unknown_action` | | "Amounts are integer minor units; floats are rejected outright" | `float` refused at any depth — `action.py:79` | `test_T7_canonical_form_is_exactly_the_specified_serialization` | -| "The policy cannot see who is asking — deliberately, since v0.1" | `Policy.evaluate` still takes only the action's name and arguments; `RESERVED_ARGUMENTS` — `policy.py:590` — 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:876`; `Control._authority_result` — `control.py:808` | `test_T67_a_principal_with_no_grant_is_denied` | +| "The policy cannot see who is asking — deliberately, since v0.1" | `Policy.evaluate` still takes only the action's name and arguments; `RESERVED_ARGUMENTS` — `policy.py:596` — refuses `agent_eq` and every other principal-addressing condition at load, in a document of **every** schema version | `test_T74b_a_reserved_name_in_a_policy_rule_is_a_load_error`, `test_T74b_a_reserved_name_in_a_grant_constraint_is_a_load_error` | +| "the second axis, `authority:`" | `Authority.evaluate` — `authority.py:1191`; `Control._authority_result` — `control.py:921` | `test_T67_a_principal_with_no_grant_is_denied` | | "opt-in, and then fail-closed" | `_optional_authority` returns `None` for a document with no section — `control.py`; `Control.authority is None` is v0.2 behaviour exactly | `test_T66_a_document_with_no_authority_section_leaves_control_authority_none`, `test_T66_no_authority_event_is_appended_without_a_section`, and T66's session-wide guard in `tests/conftest.py` | -| "every principal needs a grant and no grant means denied" | `NO_AUTHORITY` — the fail-closed default of `Authority.evaluate` (`authority.py:64`), reached for reads and for actions with no effect key alike | `test_T67_an_action_the_policy_allows_outright_still_needs_a_grant` | +| "every principal needs a grant and no grant means denied" | `NO_AUTHORITY` — the fail-closed default of `Authority.evaluate` (`authority.py:77`), reached for reads and for actions with no effect key alike | `test_T67_an_action_the_policy_allows_outright_still_needs_a_grant` | | "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:3132`; `Authority.plan_delegation` — `authority.py:1054`; `ctrlrun delegate` — `cli/main.py:920` | `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:622` — runs from `plan_delegation` (`authority.py:1054`) **and** from the chain walk in `Authority.evaluate` (`authority.py:884`) | `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:622`; the subject half is `_subject_contained` (`authority.py:653`) | `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:3431` — writes one row — `revoke_delegation` — `state.py:663` 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:713`; `Control._observed` — `control.py:1286`; `_WouldHave` — `receipt.py:319`; `ReceiptResult.OBSERVED` — `receipt.py:231` | `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:713` | `test_T84_mode_is_refused_anywhere_but_the_top_level` | -| "`ctrlrun stats` gives you the numbers" | `stats` — `cli/main.py:749`; 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:1286` | `test_T82_observe_executes_what_enforce_would_deny`, `test_T83_an_executor_that_fails_on_a_held_key_still_writes_the_record` | +| "narrow it at runtime with `ctrlrun delegate`" | `Control.delegate` — `control.py:3717`; `Authority.plan_delegation` — `authority.py:1430`; `ctrlrun delegate` — `cli/main.py:1045` | `test_t75_the_delegation_authorizes_an_action_within_its_limits` | +| "provably a subset of its parent on every dimension, at creation and again at every evaluation" | `contained_dimension` — `authority.py:889` — runs from `plan_delegation` (`authority.py:1430`) **and** from the chain walk in `Authority.evaluate` (`authority.py:1199`) | `test_t76_each_dimension_violated_alone`, `test_t77b_a_narrowed_parent_narrows_its_children` | +| "omitting a dimension the parent constrains is rejected rather than inherited" | `contained_dimension` treats an absent child dimension as unconstrained and therefore wider — `authority.py:889`; the subject half is `_subject_contained` (`authority.py:968`) | `test_t81_omission_is_not_unlimited`, `test_T73b_a_subject_addressed_to_every_principal_is_refused`, `test_t76_each_dimension_violated_alone` | +| "`ctrlrun revoke` cuts a chain of any depth with one write" | `Control.revoke` — `control.py:4016` — writes one row — `revoke_delegation` — `state.py:835` and visits no children; every evaluation walks to the root | `test_t78_a_revoked_parent_denies_its_grandchild`, `test_put_delegation_is_never_an_upsert` | +| "`mode: observe` … records what *would* have been blocked, without blocking anything" | `_parse_mode` — `policy.py:719`; `Control._observed` — `control.py:1462`; `_WouldHave` — `receipt.py:336`; `ReceiptResult.OBSERVED` — `receipt.py:248` | `test_T82_observe_executes_what_enforce_would_deny`, `test_T83_a_duplicate_is_recorded_and_still_runs` | +| "One top-level line" | `mode:` is refused anywhere but the top level — `reject_nested_mode`, `policy.py:719` | `test_T84_mode_is_refused_anywhere_but_the_top_level` | +| "`ctrlrun stats` gives you the numbers" | `stats` — `cli/main.py:866`; counted from `would_have.blocked_reason` and nothing else | `test_T86_stats_counts_what_observe_mode_recorded`, `test_T86_stats_reaches_no_network` | +| "It is not a dry run: it executes" | `_observed` runs the executor on every path, including the ones enforce mode would have refused — `control.py:1462` | `test_T82_observe_executes_what_enforce_would_deny`, `test_T83_an_executor_that_fails_on_a_held_key_still_writes_the_record` | ## Prove it holds in your setup | Claim | Code | Proof | |---|---|---| -| "runs the kernel's own failure scenarios against the configuration in front of it" | `ctrlrun.verify.run` — `verify/__init__.py:154`; the eleven guarantees — `GUARANTEES` — `verify/guarantees.py:44`; the scenarios — `verify/scenarios.py` | `test_T100_the_authority_example_passes_every_non_authority_guarantee` (11/11), `test_T100_a_v1_document_with_no_templates_and_no_grants` | +| "runs the kernel's own failure scenarios against the configuration in front of it" | `ctrlrun.verify.run` — `verify/__init__.py:154`; the eleven guarantees — `GUARANTEES` — `verify/guarantees.py:47`; the scenarios — `verify/scenarios.py` | `test_T100_the_authority_example_passes_every_non_authority_guarantee` (11/11), `test_T100_a_v1_document_with_no_templates_and_no_grants` | | "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` | @@ -132,52 +132,52 @@ keeps it honest: ## The capability matrix Rendered from `capabilities.yaml`; the six rows are the six groups of the verify -catalogue, `GUARANTEES` (`verify/guarantees.py:44`). +catalogue, `GUARANTEES` (`verify/guarantees.py:47`). | 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:527`; 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:514`, decided inside the `BEGIN IMMEDIATE` of `_authorize_and_reserve` (`state.py:1014`) 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:1550`. Every other exception, timeouts included, yields `AMBIGUOUS`. A retry against an `AMBIGUOUS` key is refused — `effect.py:172`, the one place `plan_reservation` decides it for every store | `test_T1_a_blind_retry_writes_a_blocked_receipt`, `test_T1_the_ambiguous_record_survives_the_blocked_retry`, `test_T1_a_lost_response_leaves_the_effect_ambiguous` | -| "An unknown action, a missing policy or a missing principal is denied." | Unknown action: `Policy.evaluate` — `policy.py:590` — answers `deny` for a name the document does not list. Missing or malformed policy: `Policy.from_file` — `policy.py:747` — raises `PolicyError`, and there is no `Control` without a policy. Missing principal: `_refuse_no_principal` — `control.py:3738` | `test_T6_unknown_action_raises_ActionDenied_with_reason_unknown_action`, `test_missing_policy_file_is_a_policy_error`, `test_malformed_policy_document_is_a_policy_error`, `test_T62_a_declining_provider_with_no_context_is_no_principal` | -| "With authority on, every principal needs a grant, and delegation cannot widen one." | `NO_AUTHORITY` — the fail-closed default of `Authority.evaluate` (`authority.py:64`); `contained_dimension` — `authority.py:622` — runs from `plan_delegation` (`authority.py:1054`) 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:215`; `Event` — `receipt.py:281`; the store is authoritative — `append_event` — `state.py:674`; the JSONL export — `JSONLEventSink` — `receipt.py:722` | `test_T11_every_demo_receipt_carries_every_field_in_the_spec`, `test_T11_every_demo_receipt_parses_back_into_a_Receipt` | +| "An approval is bound to the exact action; a mutated or replayed one is refused." | `action_hash` — `action.py`; the approval record stores it and `_authorize_and_reserve` compares it — `state.py:665`; single use is the `granted → consumed` transition in the same `BEGIN IMMEDIATE` | `test_T2_a_mutated_action_presenting_the_approval_raises_ApprovalMismatch`, `test_T4_replaying_the_approval_raises_ApprovalMismatch_with_reason_consumed`, `test_T5_expiry_is_checked_at_consumption_not_only_at_grant` | +| "One logical effect happens at most once, across threads, processes and hosts." | `reserve_effect` — `state.py:637`, decided inside the `BEGIN IMMEDIATE` of `_authorize_and_reserve` (`state.py:1196`) against `effect_key TEXT PRIMARY KEY` (`migrations.py:107`; `COLLATE "C"` on Postgres, §4.4) | `test_T3_exactly_one_agent_reserves_and_seven_are_blocked` (8 OS processes, both backends), `test_T3_the_fake_remote_is_called_exactly_once` | +| "An unknown outcome is AMBIGUOUS, never FAILED, and blocks a blind retry." | Only `NotExecuted` maps to `FAILED` — `_outcome` — `control.py:1877`. Every other exception, timeouts included, yields `AMBIGUOUS`. A retry against an `AMBIGUOUS` key is refused — `effect.py:172`, the one place `plan_reservation` decides it for every store | `test_T1_a_blind_retry_writes_a_blocked_receipt`, `test_T1_the_ambiguous_record_survives_the_blocked_retry`, `test_T1_a_lost_response_leaves_the_effect_ambiguous` | +| "An unknown action, a missing policy or a missing principal is denied." | Unknown action: `Policy.evaluate` — `policy.py:596` — answers `deny` for a name the document does not list. Missing or malformed policy: `Policy.from_file` — `policy.py:753` — raises `PolicyError`, and there is no `Control` without a policy. Missing principal: `_refuse_no_principal` — `control.py:4326` | `test_T6_unknown_action_raises_ActionDenied_with_reason_unknown_action`, `test_missing_policy_file_is_a_policy_error`, `test_malformed_policy_document_is_a_policy_error`, `test_T62_a_declining_provider_with_no_context_is_no_principal` | +| "With authority on, every principal needs a grant, and delegation cannot widen one." | `NO_AUTHORITY` — the fail-closed default of `Authority.evaluate` (`authority.py:77`); `contained_dimension` — `authority.py:889` — runs from `plan_delegation` (`authority.py:1430`) and from the chain walk in `Authority.evaluate` | `test_T67_a_principal_with_no_grant_is_denied`, `test_t76_each_dimension_violated_alone` | +| "Every executed action leaves a portable JSON receipt" | `ReceiptResult` — `receipt.py:232`; `Event` — `receipt.py:298`; the store is authoritative — `append_event` — `state.py:846`; the JSONL export — `JSONLEventSink` — `receipt.py:786` | `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:1014` | `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:737` — `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:737` — and every other method implement `v0.1 §5.3`'s frozen protocol; the decisions stay in `plan_reservation` (`effect.py:248`) | `test_T154_postgres_passes_the_store_conformance_suite` | +| "On SQLite that is `BEGIN IMMEDIATE`" | `_authorize_and_reserve` — `state.py:1196` | `test_T3_exactly_one_agent_reserves_and_seven_are_blocked` | +| "a unique index on the effect key and compare-and-set updates whose row counts are checked" | `reserve_effect` — `postgres.py:740` — `INSERT … ON CONFLICT DO NOTHING` against `effect_key TEXT PRIMARY KEY COLLATE "C"` (`migrations.py:107`) | `test_T3_exactly_one_agent_reserves_and_seven_are_blocked` (8 OS processes, both backends) | +| "Same `StateStore` protocol, extended by nothing" | `PostgresStateStore.reserve_effect` — `postgres.py:740` — and every other method implement `v0.1 §5.3`'s frozen protocol; the decisions stay in `plan_reservation` (`effect.py:248`) | `test_T154_postgres_passes_the_store_conformance_suite` | | "graded by the suite written for SQLite" | `ctrlrun.conformance.store.run` — `conformance/store/__init__.py:53` | `test_T140_every_fixture_fails_the_suite_named_for_it` | -| "It will not *knowingly* execute the same logical effect twice, and will never treat an unknown outcome as a failure." | `plan_reservation` — `effect.py:248` (refuse retry on `AMBIGUOUS`) and `control.py:1550` (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:889`; `_resolve_lost_update` — `postgres.py:1367`; only `NotExecuted` maps to `FAILED` — `control.py:1550` | `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:137` | `test_T155b_a_landed_commit_on_a_transition_is_seen_as_landed`, `test_T155d_a_commit_the_server_never_received_retries_the_insert` | -| "A crashed worker's effect stays `AMBIGUOUS` until a human runs `ctrlrun resolve` or a `reconcile` hook asks the remote what happened" | An expired lease is `AMBIGUOUS` and nothing sweeps it — `LEASE_EXPIRED` — `effect.py:172`; who resolved it — `resolved_by` — `effect.py:208`; `resolve` — `cli/main.py:540` | `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:2233`; `RECONCILED_STATES` — `effect.py` | `test_T13_a_hook_answering_not_executed_moves_the_record_to_failed`, `test_T14_a_hook_answering_committed_refuses_the_retry_as_a_duplicate` | +| "It will not *knowingly* execute the same logical effect twice, and will never treat an unknown outcome as a failure." | `plan_reservation` — `effect.py:248` (refuse retry on `AMBIGUOUS`) and `_outcome` — `control.py:1877` (only `NotExecuted` → `FAILED`) | `test_T1_a_blind_retry_is_refused_and_never_reaches_the_remote`, `test_T1_a_lost_response_leaves_the_effect_ambiguous` | +| "a lost connection during `COMMIT` ... are `AMBIGUOUS`" | `_resolve_lost_insert` — `postgres.py:931`; `_resolve_lost_update` — `postgres.py:1557`; only `NotExecuted` maps to `FAILED` — `_outcome` — `control.py:1877` | `test_T155_a_connection_killed_during_commit_is_resolved_by_the_re_read`, `test_T155_no_effect_is_ever_recorded_failed_by_a_lost_commit` | +| "the store re-reads the row to find out which" | The six branches, named and logged — `A2_LANDED` — `postgres.py:140` | `test_T155b_a_landed_commit_on_a_transition_is_seen_as_landed`, `test_T155d_a_commit_the_server_never_received_retries_the_insert` | +| "A crashed worker's effect stays `AMBIGUOUS` until a human runs `ctrlrun resolve` or a `reconcile` hook asks the remote what happened" | An expired lease is `AMBIGUOUS` and nothing sweeps it — `LEASE_EXPIRED` — `effect.py:172`; who resolved it — `resolved_by` — `effect.py:208`; `resolve` — `cli/main.py:587` | `test_T159_ambiguous_survives_a_restart_and_still_refuses_a_blind_retry`, `test_T160_there_is_no_reaper`, `test_T161_a_human_resolution_records_who` | +| "the only thing besides a human permitted to move a record out of `AMBIGUOUS`" | `Control._reconciled` — `control.py:2512`; `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:590`; `Policy.from_file` — `policy.py:747`; `_refuse_no_principal` — `control.py:3738`; `_authorize_and_reserve` — `state.py:1014` | `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:117` | `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:876`; `contained_dimension` — `authority.py:622`; `Control.revoke` — `control.py:3431` | `test_T67_a_principal_with_no_grant_is_denied`, `test_t76_each_dimension_violated_alone`, `test_t78_a_revoked_parent_denies_its_grandchild` | +| "Unknown action, missing policy, malformed policy, missing principal, missing or mismatched approval and inconsistent state are all `deny`." | `Policy.evaluate` — `policy.py:596`; `Policy.from_file` — `policy.py:753`; `_refuse_no_principal` — `control.py:4326`; `_authorize_and_reserve` — `state.py:1196` | `test_T6_unknown_action_raises_ActionDenied_with_reason_unknown_action`, `test_malformed_policy_document_is_a_policy_error`, `test_T62_a_declining_provider_with_no_context_is_no_principal`, `test_T2_a_mutated_action_presenting_the_approval_raises_ApprovalMismatch` | +| "No flag makes a consequential action permissive by default" | There is no such option on `Control`, on `@protect`, on the CLI or in the policy schema's closed key sets — `_TOP_LEVEL_KEYS` — `policy.py:123` | `test_T84_mode_is_refused_anywhere_but_the_top_level`, `test_T101b_zero_applicable_guarantees_is_not_a_pass` | +| "With `authority:` on, every principal needs a grant, delegation cannot widen one, and `ctrlrun revoke` cuts a chain with one write." | `Authority.evaluate` — `authority.py:1191`; `contained_dimension` — `authority.py:889`; `Control.revoke` — `control.py:4016` | `test_T67_a_principal_with_no_grant_is_denied`, `test_t76_each_dimension_violated_alone`, `test_t78_a_revoked_parent_denies_its_grandchild` | | "verifies a bearer token against a JWKS or a pinned key" | `JWTIdentityProvider._verified` — `jwt_identity.py:208`; the algorithm comes from the configured list and never from the token | `test_T88_a_valid_token_becomes_a_principal`, `test_T89_every_invalid_token_is_refused_by_cause` | | "maps the verified claims onto a principal" | `_principal` — `jwt_identity.py` — copies only the claims named in `claim_names` | `test_T88_only_the_named_claims_reach_the_principal` | | "`pip install \"ctrlrun[identity]\"`" | `identity = ["pyjwt[crypto]>=2.8"]` in `pyproject.toml`; imported lazily by `_jwt()` — `jwt_identity.py` | `test_T92_constructing_without_the_extra_names_the_install_command`, `test_T92_importing_ctrlrun_pulls_in_no_jwt_module` | | "CTRLRun issues no credential and defines no identity format" | There is no minting, signing or issuing code path in the package: `jwt_identity.py` calls `decode` and never `encode` | `test_the_package_never_encodes_a_token` | -| "every receipt records which policy decided it" | `Policy.policy_hash` — `policy.py:728`, over `_canonical_policy` — `policy.py:956`; carried into the receipt by `_record` — `control.py:3636` | `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:718` | `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:954`; `_spend_unneeded_approval` — `control.py:2173` | `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:476`; `prev_hash` — `receipt.py:427`; `GENESIS_HASH` — `receipt.py:100`; `put_receipt` takes the head row's lock first — `postgres.py:1899` | `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:840`; the six names — `CHAIN_BREAKS` — `receipt.py:766` | `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:573`, called from both stores' constructors; `HEAD` — `migrations.py:354` | `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:499`; `SchemaMismatch` — `errors.py` | `test_T148_an_older_binary_refuses_a_newer_database`, `test_T148_no_other_table_is_read_before_the_refusal` | +| "every receipt records which policy decided it" | `Policy.policy_hash` — `policy.py:734`, over `_canonical_policy` — `policy.py:963`; carried into the receipt by `_record` — `control.py:4221` | `test_T172_every_receipt_carries_the_hash_and_the_declared_version`, `test_T172_two_policies_sharing_a_version_string_are_told_apart_by_the_hash` | +| "the policy's declared `version:` and a hash of its canonical content" | `version:` is recorded and never authoritative; `policy_hash` is what tells two documents apart — `policy.py:724` | `test_T171_the_declared_version_alone_does_not_change_the_hash`, `test_T171_comments_key_order_and_whitespace_do_not_change_the_hash` | +| "the approval is re-checked against the policy in force at execution" | `Control.execute` — `control.py:1086`; `_spend_unneeded_approval` — `control.py:2452` | `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:508`; `prev_hash` — `receipt.py:444`; `GENESIS_HASH` — `receipt.py:117`; `put_receipt` takes the head row's lock first — `postgres.py:2092` | `test_T164_an_altered_receipt_is_content_altered_at_its_seq`, `test_T164_reordering_two_receipts_is_detected_either_way` | +| "`ctrlrun receipts --verify-chain` reports it by `seq`" | `verify_chain` — `receipt.py:904`; the six names — `CHAIN_BREAKS` — `receipt.py:841` | `test_the_verify_chain_flag_reports_a_break_by_seq_and_by_name`, `test_verify_chain_reads_a_postgres_store_through_store_url` | +| "migrations are automatic at open, forward-only" | `migrate` — `migrations.py:633`, called from both stores' constructors; `HEAD` — `migrations.py:414` | `test_T147_a_v05_database_migrates_and_keeps_every_row`, `test_T150_reopening_does_not_rerun` | +| "An older binary against a newer schema refuses immediately" | `_refuse` — `migrations.py:559`; `SchemaMismatch` — `errors.py` | `test_T148_an_older_binary_refuses_a_newer_database`, `test_T148_no_other_table_is_read_before_the_refusal` | | "Releases carry PyPI provenance attestations from GitHub Actions" | `.github/workflows/publish.yml` — `pypa/gh-action-pypi-publish` pinned at v1.14.2, which generates and uploads PEP 740 attestations by default since v1.11.0 (its release notes, read 2026-09-06), with no `attestations: false`; the `pypi` job's only permission is `id-token: write` | `test_the_publish_workflow_attests_through_trusted_publishing`, `test_every_action_is_pinned_to_a_commit` | -| "`ctrlrun approve`, `deny`, `resolve`, `inspect`, `receipts` and `stats` work from the shell against any store" | `approve` — `cli/main.py:370`; `receipts` — `cli/main.py:448`; `effects` — `cli/main.py:522`; `resolve` — `cli/main.py:540`; `inspect` — `cli/main.py:574`; `stats` — `cli/main.py:749`; 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:377`; `receipts` — `cli/main.py:455`; `effects` — `cli/main.py:529`; `resolve` — `cli/main.py:587`; `inspect` — `cli/main.py:626`; `stats` — `cli/main.py:866`; every one takes `--store-url` (SPEC-v0.6 §9.4) | `test_T10_resolve_failed_permits_a_retry`, `test_T18_inspect_json_emits_the_inspection_schema`, `test_T86_stats_counts_what_observe_mode_recorded`, `test_verify_chain_reads_a_postgres_store_through_store_url` | | "`WebhookApprovalProvider` sends an approval request to a webhook, such as Slack, and takes the answer back through the same grant calls" | `WebhookApprovalProvider` — `webhook.py:141` — one signed POST on `APPROVAL_REQUESTED`; the inbound answer lands through `grant_approval` / `deny_approval` like the CLI's | `test_T27_the_outbound_post_carries_a_signature_over_the_exact_bytes_sent`, `test_T27_the_payload_carries_what_the_spec_names` | | "one OpenTelemetry span per action, one span event per step" | `OTelEventSink` — `otel.py:45` | `test_T29_one_action_produces_one_span_named_for_the_action`, `test_T29_every_event_becomes_a_span_event_named_by_its_type` | | "argument values stay out of it unless you ask for them" | `OTelEventSink(arguments=...)` — `otel.py:45` | `test_T29_argument_values_are_not_attributes_by_default` | | "Receipts in a `ctrlrun.policy/v4` document can cite the `controls:` an action satisfies" | `PolicyControl` — `policy.py:327`; `Receipt` — `receipt.py:373` — carries `controls`; attribution only, never a decision | `test_T175_the_receipt_carries_the_union_of_the_action_and_the_matched_rule`, `test_T175_a_control_is_attribution_and_changes_no_decision` | -| "a rule can condition on the `data:` labels present in an action's arguments" | `DataLabel` — `policy.py:562`; `Policy.data_scope` — `policy.py:582`; `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` | +| "a rule can condition on the `data:` labels present in an action's arguments" | `DataLabel` — `policy.py:568`; `Policy.data_scope` — `policy.py:588`; `data_scope_in` in `v0.1 §3.2`'s grammar with no new operator | `test_T176_the_derived_set_is_the_labels_of_the_arguments_actually_supplied`, `test_T176_the_derived_set_drives_a_decision` | ## What it can't, stated as limits @@ -189,7 +189,7 @@ The README also makes negative claims. They matter as much as the positive ones. | "CTRLRun is not a transaction manager: it rolls nothing back" | There is no compensation, saga or rollback code path in the package; an `AMBIGUOUS` effect is resolved by a human or a reconcile hook and never undone — `RECONCILED_STATES` — `effect.py` | | "The receipt chain detects alteration, and alteration is not authorship." | n/a — a disclaimer, and the scan that keeps it one: `test_T180_the_release_documents_do_not_blur_alteration_and_authorship` | | "erasing the end of the log costs two statements" | No code — this is what the chain does **not** cover, and it is asserted rather than argued: `test_erasing_a_suffix_and_rewinding_the_head_is_two_statements_and_undetected` | -| "CTRLRun does not detect prompt injection" | No code — and that is the point. Nothing in the package reads the agent's instructions: `Policy.evaluate` takes the action's name and arguments (`policy.py:590`) and `Authority` matches a grant against the action, so neither axis has the prompt to inspect. The README's problem table claims containment of the consequence, and this row is the sentence that stops it being read as detection. | `test_T6_an_action_name_is_matched_exactly`, `test_a_condition_naming_an_action_field_is_refused_at_load` | +| "CTRLRun does not detect prompt injection" | No code — and that is the point. Nothing in the package reads the agent's instructions: `Policy.evaluate` takes the action's name and arguments (`policy.py:596`) and `Authority` matches a grant against the action, so neither axis has the prompt to inspect. The README's problem table claims containment of the consequence, and this row is the sentence that stops it being read as detection. | `test_T6_an_action_name_is_matched_exactly`, `test_a_condition_naming_an_action_field_is_refused_at_load` | | "`ctrlrun verify` cannot see your executors" | `docs/verify.md`, "What it does not mean"; `THREAT_MODEL.md`, "Known v0.4 limitations" | | "`ctrlrun scan` … reports the consequential call sites and policy entries CTRLRun is **not** covering" and "has no score, no percentage and no badge" | `ctrlrun/scan/` reads the tree with `ast` and never imports it, resolves no principal, evaluates no policy and opens no store (SPEC-scan §9.2); the limits sentence is emitted on every run including a clean one, and no percentage is computed anywhere | `test_T194_scan_never_imports_the_tree_it_reads`, `test_T205_scan_resolves_no_principal_evaluates_no_policy_and_opens_no_store`, `test_T203_the_limits_sentence_is_in_every_run_including_a_clean_one` | | "`ctrlrun mcp-operator` … It authenticates who answered and records it; it does not check that they were entitled to." | the write tools refuse without a principal and attribute the answer to the verified one; there is no entitlement check, and `docs/SPEC-mcp-operator.md` §10 says so | `test_T184_approve_refuses_without_a_principal`, `test_T184_approve_succeeds_with_one_and_is_attributed`, `test_T183_there_is_no_flag_that_permits_a_remote_bind` | @@ -200,10 +200,10 @@ The README also makes negative claims. They matter as much as the positive ones. | Claim | Code | Proof | |---|---|---| | "the same `StateStore` protocol, extended by nothing, graded by the suite written for SQLite rather than one written for it" | `PostgresStateStore` — `postgres.py` — satisfies `StateStore` and adds no method (SPEC-v0.6 §9.1); `ctrlrun.conformance.store.SUITES` is the SQLite suite, run against both | `test_T141_the_shipped_backends_pass`, `test_T154_postgres_passes_the_store_conformance_suite` | -| "automatic at open and forward-only, with no flag that opens a database un-migrated. An older binary against a newer schema refuses immediately." | `migrate` — `migrations.py:573` — called from both stores' constructors; `_refuse` — `migrations.py:499` — raises `SchemaMismatch` on a newer `user_version` | `test_T147_a_v05_database_migrates_and_keeps_every_row`, `test_T148_an_older_binary_refuses_a_newer_database`, `test_T152b_no_flag_opens_a_database_without_migrating` | -| "an edit, a deletion from the middle or a reordering is detected and named by `seq`" | `verify_chain` — `receipt.py:425` — and the six break names in `CHAIN_BREAKS` | `test_T164_an_altered_receipt_is_content_altered_at_its_seq`, `test_T164_reordering_two_receipts_is_detected_either_way`, `test_the_verify_chain_flag_reports_a_break_by_seq_and_by_name` | +| "automatic at open and forward-only, with no flag that opens a database un-migrated. An older binary against a newer schema refuses immediately." | `migrate` — `migrations.py:633` — called from both stores' constructors; `_refuse` — `migrations.py:559` — raises `SchemaMismatch` on a newer `user_version` | `test_T147_a_v05_database_migrates_and_keeps_every_row`, `test_T148_an_older_binary_refuses_a_newer_database`, `test_T152b_no_flag_opens_a_database_without_migrating` | +| "an edit, a deletion from the middle or a reordering is detected and named by `seq`" | `verify_chain` — `receipt.py:442` — and the six break names in `CHAIN_BREAKS` | `test_T164_an_altered_receipt_is_content_altered_at_its_seq`, `test_T164_reordering_two_receipts_is_detected_either_way`, `test_the_verify_chain_flag_reports_a_break_by_seq_and_by_name` | | "It detects **alteration**, which is not authorship: receipts are not signed." | No signing code, and a release scan keeps the vocabulary out | `test_T180_the_release_documents_do_not_blur_alteration_and_authorship` | -| "every receipt records the policy that decided it, so a receipt from six months ago says what the rules were" | `Policy.policy_hash` — `policy.py:728` — over the parsed decision inputs, recorded on the receipt | `test_T172_every_receipt_carries_the_hash_and_the_declared_version`, `test_T171_any_decision_input_changes_the_hash`, `test_T171_the_declared_version_alone_does_not_change_the_hash` | +| "every receipt records the policy that decided it, so a receipt from six months ago says what the rules were" | `Policy.policy_hash` — `policy.py:734` — over the parsed decision inputs, recorded on the receipt | `test_T172_every_receipt_carries_the_hash_and_the_declared_version`, `test_T171_any_decision_input_changes_the_hash`, `test_T171_the_declared_version_alone_does_not_change_the_hash` | ## The docs site: Home and Concepts @@ -227,7 +227,7 @@ restating the code; the ones that are new to the site carry their own code and p | `concepts/outcomes-and-ambiguous` | the outcome table; only a human or a reconcile hook moves a record on, and only in the direction the answer points; nothing sweeps; a lost `COMMIT` on Postgres is `AMBIGUOUS` | the matrix row "An unknown outcome is AMBIGUOUS…", the reconciliation rows, "A crashed worker's effect stays `AMBIGUOUS`…" and the Postgres rows above; `test_T160_there_is_no_reaper` | | `concepts/receipts-and-evidence` | the receipt's fields, the JSONL sink, the policy hash and version, the chain and what it does not prove | the matrix row "Every executed action leaves a portable JSON receipt", the receipt-chain and policy-versioning rows above, and `test_T11_every_demo_receipt_carries_every_field_in_the_spec` | | `concepts/authority-and-delegation` | opt-in then fail-closed, no `decision:` on a grant, stricter of the two, containment at creation and at every evaluation, omission rejected, one-write revocation, identity consumed | the authority rows under "Write down what the agent may do" and "What it guarantees" above | -| `concepts/observe-mode` | executes, records `would_have`, one top-level line, counted by `ctrlrun stats`, never asks a human | the observe-mode rows above; `_observed` — `control.py:3681` | +| `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:4269` | | `concepts/fail-closed` | the refusal table, one exception per row | the matrix row "An unknown action, a missing policy or a missing principal is denied.", `ActionDenied` — `errors.py:29`, `DuplicateEffect` — `errors.py:126`, `AmbiguousEffect` — `errors.py:141`, and `test_a_policy_deny_is_denied_the_same_way_as_an_unknown_action` | ## The docs site: Production @@ -238,7 +238,7 @@ sentence that rots quietly. | Page | Claim | Proved by | |---|---|---| -| `production/index` | the readiness block — version, test count, guarantee count, the two stores, the soak, the chain, the licence | rendered by `tools/docs_audit/render_readiness.py` from `pyproject.toml`, `pytest --collect-only`, the `GUARANTEES` catalogue — `verify/guarantees.py:44` — and `research/soak/results/`; `test_the_readiness_block_is_the_generators_in_every_place_it_appears` asserts the same block in the README, the docs home and this page, and `test_the_readiness_block_refuses_a_shrunken_suite_and_accepts_a_grown_one` makes the count a floor | +| `production/index` | the readiness block — version, test count, guarantee count, the two stores, the soak, the chain, the licence | rendered by `tools/docs_audit/render_readiness.py` from `pyproject.toml`, `pytest --collect-only`, the `GUARANTEES` catalogue — `verify/guarantees.py:47` — and `research/soak/results/`; `test_the_readiness_block_is_the_generators_in_every_place_it_appears` asserts the same block in the README, the docs home and this page, and `test_the_readiness_block_refuses_a_shrunken_suite_and_accepts_a_grown_one` makes the count a floor | | `production/index` | the **Not yet** list: no external security audit, no third-party review of the kernel, no sector packs | stated rather than measured, because nothing in a repository can measure an absence. A fourth line — *no soak of the length the roadmap asks for* — was **derived** from the published run until `SPEC-v0.6.md` §8.1 removed the duration from the criterion on 2026-09-07, which removed the thing being derived; the run's own duration is still printed on the soak line above the list. The list lives inside the generated block so it cannot be scrolled past. `test_the_not_yet_list_is_inside_the_block_and_not_below_it`, `test_the_not_yet_list_is_the_constant_and_derives_nothing_from_the_soak` and `test_the_readiness_block_does_not_report_the_soak_as_an_unmet_gate` assert all of it; removing a stated line is its own pull request with the row that makes the new sentence true | | `production/index` | "SQLite is the default and it is production-grade on one host… Postgres is for many hosts" | the header row above; `test_the_first_line_of_the_section_says_which_store_and_why` asserts the order, because Postgres first would tell a reader with one host something false | | `production/how-reservation-works` | the two rows: an exception before `COMMIT` is a failed write; one during it is unknown and is re-read | SPEC-v0.6 §4.3 Tables A, A1 and A2; `test_T155_a_connection_killed_during_commit_is_resolved_by_the_re_read`, `test_T155e_a_commit_the_server_never_received_re_issues_the_update`, `test_T155c_the_re_read_identity_check_is_not_an_action_id_match`, `test_T156_a_failed_re_read_refuses_to_proceed`; `test_the_two_rows_of_the_lost_commit_are_not_merged` asserts the page keeps them apart | diff --git a/docs/OWASP-AGENTIC-TOP10.md b/docs/OWASP-AGENTIC-TOP10.md index 0ac1c22..f2752da 100644 --- a/docs/OWASP-AGENTIC-TOP10.md +++ b/docs/OWASP-AGENTIC-TOP10.md @@ -84,6 +84,9 @@ mechanism, not the entry. | **G19** one principal counts once | An action entry may require several approvals. The threshold counts **distinct verified principals**: a second yes from a principal that already answered is recorded, moves that entry's timestamp, and does not move the count. The count is decided inside the store's own write on all three backends, never by a read followed by a write. | `ASI09:2026` (partly), `ASI03:2026` (partly) | M-of-N is worth nothing if one person can be N of it. What this closes is a miscount: two processes answering at the same instant produce two approvers or one, never a threshold reached twice, and the test that proves it opens the window between the count's read and its write rather than starting two processes and hoping. A threshold above one in a deployment that verifies nobody is a denial, not a silent downgrade to one approval. It does not make several humans independent of each other, and it does not know whether they discussed it. | | **G20** revoked before its exp: no | Where a deployment configures a revocation feed, Security Event Tokens are consumed and a credential the issuer revoked is refused **at resolution**, before its `exp`, as an `IdentityError`. The match is against the token's own `iss`, `sub` and `jti` and never against the principal's agent name. | `ASI03:2026` (partly), `ASI10:2026` (partly) | Until v0.8 a verified token was valid until it expired, so a compromised credential stayed good for the rest of its lifetime and short lifetimes were the whole of the answer. Two things this closes less than it sounds, and both are stated wherever the feature is described. A revoked credential leaves a **log line and no receipt**: resolution happens before an action exists, where an expired credential leaves a full receipt. And a feed is worth what its source is worth: whoever can write it can refuse the operator's own agents, which is a denial of service against them and is fail-closed. What they cannot do is admit a principal the issuer revoked, because the feed is only ever consulted to refuse. | | **G21** unapproved policy decides no | Where a deployment requires it, a decision is made only where a committed effect records that this exact policy hash was approved, and the policy in force must send its own change to a human. Otherwise every action is denied `policy_unapproved`, with an `ACTION_DENIED` event and a `DENIED` receipt. A policy change is an ordinary action, so the approval path applies to it in full. | `ASI03:2026` (partly), `ASI01:2026` (partly), `ASI10:2026` (partly) | The policy is the one file that decides every other decision, and until v0.8 it was changed by editing it. The property is exactly **a policy change that no verified principal other than the proposer approved decides nothing** — not "a policy cannot be changed by whoever holds the file". An administrator with write access can still widen *who* may approve the next change; what they cannot manufacture is the approving principal, whose credential is verified by the provider configured in code. An approval binds a hash and not an ordering, so any hash ever approved stays approved and a superseded policy can be restored with nothing in the evidence saying so. | +| **G22** held budget refuses next reserve | A grant may carry a consequence budget: a metric, a limit and a rolling window. The sum is consumed **when the effect is reserved, in the same transaction**, and an action that would take it past the limit is refused `budget_exhausted` with an `ACTION_DENIED` event and a `DENIED` receipt. Every grant in the delegation chain is charged, so a child cannot spend its parent's budget over again. | `ASI08:2026` (partly), `ASI01:2026` (partly), `ASI10:2026` (partly) | Until v0.9 the authority model bounded **one action** and never an aggregate: `amount_lte: 5000` says nothing about the thousand actions that each pass it, so an agent acting entirely within its permissions could empty an account one permitted refund at a time. Three limits stated wherever the feature is described. A budget **cannot recall an action already in flight**: the window rolls forward and a reservation taken a second before it rolls is committed regardless. An `AMBIGUOUS` effect **holds** its charge until a human resolves it, which is deliberate and which means an agent that can manufacture ambiguity can exhaust a budget it cannot spend, a denial of service against its own principal that is fail-closed. And a budget counts a **metric an operator named** — an action argument — not a consequence: nothing here ranks, scores or classifies what an action means. | +| **G23** a failing scope provider refuses | Where a deployment configures one, a scope provider answers *is this record this principal's?* and the kernel matches the answer against the action's resource, strictly **before** the reservation. Two distinct refusals, never conflated: `scope_unavailable` when the provider raises, answers the wrong shape, or answers something the canonicalizer refuses; `out_of_scope` when it answered and the record is not covered. Only the **hash** of the answer reaches the receipt. | `ASI06:2026` (partly), `ASI01:2026` (partly), `ASI02:2026` (partly) | This is the bite on an identifier an attacker chose, which the `ASI06` row below said nothing had: a grant permits `records.read` on `customer:*`, and until v0.9 nothing had an opinion about *whose* record `customer:90210` is. Two limits. **A scope provider is worth what its source is worth**: it is the operator's own code answering from the operator's own system of record, and a poisoned source answers wrongly with the kernel none the wiser. And whether a deployment configures one is a fact about its code, which `ctrlrun verify` cannot read: verify grades the mechanism against a provider it supplies. The gateway and the ACS hook cannot name a provider at all. | +| **G24** grant refused off its task | A grant may name `tasks:`, the unit of work the authority is for, attenuated by the same `child ⊆ parent` rule as actions, resources and environments. An action bound to a task the grant does not cover is refused by reason and not by exception type. A grant that names no `tasks:` authorises any task, so every grant written before v0.9 keeps its meaning. | `ASI01:2026` (partly), `ASI03:2026` (partly), `ASI10:2026` (partly) | **Task binding limits blast radius; it does not detect a hijack.** The task id is supplied by the caller, so an agent that has been talked into a different goal will often still be operating inside the task it was legitimately given, and the kernel cannot tell a hijacked run from an honest one. What it stops is a credential or a delegation issued for one unit of work being spent on another, which is the shape a stolen grant most often takes. The task never enters the action hash, so binding one changes no existing receipt. | --- @@ -102,7 +105,7 @@ stated here rather than left implied: | Entry | Title | Covered | Not covered | |---|---|---|---| -| `ASI06:2026` | Memory & Context Poisoning | G6 and G1 constrain what an agent acting on a poisoned context can *do*: the action must still be named in the policy, so a belief an attacker planted cannot reach a tool the agent was never entitled to use, and an approval granted for one action cannot be spent on another. This is the same downstream constraint that makes `ASI01` partial, and it is here for the same reason. | CTRLRun never reads a model's memory, its context or its prompt, so it neither detects nor prevents the poisoning. And the shape poisoning most often takes is the one the kernel has least to say about: **corrupted arguments to an action the agent is entitled to take** — the right operation against the wrong record. Policy conditions, resource patterns and v0.6 data scope bite on part of that; nothing bites on an identifier an attacker chose. | +| `ASI06:2026` | Memory & Context Poisoning | G6 and G1 constrain what an agent acting on a poisoned context can *do*: the action must still be named in the policy, so a belief an attacker planted cannot reach a tool the agent was never entitled to use, and an approval granted for one action cannot be spent on another. This is the same downstream constraint that makes `ASI01` partial, and it is here for the same reason. | CTRLRun never reads a model's memory, its context or its prompt, so it neither detects nor prevents the poisoning. And the shape poisoning most often takes is the one the kernel has least to say about: **corrupted arguments to an action the agent is entitled to take** — the right operation against the wrong record. Policy conditions, resource patterns and v0.6 data scope bite on part of that, and since v0.9 **G23 bites on the identifier itself**: where a deployment configures a scope provider, the kernel asks its system of record whether `customer:90210` is this principal's before reserving anything. That closes the sharpest version of this and not the category. The provider is the operator's own code reading the operator's own data, so a poisoned source answers wrongly with the kernel none the wiser, and a deployment that configures no provider is exactly where it was. | | `ASI01:2026` | Agent Goal Hijack | G1 and G6 constrain what a hijacked agent can *do*: it still meets the policy, and it still cannot present an approval granted for a different action. | CTRLRun does not detect or prevent the hijack. It never sees the prompt, the plan or the reasoning, so an agent whose goal was replaced proposes actions exactly as a healthy one would — and every action inside its policy and its grants will run. | | `ASI09:2026` | Human-Agent Trust Exploitation | G1 and G2 close the shape where an approval a human gave for one action is spent on another, or spent twice. | CTRLRun does not authenticate the *approver*, does not model separation of duties, and has no opinion on whether the human was misled into approving. A human persuaded to approve the right action for the wrong reason gets a valid approval, and the receipt records it as one. | diff --git a/docs/cookbook/verify-in-github-actions.mdx b/docs/cookbook/verify-in-github-actions.mdx index d23b318..455cc04 100644 --- a/docs/cookbook/verify-in-github-actions.mdx +++ b/docs/cookbook/verify-in-github-actions.mdx @@ -62,8 +62,8 @@ by tag where you want a ref nobody can move. The agent sees nothing; this is the operator's check. The build sees: ```text -CTRLRun verify — ctrlrun 0.8.0, catalogue ctrlrun.guarantees/v4 -policy /home/runner/work/agent/agent/ctrlrun.yaml (ctrlrun.policy/v2, mode: enforce) +CTRLRun verify — ctrlrun 0.9.0, catalogue ctrlrun.guarantees/v5 +policy /private/var/folders/gp/jrq5dccs6xldv1jw839n7dsw0000gn/T/tmpmax85d9e/ctrlrun.yaml (ctrlrun.policy/v2, mode: enforce) authority none store sqlite, scratch (created and destroyed for this run) @@ -99,12 +99,15 @@ G21 unapproved policy decides no PASS stripe.refund (G21 is graded with require_approved_policy set by verify: whether this deployment sets it is a fact about its own code, which verify cannot read) +G22 held budget refuses next reserve N/A no authority section +G23 a failing scope provider refuses N/A no action this configuration admits carries a resource +G24 grant refused off its task N/A no authority section (a token is unique only as far as your effect keys are: two stores sharing a provider account must not produce the same effect-key string for different effects, and nothing here can check that) -15/15 declared guarantees pass. 6 not applicable: G8, G9, G13, G15, G17, G19. +15/15 declared guarantees pass. 9 not applicable: G8, G9, G13, G15, G17, G19, G22, G23, G24. ``` 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 624a559..0788c36 100644 --- a/docs/guides/verify-in-ci.mdx +++ b/docs/guides/verify-in-ci.mdx @@ -32,8 +32,8 @@ guarantees pass. ``` ```text - CTRLRun verify — ctrlrun 0.8.0, catalogue ctrlrun.guarantees/v4 - policy /home/you/agent/ctrlrun.yaml (ctrlrun.policy/v2, mode: enforce) + CTRLRun verify — ctrlrun 0.9.0, catalogue ctrlrun.guarantees/v5 + policy /private/var/folders/gp/jrq5dccs6xldv1jw839n7dsw0000gn/T/tmpdi6bvpqd/ctrlrun.yaml (ctrlrun.policy/v2, mode: enforce) authority none store sqlite, scratch (created and destroyed for this run) @@ -69,13 +69,16 @@ guarantees pass. (G21 is graded with require_approved_policy set by verify: whether this deployment sets it is a fact about its own code, which verify cannot read) + G22 held budget refuses next reserve N/A no authority section + G23 a failing scope provider refuses N/A no action this configuration admits carries a resource + G24 grant refused off its task N/A no authority section (a token is unique only as far as your effect keys are: two stores sharing a provider account must not produce the same effect-key string for different effects, and nothing here can check that) - 15/15 declared guarantees pass. 6 not applicable: G8, G9, G13, G15, G17, G19. - ``` + 15/15 declared guarantees pass. 9 not applicable: G8, G9, G13, G15, G17, G19, G22, G23, G24. +``` The first line is on **stderr**, from G7's own scenario: an action with no principal is refused and the refusal logs, which is the guarantee passing. Every row names diff --git a/docs/reference/authority-yaml.mdx b/docs/reference/authority-yaml.mdx index 441ddb4..6211dca 100644 --- a/docs/reference/authority-yaml.mdx +++ b/docs/reference/authority-yaml.mdx @@ -75,6 +75,42 @@ resolve the gate against, so the only envelope it could express is an ungated on | `environments` | list of environment names | any environment | `production`, `staging` | | `delegable` | `true` or `false` | `false` | whether the holder may narrow this grant at runtime with `ctrlrun delegate`; `true` **requires** `expires_at` | | `expires_at` | ISO-8601 timestamp with an offset | never expires | checked at every evaluation, and refused as a naive datetime | +| `tasks` | list of task-id patterns | **any task** | since v7: the unit of work this authority is for. `refund-run:*`. A grant that omits it authorises any task, so every grant written before v7 keeps its meaning | +| `budgets` | list of budgets | **bounds no aggregate** | since v7: how much, over how long. Each is `{metric, limit, window}` | + +### `budgets` + +`constraints` bounds **one action**; a budget bounds the **aggregate**. They are different +questions, and a grant that answers only the first permits a thousand actions that each pass it. + +| Key | Type | Means | +|---|---|---| +| `metric` | the name of an action argument | which number is summed. The action must carry it, as an integer: a missing value is refused rather than counted as zero, because absence-as-zero turns a missing field into unlimited authority | +| `limit` | non-negative integer | what the sum over the window may **reach**. The comparison is inclusive | +| `window` | ISO-8601 duration, whole seconds | how far back the sum runs, rolling. `PT24H`, `P30D`. Sub-second windows are refused | + +```yaml +budgets: + - { metric: amount, limit: 100000, window: PT24H } # 1,000.00 a day + - { metric: amount, limit: 2000000, window: P30D } # 20,000.00 a month +``` + +Two budgets on one metric over two windows is the ordinary shape, and it is one spend measured +against two windows rather than two spends. + +**A budget is consumed when the effect is reserved, in the same transaction**, so two processes +cannot both pass a check and then both spend. It is released only when the effect reaches +`FAILED`, which is the one state where the executor proved nothing happened. An `AMBIGUOUS` +effect **holds** its consumption until a human or a `reconcile` hook resolves it: ambiguity is +not a refund, because otherwise an agent able to manufacture ambiguity is able to manufacture +authority. `ctrlrun inspect --grant` shows what is held and what holds it. + +A delegation's budget must be **contained** by its parent's: no higher limit, and no *shorter* +window, because a shorter window over the same limit is a higher rate and therefore more +authority. + +**An action a budgeted grant covers must resolve an effect key.** Without one there is nothing +to charge against, so the action is refused rather than spending nothing for ever. A grant carries no `decision:`. How much autonomy an action has is the policy's answer, the same for everyone; the grant answers whether this principal may ask at all. diff --git a/docs/verify.md b/docs/verify.md index 1a83dbd..4ee5e68 100644 --- a/docs/verify.md +++ b/docs/verify.md @@ -14,8 +14,8 @@ what could not be tested at all. ```console $ ctrlrun verify -CTRLRun verify — ctrlrun 0.8.0, catalogue ctrlrun.guarantees/v4 -policy examples/authority/payments.yaml (ctrlrun.policy/v6, mode: enforce) +CTRLRun verify — ctrlrun 0.9.0, catalogue ctrlrun.guarantees/v5 +policy examples/authority/payments.yaml (ctrlrun.policy/v7, mode: enforce) authority same document, 3 grants store sqlite, scratch (created and destroyed for this run) @@ -27,7 +27,7 @@ G5 ambiguous blocks a blind retry PASS stripe.refund G6 unknown action refused PASS G7 no principal refused PASS stripe.refund G8 expired authority refused PASS head-of-support -G9 delegation cannot escalate PASS head-of-support (6 of 6 dimensions) +G9 delegation cannot escalate PASS head-of-support (8 of 8 dimensions) G10 unknown exception is ambiguous PASS stripe.refund G11 an altered receipt is detected PASS stripe.refund G12 a byte written is ambiguous PASS stripe.refund @@ -51,12 +51,19 @@ G21 unapproved policy decides no PASS stripe.refund (G21 is graded with require_approved_policy set by verify: whether this deployment sets it is a fact about its own code, which verify cannot read) +G22 held budget refuses next reserve PASS head-of-support +G23 a failing scope provider refuses PASS stripe.refund + (G23 is graded against a scope provider verify supplies: + whether this deployment configures one is a fact about its + own code, which verify cannot read. The gateway and the + ACS hook cannot name a provider at all (SPEC-v0.9 §5.2.2)) +G24 grant refused off its task PASS head-of-support (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) -19/19 declared guarantees pass. 2 not applicable: G13, G15. +22/22 declared guarantees pass. 2 not applicable: G13, G15. ``` It reads the policy document — `$CTRLRUN_CONFIG`, else `./ctrlrun.yaml` — and the authority diff --git a/tools/docs_audit/render_badges.py b/tools/docs_audit/render_badges.py index c850471..efcc64f 100644 --- a/tools/docs_audit/render_badges.py +++ b/tools/docs_audit/render_badges.py @@ -196,7 +196,12 @@ def check(pages: list[Path] | None = None) -> list[str]: if not target.exists() or target.read_text(encoding="utf-8") != render(fmt): drift.append(f"{relative(target)} differs from the generator; run --write") if pages is None: - pages = documents(patterns=("README.md", "docs/**/*.md", "docs/**/*.mdx")) + # **Root-level pages too.** `docs.mdx` is the docs home and carries two marker blocks, + # and a glob of `docs/**` does not reach a file called `docs.mdx` beside that directory: + # its capability grid and its readiness block went unchecked, and the readiness one sat + # at version 0.8.0 with a stale guarantee count through a whole milestone. `*.md` and + # `*.mdx` are already in `SITE_PATTERNS` for exactly this reason. + pages = documents(patterns=("README.md", "*.md", "*.mdx", "docs/**/*.md", "docs/**/*.mdx")) for page in pages: if page.parent == GENERATED: continue diff --git a/tools/docs_audit/render_capabilities.py b/tools/docs_audit/render_capabilities.py index e086f24..7a1b6e4 100644 --- a/tools/docs_audit/render_capabilities.py +++ b/tools/docs_audit/render_capabilities.py @@ -282,7 +282,12 @@ def check( elif target.read_text(encoding="utf-8") != expected: drift.append(Drift(relative(target), None, "differs from the generator; run --write")) if pages is None: - pages = documents(patterns=("README.md", "docs/**/*.md", "docs/**/*.mdx")) + # **Root-level pages too.** `docs.mdx` is the docs home and carries two marker blocks, + # and a glob of `docs/**` does not reach a file called `docs.mdx` beside that directory: + # its capability grid and its readiness block went unchecked, and the readiness one sat + # at version 0.8.0 with a stale guarantee count through a whole milestone. `*.md` and + # `*.mdx` are already in `SITE_PATTERNS` for exactly this reason. + pages = documents(patterns=("README.md", "*.md", "*.mdx", "docs/**/*.md", "docs/**/*.mdx")) for page in pages: if page.parent == directory and page.name in FILENAMES.values(): continue diff --git a/tools/docs_audit/render_readiness.py b/tools/docs_audit/render_readiness.py index 3762dd9..f5d2c72 100644 --- a/tools/docs_audit/render_readiness.py +++ b/tools/docs_audit/render_readiness.py @@ -289,7 +289,12 @@ def check(data: dict, pages: list[Path] | None = None) -> list[str]: if pages is None: from _files import documents - pages = documents(patterns=("README.md", "docs/**/*.md", "docs/**/*.mdx")) + # **Root-level pages too.** `docs.mdx` is the docs home and carries two marker blocks, + # and a glob of `docs/**` does not reach a file called `docs.mdx` beside that directory: + # its capability grid and its readiness block went unchecked, and the readiness one sat + # at version 0.8.0 with a stale guarantee count through a whole milestone. `*.md` and + # `*.mdx` are already in `SITE_PATTERNS` for exactly this reason. + pages = documents(patterns=("README.md", "*.md", "*.mdx", "docs/**/*.md", "docs/**/*.mdx")) for page in pages: if page.parent == GENERATED: continue From 208c8e176a8ef9144aff6922821a55adaa2c1a0c Mon Sep 17 00:00:00 2001 From: arpan Date: Sun, 13 Sep 2026 11:58:22 +0530 Subject: [PATCH 3/6] ROADMAP reconciled, the threat model for v0.9, and budgets in the authority guide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v0.8 and v0.9 marked shipped. Neither was, and v0.8 tagged a day ago. The v0.9 section is reconciled against what shipped rather than adjusted quietly, on the rule this file already follows in three other places. It said a budget is a metric, a scope and a window, which conflated two of the three dimensions; and it said scope providers would go through v0.7's fingerprint mechanism, which they do not. SPEC-v0.9 §5.2.1 records that amendment and the three mechanical differences behind it. The threat model gains the v0.9 row set and, at more length, the three things a budget is not: it cannot recall an action already in flight, it is fail-closed against its own principal because R2 holds a charge an agent can pin, and it counts a metric an operator named rather than a consequence. A limit reads like more of a defence than it is, and this is where that gets said. Signed-off-by: arpan --- docs/ROADMAP.md | 26 +++++++++++++++++++-- docs/THREAT_MODEL.md | 54 ++++++++++++++++++++++++++++++++++++++++++++ docs/authority.md | 41 +++++++++++++++++++++++++++++++++ 3 files changed, 119 insertions(+), 2 deletions(-) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index da7e07b..5063ad8 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -268,7 +268,7 @@ Standards: none new. **Renumbered on 2026-09-10, and the reason is recorded here rather than made silently.** The chain below used to run *boundary → multi-agent → hardening → 1.0*, and nowhere in it did the kernel learn who may say yes or how much. An approval is a string somebody typed; a grant limits one action and says nothing about a thousand of them. Both are what a reader means by governance, and the homepage now says *action governance* — so the two milestones that make it true go in, before multi-agent, because propagating authority across hops needs authority that can be bounded and a yes that can be attributed underneath it. Nothing already shipped moves. A2A moves once more, to v0.10, and v0.10 says why. The homepage H1 stays *Execution safety for AI agents* until v0.9 exits; `internal/POSITIONING.md` carries that gate. -## v0.8 — Oversight +## v0.8 — Oversight ✅ shipped One question: who may say yes, and can the kernel tell? @@ -290,7 +290,7 @@ Exit: `ctrlrun.guarantees/v4` — G17 unentitled approver refused · G18 self-ap Standards: none new. RFC 8935 and RFC 8936 (SSF push and poll delivery) and CAEP are consumed as code; they appear in a mapping doc only after the test above exists, and "SSF-compatible" is unearned until a conformance suite says otherwise. -## v0.9 — Envelope +## v0.9 — Envelope ✅ shipped One question: how much, over which records, for which task? @@ -306,6 +306,28 @@ Do not build: a consequence taxonomy — a budget names a metric, not a class · Exit: `ctrlrun.guarantees/v5` — G22 a budget exhausted by ambiguity refuses the next reserve until reconciled, and releases on `FAILED`, under the v0.6 multi-process standard against Postgres · G23 a scope provider that raises leaves nothing reserved and nothing executed · G24 a task-bound grant is refused on a task it does not name, by name — each with a positive control, each `N/A` with a reason on a grant that carries no budget, no scope, or no task. +**Reconciled against what shipped.** Three things differed from this section, and each is +recorded where it was decided rather than quietly adjusted here. + +- **A budget is a metric, a limit and a window.** This section said "a metric, a scope and a + window", which conflated two of v0.9's three dimensions: a budget bounds an aggregate and a + scope provider answers about a record, and nothing about the budget is scoped. +- **Scope providers are a second hook, not the precondition mechanism.** This section said they + would go "through v0.7's fingerprint mechanism". They do not: `SPEC-v0.9.md` §5.2.1 records the + amendment to `SPEC-v0.7.md` §6.9 and the three mechanical differences that justify it, the + first being that a precondition answers *has this changed* and a scope answers *is this yours*. + A deployment may declare both over the same mapping, which is why the scope hash carries its + own domain tag. +- **Two refusals, not one.** `scope_unavailable` and `out_of_scope` are distinct reasons, because + a deployment whose scope source is down and one whose agent reached for somebody else's record + are different incidents, and observe mode reported the wrong one until an independent review + found it. + +The exit criteria are met: `ctrlrun.guarantees/v5`, G22, G23 and G24, each with a positive +control and each `N/A` with a true reason on a configuration that carries no budget, scope or +task. All three PASS on `examples/authority/payments.yaml`, so the milestone's own guarantees are +graded on what this repository ships rather than only on a fixture. + **This is the gate for the category line, and it used to be the gate for the H1.** Recorded 2026-09-12: the H1 moved ahead of v0.9, to *CTRLRun stops AI agents from taking wrong, restricted, or malicious actions in your workflows*, because it states what the shipped kernel does today and claims nothing about authority. *Action governance* still waits: after v0.9 it is true in code, and only then does the category line move up. Standards: none new. diff --git a/docs/THREAT_MODEL.md b/docs/THREAT_MODEL.md index ebb23bf..49ee477 100644 --- a/docs/THREAT_MODEL.md +++ b/docs/THREAT_MODEL.md @@ -61,6 +61,60 @@ they entitled to?* Everything above still holds; these are the threats the secon | An unauthenticated principal reaching an authorization decision | `--principal-from-client-info` removed; `AcsControlHook` refuses an `Authority` without an `identity` provider | | An environment chosen by the caller | The environment is set once on the `Control` and is never read off the wire | +## In scope — CTRLRun v0.9 adds + +The authority model bounded **one action** and never an aggregate: a grant saying +`amount_lte: 5000` is silent about the thousand actions that each pass it. v0.9 answers *how +much, over which records, for which task?* Everything above still holds. + +| Threat | Control | +|---|---| +| An agent draining an account one permitted action at a time | A consequence budget on the grant: a metric, a limit, a rolling window. The sum is consumed when the effect is **reserved**, in the same transaction, so two processes cannot both pass a check and then both spend | +| A budget check racing its own consumption across hosts | The charge is written inside the reservation's transaction, under a per-grant lock taken in a fixed order on Postgres. Verified multi-process against Postgres, not argued | +| An agent buying authority by manufacturing ambiguity | An `AMBIGUOUS` effect **holds** its charge until a human or a `reconcile` hook resolves it. Ambiguity is not a refund | +| A delegation spending its parent's budget over again | Every grant in the chain is charged on every reserve, so a child's spend is its parent's spend | +| A child grant taking a higher rate than its parent | Containment on both axes: no higher limit, and no **shorter** window, because a shorter window over the same limit is a higher rate | +| A negative amount refilling a budget | A metric value that is negative, missing, or not an integer is refused, with an event and a receipt. Absence is never counted as zero | +| An agent reaching for a record that is not its principal's | Where a deployment configures a scope provider, the kernel asks it before reserving anything, and refuses `out_of_scope`. This is the bite on an identifier an attacker chose | +| A scope source that is down failing open | `scope_unavailable`, a distinct refusal from `out_of_scope`, fail-closed. Observe mode reports which | +| A grant or delegation issued for one unit of work being spent on another | `tasks:` on a grant, attenuated by the same `child ⊆ parent` rule as every other dimension | + +### What a budget does not do + +Stated here because a limit reads like more of a defence than it is. + +- **A budget cannot recall an action already in flight.** The window rolls forward, and a + reservation taken a second before it rolls commits regardless. A budget bounds what may + *start*, never what has started. +- **A budget is fail-closed against its own principal.** An agent that can manufacture + ambiguity can pin a budget it is not entitled to spend, because R2 holds the charge until a + human resolves it. That is a denial of service against the operator's own agents, and it is + the deliberate side of the trade: the alternative is an agent that manufactures authority. +- **A budget counts a metric an operator named**, an argument on the action. It is not a + consequence model: nothing ranks, scores or classifies what an action means, and a budget on + `amount` says nothing about an action whose damage is not in that field. +- **A budget is per store.** Two deployments sharing a provider account and not a store each + enforce their own, and the provider sees the sum. +- **A budget is not a rate limit on the remote.** It bounds authority, not traffic. + +### What a scope provider does not do + +- **It is worth what its source is worth.** It is the operator's own code answering from the + operator's own system of record. A poisoned source answers wrongly and the kernel cannot tell. +- **The residual gap `SPEC-v0.7.md` states for preconditions applies unchanged**: the check + cannot run inside the atomic reservation write, so a record that changes hands in the window + between the answer and the reservation is not caught. +- **Only the hash of the answer reaches the receipt.** An auditor can prove the scope was the + one the kernel matched against, and cannot read what it contained. + +### What task binding does not do + +- **It limits blast radius; it does not detect a hijack.** The task id is supplied by the + caller, and an agent talked into a different goal is usually still inside the task it was + legitimately given. `ASI01` stays partial for this reason. +- **It does not propagate across agent hops.** A grant is evaluated where the action is + proposed; `docs/ROADMAP.md` puts propagation in v0.10. + ## Out of scope — CTRLRun does not defend against - A compromised CTRLRun process, host, or Python environment. diff --git a/docs/authority.md b/docs/authority.md index cbb36ef..4a95a2e 100644 --- a/docs/authority.md +++ b/docs/authority.md @@ -72,6 +72,8 @@ purpose. | `constraints` | the action's arguments, in the same syntax a rule's `when:` uses | | `environments` | the deployment the action is running in | | `expires_at` | when the grant stops working | +| `tasks` | the unit of work the action was bound to, as patterns | +| `budgets` | **not a match: a sum.** How much this grant's actions may add up to, over a rolling window | **Patterns are deliberately small**, because containment between two of them has to be decidable: a literal, a `prefix*` that cannot cross a separator, and a final `**`. So @@ -83,6 +85,45 @@ in review, and impossible to write by accident. on every Action, so a grant scoped to `["staging"]` cannot be satisfied by a call that describes itself as staging. An authorization dimension the subject can set is not one. +## How much, not just whether + +Every key above except one answers *may this principal do this?* A budget answers a different +question, and it is the one a grant could not ask before v0.9: **how much, in total?** + +```yaml +- id: head-of-support + subject: { agent: "support-agent" } + actions: ["stripe.refund"] + constraints: { amount_lte: 10000000 } # one refund, up to 100,000.00 + budgets: + - { metric: amount, limit: 50000000, window: PT24H } # 500,000.00 a day, across the grant +``` + +`constraints` bounds one action. `budgets` bounds the aggregate. A grant that answers only the +first permits a thousand actions that each pass it, which is how an agent acting entirely within +its permissions empties an account one permitted refund at a time. + +Four things worth knowing, because each one is a decision that could have gone the other way. + +**It is consumed when the effect is reserved**, in the same transaction, not checked on one line +and consumed on another. Two processes cannot both pass a check and then both spend. + +**It is released only when the effect reaches `FAILED`** — the one state in which the executor +proved nothing happened. A committed spend is a spend, permanently. + +**Ambiguity is not a refund.** An `AMBIGUOUS` effect holds its charge until a human or a +`reconcile` hook says what happened, because otherwise an agent that can manufacture ambiguity +can manufacture authority. This is the one that surprises people, and it is why +`ctrlrun inspect --grant` exists: a budget refusing while it looks nowhere near its limit is +almost always one unresolved effect, and the view names it and the command that clears it. + +**Every grant in the chain is charged.** A delegation spends its own budget and its parent's, so +a holder cannot mint children to spend the same budget over again. + +A daily budget *smaller* than one action the same grant permits is legal and almost always a +mistake: the first action of the day exhausts it, and any rule band above it becomes unreachable. +`ctrlrun verify` reports that rather than failing. + ## Delegation, and the rule that makes it safe A principal holding a `delegable` grant can create a narrower one at runtime: From 2d07d39ae1e0db80c6495be7f68dfec7141424d3 Mon Sep 17 00:00:00 2001 From: arpan Date: Sun, 13 Sep 2026 11:59:30 +0530 Subject: [PATCH 4/6] ROADMAP: v0.11's retention debt now has a second growing table The budget ledger only grows, like the receipt chain. v0.9 ships no retention command and states the invariant instead, and the caveat travels with it: an AMBIGUOUS effect older than the longest window still holds a charge the operator surfaces display, so an archiver on a live ledger excludes un-released rows. docs/CONTROL-MAPPING.md is untouched because it does not exist: this file lists it under Standards as written only when a design partner asks. Signed-off-by: arpan --- docs/ROADMAP.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 5063ad8..b099941 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -347,7 +347,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 a suffix erased or appended between two anchors is detected and named, in the same vocabulary as the six existing break kinds. No keys of its own: it consumes a timestamp and issues nothing, which is why it is here and signing is not. -- **Retention and legal hold.** There is no retention policy today and `docs/postgres.md` says so, while `docs/CONTROL-MAPPING.md` maps receipt retention to a clause. 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. +- **Retention and legal hold.** There is no retention policy today and `docs/postgres.md` says so, while `docs/CONTROL-MAPPING.md` maps receipt retention to a clause. 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. - **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/v4` 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) and v0.9 (budget consumption) each add fields and each bump the version, so a chain kept from v0.6 across them holds **four receipt schema versions**: `v3`, which 0.6 wrote, `v4`, which v0.7 added, and the two that follow. This sentence said *three shapes* and named `v3` as the schema today. It was written before v0.7's precondition fields bumped the schema, and v0.7's release pass corrects it here rather than quietly. And nothing yet proves that `verify` walks it end to end, hash by hash, each receipt hashed by the rule its own version wrote. v0.11 proves it, here, because this is the milestone about whether the record can be trusted after the fact. No new field: the version string already exists. What is new is the test, and the rule that a receipt whose version the binary does not know is *named* and not reported as a break — which is the same distinction v0.6 §3.2 draws for a `schema_version` row the binary does not know. Added 2026-09-10. From 8244639acaac2eab3509b9bbbe25b3935a1db920 Mon Sep 17 00:00:00 2001 From: arpan Date: Sun, 13 Sep 2026 12:06:22 +0530 Subject: [PATCH 5/6] The authority capability says how much, not just whether The README's only authority sentence described whether a principal may act and said nothing about how much, which is exactly the half v0.9 adds. The capability row now says a grant bounds the total, and the claim beside it carries the two facts that make that worth printing: the budget is consumed inside the reservation's own transaction, and a rolling window bounds what may start rather than recalling what already did. Every capability claim has to exist verbatim as a CLAIMS.md row citing code and tests, and the test that enforces that caught the new one immediately. The row names check_charges, the per-ancestor walk, and the four tests for the transaction, the race, the chain and the window. The kernel README's copy of the table is pasted in the companion PR, since it lives there. Signed-off-by: arpan --- capabilities.yaml | 4 ++-- docs.mdx | 2 +- docs/CLAIMS.md | 1 + generated/capabilities.mdx | 2 +- generated/capabilities.readme.md | 2 +- generated/capabilities.txt | 2 +- 6 files changed, 7 insertions(+), 6 deletions(-) diff --git a/capabilities.yaml b/capabilities.yaml index 8b77995..9c05641 100644 --- a/capabilities.yaml +++ b/capabilities.yaml @@ -71,7 +71,7 @@ capabilities: - id: authority-containment name: Authority and delegation - description: With authority on, every principal needs a grant, and delegation cannot widen one. + description: Every principal needs a grant, delegation cannot widen one, and a grant bounds the total. guarantee: true ways_in: decorator: true @@ -79,7 +79,7 @@ capabilities: adapter: true since: v0.3 page: docs/concepts/authority-and-delegation - claim: provably a subset of its parent on every dimension, at creation and again at every evaluation + claim: 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 - id: receipts name: Receipts diff --git a/docs.mdx b/docs.mdx index 1c39141..4130bf9 100644 --- a/docs.mdx +++ b/docs.mdx @@ -131,7 +131,7 @@ the same decisions without an install, or read the full transcript in the An unknown action, a missing policy or a missing principal is denied. Since v0.1. - With authority on, every principal needs a grant, and delegation cannot widen one. Since v0.3. + Every principal needs a grant, delegation cannot widen one, and a grant bounds the total. Since v0.3. Every executed action leaves a portable JSON receipt of who, what and outcome. Since v0.1. diff --git a/docs/CLAIMS.md b/docs/CLAIMS.md index e7c4c19..2584413 100644 --- a/docs/CLAIMS.md +++ b/docs/CLAIMS.md @@ -96,6 +96,7 @@ by its quoted claim, and `tests/test_docs_audit.py` fails if a named row is not | "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:3717`; `Authority.plan_delegation` — `authority.py:1430`; `ctrlrun delegate` — `cli/main.py:1045` | `test_t75_the_delegation_authorizes_an_action_within_its_limits` | | "provably a subset of its parent on every dimension, at creation and again at every evaluation" | `contained_dimension` — `authority.py:889` — runs from `plan_delegation` (`authority.py:1430`) **and** from the chain walk in `Authority.evaluate` (`authority.py:1199`) | `test_t76_each_dimension_violated_alone`, `test_t77b_a_narrowed_parent_narrows_its_children` | +| "provably a subset of its parent on every dimension, at creation and again at every evaluation; a consequence budget is consumed inside the reservation's own transaction, and a rolling window bounds what may start rather than recalling what already did" | Containment as in the row above. The budget: `check_charges` — `state.py:569` — is evaluated inside `reserve_effect`'s own transaction on all three backends, and `_charges_for` — `authority.py:1285` — charges every ancestor in the chain. The window is rolling and bounds the next reserve only: `_spent` sums `[now - window, now]` and nothing reads it again after a reservation is taken | `test_T408_a_charge_and_its_reservation_are_one_transaction`, `test_T409_N_processes_racing_one_budget_spend_at_most_the_limit`, `test_T412b_every_ancestor_is_charged_through_a_real_chain`, `test_T408c_the_rolling_window_forgets` | | "omitting a dimension the parent constrains is rejected rather than inherited" | `contained_dimension` treats an absent child dimension as unconstrained and therefore wider — `authority.py:889`; the subject half is `_subject_contained` (`authority.py:968`) | `test_t81_omission_is_not_unlimited`, `test_T73b_a_subject_addressed_to_every_principal_is_refused`, `test_t76_each_dimension_violated_alone` | | "`ctrlrun revoke` cuts a chain of any depth with one write" | `Control.revoke` — `control.py:4016` — writes one row — `revoke_delegation` — `state.py:835` and visits no children; every evaluation walks to the root | `test_t78_a_revoked_parent_denies_its_grandchild`, `test_put_delegation_is_never_an_upsert` | | "`mode: observe` … records what *would* have been blocked, without blocking anything" | `_parse_mode` — `policy.py:719`; `Control._observed` — `control.py:1462`; `_WouldHave` — `receipt.py:336`; `ReceiptResult.OBSERVED` — `receipt.py:248` | `test_T82_observe_executes_what_enforce_would_deny`, `test_T83_a_duplicate_is_recorded_and_still_runs` | diff --git a/generated/capabilities.mdx b/generated/capabilities.mdx index 38d92fe..36a673b 100644 --- a/generated/capabilities.mdx +++ b/generated/capabilities.mdx @@ -13,7 +13,7 @@ An unknown action, a missing policy or a missing principal is denied. Since v0.1. - With authority on, every principal needs a grant, and delegation cannot widen one. Since v0.3. + Every principal needs a grant, delegation cannot widen one, and a grant bounds the total. Since v0.3. Every executed action leaves a portable JSON receipt of who, what and outcome. Since v0.1. diff --git a/generated/capabilities.readme.md b/generated/capabilities.readme.md index 1b8ede2..6595549 100644 --- a/generated/capabilities.readme.md +++ b/generated/capabilities.readme.md @@ -5,6 +5,6 @@ | **One effect, once** — One logical effect happens at most once, across threads, processes and hosts. | yes | yes | yes | | **Unknown is not failed** — An unknown outcome is AMBIGUOUS, never FAILED, and blocks a blind retry. | yes | yes | yes | | **Fail closed** — An unknown action, a missing policy or a missing principal is denied. | yes | yes | yes | -| **Authority and delegation** — With authority on, every principal needs a grant, and delegation cannot widen one. | yes | yes | yes | +| **Authority and delegation** — Every principal needs a grant, delegation cannot widen one, and a grant bounds the total. | yes | yes | yes | | **Receipts** — Every executed action leaves a portable JSON receipt of who, what and outcome. | yes | yes | yes | diff --git a/generated/capabilities.txt b/generated/capabilities.txt index 9b28104..1fbdc0d 100644 --- a/generated/capabilities.txt +++ b/generated/capabilities.txt @@ -3,7 +3,7 @@ generated from capabilities.yaml (text) — edit the YAML, never this list - One effect, once: One logical effect happens at most once, across threads, processes and hosts. - Unknown is not failed: An unknown outcome is AMBIGUOUS, never FAILED, and blocks a blind retry. - Fail closed: An unknown action, a missing policy or a missing principal is denied. -- Authority and delegation: With authority on, every principal needs a grant, and delegation cannot widen one. +- Authority and delegation: Every principal needs a grant, delegation cannot widen one, and a grant bounds the total. - Receipts: Every executed action leaves a portable JSON receipt of who, what and outcome. - Per-action policy: One YAML file decides allow, approve or deny per action and argument. - Operator CLI: Approve, deny, resolve, inspect and count from the shell, against any store. From b2de63796a5cd9e1754e0f0130f34893ecc13fca Mon Sep 17 00:00:00 2001 From: arpan Date: Sun, 13 Sep 2026 12:52:28 +0530 Subject: [PATCH 6/6] Regenerate against the review fixes: 6,019 tests, 83 pages The line numbers CLAIMS.md cites moved with the fixes, and the API reference is rendered from docstrings that changed. The readiness blocks carry the new count. Signed-off-by: arpan --- docs.mdx | 2 +- docs/CLAIMS.md | 48 +++++++++++++-------------- docs/production/index.mdx | 2 +- docs/reference/api/Control.mdx | 2 +- docs/reference/api/EventSink.mdx | 2 +- docs/reference/api/JSONLEventSink.mdx | 2 +- docs/reference/api/protect.mdx | 2 +- generated/readiness.full.mdx | 2 +- generated/readiness.json | 2 +- generated/readiness.mdx | 2 +- generated/readiness.readme.md | 2 +- 11 files changed, 34 insertions(+), 34 deletions(-) diff --git a/docs.mdx b/docs.mdx index 4130bf9..9735efb 100644 --- a/docs.mdx +++ b/docs.mdx @@ -219,7 +219,7 @@ the framework's own interrupt, and a framework with no such primitive does not n {/* generated from the suite, pyproject and the soak (mdx) — run the generator */} - **Version 0.9.0**, on [PyPI](https://pypi.org/project/ctrlrun/), Python 3.11 and later. -- **5,982 tests**, every version specified before it was written and every requirement mutation-tested. +- **6,019 tests**, every version specified before it was written and every requirement mutation-tested. - **24 guarantees you can check in your own setup**, with `ctrlrun verify` against your policy, on your store's backend, in a scratch store it creates. - **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 2584413..d3aab8c 100644 --- a/docs/CLAIMS.md +++ b/docs/CLAIMS.md @@ -25,9 +25,9 @@ by its quoted claim, and `tests/test_docs_audit.py` fails if a named row is not | Claim | Code | Proof | |---|---|---| -| "The last check before an AI agent does something it can't undo." | `Control.execute` — `control.py:1086` — resolves the principal, evaluates authority and policy, consumes the approval and reserves the effect key **before** the executor runs; nothing in the wrapper calls the function first | `test_T1_a_blind_retry_is_refused_and_never_reaches_the_remote`, `test_T3_the_fake_remote_is_called_exactly_once` | +| "The last check before an AI agent does something it can't undo." | `Control.execute` — `control.py:1111` — resolves the principal, evaluates authority and policy, consumes the approval and reserves the effect key **before** the executor runs; nothing in the wrapper calls the function first | `test_T1_a_blind_retry_is_refused_and_never_reaches_the_remote`, `test_T3_the_fake_remote_is_called_exactly_once` | | "Autonomy belongs to the action, not the agent." | `Policy.evaluate(action)` — `policy.py:596` — passes only the action's **name and arguments** to `_ActionPolicy.evaluate` (`policy.py:596`), whose signature has no principal in it. A rule cannot read who is acting even by accident. `agent_eq` and `user_eq` are refused at load by `RESERVED_ARGUMENTS` (`policy.py:224`) rather than silently matching nothing. | `test_T6_an_action_name_is_matched_exactly`, `test_a_condition_naming_an_action_field_is_refused_at_load` | -| "A consequential action happens at most once, exactly as approved, and leaves a receipt — and when the outcome is unknown, CTRLRun says so instead of guessing." | At most once: `plan_reservation` — `effect.py:248`. Exactly as approved: the approval is bound to `action_hash` and consumed with the reservation — `_authorize_and_reserve` — `state.py:1196`. Or not at all: a refusal raises before the executor — `Control.execute` — `control.py:1086`. Says so instead of guessing: only `NotExecuted` maps to `FAILED` — `_outcome` — `control.py:1877` — and everything else is `AMBIGUOUS`. A receipt: `Receipt` — `receipt.py:242`. **This sentence read *happens once … or not at all* until 0.6**, a two-way disjunction that excluded the third outcome the product exists for: a lost reply is neither, and the README's own first section says so. | `test_T3_exactly_one_agent_reserves_and_seven_are_blocked`, `test_T2_a_mutated_action_presenting_the_approval_raises_ApprovalMismatch`, `test_T1_a_lost_response_leaves_the_effect_ambiguous`, `test_T11_every_demo_receipt_carries_every_field_in_the_spec` | +| "A consequential action happens at most once, exactly as approved, and leaves a receipt — and when the outcome is unknown, CTRLRun says so instead of guessing." | At most once: `plan_reservation` — `effect.py:248`. Exactly as approved: the approval is bound to `action_hash` and consumed with the reservation — `_authorize_and_reserve` — `state.py:1196`. Or not at all: a refusal raises before the executor — `Control.execute` — `control.py:1111`. Says so instead of guessing: only `NotExecuted` maps to `FAILED` — `_outcome` — `control.py:1927` — and everything else is `AMBIGUOUS`. A receipt: `Receipt` — `receipt.py:242`. **This sentence read *happens once … or not at all* until 0.6**, a two-way disjunction that excluded the third outcome the product exists for: a lost reply is neither, and the README's own first section says so. | `test_T3_exactly_one_agent_reserves_and_seven_are_blocked`, `test_T2_a_mutated_action_presenting_the_approval_raises_ApprovalMismatch`, `test_T1_a_lost_response_leaves_the_effect_ambiguous`, `test_T11_every_demo_receipt_carries_every_field_in_the_spec` | | "A Python library that sits between the decision to act and the call that acts." | `@protect` — `control.py` — wraps the callable that acts, and `Control.execute` runs every check before invoking it. The category noun was on `docs.mdx` and in `pyproject.toml`'s `description` and nowhere in the README until 0.6, so a reader had to infer what CTRLRun **is** from three slogans. | `test_the_header_carries_the_fixed_copy_and_the_five_badges`, `test_T1_a_blind_retry_is_refused_and_never_reaches_the_remote` | | "Runs in production on a single file, or on Postgres across hosts" | SQLite: `SQLiteStateStore` reserves inside the `BEGIN IMMEDIATE` of `_authorize_and_reserve` — `state.py:1476` — which is a write lock on the file and holds across OS processes. Postgres: `PostgresStateStore` over `UNIQUE(effect_key)` with `INSERT … ON CONFLICT DO NOTHING` and checked row counts (SPEC-v0.6 §4.2), the same `StateStore` protocol, extended by nothing | `test_T3_exactly_one_agent_reserves_and_seven_are_blocked` (8 OS processes, both backends), `test_T141_the_shipped_backends_pass`, `test_T154_postgres_passes_the_store_conformance_suite` | @@ -35,7 +35,7 @@ by its quoted claim, and `tests/test_docs_audit.py` fails if a named row is not | Claim | Code | Proof | |---|---|---| -| "A lost reply is `AMBIGUOUS`, never `FAILED`, and a retry against an `AMBIGUOUS` effect is refused — until a human, or a `reconcile` hook, says what happened." | Only `NotExecuted` maps to `FAILED` — `_outcome` — `control.py:1877`; a retry against an `AMBIGUOUS` key is refused by `plan_reservation` — `effect.py:248`; the two things permitted to move the record on and nothing else — `resolve` — `cli/main.py:587` — and `Control._reconciled` — `control.py:2512` | `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:1118`; a retry against an `AMBIGUOUS` key is refused by `plan_reservation` — `effect.py:248`; the two things permitted to move the record on and nothing else — `resolve` — `cli/main.py:587` — and `Control._reconciled` — `control.py:2562` | `test_T1_a_lost_response_leaves_the_effect_ambiguous`, `test_T1_a_blind_retry_is_refused_and_never_reaches_the_remote`, `test_T160_there_is_no_reaper`, `test_T13_a_hook_answering_not_executed_moves_the_record_to_failed` | | "The customer is refunded twice, and nothing in the stack noticed." — said of a stack without CTRLRun; the demo runs the same sequence with it, and counts the calls the remote received | `ctrlrun demo` scenario 1, which retries against a fake remote that counts its calls and prints the count | `test_T3_the_fake_remote_is_called_exactly_once`, `test_T1_a_blind_retry_is_refused_and_never_reaches_the_remote` | ## Protect your first action @@ -50,22 +50,22 @@ 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:1877`; `plan_reservation` — `effect.py:248` | `test_T1_a_lost_response_leaves_the_effect_ambiguous`, `test_T1_a_blind_retry_is_refused_and_never_reaches_the_remote` | +| "A lost reply is `AMBIGUOUS`, never `FAILED`, and a retry against an `AMBIGUOUS` effect is refused." | Only `NotExecuted` maps to `FAILED` — `_outcome` — `control.py:1927`; `plan_reservation` — `effect.py:248` | `test_T1_a_lost_response_leaves_the_effect_ambiguous`, `test_T1_a_blind_retry_is_refused_and_never_reaches_the_remote` | | "reserved atomically across processes and hosts; one worker wins" | `reserve_effect` — `state.py:637`; `PostgresStateStore.reserve_effect` — `postgres.py:740` | `test_T3_exactly_one_agent_reserves_and_seven_are_blocked`, `test_T154_postgres_passes_the_store_conformance_suite` | | "bound to the hash of the exact action a human saw, used once, and refused for anything else" | `_authorize_and_reserve` — `state.py:1196` | `test_T2_a_mutated_action_presenting_the_approval_raises_ApprovalMismatch`, `test_T4_replaying_the_approval_raises_ApprovalMismatch_with_reason_consumed` | | "An action the policy does not list is denied" | `Policy.evaluate` — `policy.py:596` | `test_T6_unknown_action_is_denied_with_reason_unknown_action` | -| "Authority first ... then policy" / "authority first" | `Control.execute` evaluates authority before policy and a denial appends `AUTHORITY_DENIED` and never `POLICY_EVALUATED` — `control.py:1086` | `test_T74_a_denial_leaves_no_pending_approval_request` | +| "Authority first ... then policy" / "authority first" | `Control.execute` evaluates authority before policy and a denial appends `AUTHORITY_DENIED` and never `POLICY_EVALUATED` — `control.py:1111` | `test_T74_a_denial_leaves_no_pending_approval_request` | | "Neither axis reads the agent's instructions" | `Policy.evaluate` — `policy.py:596` — sees the action's name and arguments; `Authority.evaluate` — `authority.py:1199` — sees the action and the principal; neither is handed a prompt, a message or a tool result | `test_T6_an_action_name_is_matched_exactly`, `test_T67_a_principal_with_no_grant_is_denied` | | "canonical arguments (sorted keys, no floats) ... Its SHA-256 is the action hash" | `canonicalize` / `action_hash` — `action.py`; `float` refused at any depth — `action.py:79` | `test_T7_canonical_form_is_exactly_the_specified_serialization`, `test_T7_nested_dicts_are_sorted_recursively` | | "The approval is single-use, expires, and matches nothing but that exact action." | `_authorize_and_reserve` — `state.py:1196` — checks expiry at consumption | `test_T5_expiry_is_checked_at_consumption_not_only_at_grant`, `test_T4_replaying_the_approval_raises_ApprovalMismatch_with_reason_consumed` | -| "Only `NotExecuted`, raised by you, means `FAILED`." | `_outcome` — `control.py:1877`; `NotExecuted` — `errors.py:157` | `test_T1_a_lost_response_leaves_the_effect_ambiguous` | +| "Only `NotExecuted`, raised by you, means `FAILED`." | `_outcome` — `control.py:1927`; `NotExecuted` — `errors.py:157` | `test_T1_a_lost_response_leaves_the_effect_ambiguous` | | "the hash of the policy that decided it, chained to the receipt before it" | `Policy.policy_hash` — `policy.py:734`; `prev_hash`, `GENESIS_HASH` for the first — `receipt.py:117` | `test_T172_every_receipt_carries_the_hash_and_the_declared_version`, `test_T164_an_altered_receipt_is_content_altered_at_its_seq` | ## Three ways to use it | Claim | Code | Proof | |---|---|---| -| "You probably do not need an adapter" | Three ways in, and `@protect` (`control.py:4444`) covers this process while the gateway covers MCP — an adapter buys only the interrupt | `test_T139_the_adapter_section_says_when_you_do_not_need_one_up_front` | +| "You probably do not need an adapter" | Three ways in, and `@protect` (`control.py:4513`) covers this process while the gateway covers MCP — an adapter buys only the interrupt | `test_T139_the_adapter_section_says_when_you_do_not_need_one_up_front` | | "`ctrlrun init` writes a starter" | `init` — `cli/main.py:353` | CI's `package` job runs `ctrlrun init` from the wheel and asserts `ctrlrun.yaml` exists | | "The human runs `ctrlrun approve ` and the agent calls again inside `ctrlrun.with_approval(request_id)`" | `approve` — `cli/main.py:377`; `with_approval` — `control.py:346`; `ApprovalRequired` (`errors.py:88`) carries `request_id` | `test_T2_a_mutated_action_presenting_the_approval_raises_ApprovalMismatch` (the granted path first), `test_T4_replaying_the_approval_raises_ApprovalMismatch_with_reason_consumed` | | "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` | @@ -89,20 +89,20 @@ by its quoted claim, and `tests/test_docs_audit.py` fails if a named row is not | "Unknown actions are denied; there is no default-allow." | `Policy.evaluate` — `policy.py:596` | `test_T6_unknown_action_is_denied_with_reason_unknown_action` | | "Amounts are integer minor units; floats are rejected outright" | `float` refused at any depth — `action.py:79` | `test_T7_canonical_form_is_exactly_the_specified_serialization` | | "The policy cannot see who is asking — deliberately, since v0.1" | `Policy.evaluate` still takes only the action's name and arguments; `RESERVED_ARGUMENTS` — `policy.py:596` — refuses `agent_eq` and every other principal-addressing condition at load, in a document of **every** schema version | `test_T74b_a_reserved_name_in_a_policy_rule_is_a_load_error`, `test_T74b_a_reserved_name_in_a_grant_constraint_is_a_load_error` | -| "the second axis, `authority:`" | `Authority.evaluate` — `authority.py:1191`; `Control._authority_result` — `control.py:921` | `test_T67_a_principal_with_no_grant_is_denied` | +| "the second axis, `authority:`" | `Authority.evaluate` — `authority.py:1191`; `Control._authority_result` — `control.py:946` | `test_T67_a_principal_with_no_grant_is_denied` | | "opt-in, and then fail-closed" | `_optional_authority` returns `None` for a document with no section — `control.py`; `Control.authority is None` is v0.2 behaviour exactly | `test_T66_a_document_with_no_authority_section_leaves_control_authority_none`, `test_T66_no_authority_event_is_appended_without_a_section`, and T66's session-wide guard in `tests/conftest.py` | | "every principal needs a grant and no grant means denied" | `NO_AUTHORITY` — the fail-closed default of `Authority.evaluate` (`authority.py:77`), reached for reads and for actions with no effect key alike | `test_T67_an_action_the_policy_allows_outright_still_needs_a_grant` | | "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:3717`; `Authority.plan_delegation` — `authority.py:1430`; `ctrlrun delegate` — `cli/main.py:1045` | `test_t75_the_delegation_authorizes_an_action_within_its_limits` | +| "narrow it at runtime with `ctrlrun delegate`" | `Control.delegate` — `control.py:3786`; `Authority.plan_delegation` — `authority.py:1430`; `ctrlrun delegate` — `cli/main.py:1045` | `test_t75_the_delegation_authorizes_an_action_within_its_limits` | | "provably a subset of its parent on every dimension, at creation and again at every evaluation" | `contained_dimension` — `authority.py:889` — runs from `plan_delegation` (`authority.py:1430`) **and** from the chain walk in `Authority.evaluate` (`authority.py:1199`) | `test_t76_each_dimension_violated_alone`, `test_t77b_a_narrowed_parent_narrows_its_children` | | "provably a subset of its parent on every dimension, at creation and again at every evaluation; a consequence budget is consumed inside the reservation's own transaction, and a rolling window bounds what may start rather than recalling what already did" | Containment as in the row above. The budget: `check_charges` — `state.py:569` — is evaluated inside `reserve_effect`'s own transaction on all three backends, and `_charges_for` — `authority.py:1285` — charges every ancestor in the chain. The window is rolling and bounds the next reserve only: `_spent` sums `[now - window, now]` and nothing reads it again after a reservation is taken | `test_T408_a_charge_and_its_reservation_are_one_transaction`, `test_T409_N_processes_racing_one_budget_spend_at_most_the_limit`, `test_T412b_every_ancestor_is_charged_through_a_real_chain`, `test_T408c_the_rolling_window_forgets` | | "omitting a dimension the parent constrains is rejected rather than inherited" | `contained_dimension` treats an absent child dimension as unconstrained and therefore wider — `authority.py:889`; the subject half is `_subject_contained` (`authority.py:968`) | `test_t81_omission_is_not_unlimited`, `test_T73b_a_subject_addressed_to_every_principal_is_refused`, `test_t76_each_dimension_violated_alone` | -| "`ctrlrun revoke` cuts a chain of any depth with one write" | `Control.revoke` — `control.py:4016` — writes one row — `revoke_delegation` — `state.py:835` and visits no children; every evaluation walks to the root | `test_t78_a_revoked_parent_denies_its_grandchild`, `test_put_delegation_is_never_an_upsert` | -| "`mode: observe` … records what *would* have been blocked, without blocking anything" | `_parse_mode` — `policy.py:719`; `Control._observed` — `control.py:1462`; `_WouldHave` — `receipt.py:336`; `ReceiptResult.OBSERVED` — `receipt.py:248` | `test_T82_observe_executes_what_enforce_would_deny`, `test_T83_a_duplicate_is_recorded_and_still_runs` | +| "`ctrlrun revoke` cuts a chain of any depth with one write" | `Control.revoke` — `control.py:4085` — writes one row — `revoke_delegation` — `state.py:835` and visits no children; every evaluation walks to the root | `test_t78_a_revoked_parent_denies_its_grandchild`, `test_put_delegation_is_never_an_upsert` | +| "`mode: observe` … records what *would* have been blocked, without blocking anything" | `_parse_mode` — `policy.py:719`; `Control._observed` — `control.py:1487`; `_WouldHave` — `receipt.py:336`; `ReceiptResult.OBSERVED` — `receipt.py:248` | `test_T82_observe_executes_what_enforce_would_deny`, `test_T83_a_duplicate_is_recorded_and_still_runs` | | "One top-level line" | `mode:` is refused anywhere but the top level — `reject_nested_mode`, `policy.py:719` | `test_T84_mode_is_refused_anywhere_but_the_top_level` | | "`ctrlrun stats` gives you the numbers" | `stats` — `cli/main.py:866`; counted from `would_have.blocked_reason` and nothing else | `test_T86_stats_counts_what_observe_mode_recorded`, `test_T86_stats_reaches_no_network` | -| "It is not a dry run: it executes" | `_observed` runs the executor on every path, including the ones enforce mode would have refused — `control.py:1462` | `test_T82_observe_executes_what_enforce_would_deny`, `test_T83_an_executor_that_fails_on_a_held_key_still_writes_the_record` | +| "It is not a dry run: it executes" | `_observed` runs the executor on every path, including the ones enforce mode would have refused — `control.py:1487` | `test_T82_observe_executes_what_enforce_would_deny`, `test_T83_an_executor_that_fails_on_a_held_key_still_writes_the_record` | ## Prove it holds in your setup @@ -139,10 +139,10 @@ catalogue, `GUARANTEES` (`verify/guarantees.py:47`). |---|---|---| | "An approval is bound to the exact action; a mutated or replayed one is refused." | `action_hash` — `action.py`; the approval record stores it and `_authorize_and_reserve` compares it — `state.py:665`; single use is the `granted → consumed` transition in the same `BEGIN IMMEDIATE` | `test_T2_a_mutated_action_presenting_the_approval_raises_ApprovalMismatch`, `test_T4_replaying_the_approval_raises_ApprovalMismatch_with_reason_consumed`, `test_T5_expiry_is_checked_at_consumption_not_only_at_grant` | | "One logical effect happens at most once, across threads, processes and hosts." | `reserve_effect` — `state.py:637`, decided inside the `BEGIN IMMEDIATE` of `_authorize_and_reserve` (`state.py:1196`) against `effect_key TEXT PRIMARY KEY` (`migrations.py:107`; `COLLATE "C"` on Postgres, §4.4) | `test_T3_exactly_one_agent_reserves_and_seven_are_blocked` (8 OS processes, both backends), `test_T3_the_fake_remote_is_called_exactly_once` | -| "An unknown outcome is AMBIGUOUS, never FAILED, and blocks a blind retry." | Only `NotExecuted` maps to `FAILED` — `_outcome` — `control.py:1877`. Every other exception, timeouts included, yields `AMBIGUOUS`. A retry against an `AMBIGUOUS` key is refused — `effect.py:172`, the one place `plan_reservation` decides it for every store | `test_T1_a_blind_retry_writes_a_blocked_receipt`, `test_T1_the_ambiguous_record_survives_the_blocked_retry`, `test_T1_a_lost_response_leaves_the_effect_ambiguous` | -| "An unknown action, a missing policy or a missing principal is denied." | Unknown action: `Policy.evaluate` — `policy.py:596` — answers `deny` for a name the document does not list. Missing or malformed policy: `Policy.from_file` — `policy.py:753` — raises `PolicyError`, and there is no `Control` without a policy. Missing principal: `_refuse_no_principal` — `control.py:4326` | `test_T6_unknown_action_raises_ActionDenied_with_reason_unknown_action`, `test_missing_policy_file_is_a_policy_error`, `test_malformed_policy_document_is_a_policy_error`, `test_T62_a_declining_provider_with_no_context_is_no_principal` | +| "An unknown outcome is AMBIGUOUS, never FAILED, and blocks a blind retry." | Only `NotExecuted` maps to `FAILED` — `_outcome` — `control.py:1927`. Every other exception, timeouts included, yields `AMBIGUOUS`. A retry against an `AMBIGUOUS` key is refused — `effect.py:172`, the one place `plan_reservation` decides it for every store | `test_T1_a_blind_retry_writes_a_blocked_receipt`, `test_T1_the_ambiguous_record_survives_the_blocked_retry`, `test_T1_a_lost_response_leaves_the_effect_ambiguous` | +| "An unknown action, a missing policy or a missing principal is denied." | Unknown action: `Policy.evaluate` — `policy.py:596` — answers `deny` for a name the document does not list. Missing or malformed policy: `Policy.from_file` — `policy.py:753` — raises `PolicyError`, and there is no `Control` without a policy. Missing principal: `_refuse_no_principal` — `control.py:4395` | `test_T6_unknown_action_raises_ActionDenied_with_reason_unknown_action`, `test_missing_policy_file_is_a_policy_error`, `test_malformed_policy_document_is_a_policy_error`, `test_T62_a_declining_provider_with_no_context_is_no_principal` | | "With authority on, every principal needs a grant, and delegation cannot widen one." | `NO_AUTHORITY` — the fail-closed default of `Authority.evaluate` (`authority.py:77`); `contained_dimension` — `authority.py:889` — runs from `plan_delegation` (`authority.py:1430`) and from the chain walk in `Authority.evaluate` | `test_T67_a_principal_with_no_grant_is_denied`, `test_t76_each_dimension_violated_alone` | -| "Every executed action leaves a portable JSON receipt" | `ReceiptResult` — `receipt.py:232`; `Event` — `receipt.py:298`; the store is authoritative — `append_event` — `state.py:846`; the JSONL export — `JSONLEventSink` — `receipt.py:786` | `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:232`; `Event` — `receipt.py:298`; the store is authoritative — `append_event` — `state.py:846`; the JSONL export — `JSONLEventSink` — `receipt.py:792` | `test_T11_every_demo_receipt_carries_every_field_in_the_spec`, `test_T11_every_demo_receipt_parses_back_into_a_Receipt` | ## What it guarantees @@ -152,23 +152,23 @@ catalogue, `GUARANTEES` (`verify/guarantees.py:47`). | "a unique index on the effect key and compare-and-set updates whose row counts are checked" | `reserve_effect` — `postgres.py:740` — `INSERT … ON CONFLICT DO NOTHING` against `effect_key TEXT PRIMARY KEY COLLATE "C"` (`migrations.py:107`) | `test_T3_exactly_one_agent_reserves_and_seven_are_blocked` (8 OS processes, both backends) | | "Same `StateStore` protocol, extended by nothing" | `PostgresStateStore.reserve_effect` — `postgres.py:740` — and every other method implement `v0.1 §5.3`'s frozen protocol; the decisions stay in `plan_reservation` (`effect.py:248`) | `test_T154_postgres_passes_the_store_conformance_suite` | | "graded by the suite written for SQLite" | `ctrlrun.conformance.store.run` — `conformance/store/__init__.py:53` | `test_T140_every_fixture_fails_the_suite_named_for_it` | -| "It will not *knowingly* execute the same logical effect twice, and will never treat an unknown outcome as a failure." | `plan_reservation` — `effect.py:248` (refuse retry on `AMBIGUOUS`) and `_outcome` — `control.py:1877` (only `NotExecuted` → `FAILED`) | `test_T1_a_blind_retry_is_refused_and_never_reaches_the_remote`, `test_T1_a_lost_response_leaves_the_effect_ambiguous` | -| "a lost connection during `COMMIT` ... are `AMBIGUOUS`" | `_resolve_lost_insert` — `postgres.py:931`; `_resolve_lost_update` — `postgres.py:1557`; only `NotExecuted` maps to `FAILED` — `_outcome` — `control.py:1877` | `test_T155_a_connection_killed_during_commit_is_resolved_by_the_re_read`, `test_T155_no_effect_is_ever_recorded_failed_by_a_lost_commit` | +| "It will not *knowingly* execute the same logical effect twice, and will never treat an unknown outcome as a failure." | `plan_reservation` — `effect.py:248` (refuse retry on `AMBIGUOUS`) and `_outcome` — `control.py:1927` (only `NotExecuted` → `FAILED`) | `test_T1_a_blind_retry_is_refused_and_never_reaches_the_remote`, `test_T1_a_lost_response_leaves_the_effect_ambiguous` | +| "a lost connection during `COMMIT` ... are `AMBIGUOUS`" | `_resolve_lost_insert` — `postgres.py:931`; `_resolve_lost_update` — `postgres.py:1557`; only `NotExecuted` maps to `FAILED` — `_outcome` — `control.py:1927` | `test_T155_a_connection_killed_during_commit_is_resolved_by_the_re_read`, `test_T155_no_effect_is_ever_recorded_failed_by_a_lost_commit` | | "the store re-reads the row to find out which" | The six branches, named and logged — `A2_LANDED` — `postgres.py:140` | `test_T155b_a_landed_commit_on_a_transition_is_seen_as_landed`, `test_T155d_a_commit_the_server_never_received_retries_the_insert` | | "A crashed worker's effect stays `AMBIGUOUS` until a human runs `ctrlrun resolve` or a `reconcile` hook asks the remote what happened" | An expired lease is `AMBIGUOUS` and nothing sweeps it — `LEASE_EXPIRED` — `effect.py:172`; who resolved it — `resolved_by` — `effect.py:208`; `resolve` — `cli/main.py:587` | `test_T159_ambiguous_survives_a_restart_and_still_refuses_a_blind_retry`, `test_T160_there_is_no_reaper`, `test_T161_a_human_resolution_records_who` | -| "the only thing besides a human permitted to move a record out of `AMBIGUOUS`" | `Control._reconciled` — `control.py:2512`; `RECONCILED_STATES` — `effect.py` | `test_T13_a_hook_answering_not_executed_moves_the_record_to_failed`, `test_T14_a_hook_answering_committed_refuses_the_retry_as_a_duplicate` | +| "the only thing besides a human permitted to move a record out of `AMBIGUOUS`" | `Control._reconciled` — `control.py:2562`; `RECONCILED_STATES` — `effect.py` | `test_T13_a_hook_answering_not_executed_moves_the_record_to_failed`, `test_T14_a_hook_answering_committed_refuses_the_retry_as_a_duplicate` | | "and only in the direction its answer points" | `"unknown"` is absent from `RECONCILED_STATES` — `effect.py` | `test_T15_a_hook_that_cannot_answer_leaves_the_record_ambiguous` | -| "Unknown action, missing policy, malformed policy, missing principal, missing or mismatched approval and inconsistent state are all `deny`." | `Policy.evaluate` — `policy.py:596`; `Policy.from_file` — `policy.py:753`; `_refuse_no_principal` — `control.py:4326`; `_authorize_and_reserve` — `state.py:1196` | `test_T6_unknown_action_raises_ActionDenied_with_reason_unknown_action`, `test_malformed_policy_document_is_a_policy_error`, `test_T62_a_declining_provider_with_no_context_is_no_principal`, `test_T2_a_mutated_action_presenting_the_approval_raises_ApprovalMismatch` | +| "Unknown action, missing policy, malformed policy, missing principal, missing or mismatched approval and inconsistent state are all `deny`." | `Policy.evaluate` — `policy.py:596`; `Policy.from_file` — `policy.py:753`; `_refuse_no_principal` — `control.py:4395`; `_authorize_and_reserve` — `state.py:1196` | `test_T6_unknown_action_raises_ActionDenied_with_reason_unknown_action`, `test_malformed_policy_document_is_a_policy_error`, `test_T62_a_declining_provider_with_no_context_is_no_principal`, `test_T2_a_mutated_action_presenting_the_approval_raises_ApprovalMismatch` | | "No flag makes a consequential action permissive by default" | There is no such option on `Control`, on `@protect`, on the CLI or in the policy schema's closed key sets — `_TOP_LEVEL_KEYS` — `policy.py:123` | `test_T84_mode_is_refused_anywhere_but_the_top_level`, `test_T101b_zero_applicable_guarantees_is_not_a_pass` | -| "With `authority:` on, every principal needs a grant, delegation cannot widen one, and `ctrlrun revoke` cuts a chain with one write." | `Authority.evaluate` — `authority.py:1191`; `contained_dimension` — `authority.py:889`; `Control.revoke` — `control.py:4016` | `test_T67_a_principal_with_no_grant_is_denied`, `test_t76_each_dimension_violated_alone`, `test_t78_a_revoked_parent_denies_its_grandchild` | +| "With `authority:` on, every principal needs a grant, delegation cannot widen one, and `ctrlrun revoke` cuts a chain with one write." | `Authority.evaluate` — `authority.py:1191`; `contained_dimension` — `authority.py:889`; `Control.revoke` — `control.py:4085` | `test_T67_a_principal_with_no_grant_is_denied`, `test_t76_each_dimension_violated_alone`, `test_t78_a_revoked_parent_denies_its_grandchild` | | "verifies a bearer token against a JWKS or a pinned key" | `JWTIdentityProvider._verified` — `jwt_identity.py:208`; the algorithm comes from the configured list and never from the token | `test_T88_a_valid_token_becomes_a_principal`, `test_T89_every_invalid_token_is_refused_by_cause` | | "maps the verified claims onto a principal" | `_principal` — `jwt_identity.py` — copies only the claims named in `claim_names` | `test_T88_only_the_named_claims_reach_the_principal` | | "`pip install \"ctrlrun[identity]\"`" | `identity = ["pyjwt[crypto]>=2.8"]` in `pyproject.toml`; imported lazily by `_jwt()` — `jwt_identity.py` | `test_T92_constructing_without_the_extra_names_the_install_command`, `test_T92_importing_ctrlrun_pulls_in_no_jwt_module` | | "CTRLRun issues no credential and defines no identity format" | There is no minting, signing or issuing code path in the package: `jwt_identity.py` calls `decode` and never `encode` | `test_the_package_never_encodes_a_token` | -| "every receipt records which policy decided it" | `Policy.policy_hash` — `policy.py:734`, over `_canonical_policy` — `policy.py:963`; carried into the receipt by `_record` — `control.py:4221` | `test_T172_every_receipt_carries_the_hash_and_the_declared_version`, `test_T172_two_policies_sharing_a_version_string_are_told_apart_by_the_hash` | +| "every receipt records which policy decided it" | `Policy.policy_hash` — `policy.py:734`, over `_canonical_policy` — `policy.py:963`; carried into the receipt by `_record` — `control.py:4290` | `test_T172_every_receipt_carries_the_hash_and_the_declared_version`, `test_T172_two_policies_sharing_a_version_string_are_told_apart_by_the_hash` | | "the policy's declared `version:` and a hash of its canonical content" | `version:` is recorded and never authoritative; `policy_hash` is what tells two documents apart — `policy.py:724` | `test_T171_the_declared_version_alone_does_not_change_the_hash`, `test_T171_comments_key_order_and_whitespace_do_not_change_the_hash` | -| "the approval is re-checked against the policy in force at execution" | `Control.execute` — `control.py:1086`; `_spend_unneeded_approval` — `control.py:2452` | `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:508`; `prev_hash` — `receipt.py:444`; `GENESIS_HASH` — `receipt.py:117`; `put_receipt` takes the head row's lock first — `postgres.py:2092` | `test_T164_an_altered_receipt_is_content_altered_at_its_seq`, `test_T164_reordering_two_receipts_is_detected_either_way` | +| "the approval is re-checked against the policy in force at execution" | `Control.execute` — `control.py:1111`; `_spend_unneeded_approval` — `control.py:2502` | `test_T173_the_DENY_row_refuses_and_leaves_the_approval_granted`, `test_T173_the_ALLOW_row_invalidates_the_approval_it_did_not_need` | +| "Each receipt carries the hash of the one before it" | `Receipt.chain_hash` — `receipt.py:514`; `prev_hash` — `receipt.py:444`; `GENESIS_HASH` — `receipt.py:117`; `put_receipt` takes the head row's lock first — `postgres.py:2092` | `test_T164_an_altered_receipt_is_content_altered_at_its_seq`, `test_T164_reordering_two_receipts_is_detected_either_way` | | "`ctrlrun receipts --verify-chain` reports it by `seq`" | `verify_chain` — `receipt.py:904`; the six names — `CHAIN_BREAKS` — `receipt.py:841` | `test_the_verify_chain_flag_reports_a_break_by_seq_and_by_name`, `test_verify_chain_reads_a_postgres_store_through_store_url` | | "migrations are automatic at open, forward-only" | `migrate` — `migrations.py:633`, called from both stores' constructors; `HEAD` — `migrations.py:414` | `test_T147_a_v05_database_migrates_and_keeps_every_row`, `test_T150_reopening_does_not_rerun` | | "An older binary against a newer schema refuses immediately" | `_refuse` — `migrations.py:559`; `SchemaMismatch` — `errors.py` | `test_T148_an_older_binary_refuses_a_newer_database`, `test_T148_no_other_table_is_read_before_the_refusal` | @@ -228,7 +228,7 @@ restating the code; the ones that are new to the site carry their own code and p | `concepts/outcomes-and-ambiguous` | the outcome table; only a human or a reconcile hook moves a record on, and only in the direction the answer points; nothing sweeps; a lost `COMMIT` on Postgres is `AMBIGUOUS` | the matrix row "An unknown outcome is AMBIGUOUS…", the reconciliation rows, "A crashed worker's effect stays `AMBIGUOUS`…" and the Postgres rows above; `test_T160_there_is_no_reaper` | | `concepts/receipts-and-evidence` | the receipt's fields, the JSONL sink, the policy hash and version, the chain and what it does not prove | the matrix row "Every executed action leaves a portable JSON receipt", the receipt-chain and policy-versioning rows above, and `test_T11_every_demo_receipt_carries_every_field_in_the_spec` | | `concepts/authority-and-delegation` | opt-in then fail-closed, no `decision:` on a grant, stricter of the two, containment at creation and at every evaluation, omission rejected, one-write revocation, identity consumed | the authority rows under "Write down what the agent may do" and "What it guarantees" above | -| `concepts/observe-mode` | executes, records `would_have`, one top-level line, counted by `ctrlrun stats`, never asks a human | the observe-mode rows above; `_observed` — `control.py:4269` | +| `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:4338` | | `concepts/fail-closed` | the refusal table, one exception per row | the matrix row "An unknown action, a missing policy or a missing principal is denied.", `ActionDenied` — `errors.py:29`, `DuplicateEffect` — `errors.py:126`, `AmbiguousEffect` — `errors.py:141`, and `test_a_policy_deny_is_denied_the_same_way_as_an_unknown_action` | ## The docs site: Production diff --git a/docs/production/index.mdx b/docs/production/index.mdx index b0b4572..55250c2 100644 --- a/docs/production/index.mdx +++ b/docs/production/index.mdx @@ -28,7 +28,7 @@ need. `test_the_first_line_of_the_section_says_which_store_and_why` asserts the {/* generated from the suite, pyproject and the soak (full) — run the generator */} - **Version 0.9.0**, on [PyPI](https://pypi.org/project/ctrlrun/), Python 3.11 and later. -- **5,982 tests**, every version specified before it was written and every requirement mutation-tested. [Read more](/docs/how-this-is-built). +- **6,019 tests**, every version specified before it was written and every requirement mutation-tested. [Read more](/docs/how-this-is-built). - **24 guarantees you can check in your own setup**, with `ctrlrun verify` against your policy, on your store's backend, in a scratch store it creates. [Read more](/docs/security/verify-guarantees). - **One host: a file.** SQLite, no server, no ops. **Many hosts: Postgres**, the same guarantees, graded by the same suite. [Read more](/docs/production/postgres). - **Soaked for 20m 0s on postgres**: 889,735 actions, 0 unattributed ambiguous outcomes, positive control fired. Nothing here establishes what only accumulates over days. [Read more](/docs/production/soak). diff --git a/docs/reference/api/Control.mdx b/docs/reference/api/Control.mdx index 7887045..40b05ba 100644 --- a/docs/reference/api/Control.mdx +++ b/docs/reference/api/Control.mdx @@ -5,7 +5,7 @@ description: "Policy, state and evidence composed around a single action (SPEC-v {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.Control` — class, defined at `src/ctrlrun/control.py:636` +`ctrlrun.Control` — class, defined at `src/ctrlrun/control.py:661` ```python from ctrlrun import Control diff --git a/docs/reference/api/EventSink.mdx b/docs/reference/api/EventSink.mdx index dc187c4..ea24ea4 100644 --- a/docs/reference/api/EventSink.mdx +++ b/docs/reference/api/EventSink.mdx @@ -5,7 +5,7 @@ description: "Somewhere a copy of every `Event` and `Receipt` goes (SPEC-v0.2 § {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.EventSink` — class, defined at `src/ctrlrun/receipt.py:767` +`ctrlrun.EventSink` — class, defined at `src/ctrlrun/receipt.py:773` ```python from ctrlrun import EventSink diff --git a/docs/reference/api/JSONLEventSink.mdx b/docs/reference/api/JSONLEventSink.mdx index a2a200b..deda44e 100644 --- a/docs/reference/api/JSONLEventSink.mdx +++ b/docs/reference/api/JSONLEventSink.mdx @@ -5,7 +5,7 @@ description: "The JSONL half of the evidence: two append-only files in one direc {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.JSONLEventSink` — class, defined at `src/ctrlrun/receipt.py:786` +`ctrlrun.JSONLEventSink` — class, defined at `src/ctrlrun/receipt.py:792` ```python from ctrlrun import JSONLEventSink diff --git a/docs/reference/api/protect.mdx b/docs/reference/api/protect.mdx index df40f63..807dae0 100644 --- a/docs/reference/api/protect.mdx +++ b/docs/reference/api/protect.mdx @@ -5,7 +5,7 @@ description: "Bind a function to an action name: every call becomes a decided, r {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.protect` — function, defined at `src/ctrlrun/control.py:4444` +`ctrlrun.protect` — function, defined at `src/ctrlrun/control.py:4513` ```python from ctrlrun import protect diff --git a/generated/readiness.full.mdx b/generated/readiness.full.mdx index 8031993..79737f3 100644 --- a/generated/readiness.full.mdx +++ b/generated/readiness.full.mdx @@ -1,6 +1,6 @@ {/* generated from the suite, pyproject and the soak (full) — run the generator */} - **Version 0.9.0**, on [PyPI](https://pypi.org/project/ctrlrun/), Python 3.11 and later. -- **5,982 tests**, every version specified before it was written and every requirement mutation-tested. [Read more](/docs/how-this-is-built). +- **6,019 tests**, every version specified before it was written and every requirement mutation-tested. [Read more](/docs/how-this-is-built). - **24 guarantees you can check in your own setup**, with `ctrlrun verify` against your policy, on your store's backend, in a scratch store it creates. [Read more](/docs/security/verify-guarantees). - **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 e322f9e..503ac9f 100644 --- a/generated/readiness.json +++ b/generated/readiness.json @@ -9,6 +9,6 @@ "positive_control": true, "unexplained": 0 }, - "tests": 5982, + "tests": 6019, "version": "0.9.0" } diff --git a/generated/readiness.mdx b/generated/readiness.mdx index 0f51664..7563514 100644 --- a/generated/readiness.mdx +++ b/generated/readiness.mdx @@ -1,6 +1,6 @@ {/* generated from the suite, pyproject and the soak (mdx) — run the generator */} - **Version 0.9.0**, on [PyPI](https://pypi.org/project/ctrlrun/), Python 3.11 and later. -- **5,982 tests**, every version specified before it was written and every requirement mutation-tested. +- **6,019 tests**, every version specified before it was written and every requirement mutation-tested. - **24 guarantees you can check in your own setup**, with `ctrlrun verify` against your policy, on your store's backend, in a scratch store it creates. - **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 a27001b..abaa8e2 100644 --- a/generated/readiness.readme.md +++ b/generated/readiness.readme.md @@ -1,6 +1,6 @@ - **Version 0.9.0**, on [PyPI](https://pypi.org/project/ctrlrun/), Python 3.11 and later. -- **5,982 tests**, every version specified before it was written and every requirement mutation-tested. +- **6,019 tests**, every version specified before it was written and every requirement mutation-tested. - **24 guarantees you can check in your own setup**, with `ctrlrun verify` against your policy, on your store's backend, in a scratch store it creates. - **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).