From 9dc4c4f246f28df3054e42494f5bf22ac0f70cb7 Mon Sep 17 00:00:00 2001 From: arpan Date: Sat, 12 Sep 2026 21:23:35 +0530 Subject: [PATCH 1/4] Regenerate after v0.8 items 1 to 5 Every page here is generated from the kernel, and four generators had drifted since the milestone started. The docs job has been red on main for that whole time, which is why this is one commit rather than five. cli `revoke --created-by/--under` (item 1), `mcp-operator --approver-roles-claim` (item 3), and `break-glass` (item 5) api ApproverIdentity and VerifiedApprover, and the docs.json group schemas the receipt is ctrlrun.receipt/v5: approvers, authority_grant_id readiness 5,528 tests and 19 guarantees, from 5,167 and 16 Version stays 0.7.0: v0.8 is not released, and readiness reports what is on PyPI rather than what is on main. Signed-off-by: arpan --- docs.json | 2 + docs/reference/api/Action.mdx | 2 +- docs/reference/api/Approval.mdx | 2 +- docs/reference/api/ApprovalProvider.mdx | 2 +- docs/reference/api/ApprovalRequest.mdx | 4 +- docs/reference/api/ApproverIdentity.mdx | 38 ++++++++++++ docs/reference/api/Authority.mdx | 4 +- docs/reference/api/AuthorityResult.mdx | 2 +- docs/reference/api/Condition.mdx | 2 +- docs/reference/api/Control.mdx | 4 +- docs/reference/api/Decision.mdx | 2 +- docs/reference/api/Delegation.mdx | 4 +- docs/reference/api/DelegationRecord.mdx | 2 +- docs/reference/api/Event.mdx | 2 +- docs/reference/api/EventSink.mdx | 2 +- docs/reference/api/Grant.mdx | 2 +- docs/reference/api/InMemoryStateStore.mdx | 2 +- docs/reference/api/JSONLEventSink.mdx | 2 +- docs/reference/api/LocalApprovalProvider.mdx | 2 +- docs/reference/api/Policy.mdx | 2 +- docs/reference/api/Principal.mdx | 2 +- docs/reference/api/Receipt.mdx | 4 +- docs/reference/api/SQLiteStateStore.mdx | 2 +- .../api/ScriptedApprovalProvider.mdx | 2 +- docs/reference/api/StateStore.mdx | 2 +- docs/reference/api/Subject.mdx | 2 +- docs/reference/api/VerifiedApprover.mdx | 32 ++++++++++ docs/reference/api/action_hash.mdx | 2 +- docs/reference/api/banner.mdx | 2 +- docs/reference/api/canonical_bytes.mdx | 2 +- docs/reference/api/canonicalize.mdx | 2 +- docs/reference/api/context.mdx | 2 +- docs/reference/api/idempotency_token.mdx | 2 +- docs/reference/api/index.mdx | 2 + docs/reference/api/needs_approval.mdx | 2 +- docs/reference/api/parse_conditions.mdx | 2 +- .../api/postgres-PostgresStateStore.mdx | 2 +- docs/reference/api/protect.mdx | 2 +- docs/reference/api/with_approval.mdx | 2 +- docs/reference/cli.mdx | 62 +++++++++++++++++-- docs/reference/receipt-and-event-schemas.mdx | 4 +- generated/readiness.full.mdx | 4 +- generated/readiness.json | 4 +- generated/readiness.mdx | 4 +- generated/readiness.readme.md | 4 +- 45 files changed, 181 insertions(+), 55 deletions(-) create mode 100644 docs/reference/api/ApproverIdentity.mdx create mode 100644 docs/reference/api/VerifiedApprover.mdx diff --git a/docs.json b/docs.json index 784d82c..e9f96f7 100644 --- a/docs.json +++ b/docs.json @@ -249,6 +249,7 @@ "docs/reference/api/ApprovalRequest", "docs/reference/api/ApprovalRequired", "docs/reference/api/ApprovalTimeout", + "docs/reference/api/ApproverIdentity", "docs/reference/api/Authority", "docs/reference/api/AuthorityDenied", "docs/reference/api/AuthorityEscalation", @@ -291,6 +292,7 @@ "docs/reference/api/StaticIdentityProvider", "docs/reference/api/Subject", "docs/reference/api/Suspended", + "docs/reference/api/VerifiedApprover", "docs/reference/api/WebhookApprovalProvider", "docs/reference/api/acs-AcsControlHook", "docs/reference/api/action_hash", diff --git a/docs/reference/api/Action.mdx b/docs/reference/api/Action.mdx index 250b3b6..fa03332 100644 --- a/docs/reference/api/Action.mdx +++ b/docs/reference/api/Action.mdx @@ -5,7 +5,7 @@ description: "A proposed agent action: what, with which arguments, by whom, on w {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.Action` — class, defined at `src/ctrlrun/action.py:178` +`ctrlrun.Action` — class, defined at `src/ctrlrun/action.py:202` ```python from ctrlrun import Action diff --git a/docs/reference/api/Approval.mdx b/docs/reference/api/Approval.mdx index b1cbbf3..c78d4d9 100644 --- a/docs/reference/api/Approval.mdx +++ b/docs/reference/api/Approval.mdx @@ -5,7 +5,7 @@ description: "A human's grant, bound to one `action_hash` (SPEC-v0.1 §4.1)." {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.Approval` — class, defined at `src/ctrlrun/approval.py:125` +`ctrlrun.Approval` — class, defined at `src/ctrlrun/approval.py:463` ```python from ctrlrun import Approval diff --git a/docs/reference/api/ApprovalProvider.mdx b/docs/reference/api/ApprovalProvider.mdx index 883d1e1..644c104 100644 --- a/docs/reference/api/ApprovalProvider.mdx +++ b/docs/reference/api/ApprovalProvider.mdx @@ -5,7 +5,7 @@ description: "How a human is asked, and how the answer comes back (SPEC-v0.1 §4 {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.ApprovalProvider` — class, defined at `src/ctrlrun/approval.py:318` +`ctrlrun.ApprovalProvider` — class, defined at `src/ctrlrun/approval.py:671` ```python from ctrlrun import ApprovalProvider diff --git a/docs/reference/api/ApprovalRequest.mdx b/docs/reference/api/ApprovalRequest.mdx index 0a57d6d..64e5036 100644 --- a/docs/reference/api/ApprovalRequest.mdx +++ b/docs/reference/api/ApprovalRequest.mdx @@ -5,7 +5,7 @@ description: "A pending question for a human: may this exact action run? (SPEC-v {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.ApprovalRequest` — class, defined at `src/ctrlrun/approval.py:79` +`ctrlrun.ApprovalRequest` — class, defined at `src/ctrlrun/approval.py:392` ```python from ctrlrun import ApprovalRequest @@ -14,7 +14,7 @@ from ctrlrun import ApprovalRequest ```python class ApprovalRequest - def __init__(request_id: str, action_hash: str, action: Action, created_at: datetime, expires_at: datetime, policy_hash: str | None = None, precondition_fingerprint: str | None = None) + def __init__(request_id: str, action_hash: str, action: Action, created_at: datetime, expires_at: datetime, policy_hash: str | None = None, precondition_fingerprint: str | None = None, required_roles: tuple[RequiredRole, ...] = (), approvals_required: int = 1) ``` A pending question for a human: may this exact action run? (SPEC-v0.1 §4.1) diff --git a/docs/reference/api/ApproverIdentity.mdx b/docs/reference/api/ApproverIdentity.mdx new file mode 100644 index 0000000..b18d06e --- /dev/null +++ b/docs/reference/api/ApproverIdentity.mdx @@ -0,0 +1,38 @@ +--- +title: "ApproverIdentity" +description: "How a deployment verifies who answered an approval (SPEC-v0.8 §2.3)." +--- + +{/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} + +`ctrlrun.ApproverIdentity` — class, defined at `src/ctrlrun/approval.py:201` + +```python +from ctrlrun import ApproverIdentity +``` + + +```python +class ApproverIdentity + def __init__(provider: IdentityProvider, roles_claim: str | None = None) +``` + +How a deployment verifies who answered an approval (SPEC-v0.8 §2.3). + +**Opt in, then fail closed.** A `Control` built without one behaves exactly as 0.7.0 did; +one built with it refuses any approval whose row carries no `VerifiedApprover`, wherever +that approval came from and whatever the store did with the column. + +A second instance of `v0.3`'s `IdentityProvider` and never the agent's: the agent's provider +reads what a proxy set for the agent, and a deployment where one object answers both doors +is one where the agent's own token can grant the agent's own approvals. + +`roles_claim` names the claim this issuer puts roles in (§3.4). It lives here because it is +a property of the issuer and not of any one surface: the operator MCP server reads it where +`--approver-roles-claim` does not override it, and a surface that reads neither holds no +roles and so satisfies no control that names one. + +## Next + +- [Python API index](/docs/reference/api/index). +- [Get started](/docs/get-started/quickstart) · [Why](/docs/why). diff --git a/docs/reference/api/Authority.mdx b/docs/reference/api/Authority.mdx index e4f5df2..13fbd4a 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:751` +`ctrlrun.Authority` — class, defined at `src/ctrlrun/authority.py:819` ```python from ctrlrun import Authority @@ -14,7 +14,7 @@ from ctrlrun import Authority ```python class Authority - def __init__(grants: Mapping[str, Grant], *, max_delegation_depth: int = DEFAULT_MAX_DELEGATION_DEPTH, source: str = '') + def __init__(grants: Mapping[str, Grant], *, max_delegation_depth: int = DEFAULT_MAX_DELEGATION_DEPTH, source: str = '', envelopes: Mapping[str, BreakGlassEnvelope] | None = None) ``` The `authority:` section, loaded and evaluable (SPEC-v0.3 §4). diff --git a/docs/reference/api/AuthorityResult.mdx b/docs/reference/api/AuthorityResult.mdx index 43c9b60..6d57ed8 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:456` +`ctrlrun.AuthorityResult` — class, defined at `src/ctrlrun/authority.py:475` ```python from ctrlrun import AuthorityResult diff --git a/docs/reference/api/Condition.mdx b/docs/reference/api/Condition.mdx index 1265fd0..979f15a 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:333` +`ctrlrun.Condition` — class, defined at `src/ctrlrun/policy.py:351` ```python from ctrlrun import Condition diff --git a/docs/reference/api/Control.mdx b/docs/reference/api/Control.mdx index b4ca510..78d43f5 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:461` +`ctrlrun.Control` — class, defined at `src/ctrlrun/control.py:497` ```python from ctrlrun import Control @@ -14,7 +14,7 @@ from ctrlrun import Control ```python class Control - def __init__(policy: Policy, store: StateStore, approvals: ApprovalProvider | None = None, *, clock: Callable[[], datetime] = _utc_now, approval_ttl: timedelta = DEFAULT_APPROVAL_TTL, lease: timedelta = DEFAULT_LEASE, sinks: Sequence[EventSink] = (), suspend_timeout: timedelta = DEFAULT_SUSPEND_TIMEOUT, identity: IdentityProvider | None = None, authority: Authority | None = None, environment: str | None = None) + def __init__(policy: Policy, store: StateStore, approvals: ApprovalProvider | None = None, *, clock: Callable[[], datetime] = _utc_now, approval_ttl: timedelta = DEFAULT_APPROVAL_TTL, lease: timedelta = DEFAULT_LEASE, sinks: Sequence[EventSink] = (), suspend_timeout: timedelta = DEFAULT_SUSPEND_TIMEOUT, identity: IdentityProvider | None = None, authority: Authority | None = None, environment: str | None = None, approver_identity: ApproverIdentity | None = None) ``` Policy, state and evidence composed around a single action (SPEC-v0.1 §8). diff --git a/docs/reference/api/Decision.mdx b/docs/reference/api/Decision.mdx index 18ada64..1bdba1e 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:270` +`ctrlrun.Decision` — class, defined at `src/ctrlrun/policy.py:288` ```python from ctrlrun import Decision diff --git a/docs/reference/api/Delegation.mdx b/docs/reference/api/Delegation.mdx index abd449d..252888d 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:416` +`ctrlrun.Delegation` — class, defined at `src/ctrlrun/authority.py:435` ```python from ctrlrun import Delegation @@ -14,7 +14,7 @@ from ctrlrun import Delegation ```python class Delegation - def __init__(delegation_id: str, parent_id: str, depth: int, grant: Grant, created_by: Principal, created_via: Literal['api', 'cli'], created_at: datetime, revoked_at: datetime | None = None, revoked_by: str | None = None) + def __init__(delegation_id: str, parent_id: str, depth: int, grant: Grant, created_by: Principal, created_via: CreatedVia, created_at: datetime, revoked_at: datetime | None = None, revoked_by: str | None = None) ``` A grant created at runtime by a principal who already holds one (SPEC-v0.3 §5.1). diff --git a/docs/reference/api/DelegationRecord.mdx b/docs/reference/api/DelegationRecord.mdx index 628ab91..7c7aca2 100644 --- a/docs/reference/api/DelegationRecord.mdx +++ b/docs/reference/api/DelegationRecord.mdx @@ -5,7 +5,7 @@ description: "One row of the `delegations` table (SPEC-v0.3 §5.2)." {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.DelegationRecord` — class, defined at `src/ctrlrun/state.py:380` +`ctrlrun.DelegationRecord` — class, defined at `src/ctrlrun/state.py:384` ```python from ctrlrun import DelegationRecord diff --git a/docs/reference/api/Event.mdx b/docs/reference/api/Event.mdx index 35981f6..b017791 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:205` +`ctrlrun.Event` — class, defined at `src/ctrlrun/receipt.py:269` ```python from ctrlrun import Event diff --git a/docs/reference/api/EventSink.mdx b/docs/reference/api/EventSink.mdx index 277cd12..22f77d0 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:564` +`ctrlrun.EventSink` — class, defined at `src/ctrlrun/receipt.py:692` ```python from ctrlrun import EventSink diff --git a/docs/reference/api/Grant.mdx b/docs/reference/api/Grant.mdx index b5c24ce..e9b14fe 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:298` +`ctrlrun.Grant` — class, defined at `src/ctrlrun/authority.py:317` ```python from ctrlrun import Grant diff --git a/docs/reference/api/InMemoryStateStore.mdx b/docs/reference/api/InMemoryStateStore.mdx index 535b5cd..37eafd4 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:736` +`ctrlrun.InMemoryStateStore` — class, defined at `src/ctrlrun/state.py:796` ```python from ctrlrun import InMemoryStateStore diff --git a/docs/reference/api/JSONLEventSink.mdx b/docs/reference/api/JSONLEventSink.mdx index 970b1e1..23ad8aa 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:583` +`ctrlrun.JSONLEventSink` — class, defined at `src/ctrlrun/receipt.py:711` ```python from ctrlrun import JSONLEventSink diff --git a/docs/reference/api/LocalApprovalProvider.mdx b/docs/reference/api/LocalApprovalProvider.mdx index 7eb815c..4331f16 100644 --- a/docs/reference/api/LocalApprovalProvider.mdx +++ b/docs/reference/api/LocalApprovalProvider.mdx @@ -5,7 +5,7 @@ description: "Requests go to the StateStore; `wait()` polls it (SPEC-v0.1 §4.3) {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.LocalApprovalProvider` — class, defined at `src/ctrlrun/approval.py:423` +`ctrlrun.LocalApprovalProvider` — class, defined at `src/ctrlrun/approval.py:800` ```python from ctrlrun import LocalApprovalProvider diff --git a/docs/reference/api/Policy.mdx b/docs/reference/api/Policy.mdx index 1e6db84..105a095 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:638` +`ctrlrun.Policy` — class, defined at `src/ctrlrun/policy.py:668` ```python from ctrlrun import Policy diff --git a/docs/reference/api/Principal.mdx b/docs/reference/api/Principal.mdx index f7b584f..8c5c6df 100644 --- a/docs/reference/api/Principal.mdx +++ b/docs/reference/api/Principal.mdx @@ -5,7 +5,7 @@ description: "Who is acting: an agent, optionally on behalf of a human." {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.Principal` — class, defined at `src/ctrlrun/action.py:139` +`ctrlrun.Principal` — class, defined at `src/ctrlrun/action.py:163` ```python from ctrlrun import Principal diff --git a/docs/reference/api/Receipt.mdx b/docs/reference/api/Receipt.mdx index 4f780ea..f6fe5e9 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:297` +`ctrlrun.Receipt` — class, defined at `src/ctrlrun/receipt.py:361` ```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, 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, 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 ee638e7..4dbeae6 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:1113` +`ctrlrun.SQLiteStateStore` — class, defined at `src/ctrlrun/state.py:1188` ```python from ctrlrun import SQLiteStateStore diff --git a/docs/reference/api/ScriptedApprovalProvider.mdx b/docs/reference/api/ScriptedApprovalProvider.mdx index 8ea1a3c..af9eb71 100644 --- a/docs/reference/api/ScriptedApprovalProvider.mdx +++ b/docs/reference/api/ScriptedApprovalProvider.mdx @@ -5,7 +5,7 @@ description: "A human replaced by a fixed script: for tests and `ctrlrun demo` ( {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.ScriptedApprovalProvider` — class, defined at `src/ctrlrun/approval.py:479` +`ctrlrun.ScriptedApprovalProvider` — class, defined at `src/ctrlrun/approval.py:856` ```python from ctrlrun import ScriptedApprovalProvider diff --git a/docs/reference/api/StateStore.mdx b/docs/reference/api/StateStore.mdx index 023c2db..26e6077 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:507` +`ctrlrun.StateStore` — class, defined at `src/ctrlrun/state.py:511` ```python from ctrlrun import StateStore diff --git a/docs/reference/api/Subject.mdx b/docs/reference/api/Subject.mdx index f6501e8..5fc9442 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:263` +`ctrlrun.Subject` — class, defined at `src/ctrlrun/authority.py:282` ```python from ctrlrun import Subject diff --git a/docs/reference/api/VerifiedApprover.mdx b/docs/reference/api/VerifiedApprover.mdx new file mode 100644 index 0000000..4a7f817 --- /dev/null +++ b/docs/reference/api/VerifiedApprover.mdx @@ -0,0 +1,32 @@ +--- +title: "VerifiedApprover" +description: "Who answered, as the surface that took the answer verified them (SPEC-v0.8 §2.5)." +--- + +{/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} + +`ctrlrun.VerifiedApprover` — class, defined at `src/ctrlrun/approval.py:127` + +```python +from ctrlrun import VerifiedApprover +``` + + +```python +class VerifiedApprover + def __init__(agent: str, user: str | None, issuer: str | None, granted_at: datetime, entitled: tuple[str, ...] = ()) +``` + +Who answered, as the surface that took the answer verified them (SPEC-v0.8 §2.5). + +**No claim value is here and none ever will be.** `v0.3 §2.4`'s rule is that evidence +carries claim *names* where values are withheld, and what an entitlement decision means is +*which control this approver satisfied*, which is what `entitled` says. A row holding the +role value would put an identity provider's payload in an evidence table for no gain. + +`entitled` is filled by item 3 and is empty until then. + +## Next + +- [Python API index](/docs/reference/api/index). +- [Get started](/docs/get-started/quickstart) · [Why](/docs/why). diff --git a/docs/reference/api/action_hash.mdx b/docs/reference/api/action_hash.mdx index 0ef1302..adf9177 100644 --- a/docs/reference/api/action_hash.mdx +++ b/docs/reference/api/action_hash.mdx @@ -5,7 +5,7 @@ description: "Return the action hash used to bind approvals to an exact action ( {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.action_hash` — function, defined at `src/ctrlrun/action.py:327` +`ctrlrun.action_hash` — function, defined at `src/ctrlrun/action.py:351` ```python from ctrlrun import action_hash diff --git a/docs/reference/api/banner.mdx b/docs/reference/api/banner.mdx index cfff3ec..fd0308d 100644 --- a/docs/reference/api/banner.mdx +++ b/docs/reference/api/banner.mdx @@ -5,7 +5,7 @@ description: "Log SPEC-v0.3 §6.5's observe banner, once per `Control`. An adapt {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.banner` — function, defined at `src/ctrlrun/adapter.py:453` +`ctrlrun.banner` — function, defined at `src/ctrlrun/adapter.py:473` ```python from ctrlrun import banner diff --git a/docs/reference/api/canonical_bytes.mdx b/docs/reference/api/canonical_bytes.mdx index 61d8883..a954cdd 100644 --- a/docs/reference/api/canonical_bytes.mdx +++ b/docs/reference/api/canonical_bytes.mdx @@ -5,7 +5,7 @@ description: "The canonical form of an arbitrary mapping: UTF-8 JSON, sorted key {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.canonical_bytes` — function, defined at `src/ctrlrun/action.py:228` +`ctrlrun.canonical_bytes` — function, defined at `src/ctrlrun/action.py:252` ```python from ctrlrun import canonical_bytes diff --git a/docs/reference/api/canonicalize.mdx b/docs/reference/api/canonicalize.mdx index 5dcccd5..e2fbc06 100644 --- a/docs/reference/api/canonicalize.mdx +++ b/docs/reference/api/canonicalize.mdx @@ -5,7 +5,7 @@ description: "Return the canonical form of an Action: UTF-8 JSON, sorted keys, n {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.canonicalize` — function, defined at `src/ctrlrun/action.py:308` +`ctrlrun.canonicalize` — function, defined at `src/ctrlrun/action.py:332` ```python from ctrlrun import canonicalize diff --git a/docs/reference/api/context.mdx b/docs/reference/api/context.mdx index f1d38a4..26f51c2 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:240` +`ctrlrun.context` — function, defined at `src/ctrlrun/control.py:266` ```python from ctrlrun import context diff --git a/docs/reference/api/idempotency_token.mdx b/docs/reference/api/idempotency_token.mdx index 1887964..98db267 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:208` +`ctrlrun.idempotency_token` — function, defined at `src/ctrlrun/control.py:234` ```python from ctrlrun import idempotency_token diff --git a/docs/reference/api/index.mdx b/docs/reference/api/index.mdx index 5b94720..9030883 100644 --- a/docs/reference/api/index.mdx +++ b/docs/reference/api/index.mdx @@ -21,6 +21,7 @@ a name with no docstring fails a test, so every page has one. | [`ctrlrun.ApprovalRequest`](/docs/reference/api/ApprovalRequest) | class | A pending question for a human: may this exact action run? (SPEC-v0.1 §4.1) | | [`ctrlrun.ApprovalRequired`](/docs/reference/api/ApprovalRequired) | class | The action needs a human. `request_id` is what `ctrlrun approve` takes (SPEC §4.3). | | [`ctrlrun.ApprovalTimeout`](/docs/reference/api/ApprovalTimeout) | class | Nobody answered the approval request in time (SPEC-v0.1 §4.3). | +| [`ctrlrun.ApproverIdentity`](/docs/reference/api/ApproverIdentity) | class | How a deployment verifies who answered an approval (SPEC-v0.8 §2.3). | | [`ctrlrun.Authority`](/docs/reference/api/Authority) | class | The `authority:` section, loaded and evaluable (SPEC-v0.3 §4). | | [`ctrlrun.AuthorityDenied`](/docs/reference/api/AuthorityDenied) | class | The principal holds no grant that covers this action (SPEC-v0.3 §4.3). | | [`ctrlrun.AuthorityEscalation`](/docs/reference/api/AuthorityEscalation) | class | A delegation that may not exist: it is not contained in its parent (SPEC-v0.3 §5.3). | @@ -63,6 +64,7 @@ a name with no docstring fails a test, so every page has one. | [`ctrlrun.StaticIdentityProvider`](/docs/reference/api/StaticIdentityProvider) | class | A fixed principal, for development, tests and single-tenant demonstrations (§3.3). | | [`ctrlrun.Subject`](/docs/reference/api/Subject) | class | Who a grant is addressed to: an agent pattern, a user pattern, or both (§4.2). | | [`ctrlrun.Suspended`](/docs/reference/api/Suspended) | class | Raised by an executor: the remote asked for something before it will finish. | +| [`ctrlrun.VerifiedApprover`](/docs/reference/api/VerifiedApprover) | class | Who answered, as the surface that took the answer verified them (SPEC-v0.8 §2.5). | | [`ctrlrun.WebhookApprovalProvider`](/docs/reference/api/WebhookApprovalProvider) | class | Notify a human system on `APPROVAL_REQUESTED`, and let it answer (SPEC-v0.2 §7). | | [`ctrlrun.action_hash`](/docs/reference/api/action_hash) | function | Return the action hash used to bind approvals to an exact action (SPEC-v0.1 §2.3). | | [`ctrlrun.banner`](/docs/reference/api/banner) | function | Log SPEC-v0.3 §6.5's observe banner, once per `Control`. An adapter MUST call it (§3.6). | diff --git a/docs/reference/api/needs_approval.mdx b/docs/reference/api/needs_approval.mdx index 9a47a88..59a0523 100644 --- a/docs/reference/api/needs_approval.mdx +++ b/docs/reference/api/needs_approval.mdx @@ -5,7 +5,7 @@ description: "Does this call need a human? For a framework that asks before it i {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.needs_approval` — function, defined at `src/ctrlrun/adapter.py:408` +`ctrlrun.needs_approval` — function, defined at `src/ctrlrun/adapter.py:428` ```python from ctrlrun import needs_approval diff --git a/docs/reference/api/parse_conditions.mdx b/docs/reference/api/parse_conditions.mdx index d670a32..44b0429 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:1061` +`ctrlrun.parse_conditions` — function, defined at `src/ctrlrun/policy.py:1103` ```python from ctrlrun import parse_conditions diff --git a/docs/reference/api/postgres-PostgresStateStore.mdx b/docs/reference/api/postgres-PostgresStateStore.mdx index ec6fac0..18b95f8 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:317` +`ctrlrun.postgres.PostgresStateStore` — class, defined at `src/ctrlrun/postgres.py:330` ```python from ctrlrun.postgres import PostgresStateStore diff --git a/docs/reference/api/protect.mdx b/docs/reference/api/protect.mdx index 4270aa2..3c20fe0 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:3056` +`ctrlrun.protect` — function, defined at `src/ctrlrun/control.py:3538` ```python from ctrlrun import protect diff --git a/docs/reference/api/with_approval.mdx b/docs/reference/api/with_approval.mdx index d1d04f2..e09b67c 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:263` +`ctrlrun.with_approval` — function, defined at `src/ctrlrun/control.py:289` ```python from ctrlrun import with_approval diff --git a/docs/reference/cli.mdx b/docs/reference/cli.mdx index 3f7eb2f..15fdcdd 100644 --- a/docs/reference/cli.mdx +++ b/docs/reference/cli.mdx @@ -23,6 +23,7 @@ Options: Commands: approve Grant a pending approval request. + break-glass Open a break-glass grant beneath a declared envelope. delegate Create a delegated grant beneath an existing one. demo Run the five scenarios, in process, with no network. deny Refuse a pending approval request. @@ -232,10 +233,41 @@ Options: --help Show this message and exit. ``` +## ctrlrun break-glass + +```text +Usage: ctrlrun break-glass [OPTIONS] + + Open a break-glass grant beneath a declared envelope. + + An incident needs authority nobody was granted in advance, and the wrong + answer is a flag: a flag leaves no record, expires never and cannot be + revoked. What this creates is an ordinary delegation, bounded by the envelope + on every dimension, expiring within its 'max_ttl', revocable, attenuable, and + named on the receipt of every action taken under it. + + **There is no --as.** Whoever opens one is the principal the deployment's + approver identity resolves, and a deployment that names none cannot open one + at all: an assertion typed at a shell is exactly what break-glass must not + accept (SPEC-v0.8 §5.3.1). + +Options: + --envelope TEXT The break-glass envelope declared under 'authority: + break_glass:'. [required] + --file FILE A one-grant YAML document, as --file takes for delegate. + 'expires_at' is required. [required] + --reason TEXT Free text recorded on the DELEGATION_CREATED event. + --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 + wherever $CTRLRUN_STATE points). + --help Show this message and exit. +``` + ## ctrlrun revoke ```text -Usage: ctrlrun revoke [OPTIONS] DELEGATION_ID +Usage: ctrlrun revoke [OPTIONS] [DELEGATION_ID] Revoke a delegation, and with it every delegation beneath it. @@ -243,12 +275,26 @@ Usage: ctrlrun revoke [OPTIONS] DELEGATION_ID the operation whose safety matters is the one taken in a hurry (SPEC-v0.3 §5.7). Revoking an already-revoked delegation is idempotent and exits 0. + `--created-by` and `--under` are selectors over rows that already exist + (SPEC-v0.8 §7). Each match is revoked **exactly as one id is**: one + revocation, one record, one event, in turn, so a run that stops halfway leaves + the rows it reached revoked and the rest untouched, and a second run finishes. + A selector that matches nothing exits non-zero (§7.5), because during an + incident a mistyped name that exits 0 reads as "done". + + `--by` is unchanged and means what it has always meant: who performed the + revocation. + Options: - --by TEXT Who revoked it. [default: cli:local] - --store-url TEXT The store to open. Default: $CTRLRUN_STORE_URL, else the - SQLite database beside the policy (.ctrlrun/state.db, or - wherever $CTRLRUN_STATE points). - --help Show this message and exit. + --by TEXT Who revoked it. [default: cli:local] + --created-by TEXT Revoke every delegation this principal created: AGENT or + AGENT/USER. + --under TEXT Revoke every delegation beneath this grant or delegation + id, at any depth. + --store-url TEXT The store to open. Default: $CTRLRUN_STORE_URL, else the + SQLite database beside the policy (.ctrlrun/state.db, or + wherever $CTRLRUN_STATE points). + --help Show this message and exit. ``` ## ctrlrun mcp-operator @@ -302,6 +348,10 @@ Options: [default: 30.0] --identity-jwt-http-timeout FLOAT [default: 5.0] + --approver-roles-claim TEXT Which verified claim carries this issuer's + roles, for the approver entitlement of + SPEC-v0.8 §3. Without it no role can be read, + so any cited control naming one refuses. --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 a589422..f64640c 100644 --- a/docs/reference/receipt-and-event-schemas.mdx +++ b/docs/reference/receipt-and-event-schemas.mdx @@ -53,7 +53,9 @@ it. A reader checking a chain compares those two. | `hash` | `str | None` | `None` | | `precondition_at_request` | `str | None` | `None` | | `precondition_at_recheck` | `str | None` | `None` | -| `schema` | `str` | `'ctrlrun.receipt/v4'` | +| `approvers` | `tuple` | `()` | +| `authority_grant_id` | `str | None` | `None` | +| `schema` | `str` | `'ctrlrun.receipt/v5'` | | `_stored_document` | `collections.abc.Mapping[str, Any] | None` | `None` | ### Result diff --git a/generated/readiness.full.mdx b/generated/readiness.full.mdx index 4f340e6..9c1787a 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.7.0**, on [PyPI](https://pypi.org/project/ctrlrun/), Python 3.11 and later. -- **5,167 tests**, every version specified before it was written and every requirement mutation-tested. [Read more](/docs/how-this-is-built). -- **16 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). +- **5,528 tests**, every version specified before it was written and every requirement mutation-tested. [Read more](/docs/how-this-is-built). +- **19 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 54dd6d8..d342fdf 100644 --- a/generated/readiness.json +++ b/generated/readiness.json @@ -1,5 +1,5 @@ { - "guarantees": 16, + "guarantees": 19, "released": "0.7.0", "soak": { "actions": 889735, @@ -9,6 +9,6 @@ "positive_control": true, "unexplained": 0 }, - "tests": 5167, + "tests": 5528, "version": "0.7.0" } diff --git a/generated/readiness.mdx b/generated/readiness.mdx index 451301f..3446a6e 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.7.0**, on [PyPI](https://pypi.org/project/ctrlrun/), Python 3.11 and later. -- **5,167 tests**, every version specified before it was written and every requirement mutation-tested. -- **16 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. +- **5,528 tests**, every version specified before it was written and every requirement mutation-tested. +- **19 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 7bb1353..0272919 100644 --- a/generated/readiness.readme.md +++ b/generated/readiness.readme.md @@ -1,7 +1,7 @@ - **Version 0.7.0**, on [PyPI](https://pypi.org/project/ctrlrun/), Python 3.11 and later. -- **5,167 tests**, every version specified before it was written and every requirement mutation-tested. -- **16 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. +- **5,528 tests**, every version specified before it was written and every requirement mutation-tested. +- **19 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. From c9dc82f80768fa8489b7960a12ccecc30c078c4d Mon Sep 17 00:00:00 2001 From: arpan Date: Sat, 12 Sep 2026 22:31:03 +0530 Subject: [PATCH 2/4] Repoint CLAIMS, map G17 to G21, and state what revocation does not close MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things the kernel's v0.8 items moved here. CLAIMS.md's line references: 104 had drifted, because items 3 to 7 inserted some 300 lines into control.py. `scripts/repoint-claims.py` fixes 101 of them and refuses to write while any are unresolved, which is the right shape and meant fixing the other three by hand first: `Control.execute` 756 -> 815, the NotExecuted-to-FAILED branch 1451 -> 1550, `_reconciled` 1948 -> 2047, RESERVED_ARGUMENTS 186 -> 204, and the float refusal 71 -> 79. OWASP-AGENTIC-TOP10.md gains rows for G17 to G21, each stating what it closes less than it sounds: G17's check is against *recorded* entitlement and the kernel interprets no role; G18 does not establish that an approver understood what they approved; G19 does not make several humans independent of each other; G20 leaves a log line and no receipt, and a feed is worth what its source is worth; G21's property is that a change **no verified principal other than the proposer approved** decides nothing, and not that a policy cannot be changed by whoever holds the file. A first draft of those rows cited ASI04, ASI05 and ASI07, which §6.1 lists as the three entries CTRLRun does not address at all -- supply chain, sandboxing and inter-agent communication. v0.8 touches none of them. The rows now cite ASI03, ASI09, ASI10 and ASI01, which is what guarantees about who may permit an action, a revoked credential and the policy file actually bear on. The test that caught it is the one asserting those three appear in no guarantee row. THREAT_MODEL.md's "a revoked token before its exp" line is rewritten: a feed closes the window, and the two things it does not close are in the same sentence as the feature. Still owed, and deliberately after the kernel's item 7 merges rather than twice: the policy and authority reference keys, the verify transcripts, and the guarantee counts. Every one of them reads the final catalogue. Signed-off-by: arpan --- docs/CLAIMS.md | 140 ++++++++++++++++++------------------ docs/OWASP-AGENTIC-TOP10.md | 5 ++ docs/THREAT_MODEL.md | 2 +- 3 files changed, 76 insertions(+), 71 deletions(-) diff --git a/docs/CLAIMS.md b/docs/CLAIMS.md index 4b5086c..c6f8db1 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:756` — resolves the principal, evaluates authority and policy, consumes the approval and reserves the effect key **before** the executor runs; nothing in the wrapper calls the function first | `test_T1_a_blind_retry_is_refused_and_never_reaches_the_remote`, `test_T3_the_fake_remote_is_called_exactly_once` | -| "Autonomy belongs to the action, not the agent." | `Policy.evaluate(action)` — `policy.py:534` — passes only the action's **name and arguments** to `_ActionPolicy.evaluate` (`policy.py:534`), whose signature has no principal in it. A rule cannot read who is acting even by accident. `agent_eq` and `user_eq` are refused at load by `RESERVED_ARGUMENTS` (`policy.py:186`) rather than silently matching nothing. | `test_T6_an_action_name_is_matched_exactly`, `test_a_condition_naming_an_action_field_is_refused_at_load` | -| "A consequential action happens at most once, exactly as approved, and leaves a receipt — and when the outcome is unknown, CTRLRun says so instead of guessing." | At most once: `plan_reservation` — `effect.py: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:756`. Says so instead of guessing: only `NotExecuted` maps to `FAILED` — `control.py:1451` — and everything else is `AMBIGUOUS`. A receipt: `Receipt` — `receipt.py:150`. **This sentence read *happens once … or not at all* until 0.6**, a two-way disjunction that excluded the third outcome the product exists for: a lost reply is neither, and the README's own first section says so. | `test_T3_exactly_one_agent_reserves_and_seven_are_blocked`, `test_T2_a_mutated_action_presenting_the_approval_raises_ApprovalMismatch`, `test_T1_a_lost_response_leaves_the_effect_ambiguous`, `test_T11_every_demo_receipt_carries_every_field_in_the_spec` | +| "The last check before an AI agent does something it can't undo." | `Control.execute` — `control.py:815` — resolves the principal, evaluates authority and policy, consumes the approval and reserves the effect key **before** the executor runs; nothing in the wrapper calls the function first | `test_T1_a_blind_retry_is_refused_and_never_reaches_the_remote`, `test_T3_the_fake_remote_is_called_exactly_once` | +| "Autonomy belongs to the action, not the agent." | `Policy.evaluate(action)` — `policy.py:534` — passes only the action's **name and arguments** to `_ActionPolicy.evaluate` (`policy.py:534`), whose signature has no principal in it. A rule cannot read who is acting even by accident. `agent_eq` and `user_eq` are refused at load by `RESERVED_ARGUMENTS` (`policy.py:204`) 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:815`. Says so instead of guessing: only `NotExecuted` maps to `FAILED` — `control.py:1550` — and everything else is `AMBIGUOUS`. A receipt: `Receipt` — `receipt.py:214`. **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:1113` — which is a write lock on the file and holds across OS processes. Postgres: `PostgresStateStore` over `UNIQUE(effect_key)` with `INSERT … ON CONFLICT DO NOTHING` and checked row counts (SPEC-v0.6 §4.2), the same `StateStore` protocol, extended by nothing | `test_T3_exactly_one_agent_reserves_and_seven_are_blocked` (8 OS processes, both backends), `test_T141_the_shipped_backends_pass`, `test_T154_postgres_passes_the_store_conformance_suite` | +| "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` | ## 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:1451`; 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:518` — and `Control._reconciled` — `control.py:1948` | `test_T1_a_lost_response_leaves_the_effect_ambiguous`, `test_T1_a_blind_retry_is_refused_and_never_reaches_the_remote`, `test_T160_there_is_no_reaper`, `test_T13_a_hook_answering_not_executed_moves_the_record_to_failed` | +| "A lost reply is `AMBIGUOUS`, never `FAILED`, and a retry against an `AMBIGUOUS` effect is refused — until a human, or a `reconcile` hook, says what happened." | Only `NotExecuted` maps to `FAILED` — `control.py: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:534` — and `Control._reconciled` — `control.py:2047` | `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,64 +50,64 @@ 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:1451`; `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:510`; `PostgresStateStore.reserve_effect` — `postgres.py:720` | `test_T3_exactly_one_agent_reserves_and_seven_are_blocked`, `test_T154_postgres_passes_the_store_conformance_suite` | -| "bound to the hash of the exact action a human saw, used once, and refused for anything else" | `_authorize_and_reserve` — `state.py:939` | `test_T2_a_mutated_action_presenting_the_approval_raises_ApprovalMismatch`, `test_T4_replaying_the_approval_raises_ApprovalMismatch_with_reason_consumed` | -| "An action the policy does not list is denied" | `Policy.evaluate` — `policy.py:534` | `test_T6_unknown_action_is_denied_with_reason_unknown_action` | -| "Authority first ... then policy" / "authority first" | `Control.execute` evaluates authority before policy and a denial appends `AUTHORITY_DENIED` and never `POLICY_EVALUATED` — `control.py:756` | `test_T74_a_denial_leaves_no_pending_approval_request` | -| "Neither axis reads the agent's instructions" | `Policy.evaluate` — `policy.py:534` — sees the action's name and arguments; `Authority.evaluate` — `authority.py:806` — sees the action and the principal; neither is handed a prompt, a message or a tool result | `test_T6_an_action_name_is_matched_exactly`, `test_T67_a_principal_with_no_grant_is_denied` | -| "canonical arguments (sorted keys, no floats) ... Its SHA-256 is the action hash" | `canonicalize` / `action_hash` — `action.py`; `float` refused at any depth — `action.py:71` | `test_T7_canonical_form_is_exactly_the_specified_serialization`, `test_T7_nested_dicts_are_sorted_recursively` | -| "The approval is single-use, expires, and matches nothing but that exact action." | `_authorize_and_reserve` — `state.py:939` — checks expiry at consumption | `test_T5_expiry_is_checked_at_consumption_not_only_at_grant`, `test_T4_replaying_the_approval_raises_ApprovalMismatch_with_reason_consumed` | -| "Only `NotExecuted`, raised by you, means `FAILED`." | `control.py:1451`; `NotExecuted` — `errors.py:157` | `test_T1_a_lost_response_leaves_the_effect_ambiguous` | -| "the hash of the policy that decided it, chained to the receipt before it" | `Policy.policy_hash` — `policy.py:672`; `prev_hash`, `GENESIS_HASH` for the first — `receipt.py:78` | `test_T172_every_receipt_carries_the_hash_and_the_declared_version`, `test_T164_an_altered_receipt_is_content_altered_at_its_seq` | +| "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:564` | `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:815` | `test_T74_a_denial_leaves_no_pending_approval_request` | +| "Neither axis reads the agent's instructions" | `Policy.evaluate` — `policy.py:564` — 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` | +| "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:702`; `prev_hash`, `GENESIS_HASH` for the first — `receipt.py:94` | `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:3056`) 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:3538`) covers this process while the gateway covers MCP — an adapter buys only the interrupt | `test_T139_the_adapter_section_says_when_you_do_not_need_one_up_front` | | "`ctrlrun init` writes a starter" | `init` — `cli/main.py:340` | CI's `package` job runs `ctrlrun init` from the wheel and asserts `ctrlrun.yaml` exists | -| "The human runs `ctrlrun approve ` and the agent calls again inside `ctrlrun.with_approval(request_id)`" | `approve` — `cli/main.py:364`; `with_approval` — `control.py:264`; `ApprovalRequired` (`errors.py:88`) carries `request_id` | `test_T2_a_mutated_action_presenting_the_approval_raises_ApprovalMismatch` (the granted path first), `test_T4_replaying_the_approval_raises_ApprovalMismatch_with_reason_consumed` | +| "The human runs `ctrlrun approve ` and the agent calls again inside `ctrlrun.with_approval(request_id)`" | `approve` — `cli/main.py:364`; `with_approval` — `control.py:290`; `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:774`; `McpOptions` — `policy.py:496` | `test_T16_a_v2_document_loads_and_exposes_its_templates`, `test_T16_a_decorator_and_a_policy_template_produce_the_same_action_hash` | +| "they are declared in the policy" (effect and resource templates for a tool call) | `Policy.effect_template` / `resource_template` — `policy.py:804`; `McpOptions` — `policy.py:523` | `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` | | "route an `approve` decision through **the framework's own interrupt**" | `FrameworkInterrupt` — `adapter.py:180` — is a Protocol with one method returning a value; it holds no state and writes nothing | `test_T135b_the_adapter_reuses_the_sdks_primitive_and_reimplements_nothing` | | "one core provider writes the grant through the same calls `ctrlrun approve` makes" / "There is never a second place to say yes" | `InterruptApprovalProvider.wait` — `adapter.py:254` — calls `grant_approval` / `deny_approval`, and an adapter calls neither | `test_T130_each_broken_fixture_fails_the_suite_named_for_it` | -| "an adapter never constructs one and never supplies a principal" | `needs_approval` — `adapter.py:408` — resolves the principal from the `Control` so no adapter builds an `Action` | `test_T129_no_public_callable_takes_a_principal`, `test_T129_the_module_exposes_no_way_to_construct_a_control` | -| "prevention" / "attribution" | `carries_approved_arguments` gates §3.4's rebuild in `_check_answer` — `adapter.py:320` | `test_T137b_the_readme_says_the_binding_is_attribution_and_why` | +| "an adapter never constructs one and never supplies a principal" | `needs_approval` — `adapter.py:428` — resolves the principal from the `Control` so no adapter builds an `Action` | `test_T129_no_public_callable_takes_a_principal`, `test_T129_the_module_exposes_no_way_to_construct_a_control` | +| "prevention" / "attribution" | `carries_approved_arguments` gates §3.4's rebuild in `_check_answer` — `adapter.py:246` | `test_T137b_the_readme_says_the_binding_is_attribution_and_why` | | "Adapters ship on their own version line" | `adapters/*/pyproject.toml`, never in the `ctrlrun` wheel or sdist | `test_T136_the_ctrlrun_distributions_contain_no_adapter` | ## Write down what the agent may do | Claim | Code | Proof | |---|---|---| -| "cheap to undo is autonomous, anything that leaves the building needs a human, money is by amount with both ends bound" | `Decision` — `policy.py:234` — is exactly `allow`, `approve`, `deny`; rules match first-wins over `Condition` (`policy.py:334`) with the operators `eq`, `neq`, `in`, `lt`, `lte`, `gt`, `gte` — `_OPERATORS` — `policy.py:106` | `test_T6_an_action_name_is_matched_exactly`, `test_T176_the_operators_behave_as_they_do_everywhere_else` | -| "Unknown actions are denied; there is no default-allow." | `Policy.evaluate` — `policy.py:534` | `test_T6_unknown_action_is_denied_with_reason_unknown_action` | -| "Amounts are integer minor units; floats are rejected outright" | `float` refused at any depth — `action.py:71` | `test_T7_canonical_form_is_exactly_the_specified_serialization` | -| "The policy cannot see who is asking — deliberately, since v0.1" | `Policy.evaluate` still takes only the action's name and arguments; `RESERVED_ARGUMENTS` — `policy.py:534` — refuses `agent_eq` and every other principal-addressing condition at load, in a document of **every** schema version | `test_T74b_a_reserved_name_in_a_policy_rule_is_a_load_error`, `test_T74b_a_reserved_name_in_a_grant_constraint_is_a_load_error` | -| "the second axis, `authority:`" | `Authority.evaluate` — `authority.py:798`; `Control._authority_result` — `control.py:622` | `test_T67_a_principal_with_no_grant_is_denied` | +| "cheap to undo is autonomous, anything that leaves the building needs a human, money is by amount with both ends bound" | `Decision` — `policy.py:288` — is exactly `allow`, `approve`, `deny`; rules match first-wins over `Condition` (`policy.py:352`) 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:564` | `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:564` — 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:669` | `test_T67_a_principal_with_no_grant_is_denied` | | "opt-in, and then fail-closed" | `_optional_authority` returns `None` for a document with no section — `control.py`; `Control.authority is None` is v0.2 behaviour exactly | `test_T66_a_document_with_no_authority_section_leaves_control_authority_none`, `test_T66_no_authority_event_is_appended_without_a_section`, and T66's session-wide guard in `tests/conftest.py` | | "every principal needs a grant and no grant means denied" | `NO_AUTHORITY` — the fail-closed default of `Authority.evaluate` (`authority.py:64`), reached for reads and for actions with no effect key alike | `test_T67_an_action_the_policy_allows_outright_still_needs_a_grant` | | "A grant carries no `decision:`" | `_GRANT_KEYS` — `authority.py` — is a closed set that does not contain `decision` | `test_T73b_grant_refuses_what_the_loader_refuses` | | "combine as the **stricter of the two**" | `Control.evaluate` returns the combined result — `control.py`; a denial on either axis is a denial | `test_T70_the_stricter_of_the_two_wins` | -| "narrow it at runtime with `ctrlrun delegate`" | `Control.delegate` — `control.py:2626`; `Authority.plan_delegation` — `authority.py:877`; `ctrlrun delegate` — `cli/main.py:898` | `test_t75_the_delegation_authorizes_an_action_within_its_limits` | -| "provably a subset of its parent on every dimension, at creation and again at every evaluation" | `contained_dimension` — `authority.py:603` — runs from `plan_delegation` (`authority.py:877`) **and** from the chain walk in `Authority.evaluate` (`authority.py:806`) | `test_t76_each_dimension_violated_alone`, `test_t77b_a_narrowed_parent_narrows_its_children` | -| "omitting a dimension the parent constrains is rejected rather than inherited" | `contained_dimension` treats an absent child dimension as unconstrained and therefore wider — `authority.py:603`; the subject half is `_subject_contained` (`authority.py:634`) | `test_t81_omission_is_not_unlimited`, `test_T73b_a_subject_addressed_to_every_principal_is_refused`, `test_t76_each_dimension_violated_alone` | -| "`ctrlrun revoke` cuts a chain of any depth with one write" | `Control.revoke` — `control.py:2642` — writes one row — `revoke_delegation` — `state.py:659` and visits no children; every evaluation walks to the root | `test_t78_a_revoked_parent_denies_its_grandchild`, `test_put_delegation_is_never_an_upsert` | -| "`mode: observe` … records what *would* have been blocked, without blocking anything" | `_parse_mode` — `policy.py:657`; `Control._observed` — `control.py:1040`; `_WouldHave` — `receipt.py:244`; `ReceiptResult.OBSERVED` — `receipt.py:156` | `test_T82_observe_executes_what_enforce_would_deny`, `test_T83_a_duplicate_is_recorded_and_still_runs` | -| "One top-level line" | `mode:` is refused anywhere but the top level — `reject_nested_mode`, `policy.py:657` | `test_T84_mode_is_refused_anywhere_but_the_top_level` | -| "`ctrlrun stats` gives you the numbers" | `stats` — `cli/main.py:727`; counted from `would_have.blocked_reason` and nothing else | `test_T86_stats_counts_what_observe_mode_recorded`, `test_T86_stats_reaches_no_network` | -| "It is not a dry run: it executes" | `_observed` runs the executor on every path, including the ones enforce mode would have refused — `control.py:1040` | `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:2946`; `Authority.plan_delegation` — `authority.py:1054`; `ctrlrun delegate` — `cli/main.py:914` | `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:3113` — 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:687`; `Control._observed` — `control.py:1108`; `_WouldHave` — `receipt.py:308`; `ReceiptResult.OBSERVED` — `receipt.py:220` | `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:687` | `test_T84_mode_is_refused_anywhere_but_the_top_level` | +| "`ctrlrun stats` gives you the numbers" | `stats` — `cli/main.py:743`; 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:1108` | `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:41`; 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: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` | | "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:41`). +catalogue, `GUARANTEES` (`verify/guarantees.py:44`). | 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:523`; single use is the `granted → consumed` transition in the same `BEGIN IMMEDIATE` | `test_T2_a_mutated_action_presenting_the_approval_raises_ApprovalMismatch`, `test_T4_replaying_the_approval_raises_ApprovalMismatch_with_reason_consumed`, `test_T5_expiry_is_checked_at_consumption_not_only_at_grant` | -| "One logical effect happens at most once, across threads, processes and hosts." | `reserve_effect` — `state.py:510`, decided inside the `BEGIN IMMEDIATE` of `_authorize_and_reserve` (`state.py:939`) against `effect_key TEXT PRIMARY KEY` (`migrations.py:107`; `COLLATE "C"` on Postgres, §4.4) | `test_T3_exactly_one_agent_reserves_and_seven_are_blocked` (8 OS processes, both backends), `test_T3_the_fake_remote_is_called_exactly_once` | -| "An unknown outcome is AMBIGUOUS, never FAILED, and blocks a blind retry." | Only `NotExecuted` maps to `FAILED` — `control.py:1451`. 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:534` — answers `deny` for a name the document does not list. Missing or malformed policy: `Policy.from_file` — `policy.py:691` — raises `PolicyError`, and there is no `Control` without a policy. Missing principal: `_refuse_no_principal` — `control.py:2938` | `test_T6_unknown_action_raises_ActionDenied_with_reason_unknown_action`, `test_missing_policy_file_is_a_policy_error`, `test_malformed_policy_document_is_a_policy_error`, `test_T62_a_declining_provider_with_no_context_is_no_principal` | -| "With authority on, every principal needs a grant, and delegation cannot widen one." | `NO_AUTHORITY` — the fail-closed default of `Authority.evaluate` (`authority.py:64`); `contained_dimension` — `authority.py:603` — runs from `plan_delegation` (`authority.py:877`) and from the chain walk in `Authority.evaluate` | `test_T67_a_principal_with_no_grant_is_denied`, `test_t76_each_dimension_violated_alone` | -| "Every executed action leaves a portable JSON receipt" | `ReceiptResult` — `receipt.py:140`; `Event` — `receipt.py:206`; the store is authoritative — `append_event` — `state.py:670`; the JSONL export — `JSONLEventSink` — `receipt.py:583` | `test_T11_every_demo_receipt_carries_every_field_in_the_spec`, `test_T11_every_demo_receipt_parses_back_into_a_Receipt` | +| "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:564` — answers `deny` for a name the document does not list. Missing or malformed policy: `Policy.from_file` — `policy.py:721` — raises `PolicyError`, and there is no `Control` without a policy. Missing principal: `_refuse_no_principal` — `control.py:3420` | `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:204`; `Event` — `receipt.py:270`; the store is authoritative — `append_event` — `state.py:674`; the JSONL export — `JSONLEventSink` — `receipt.py:711` | `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:939` | `test_T3_exactly_one_agent_reserves_and_seven_are_blocked` | -| "a unique index on the effect key and compare-and-set updates whose row counts are checked" | `reserve_effect` — `postgres.py:720` — `INSERT … ON CONFLICT DO NOTHING` against `effect_key TEXT PRIMARY KEY COLLATE "C"` (`migrations.py:107`) | `test_T3_exactly_one_agent_reserves_and_seven_are_blocked` (8 OS processes, both backends) | -| "Same `StateStore` protocol, extended by nothing" | `PostgresStateStore.reserve_effect` — `postgres.py:720` — and every other method implement `v0.1 §5.3`'s frozen protocol; the decisions stay in `plan_reservation` (`effect.py:248`) | `test_T154_postgres_passes_the_store_conformance_suite` | +| "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` | | "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:1451` (only `NotExecuted` → `FAILED`) | `test_T1_a_blind_retry_is_refused_and_never_reaches_the_remote`, `test_T1_a_lost_response_leaves_the_effect_ambiguous` | -| "a lost connection during `COMMIT` ... are `AMBIGUOUS`" | `_resolve_lost_insert` — `postgres.py:872`; `_resolve_lost_update` — `postgres.py:1350`; only `NotExecuted` maps to `FAILED` — `control.py:1451` | `test_T155_a_connection_killed_during_commit_is_resolved_by_the_re_read`, `test_T155_no_effect_is_ever_recorded_failed_by_a_lost_commit` | -| "the store re-reads the row to find out which" | The six branches, named and logged — `A2_LANDED` — `postgres.py:131` | `test_T155b_a_landed_commit_on_a_transition_is_seen_as_landed`, `test_T155d_a_commit_the_server_never_received_retries_the_insert` | -| "A crashed worker's effect stays `AMBIGUOUS` until a human runs `ctrlrun resolve` or a `reconcile` hook asks the remote what happened" | An expired lease is `AMBIGUOUS` and nothing sweeps it — `LEASE_EXPIRED` — `effect.py:172`; who resolved it — `resolved_by` — `effect.py:208`; `resolve` — `cli/main.py:518` | `test_T159_ambiguous_survives_a_restart_and_still_refuses_a_blind_retry`, `test_T160_there_is_no_reaper`, `test_T161_a_human_resolution_records_who` | -| "the only thing besides a human permitted to move a record out of `AMBIGUOUS`" | `Control._reconciled` — `control.py:1948`; `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 `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:534` | `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:2047`; `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:534`; `Policy.from_file` — `policy.py:691`; `_refuse_no_principal` — `control.py:2938`; `_authorize_and_reserve` — `state.py:939` | `test_T6_unknown_action_raises_ActionDenied_with_reason_unknown_action`, `test_malformed_policy_document_is_a_policy_error`, `test_T62_a_declining_provider_with_no_context_is_no_principal`, `test_T2_a_mutated_action_presenting_the_approval_raises_ApprovalMismatch` | -| "No flag makes a consequential action permissive by default" | There is no such option on `Control`, on `@protect`, on the CLI or in the policy schema's closed key sets — `_TOP_LEVEL_KEYS` — `policy.py:110` | `test_T84_mode_is_refused_anywhere_but_the_top_level`, `test_T101b_zero_applicable_guarantees_is_not_a_pass` | -| "With `authority:` on, every principal needs a grant, delegation cannot widen one, and `ctrlrun revoke` cuts a chain with one write." | `Authority.evaluate` — `authority.py:798`; `contained_dimension` — `authority.py:603`; `Control.revoke` — `control.py:2642` | `test_T67_a_principal_with_no_grant_is_denied`, `test_t76_each_dimension_violated_alone`, `test_t78_a_revoked_parent_denies_its_grandchild` | -| "verifies a bearer token against a JWKS or a pinned key" | `JWTIdentityProvider._verified` — `jwt_identity.py:175`; the algorithm comes from the configured list and never from the token | `test_T88_a_valid_token_becomes_a_principal`, `test_T89_every_invalid_token_is_refused_by_cause` | +| "Unknown action, missing policy, malformed policy, missing principal, missing or mismatched approval and inconsistent state are all `deny`." | `Policy.evaluate` — `policy.py:564`; `Policy.from_file` — `policy.py:721`; `_refuse_no_principal` — `control.py:3420`; `_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:3113` | `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:200`; 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:672`, over `_canonical_policy` — `policy.py:824`; carried into the receipt by `_record` — `control.py:2847` | `test_T172_every_receipt_carries_the_hash_and_the_declared_version`, `test_T172_two_policies_sharing_a_version_string_are_told_apart_by_the_hash` | -| "the policy's declared `version:` and a hash of its canonical content" | `version:` is recorded and never authoritative; `policy_hash` is what tells two documents apart — `policy.py:662` | `test_T171_the_declared_version_alone_does_not_change_the_hash`, `test_T171_comments_key_order_and_whitespace_do_not_change_the_hash` | -| "the approval is re-checked against the policy in force at execution" | `Control.execute` — `control.py:756`; `_spend_unneeded_approval` — `control.py:1888` | `test_T173_the_DENY_row_refuses_and_leaves_the_approval_granted`, `test_T173_the_ALLOW_row_invalidates_the_approval_it_did_not_need` | -| "Each receipt carries the hash of the one before it" | `Receipt.chain_hash` — `receipt.py:393`; `prev_hash` — `receipt.py:352`; `GENESIS_HASH` — `receipt.py:78`; `put_receipt` takes the head row's lock first — `postgres.py:1838` | `test_T164_an_altered_receipt_is_content_altered_at_its_seq`, `test_T164_reordering_two_receipts_is_detected_either_way` | -| "`ctrlrun receipts --verify-chain` reports it by `seq`" | `verify_chain` — `receipt.py:701`; the six names — `CHAIN_BREAKS` — `receipt.py:638` | `test_the_verify_chain_flag_reports_a_break_by_seq_and_by_name`, `test_verify_chain_reads_a_postgres_store_through_store_url` | -| "migrations are automatic at open, forward-only" | `migrate` — `migrations.py:544`, called from both stores' constructors; `HEAD` — `migrations.py:325` | `test_T147_a_v05_database_migrates_and_keeps_every_row`, `test_T150_reopening_does_not_rerun` | -| "An older binary against a newer schema refuses immediately" | `_refuse` — `migrations.py:470`; `SchemaMismatch` — `errors.py` | `test_T148_an_older_binary_refuses_a_newer_database`, `test_T148_no_other_table_is_read_before_the_refusal` | +| "every receipt records which policy decided it" | `Policy.policy_hash` — `policy.py:702`, over `_canonical_policy` — `policy.py:866`; carried into the receipt by `_record` — `control.py:3318` | `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:692` | `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:815`; `_spend_unneeded_approval` — `control.py:1987` | `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:465`; `prev_hash` — `receipt.py:416`; `GENESIS_HASH` — `receipt.py:94`; `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:829`; 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` | | "Releases carry PyPI provenance attestations from GitHub Actions" | `.github/workflows/publish.yml` — `pypa/gh-action-pypi-publish` pinned at v1.14.2, which generates and uploads PEP 740 attestations by default since v1.11.0 (its release notes, read 2026-09-06), with no `attestations: false`; the `pypi` job's only permission is `id-token: write` | `test_the_publish_workflow_attests_through_trusted_publishing`, `test_every_action_is_pinned_to_a_commit` | -| "`ctrlrun approve`, `deny`, `resolve`, `inspect`, `receipts` and `stats` work from the shell against any store" | `approve` — `cli/main.py:366`; `receipts` — `cli/main.py:426`; `effects` — `cli/main.py:500`; `resolve` — `cli/main.py:518`; `inspect` — `cli/main.py:554`; `stats` — `cli/main.py:727`; every one takes `--store-url` (SPEC-v0.6 §9.4) | `test_T10_resolve_failed_permits_a_retry`, `test_T18_inspect_json_emits_the_inspection_schema`, `test_T86_stats_counts_what_observe_mode_recorded`, `test_verify_chain_reads_a_postgres_store_through_store_url` | +| "`ctrlrun approve`, `deny`, `resolve`, `inspect`, `receipts` and `stats` work from the shell against any store" | `approve` — `cli/main.py:366`; `receipts` — `cli/main.py:442`; `effects` — `cli/main.py:516`; `resolve` — `cli/main.py:534`; `inspect` — `cli/main.py:568`; `stats` — `cli/main.py:743`; every one takes `--store-url` (SPEC-v0.6 §9.4) | `test_T10_resolve_failed_permits_a_retry`, `test_T18_inspect_json_emits_the_inspection_schema`, `test_T86_stats_counts_what_observe_mode_recorded`, `test_verify_chain_reads_a_postgres_store_through_store_url` | | "`WebhookApprovalProvider` sends an approval request to a webhook, such as Slack, and takes the answer back through the same grant calls" | `WebhookApprovalProvider` — `webhook.py:141` — one signed POST on `APPROVAL_REQUESTED`; the inbound answer lands through `grant_approval` / `deny_approval` like the CLI's | `test_T27_the_outbound_post_carries_a_signature_over_the_exact_bytes_sent`, `test_T27_the_payload_carries_what_the_spec_names` | | "one OpenTelemetry span per action, one span event per step" | `OTelEventSink` — `otel.py:45` | `test_T29_one_action_produces_one_span_named_for_the_action`, `test_T29_every_event_becomes_a_span_event_named_by_its_type` | | "argument values stay out of it unless you ask for them" | `OTelEventSink(arguments=...)` — `otel.py:45` | `test_T29_argument_values_are_not_attributes_by_default` | -| "Receipts in a `ctrlrun.policy/v4` document can cite the `controls:` an action satisfies" | `PolicyControl` — `policy.py:420`; `Receipt` — `receipt.py:298` — carries `controls`; attribution only, never a decision | `test_T175_the_receipt_carries_the_union_of_the_action_and_the_matched_rule`, `test_T175_a_control_is_attribution_and_changes_no_decision` | -| "a rule can condition on the `data:` labels present in an action's arguments" | `DataLabel` — `policy.py:521`; `Policy.data_scope` — `policy.py:526`; `data_scope_in` in `v0.1 §3.2`'s grammar with no new operator | `test_T176_the_derived_set_is_the_labels_of_the_arguments_actually_supplied`, `test_T176_the_derived_set_drives_a_decision` | +| "Receipts in a `ctrlrun.policy/v4` document can cite the `controls:` an action satisfies" | `PolicyControl` — `policy.py:313`; `Receipt` — `receipt.py:362` — 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:548`; `Policy.data_scope` — `policy.py:526`; `data_scope_in` in `v0.1 §3.2`'s grammar with no new operator | `test_T176_the_derived_set_is_the_labels_of_the_arguments_actually_supplied`, `test_T176_the_derived_set_drives_a_decision` | ## What it can't, stated as limits @@ -189,7 +189,7 @@ The README also makes negative claims. They matter as much as the positive ones. | "CTRLRun is not a transaction manager: it rolls nothing back" | There is no compensation, saga or rollback code path in the package; an `AMBIGUOUS` effect is resolved by a human or a reconcile hook and never undone — `RECONCILED_STATES` — `effect.py` | | "The receipt chain detects alteration, and alteration is not authorship." | n/a — a disclaimer, and the scan that keeps it one: `test_T180_the_release_documents_do_not_blur_alteration_and_authorship` | | "erasing the end of the log costs two statements" | No code — this is what the chain does **not** cover, and it is asserted rather than argued: `test_erasing_a_suffix_and_rewinding_the_head_is_two_statements_and_undetected` | -| "CTRLRun does not detect prompt injection" | No code — and that is the point. Nothing in the package reads the agent's instructions: `Policy.evaluate` takes the action's name and arguments (`policy.py:534`) and `Authority` matches a grant against the action, so neither axis has the prompt to inspect. The README's problem table claims containment of the consequence, and this row is the sentence that stops it being read as detection. | `test_T6_an_action_name_is_matched_exactly`, `test_a_condition_naming_an_action_field_is_refused_at_load` | +| "CTRLRun 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:564`) 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:544` — called from both stores' constructors; `_refuse` — `migrations.py:470` — raises `SchemaMismatch` on a newer `user_version` | `test_T147_a_v05_database_migrates_and_keeps_every_row`, `test_T148_an_older_binary_refuses_a_newer_database`, `test_T152b_no_flag_opens_a_database_without_migrating` | -| "an edit, a deletion from the middle or a reordering is detected and named by `seq`" | `verify_chain` — `receipt.py:350` — and the six break names in `CHAIN_BREAKS` | `test_T164_an_altered_receipt_is_content_altered_at_its_seq`, `test_T164_reordering_two_receipts_is_detected_either_way`, `test_the_verify_chain_flag_reports_a_break_by_seq_and_by_name` | +| "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:414` — 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:672` — over the parsed decision inputs, recorded on the receipt | `test_T172_every_receipt_carries_the_hash_and_the_declared_version`, `test_T171_any_decision_input_changes_the_hash`, `test_T171_the_declared_version_alone_does_not_change_the_hash` | +| "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:702` — 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 @@ -219,7 +219,7 @@ restating the code; the ones that are new to the site carry their own code and p | `get-started/install` | "importing `ctrlrun` imports nothing from an extra" | `test_T30_a_subprocess_importing_ctrlrun_pulls_in_no_module_from_an_extra` | | `get-started/install` | "raises `MissingDependency` with the install command in the message" | `test_a_missing_extra_raises_MissingDependency_naming_the_install_command` | | `get-started/quickstart` | every block on the page, and the outputs shown | the blocks are `runnable` and pass `tools/docs_audit/snippets.py` in one temporary directory, in order; the outputs are pasted from one run of the same blocks | -| `concepts/action-and-hash` | "The action hash is the SHA-256 of that canonical form"; sorted keys, no whitespace, UTF-8, `float` rejected; `action_id` excluded | `canonicalize` / `action_hash` — `action.py`; `float` refused — `action.py:71`; `test_T7_canonical_form_is_exactly_the_specified_serialization`, `test_T7_nested_dicts_are_sorted_recursively`, `test_T60_claims_do_not_change_the_action_hash` | +| `concepts/action-and-hash` | "The action hash is the SHA-256 of that canonical form"; sorted keys, no whitespace, UTF-8, `float` rejected; `action_id` excluded | `canonicalize` / `action_hash` — `action.py`; `float` refused — `action.py:79`; `test_T7_canonical_form_is_exactly_the_specified_serialization`, `test_T7_nested_dicts_are_sorted_recursively`, `test_T60_claims_do_not_change_the_action_hash` | | `concepts/decisions` | three decisions, first match wins, unknown denied, principal-addressing conditions refused at load | the "Write down what the agent may do" rows above | | `concepts/approval-binding` | A1–A4, the mismatch leaving the approval granted, one core provider writing every grant | the matrix row "An approval is bound to the exact action…", the "Three ways to use it" adapter rows, and `test_T2_a_mutated_action_leaves_the_approval_granted` | | `concepts/approval-binding` | the `DENY` and `ALLOW` rows when the policy changed between grant and consumption | "the approval is re-checked against the policy in force at execution" above | @@ -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:2885` | +| `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:3363` | | `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:41` — 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: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 **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 0e92469..0ac1c22 100644 --- a/docs/OWASP-AGENTIC-TOP10.md +++ b/docs/OWASP-AGENTIC-TOP10.md @@ -79,6 +79,11 @@ mechanism, not the entry. | **G14** token changes across a renewal | `ctrlrun.idempotency_token()` answers inside an executor with a token derived from `(effect_key, attempt)`: stable within one attempt, including across a resume, and different after a renewal. Send it to a provider as its idempotency key. | `ASI08:2026` (partly) | A provider handed the effect key alone would answer the one retry the kernel permits, permitted *because the executor proved nothing happened*, with the cached failure of the attempt that failed. A token that moves with the attempt keeps a provider's cache from becoming a second source of stale outcomes. What it is for is reconciliation, a deterministic handle to ask a provider what became of an attempt whose outcome is unknown; it does not make a retry safe, and after `AMBIGUOUS` the kernel still refuses one. It is unique only as far as the operator's effect keys are, and nothing here checks two stores sharing a provider account. | | **G15** renewal past the ceiling refused | An action entry may declare `max_attempts`; above it the executor is not called, the record is released as `FAILED`, a `blocked` receipt names the ceiling, and `ActionDenied(reason="attempt_ceiling")` is raised. The refused attempt number is spent. | `ASI08:2026` (partly), `ASI10:2026` (partly) | Without a ceiling a renewal after `FAILED` is unbounded, so an agent that keeps proposing an action that keeps failing keeps dispatching, and one human yes on an `APPROVE` action bought unlimited dispatches. The ceiling is the operator's, not the kernel's: an entry that declares none renews exactly as before, and the decision is taken on the attempt number the store assigned, after the reservation, so two callers cannot both pass a read taken before reserving. It bounds attempts on one effect key, not what an agent does across many. | | **G16** a moved fingerprint is refused | Under `APPROVE`, a `preconditions=` provider's answer is kept as a `sha256:` fingerprint on the request; on the presenting pass it is computed again, strictly before the store call that consumes the approval, and a difference is refused with `ApprovalMismatch(reason="precondition_changed")`, reserving nothing and leaving the approval granted. | `ASI09:2026` (partly), `ASI01:2026` (partly) | A human's yes was given against the world as it was; this refuses the action where the state the approval depended on has moved since. It **narrows** the window between approval and execution and does not close it: the comparison is a network call outside the atomic reservation write, and a change that lands between the comparison and the reservation is not refused. What the provider looks at is in the operator's code, which verify does not read; verify grades the check with a provider of its own. | +| **G17** an unentitled approver refused | A control in the registry may name the role that answers for it. An approval whose **recorded** entitlement does not cover the roles the request pinned is refused with `ApprovalMismatch(reason="approver_unentitled")`, naming the control and the role in the message, the exception and the `APPROVAL_INVALIDATED` event. Roles are matched byte for byte, and every cited control must be satisfied. | `ASI03:2026` (partly), `ASI09:2026` (partly) | Before this, any principal whose credential the provider verified could answer any request: a deployment could say *who may act* and not *who may permit*. What it closes is narrower than it sounds and is stated as such. The kernel refuses an approval whose **recorded** entitlement does not cover the role; what entitled it was decided where the credential was verified, which is the operator MCP server or an embedding application. CTRLRun does not interpret the role, does not check that it exists anywhere, and makes no compliance claim on the strength of one. A control naming no role gates nobody, and a principal whose claims lack the role is not entitled: two omissions that mean opposite things. | +| **G18** the requester cannot approve | The principal that requested an action cannot be the principal that approves it, compared on the **resolved principal** and never on the approver string, so two grants whose strings differ are still one principal. Refused with `ApprovalMismatch(reason="approver_is_requester")`, leaving the approval granted and reserving nothing. | `ASI03:2026` (partly), `ASI09:2026` (partly) | An agent that could approve its own action has an approval gate in name only. The comparison is on the principal an identity provider verified, which is what makes it real: `approver` was a free string until v0.8, and a check against it would have been defeated by typing a different one. It does not establish that the approver understood what they approved: a persuaded human gives a valid approval and the receipt records it as one, and no page here says otherwise. | +| **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. | --- diff --git a/docs/THREAT_MODEL.md b/docs/THREAT_MODEL.md index 7637839..ebb23bf 100644 --- a/docs/THREAT_MODEL.md +++ b/docs/THREAT_MODEL.md @@ -73,7 +73,7 @@ they entitled to?* Everything above still holds; these are the threats the secon - Bypassing the decorator entirely (calling the raw function). v0.2 gateway mode narrows this; process-level enforcement is out of scope. - **A compromised identity provider.** CTRLRun *consumes* identities: it verifies a token somebody else issued and maps the verified claims onto a `Principal`. It issues nothing, and an issuer that signs a token for the wrong subject has told CTRLRun the truth as far as CTRLRun can tell. Everything downstream — grants, delegation, receipts — is then wrong, correctly and consistently. - **A `HeaderIdentityProvider` behind a proxy that does not overwrite the header.** It is worth exactly what the thing setting it is worth, and RFC 7239 §8.1 says the same of the header it standardizes. If the agent can set the header, the agent chooses its own authority. It warns at construction and it is still the operator's call. -- **A revoked token before its `exp`.** There is no revocation channel: a verified token is valid until it expires, which is why one with no `exp` is refused. Shared-signals mechanisms exist and v0.3 implements none of them. Short lifetimes are the whole of the story. +- **A revoked token before its `exp`, where no feed is configured.** Without one, a verified token is valid until it expires, which is why one with no `exp` is refused, and short lifetimes are the whole of the story. Since v0.8 a deployment may pass `JWTIdentityProvider(revocations=...)` a feed of Security Event Tokens, and a credential the issuer revoked is then refused at resolution. Two things that closes less than they sound: **a revoked credential leaves a log line and no receipt**, because resolution happens before an action exists, where an *expired* one leaves a receipt; and **a feed is worth what its source is worth**. Somebody who can write the file, or stand in front of the poll endpoint, can refuse the operator's own agents at will, which is a denial of service against them and is fail-closed. They cannot admit a principal the issuer revoked: the feed is only ever consulted to refuse, and there is no path on which its answer makes an otherwise-invalid credential valid. - **A tenant-templated issuer.** `issuer` is matched as an exact string, so a multi-tenant endpoint cannot be configured correctly here. Pointing it at one without pinning the tenant makes every tenant on that platform a valid issuer — stated because the fail-open is inviting. - **Authority across an agent-to-agent hop.** A grant covers the principal CTRLRun resolved for *this* call. Propagating attenuated authority across hops is v0.10. - **Approving an authority change.** `ctrlrun delegate --as` is an assertion typed at a shell, not an authentication; the record keeps `created_via` so a reader can tell an act from an assertion. Authenticating the *approver* remains out of scope, as in v0.1. From b8ef889b39e1d36121831d31444a8ff21c1b2f05 Mon Sep 17 00:00:00 2001 From: arpan Date: Sun, 13 Sep 2026 00:07:56 +0530 Subject: [PATCH 3/4] Regenerate for 0.8.0: the v6 keys, the envelopes, and the real transcripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rest of the v0.8 pass, held until the kernel's item 7 merged so the catalogue was final rather than doing it twice. policy-yaml approver_role on a control entry, approvals_required on an action entry, and a v6 row in the version table. The control entry gets a table of its own: it had one decision-free key and now has one that decides. authority-yaml break_glass, with what an envelope is, the two keys it is refused by name, and why it decides nothing by construction. transcripts verify.md, verify-in-ci and verify-in-github-actions quote what verify prints today: 21 guarantees, catalogue v4. readiness 21 guarantees, 5,653 tests, version 0.8.0, in all four places the block appears. CLAIMS 70 more line references repointed after item 7's commits. Three things found while doing it, each a small guard that was not guarding. The word-budget test stripped fenced code with `^```` — column zero only — so every fence inside a `` block, which Mintlify indents, had always been counted as prose. It went unnoticed while those blocks were short; a transcript growing from twelve guarantees to twenty-one pushed verify-in-ci.mdx to "999 words of prose", 600 of which were a transcript. Indented fences are code too. Both reference pages' descriptions went over the 155-character search-result budget the moment they named the new keys, and were rewritten rather than having the keys left out. The transcript regenerator quoted the temp directory it ran verify in, which is a path no reader has. Each page keeps its own illustrative policy line. **Version 0.8.0 is stated before it is on PyPI.** The readiness block reads pyproject, and this repository is published independently of a tag, so this should merge after the kernel's release PR and its tag. Signed-off-by: arpan --- docs.mdx | 13 ++- docs/CLAIMS.md | 80 +++++++++---------- docs/cookbook/verify-in-github-actions.mdx | 17 +++- docs/guides/verify-in-ci.mdx | 17 +++- docs/production/index.mdx | 13 ++- docs/reference/api/Condition.mdx | 2 +- docs/reference/api/Control.mdx | 4 +- docs/reference/api/Decision.mdx | 2 +- docs/reference/api/Event.mdx | 2 +- docs/reference/api/EventSink.mdx | 2 +- docs/reference/api/JSONLEventSink.mdx | 2 +- docs/reference/api/Policy.mdx | 2 +- docs/reference/api/Receipt.mdx | 2 +- docs/reference/api/context.mdx | 2 +- docs/reference/api/idempotency_token.mdx | 2 +- .../api/jwt_identity-JWTIdentityProvider.mdx | 4 +- docs/reference/api/parse_conditions.mdx | 2 +- docs/reference/api/protect.mdx | 2 +- docs/reference/api/with_approval.mdx | 2 +- docs/reference/authority-yaml.mdx | 49 +++++++++++- docs/reference/cli.mdx | 26 ++++++ docs/reference/policy-yaml.mdx | 26 +++++- docs/verify.md | 17 +++- generated/readiness.full.mdx | 6 +- generated/readiness.json | 8 +- generated/readiness.mdx | 6 +- generated/readiness.readme.md | 6 +- tests/test_docs_site.py | 7 +- 28 files changed, 238 insertions(+), 85 deletions(-) diff --git a/docs.mdx b/docs.mdx index 895d9c1..137cf56 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.7.0**, on [PyPI](https://pypi.org/project/ctrlrun/), Python 3.11 and later. -- **5,167 tests**, every version specified before it was written and every requirement mutation-tested. -- **16 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.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. - **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,6 +233,13 @@ 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 c6f8db1..9e65ffa 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:815` — resolves the principal, evaluates authority and policy, consumes the approval and reserves the effect key **before** the executor runs; nothing in the wrapper calls the function first | `test_T1_a_blind_retry_is_refused_and_never_reaches_the_remote`, `test_T3_the_fake_remote_is_called_exactly_once` | -| "Autonomy belongs to the action, not the agent." | `Policy.evaluate(action)` — `policy.py:534` — passes only the action's **name and arguments** to `_ActionPolicy.evaluate` (`policy.py:534`), whose signature has no principal in it. A rule cannot read who is acting even by accident. `agent_eq` and `user_eq` are refused at load by `RESERVED_ARGUMENTS` (`policy.py:204`) 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:815`. Says so instead of guessing: only `NotExecuted` maps to `FAILED` — `control.py:1550` — and everything else is `AMBIGUOUS`. A receipt: `Receipt` — `receipt.py:214`. **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: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` | | "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` | @@ -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` — `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:534` — and `Control._reconciled` — `control.py:2047` | `test_T1_a_lost_response_leaves_the_effect_ambiguous`, `test_T1_a_blind_retry_is_refused_and_never_reaches_the_remote`, `test_T160_there_is_no_reaper`, `test_T13_a_hook_answering_not_executed_moves_the_record_to_failed` | +| "A lost reply is `AMBIGUOUS`, never `FAILED`, and a retry against an `AMBIGUOUS` effect is refused — until a human, or a `reconcile` hook, says what happened." | Only `NotExecuted` maps to `FAILED` — `control.py: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` | | "The customer is refunded twice, and nothing in the stack noticed." — said of a stack without CTRLRun; the demo runs the same sequence with it, and counts the calls the remote received | `ctrlrun demo` scenario 1, which retries against a fake remote that counts its calls and prints the count | `test_T3_the_fake_remote_is_called_exactly_once`, `test_T1_a_blind_retry_is_refused_and_never_reaches_the_remote` | ## Protect your first action @@ -53,25 +53,25 @@ by its quoted claim, and `tests/test_docs_audit.py` fails if a named row is not | "A lost reply is `AMBIGUOUS`, never `FAILED`, and a retry against an `AMBIGUOUS` effect is refused." | Only `NotExecuted` maps to `FAILED` — `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:564` | `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:815` | `test_T74_a_denial_leaves_no_pending_approval_request` | -| "Neither axis reads the agent's instructions" | `Policy.evaluate` — `policy.py:564` — 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` | +| "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` | | "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:702`; `prev_hash`, `GENESIS_HASH` for the first — `receipt.py:94` | `test_T172_every_receipt_carries_the_hash_and_the_declared_version`, `test_T164_an_altered_receipt_is_content_altered_at_its_seq` | +| "the hash of the policy that decided it, chained to the receipt before it" | `Policy.policy_hash` — `policy.py: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` | ## Three ways to use it | Claim | Code | Proof | |---|---|---| -| "You probably do not need an adapter" | Three ways in, and `@protect` (`control.py:3538`) 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: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:364`; `with_approval` — `control.py:290`; `ApprovalRequired` (`errors.py:88`) carries `request_id` | `test_T2_a_mutated_action_presenting_the_approval_raises_ApprovalMismatch` (the granted path first), `test_T4_replaying_the_approval_raises_ApprovalMismatch_with_reason_consumed` | +| "The human runs `ctrlrun approve ` and the agent calls again inside `ctrlrun.with_approval(request_id)`" | `approve` — `cli/main.py: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` | | "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:804`; `McpOptions` — `policy.py:523` | `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: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` | | "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,23 +85,23 @@ by its quoted claim, and `tests/test_docs_audit.py` fails if a named row is not | Claim | Code | Proof | |---|---|---| -| "cheap to undo is autonomous, anything that leaves the building needs a human, money is by amount with both ends bound" | `Decision` — `policy.py:288` — is exactly `allow`, `approve`, `deny`; rules match first-wins over `Condition` (`policy.py:352`) 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:564` | `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: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` | | "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:564` — 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:669` | `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: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` | | "opt-in, and then fail-closed" | `_optional_authority` returns `None` for a document with no section — `control.py`; `Control.authority is None` is v0.2 behaviour exactly | `test_T66_a_document_with_no_authority_section_leaves_control_authority_none`, `test_T66_no_authority_event_is_appended_without_a_section`, and T66's session-wide guard in `tests/conftest.py` | | "every principal needs a grant and no grant means denied" | `NO_AUTHORITY` — the fail-closed default of `Authority.evaluate` (`authority.py:64`), reached for reads and for actions with no effect key alike | `test_T67_an_action_the_policy_allows_outright_still_needs_a_grant` | | "A grant carries no `decision:`" | `_GRANT_KEYS` — `authority.py` — is a closed set that does not contain `decision` | `test_T73b_grant_refuses_what_the_loader_refuses` | | "combine as the **stricter of the two**" | `Control.evaluate` returns the combined result — `control.py`; a denial on either axis is a denial | `test_T70_the_stricter_of_the_two_wins` | -| "narrow it at runtime with `ctrlrun delegate`" | `Control.delegate` — `control.py:2946`; `Authority.plan_delegation` — `authority.py:1054`; `ctrlrun delegate` — `cli/main.py:914` | `test_t75_the_delegation_authorizes_an_action_within_its_limits` | +| "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:3113` — 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:687`; `Control._observed` — `control.py:1108`; `_WouldHave` — `receipt.py:308`; `ReceiptResult.OBSERVED` — `receipt.py:220` | `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:687` | `test_T84_mode_is_refused_anywhere_but_the_top_level` | -| "`ctrlrun stats` gives you the numbers" | `stats` — `cli/main.py:743`; 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:1108` | `test_T82_observe_executes_what_enforce_would_deny`, `test_T83_an_executor_that_fails_on_a_held_key_still_writes_the_record` | +| "`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` | ## Prove it holds in your setup @@ -139,9 +139,9 @@ catalogue, `GUARANTEES` (`verify/guarantees.py:44`). | "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:564` — answers `deny` for a name the document does not list. Missing or malformed policy: `Policy.from_file` — `policy.py:721` — raises `PolicyError`, and there is no `Control` without a policy. Missing principal: `_refuse_no_principal` — `control.py:3420` | `test_T6_unknown_action_raises_ActionDenied_with_reason_unknown_action`, `test_missing_policy_file_is_a_policy_error`, `test_malformed_policy_document_is_a_policy_error`, `test_T62_a_declining_provider_with_no_context_is_no_principal` | +| "An unknown action, a missing policy or a missing principal is denied." | Unknown action: `Policy.evaluate` — `policy.py: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:204`; `Event` — `receipt.py:270`; the store is authoritative — `append_event` — `state.py:674`; the JSONL export — `JSONLEventSink` — `receipt.py:711` | `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: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` | ## What it guarantees @@ -154,30 +154,30 @@ catalogue, `GUARANTEES` (`verify/guarantees.py:44`). | "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:534` | `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:2047`; `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` | +| "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` | | "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:564`; `Policy.from_file` — `policy.py:721`; `_refuse_no_principal` — `control.py:3420`; `_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` | +| "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:3113` | `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:200`; 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` | +| "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` | +| "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:702`, over `_canonical_policy` — `policy.py:866`; carried into the receipt by `_record` — `control.py:3318` | `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:692` | `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:815`; `_spend_unneeded_approval` — `control.py:1987` | `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:465`; `prev_hash` — `receipt.py:416`; `GENESIS_HASH` — `receipt.py:94`; `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:829`; 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` | +| "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` | | "Releases carry PyPI provenance attestations from GitHub Actions" | `.github/workflows/publish.yml` — `pypa/gh-action-pypi-publish` pinned at v1.14.2, which generates and uploads PEP 740 attestations by default since v1.11.0 (its release notes, read 2026-09-06), with no `attestations: false`; the `pypi` job's only permission is `id-token: write` | `test_the_publish_workflow_attests_through_trusted_publishing`, `test_every_action_is_pinned_to_a_commit` | -| "`ctrlrun approve`, `deny`, `resolve`, `inspect`, `receipts` and `stats` work from the shell against any store" | `approve` — `cli/main.py:366`; `receipts` — `cli/main.py:442`; `effects` — `cli/main.py:516`; `resolve` — `cli/main.py:534`; `inspect` — `cli/main.py:568`; `stats` — `cli/main.py:743`; 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: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` | | "`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:313`; `Receipt` — `receipt.py:362` — 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:548`; `Policy.data_scope` — `policy.py:526`; `data_scope_in` in `v0.1 §3.2`'s grammar with no new operator | `test_T176_the_derived_set_is_the_labels_of_the_arguments_actually_supplied`, `test_T176_the_derived_set_drives_a_decision` | +| "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` | ## 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:564`) 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: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 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` | @@ -201,9 +201,9 @@ The README also makes negative claims. They matter as much as the positive ones. |---|---|---| | "the same `StateStore` protocol, extended by nothing, graded by the suite written for SQLite rather than one written for it" | `PostgresStateStore` — `postgres.py` — satisfies `StateStore` and adds no method (SPEC-v0.6 §9.1); `ctrlrun.conformance.store.SUITES` is the SQLite suite, run against both | `test_T141_the_shipped_backends_pass`, `test_T154_postgres_passes_the_store_conformance_suite` | | "automatic at open and forward-only, with no flag that opens a database un-migrated. An older binary against a newer schema refuses immediately." | `migrate` — `migrations.py: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:414` — and the six break names in `CHAIN_BREAKS` | `test_T164_an_altered_receipt_is_content_altered_at_its_seq`, `test_T164_reordering_two_receipts_is_detected_either_way`, `test_the_verify_chain_flag_reports_a_break_by_seq_and_by_name` | +| "an edit, a deletion from the middle or a reordering is detected and named by `seq`" | `verify_chain` — `receipt.py: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` | | "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:702` — 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: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` | ## 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:3363` | +| `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/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/cookbook/verify-in-github-actions.mdx b/docs/cookbook/verify-in-github-actions.mdx index 33c482d..d23b318 100644 --- a/docs/cookbook/verify-in-github-actions.mdx +++ b/docs/cookbook/verify-in-github-actions.mdx @@ -62,10 +62,11 @@ 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.7.0, catalogue ctrlrun.guarantees/v3 +CTRLRun verify — ctrlrun 0.8.0, catalogue ctrlrun.guarantees/v4 policy /home/runner/work/agent/agent/ctrlrun.yaml (ctrlrun.policy/v2, mode: enforce) authority none store sqlite, scratch (created and destroyed for this run) + G1 mutated approval refused PASS k8s.delete_namespace G2 replayed approval refused PASS k8s.delete_namespace G3 duplicate effect refused PASS k8s.delete_namespace @@ -87,11 +88,23 @@ G16 a moved fingerprint is refused PASS k8s.delete_namespace does not read. The gateway and the ACS hook cannot name a provider at all, and refuse an approval that carries a fingerprint) +G17 an unentitled approver refused N/A no cited control names an approver role +G18 the requester cannot approve PASS k8s.delete_namespace +G19 one principal counts once N/A no action requires more than one approval +G20 revoked before its exp: no PASS k8s.delete_namespace + (G20 is graded against a revocation feed verify supplies: + whether this deployment configures one is a fact about its + own code, which verify cannot read) +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) (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) -12/12 declared guarantees pass. 4 not applicable: G8, G9, G13, G15. + +15/15 declared guarantees pass. 6 not applicable: G8, G9, G13, G15, G17, G19. ``` 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 f7935eb..624a559 100644 --- a/docs/guides/verify-in-ci.mdx +++ b/docs/guides/verify-in-ci.mdx @@ -32,10 +32,11 @@ guarantees pass. ``` ```text - CTRLRun verify — ctrlrun 0.7.0, catalogue ctrlrun.guarantees/v3 + CTRLRun verify — ctrlrun 0.8.0, catalogue ctrlrun.guarantees/v4 policy /home/you/agent/ctrlrun.yaml (ctrlrun.policy/v2, mode: enforce) authority none store sqlite, scratch (created and destroyed for this run) + G1 mutated approval refused PASS k8s.delete_namespace G2 replayed approval refused PASS k8s.delete_namespace G3 duplicate effect refused PASS k8s.delete_namespace @@ -57,11 +58,23 @@ guarantees pass. does not read. The gateway and the ACS hook cannot name a provider at all, and refuse an approval that carries a fingerprint) + G17 an unentitled approver refused N/A no cited control names an approver role + G18 the requester cannot approve PASS k8s.delete_namespace + G19 one principal counts once N/A no action requires more than one approval + G20 revoked before its exp: no PASS k8s.delete_namespace + (G20 is graded against a revocation feed verify supplies: + whether this deployment configures one is a fact about its + own code, which verify cannot read) + 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) (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) - 12/12 declared guarantees pass. 4 not applicable: G8, G9, G13, G15. + + 15/15 declared guarantees pass. 6 not applicable: G8, G9, G13, G15, G17, G19. ``` The first line is on **stderr**, from G7's own scenario: an action with no principal is diff --git a/docs/production/index.mdx b/docs/production/index.mdx index 4aa8ab4..bc40177 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.7.0**, on [PyPI](https://pypi.org/project/ctrlrun/), Python 3.11 and later. -- **5,167 tests**, every version specified before it was written and every requirement mutation-tested. [Read more](/docs/how-this-is-built). -- **16 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.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). - **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,6 +42,13 @@ 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/Condition.mdx b/docs/reference/api/Condition.mdx index 979f15a..66d9ccd 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:351` +`ctrlrun.Condition` — class, defined at `src/ctrlrun/policy.py:365` ```python from ctrlrun import Condition diff --git a/docs/reference/api/Control.mdx b/docs/reference/api/Control.mdx index 78d43f5..7ee0f35 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:497` +`ctrlrun.Control` — class, defined at `src/ctrlrun/control.py:527` ```python from ctrlrun import Control @@ -14,7 +14,7 @@ from ctrlrun import Control ```python class Control - def __init__(policy: Policy, store: StateStore, approvals: ApprovalProvider | None = None, *, clock: Callable[[], datetime] = _utc_now, approval_ttl: timedelta = DEFAULT_APPROVAL_TTL, lease: timedelta = DEFAULT_LEASE, sinks: Sequence[EventSink] = (), suspend_timeout: timedelta = DEFAULT_SUSPEND_TIMEOUT, identity: IdentityProvider | None = None, authority: Authority | None = None, environment: str | None = None, approver_identity: ApproverIdentity | None = None) + def __init__(policy: Policy, store: StateStore, approvals: ApprovalProvider | None = None, *, clock: Callable[[], datetime] = _utc_now, approval_ttl: timedelta = DEFAULT_APPROVAL_TTL, lease: timedelta = DEFAULT_LEASE, sinks: Sequence[EventSink] = (), suspend_timeout: timedelta = DEFAULT_SUSPEND_TIMEOUT, identity: IdentityProvider | None = None, authority: Authority | None = None, environment: str | None = None, approver_identity: ApproverIdentity | None = None, require_approved_policy: bool = False) ``` Policy, state and evidence composed around a single action (SPEC-v0.1 §8). diff --git a/docs/reference/api/Decision.mdx b/docs/reference/api/Decision.mdx index 1bdba1e..b181bb5 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:288` +`ctrlrun.Decision` — class, defined at `src/ctrlrun/policy.py:302` ```python from ctrlrun import Decision diff --git a/docs/reference/api/Event.mdx b/docs/reference/api/Event.mdx index b017791..101ec08 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:269` +`ctrlrun.Event` — class, defined at `src/ctrlrun/receipt.py:280` ```python from ctrlrun import Event diff --git a/docs/reference/api/EventSink.mdx b/docs/reference/api/EventSink.mdx index 22f77d0..3c1c3c7 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:692` +`ctrlrun.EventSink` — class, defined at `src/ctrlrun/receipt.py:703` ```python from ctrlrun import EventSink diff --git a/docs/reference/api/JSONLEventSink.mdx b/docs/reference/api/JSONLEventSink.mdx index 23ad8aa..371b528 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:711` +`ctrlrun.JSONLEventSink` — class, defined at `src/ctrlrun/receipt.py:722` ```python from ctrlrun import JSONLEventSink diff --git a/docs/reference/api/Policy.mdx b/docs/reference/api/Policy.mdx index 105a095..b196369 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:668` +`ctrlrun.Policy` — class, defined at `src/ctrlrun/policy.py:694` ```python from ctrlrun import Policy diff --git a/docs/reference/api/Receipt.mdx b/docs/reference/api/Receipt.mdx index f6fe5e9..9f8829f 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:361` +`ctrlrun.Receipt` — class, defined at `src/ctrlrun/receipt.py:372` ```python from ctrlrun import Receipt diff --git a/docs/reference/api/context.mdx b/docs/reference/api/context.mdx index 26f51c2..b5b0857 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:266` +`ctrlrun.context` — function, defined at `src/ctrlrun/control.py:296` ```python from ctrlrun import context diff --git a/docs/reference/api/idempotency_token.mdx b/docs/reference/api/idempotency_token.mdx index 98db267..e4ebf8c 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:234` +`ctrlrun.idempotency_token` — function, defined at `src/ctrlrun/control.py:264` ```python from ctrlrun import idempotency_token diff --git a/docs/reference/api/jwt_identity-JWTIdentityProvider.mdx b/docs/reference/api/jwt_identity-JWTIdentityProvider.mdx index 251e155..dfb8c46 100644 --- a/docs/reference/api/jwt_identity-JWTIdentityProvider.mdx +++ b/docs/reference/api/jwt_identity-JWTIdentityProvider.mdx @@ -5,7 +5,7 @@ description: "Verify a bearer JWT and map its verified claims onto a `Principal` {/* generated by tools/docs_audit/render_api.py from the docstrings — edit the docstring, never this page */} -`ctrlrun.jwt_identity.JWTIdentityProvider` — class, defined at `src/ctrlrun/jwt_identity.py:87` +`ctrlrun.jwt_identity.JWTIdentityProvider` — class, defined at `src/ctrlrun/jwt_identity.py:113` ```python from ctrlrun.jwt_identity import JWTIdentityProvider @@ -16,7 +16,7 @@ Needs the `identity` extra: `pip install "ctrlrun[identity]"`. Importing it with ```python class JWTIdentityProvider - def __init__(*, jwks_url: str | None = None, public_key: str | None = None, secret: str | None = None, algorithms: Sequence[str], issuer: str, audience: str, token_type: str | None, header: str = DEFAULT_HEADER, agent_claim: str = DEFAULT_AGENT_CLAIM, user_claim: str | None = None, claim_names: Sequence[str] = (), leeway: timedelta = DEFAULT_LEEWAY, jwks_min_refresh_interval: timedelta = DEFAULT_JWKS_MIN_REFRESH, http_timeout: timedelta = DEFAULT_HTTP_TIMEOUT, clock: Callable[[], datetime] = _utc_now) + def __init__(*, jwks_url: str | None = None, public_key: str | None = None, secret: str | None = None, algorithms: Sequence[str], issuer: str, audience: str, token_type: str | None, header: str = DEFAULT_HEADER, agent_claim: str = DEFAULT_AGENT_CLAIM, user_claim: str | None = None, claim_names: Sequence[str] = (), leeway: timedelta = DEFAULT_LEEWAY, jwks_min_refresh_interval: timedelta = DEFAULT_JWKS_MIN_REFRESH, http_timeout: timedelta = DEFAULT_HTTP_TIMEOUT, revocations: RevocationFeed | None = None, clock: Callable[[], datetime] = _utc_now) ``` Verify a bearer JWT and map its verified claims onto a `Principal` (SPEC-v0.3 §3.4). diff --git a/docs/reference/api/parse_conditions.mdx b/docs/reference/api/parse_conditions.mdx index 44b0429..82c6a78 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:1103` +`ctrlrun.parse_conditions` — function, defined at `src/ctrlrun/policy.py:1193` ```python from ctrlrun import parse_conditions diff --git a/docs/reference/api/protect.mdx b/docs/reference/api/protect.mdx index 3c20fe0..4d88f69 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:3538` +`ctrlrun.protect` — function, defined at `src/ctrlrun/control.py:3856` ```python from ctrlrun import protect diff --git a/docs/reference/api/with_approval.mdx b/docs/reference/api/with_approval.mdx index e09b67c..3570ab6 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:289` +`ctrlrun.with_approval` — function, defined at `src/ctrlrun/control.py:319` ```python from ctrlrun import with_approval diff --git a/docs/reference/authority-yaml.mdx b/docs/reference/authority-yaml.mdx index 8d3c73f..441ddb4 100644 --- a/docs/reference/authority-yaml.mdx +++ b/docs/reference/authority-yaml.mdx @@ -1,6 +1,6 @@ --- title: "Authority YAML reference" -description: "Every key of the authority: section: max_delegation_depth and grants, and on a grant id, subject (agent, user), actions, resources, constraints." +description: "Every key of the authority: section: max_delegation_depth, grants and break_glass, and on a grant subject, actions, resources and constraints." --- The `authority:` section says which principal may propose which action. It lives at the top of @@ -15,6 +15,53 @@ asserts this page names every key the loader accepts. |---|---|---|---| | `max_delegation_depth` | non-negative integer | `3` | how long a chain of delegations may be; recomputed on every evaluation by walking to the root, never read from the stored row | | `grants` | list of grants | **error** if missing | the whole of who may ask | +| `break_glass` | map of envelope id → envelope | none | since v6: the widest authority an incident may reach, declared in advance. **Never consulted when deciding an action** | + +## Break-glass envelopes + +An incident needs authority nobody was granted in advance. The wrong answer is a flag: a flag +leaves no record, expires never, cannot be revoked and cannot be narrowed. What an envelope +declares is the **ceiling** for a grant opened beneath it during an incident. + +```yaml +authority: + grants: [] + break_glass: + incident-payments: + subject: { agent: "oncall-*" } # who a grant opened here may be FOR + actions: ["payments.*"] + resources: ["payment:*"] + constraints: { amount_lte: 50000 } + environments: ["prod"] + max_ttl: PT4H # the longest expiry a grant beneath it may carry + controls: [incident-response] # whose approver_role gates who may OPEN it +``` + +| Key | Type | Notes | +|---|---|---| +| `max_ttl` | ISO-8601 duration, positive | **required**. `PT4H`, `PT30M`, `PT90S`, `P2D`; months and years are refused, because they are not durations a clock can add without a calendar. Re-checked on every evaluation, so narrowing it cuts grants already open | +| `controls` | list of control ids | which controls' `approver_role` gates who may open one. A citation that resolves to nothing, or to a control naming no role, is refused: here the citation **is** the gate, so an omission that gates nobody fails closed, which is the opposite of what the same omission means on an action | +| everything a grant has | | minus `id`, which is the mapping key, and minus `delegable` and `expires_at`, which are **refused by name** | + +**The envelope decides nothing, by construction.** It lives in a mapping separate from `grants:`, +and the candidate set an evaluation walks is `grants:` unconditionally — so an envelope cannot +authorize an action even by accident. A deployment with an envelope and nothing opened beneath it +evaluates exactly as one without it. + +**It carries no `delegable` and no `expires_at`, and both are load errors.** An envelope exists +only to be a parent, so "delegable" is what it means rather than a key it sets; and what bounds it +in time is `max_ttl`, which every grant beneath it obeys. It is covered by the policy hash, +`max_ttl` included, so widening the widest authority an incident can reach moves every receipt. + +**Opening one is an act with its own command**, `ctrlrun break-glass --envelope --file +grant.yaml --reason ""`. There is no `--as`: the opener is the principal the deployment's +approver identity resolves, and a deployment that names none cannot open one at all. A grant opened +beneath an envelope **must** carry an `expires_at` inside `max_ttl`, is contained on every +dimension, is revocable, attenuates, and is named on the receipt of every action taken under it. + +An id declared in both `grants:` and `break_glass:` is a load error naming both, and a standalone +`--authority` document may not declare `break_glass` at all: it carries no control registry to +resolve the gate against, so the only envelope it could express is an ungated one. ## A grant diff --git a/docs/reference/cli.mdx b/docs/reference/cli.mdx index 15fdcdd..f368380 100644 --- a/docs/reference/cli.mdx +++ b/docs/reference/cli.mdx @@ -32,6 +32,7 @@ Commands: init Write a starter ctrlrun.yaml and create .ctrlrun/. inspect Show one action's whole history: proposal, decision,... mcp-operator Answer approvals from an MCP client, over loopback... + policy Propose a policy change, or replay one against what already... receipts Show the receipts this store holds. resolve Say what actually happened to an effect with an unknown... revoke Revoke a delegation, and with it every delegation beneath it. @@ -233,6 +234,31 @@ Options: --help Show this message and exit. ``` +## ctrlrun policy + +```text +Usage: ctrlrun policy [OPTIONS] COMMAND [ARGS]... + + Propose a policy change, or replay one against what already happened. + + A policy is the one file that decides every other decision, and until v0.8 it + was changed by editing it. v0.6 made the change evidenced: every receipt + records the hash of the policy that decided it. v0.8 makes it approved: **a + policy nobody approved decides nothing**, in a deployment that asks for that + with `Control(require_approved_policy=True)`. + + There is no `ctrlrun policy approve`. A proposal is an ordinary approval + request, so the command that answers it is `ctrlrun approve`, and a second one + would be a second approval path (SPEC-v0.8 §8.3). + +Options: + --help Show this message and exit. + +Commands: + propose Propose a policy change under the policy currently in force. + replay Report which recorded decisions would change under a proposed... +``` + ## ctrlrun break-glass ```text diff --git a/docs/reference/policy-yaml.mdx b/docs/reference/policy-yaml.mdx index 446d659..4ba5143 100644 --- a/docs/reference/policy-yaml.mdx +++ b/docs/reference/policy-yaml.mdx @@ -1,6 +1,6 @@ --- title: "Policy YAML reference" -description: "Every key of ctrlrun.yaml, from ctrlrun.policy/v1 to v5: schema, actions, decision, rules, max_attempts, when conditions and their seven operators." +description: "Every key of ctrlrun.yaml, from ctrlrun.policy/v1 to v6: actions, decision, rules, max_attempts, approver_role, approvals_required and the when operators." --- `ctrlrun.yaml` is one document: a `schema`, an `actions` map, and from `ctrlrun.policy/v3` the @@ -61,8 +61,31 @@ Exactly one of `decision` and `rules`, plus the optional keys. | `mcp` | mapping with one key, `not_executed_on_error` (bool) | v2 | `false` | gateway only: whether an upstream's in-band error means the tool did nothing (`FAILED`) or nobody knows (`AMBIGUOUS`). Say `true` only for an upstream that rejects before acting | | `controls` | list of ids | v4 | none | cited on every receipt for this action, in registry order | | `data` | map of argument name → label, either a bare string or a mapping with the one key `label` | v4 | no labels; `data_scope` is empty | labels the arguments carry, for the `data_scope` condition below | +| `approvals_required` | integer, at least 1 | v6 | one approval, exactly as before v6 | how many **distinct verified principals** must answer before the approval is consumable. 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. Above `1` in a deployment that configures no approver identity is a **denial**, not a silent downgrade: the kernel cannot tell two anonymous yeses apart. `ctrlrun approve` records no verified approver, so it never counts toward one, and says so when it is used. Inside the policy hash | | `max_attempts` | integer, at least 1 | v5 | no ceiling: a renewal after `FAILED` is unbounded, exactly as before v5 | the most **attempts** that may execute on one effect key, the first included: `3` is the first attempt and two renewals. Above it the executor is not called, the record is released as `FAILED`, a `blocked` receipt names the ceiling, and `ActionDenied(reason="attempt_ceiling")` is raised; the refused number is spent. `0`, a negative, a bool, a float, a string and a mapping are each a load error naming the key, the action and the line. An attempt is not an executor invocation: a suspended executor's resumes all run on one attempt. Inside the policy hash | +## A control entry + +The registry `controls:` at the top level. v4 gave a control a `title` and a `source`, which name +a requirement and decide nothing. v6 gives it one key that decides something. + +| Key | Type | Since | When omitted | Notes | +|---|---|---|---|---| +| `title` | string | v4 | required | what the control requires, in a sentence | +| `source` | string | v4 | none | where the requirement comes from. CTRLRun does not interpret it | +| `approver_role` | non-empty string | v6 | **this control gates nobody** | the role a principal must hold for their approval of an action citing this control to be consumable. Matched **byte for byte** against the claim the deployment names: no case folding, no trimming, no prefix matching and no pattern grammar, because a wildcard in a role would be an entitlement nobody wrote. Leading or trailing whitespace is a load error rather than a role that matches nothing for ever. Where an action cites several controls, **every** required role must be held, since any-of would let the weakest control in a set decide who may answer. Inside the policy hash | + +**Omission is not entitlement, and it is not refusal either.** A control with no `approver_role` +gates nobody; a principal whose claims lack the role is not entitled. The two look alike in a diff +and mean opposite things: the reading that merges them either refuses every approval in a +deployment with one unroled control, or admits every approver in a deployment with one unclaimed +principal. + +**What the kernel refuses is an approval whose *recorded* entitlement does not cover the role.** +What entitled it was decided where the credential was verified — the operator MCP server, or an +embedding application. CTRLRun does not interpret the role, does not check that such a role exists +anywhere, and claims nothing about a standard or an audit on the strength of one. + ## A rule | Key | Type | When omitted | Notes | @@ -131,6 +154,7 @@ reader would have silently misread is one it refuses instead. | `ctrlrun.policy/v3` | `mode`, `environment`, `authority` at the top | enforce an observed deployment, or run with no authority check | | `ctrlrun.policy/v4` | `version`, `controls` at the top; `controls`, `data` on an entry and a rule | cite ids nothing defines, or evaluate a `data_scope` rule against nothing | | `ctrlrun.policy/v5` | `max_attempts` on an entry | bound renewal after `FAILED` on one effect key | +| `ctrlrun.policy/v6` | `approver_role` on a control entry; `approvals_required` on an action entry; `break_glass` in the authority section | gate nobody while reporting a deployment as checking entitlement, consume on the first grant where several were required, or read an envelope as an ordinary grant | That is why the key sets are closed by version: each key is refused by every reader that would misread it. diff --git a/docs/verify.md b/docs/verify.md index f78cea1..1a83dbd 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.7.0, catalogue ctrlrun.guarantees/v3 -policy examples/authority/payments.yaml (ctrlrun.policy/v3, mode: enforce) +CTRLRun verify — ctrlrun 0.8.0, catalogue ctrlrun.guarantees/v4 +policy examples/authority/payments.yaml (ctrlrun.policy/v6, mode: enforce) authority same document, 3 grants store sqlite, scratch (created and destroyed for this run) @@ -40,12 +40,23 @@ G16 a moved fingerprint is refused PASS stripe.refund does not read. The gateway and the ACS hook cannot name a provider at all, and refuse an approval that carries a fingerprint) +G17 an unentitled approver refused PASS stripe.refund +G18 the requester cannot approve PASS stripe.refund +G19 one principal counts once PASS stripe.refund +G20 revoked before its exp: no PASS stripe.refund + (G20 is graded against a revocation feed verify supplies: + whether this deployment configures one is a fact about its + own code, which verify cannot read) +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) (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) -14/14 declared guarantees pass. 2 not applicable: G13, G15. +19/19 declared guarantees pass. 2 not applicable: G13, G15. ``` It reads the policy document — `$CTRLRUN_CONFIG`, else `./ctrlrun.yaml` — and the authority diff --git a/generated/readiness.full.mdx b/generated/readiness.full.mdx index 9c1787a..6b1c46c 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.7.0**, on [PyPI](https://pypi.org/project/ctrlrun/), Python 3.11 and later. -- **5,528 tests**, every version specified before it was written and every requirement mutation-tested. [Read more](/docs/how-this-is-built). -- **19 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.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). - **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 d342fdf..f274e24 100644 --- a/generated/readiness.json +++ b/generated/readiness.json @@ -1,6 +1,6 @@ { - "guarantees": 19, - "released": "0.7.0", + "guarantees": 21, + "released": "0.8.0", "soak": { "actions": 889735, "backend": "postgres", @@ -9,6 +9,6 @@ "positive_control": true, "unexplained": 0 }, - "tests": 5528, - "version": "0.7.0" + "tests": 5653, + "version": "0.8.0" } diff --git a/generated/readiness.mdx b/generated/readiness.mdx index 3446a6e..27be898 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.7.0**, on [PyPI](https://pypi.org/project/ctrlrun/), Python 3.11 and later. -- **5,528 tests**, every version specified before it was written and every requirement mutation-tested. -- **19 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.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. - **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 0272919..a32f584 100644 --- a/generated/readiness.readme.md +++ b/generated/readiness.readme.md @@ -1,7 +1,7 @@ -- **Version 0.7.0**, on [PyPI](https://pypi.org/project/ctrlrun/), Python 3.11 and later. -- **5,528 tests**, every version specified before it was written and every requirement mutation-tested. -- **19 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.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. - **One host: a file.** SQLite, no server, no ops. **Many hosts: Postgres**, the same guarantees, graded by the same suite. - **Soaked for 20m 0s on postgres**: 889,735 actions, 0 unattributed ambiguous outcomes, positive control fired. Nothing here establishes what only accumulates over days. [What it does not establish](https://ctrlrun.dev/docs/production/soak). - **Each receipt carries the hash of the one before it**, so an alteration is detected and named. diff --git a/tests/test_docs_site.py b/tests/test_docs_site.py index 4014c32..bbdb7d8 100644 --- a/tests/test_docs_site.py +++ b/tests/test_docs_site.py @@ -74,7 +74,12 @@ def walk(node: object) -> None: CONCEPTS = sorted((DOCS / "docs" / "concepts").glob("*.mdx")) WORD_BUDGET = 900 _FRONTMATTER = re.compile(r"\A---\n(.*?)\n---\n", re.S) -_FENCE = re.compile(r"^```.*?^```", re.M | re.S) +#: A fenced code block, **indented or not**. The `^```` spelling required column 0, so every +#: fence inside a `` block -- which Mintlify indents -- counted as prose against the word +#: budget. It went unnoticed while those blocks were short; a verify transcript that grew from +#: twelve guarantees to twenty-one pushed `verify-in-ci.mdx` to 999 words of "prose", 600 of +#: which were a transcript nobody reads as prose. Indented fences are code too. +_FENCE = re.compile(r"^[ \t]*```.*?^[ \t]*```", re.M | re.S) def _frontmatter(page: Path) -> dict[str, str]: From 5be4ef1b802c8160b0e0a37f0fc59eeb4a9ccac0 Mon Sep 17 00:00:00 2001 From: arpan Date: Sun, 13 Sep 2026 00:50:47 +0530 Subject: [PATCH 4/4] Regenerate the CLI page: break-glass was withdrawn before 0.8.0 `ctrlrun break-glass` could not succeed in any configuration the CLI can load -- `Control.from_file` wires no ApproverIdentity and there is no configuration key for one -- so the kernel withdrew it rather than ship a command that always exits 1. The mechanism ships; only the shell surface defers. Signed-off-by: arpan --- docs/reference/cli.mdx | 32 -------------------------------- 1 file changed, 32 deletions(-) diff --git a/docs/reference/cli.mdx b/docs/reference/cli.mdx index f368380..832b30f 100644 --- a/docs/reference/cli.mdx +++ b/docs/reference/cli.mdx @@ -23,7 +23,6 @@ Options: Commands: approve Grant a pending approval request. - break-glass Open a break-glass grant beneath a declared envelope. delegate Create a delegated grant beneath an existing one. demo Run the five scenarios, in process, with no network. deny Refuse a pending approval request. @@ -259,37 +258,6 @@ Commands: replay Report which recorded decisions would change under a proposed... ``` -## ctrlrun break-glass - -```text -Usage: ctrlrun break-glass [OPTIONS] - - Open a break-glass grant beneath a declared envelope. - - An incident needs authority nobody was granted in advance, and the wrong - answer is a flag: a flag leaves no record, expires never and cannot be - revoked. What this creates is an ordinary delegation, bounded by the envelope - on every dimension, expiring within its 'max_ttl', revocable, attenuable, and - named on the receipt of every action taken under it. - - **There is no --as.** Whoever opens one is the principal the deployment's - approver identity resolves, and a deployment that names none cannot open one - at all: an assertion typed at a shell is exactly what break-glass must not - accept (SPEC-v0.8 §5.3.1). - -Options: - --envelope TEXT The break-glass envelope declared under 'authority: - break_glass:'. [required] - --file FILE A one-grant YAML document, as --file takes for delegate. - 'expires_at' is required. [required] - --reason TEXT Free text recorded on the DELEGATION_CREATED event. - --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 - wherever $CTRLRUN_STATE points). - --help Show this message and exit. -``` - ## ctrlrun revoke ```text