From b2666f6b9809053264da818e9d409d7ca41e915c Mon Sep 17 00:00:00 2001 From: arpan Date: Fri, 4 Sep 2026 10:17:17 +0530 Subject: [PATCH 1/3] spec: v0.3 The v0.3 contract, and the scaffolding items 1-7 build against. No implementation: src/ is untouched and the 1269 tests of v0.1 and v0.2 pass unchanged. v0.1 and v0.2 could see the action and nothing else. A policy condition naming the principal is refused at load, deliberately, and that rule survives this release. v0.3 adds a second and separate axis - who is acting, and what are they entitled to - and combines the two results as the stricter of the pair. Three rules the document is built on. Authority is opt-in as a section and fail-closed once present: no authority: key means v0.2 behaviour exactly, one authority: key means every principal needs a grant. Attenuation is structural: a delegated grant is valid only if it is provably a subset of its parent, on every dimension, at creation and again at every evaluation, and omitting a dimension the parent constrains is a rejected delegation rather than an unconstrained one. Identity is consumed, never invented: principals come from an IdentityProvider, and claims are receipt data rather than action identity, so an approval survives a token rotation. Four decisions are worth a reviewer's attention because the first draft got them wrong and an adversarial pass caught them. A delegation may change who acts, not how many. Its subject may carry no wildcard, and where its parent names a user the child must name one the parent admits. Without both rules one delegation hands a grant to every agent in the deployment - along with the right to pass it on - or strips the human the authority was bound to, and every containment check still reports "contained". A grant carries no decision. An earlier draft gave one an optional decision: allow | approve so that "this delegate always needs a human" was expressible. It deadlocked the gateway's approval pre-check, which keys off the policy decision; it needed a containment row whose default was the loosest value, which is the shape the containment table exists to refuse; and it made whether an action needs a human depend on who proposed it, which is the sentence CLAIMS.md maps to code. Authority permits or denies. Per-action autonomy stays in policy. Revocation and expiry are live; an edit to the document is not. Grants are read when the document is loaded, so narrowing a root grant takes effect on the next load, and the runtime kill switch is ctrlrun revoke, which covers delegations only. The prose that promised otherwise is corrected rather than the behaviour invented. The JWT provider must check typ. Checking iss, aud and exp alone accepts an OIDC ID token from the same issuer, signed with the same key, whenever the operator configured audience to that client id - RFC 8725 section 2's cross-JWT confusion, live in the first draft. token_type is a required argument with no default, and the symmetric-key guard moved to the configuration end, where it can actually refuse something. Section 1.3 records what was read, with URLs, dates and status, and the document makes no compliance, conformance or alignment claim anywhere. The short version: NIST has published nothing normative on agent identity, and its one dedicated document is a concept paper with no RFC 2119 keyword in it whose open questions are the ones this specification answers. MCP at 2026-07-28 defines no principal, actor or delegation primitive, and puts an explicit warning label on the only two fields that look like identity - which is why --principal-from-client-info is removed here. In OAuth the stable ground is JWT verification; everything agent-shaped is a draft and most of it is an individual submission with no IETF standing. A SPIFFE JWT-SVID is identity and explicitly not authorization. And section 5's "contained at creation and re-checked at evaluation" is a different guarantee from the append-only one macaroons and UCAN give, not a strictly stronger one; two of its dimensions have no prior art at all, and it says so. Scaffolding: version 0.3.0.dev0, an identity extra that is empty until item 5 adds the verifier and the pyjwt line it needs, a CHANGELOG Unreleased section that says plainly that nothing has shipped, and CI plus the publish gate installing the new extra so item 5 does not have to touch them. --- .github/workflows/ci.yml | 4 +- .github/workflows/publish.yml | 2 +- CHANGELOG.md | 43 + docs/SPEC-v0.3.md | 2627 +++++++++++++++++++++++++++++++++ pyproject.toml | 10 +- 5 files changed, 2682 insertions(+), 4 deletions(-) create mode 100644 docs/SPEC-v0.3.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c4f87f20..308a2e2b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,7 +24,7 @@ jobs: python -m pip install --upgrade pip # The gateway's tests need its extra; T30 still proves `import ctrlrun` does not # touch it, in a subprocess, whether or not it happens to be installed here. - pip install -e ".[dev,gateway,otel]" + pip install -e ".[dev,gateway,otel,identity]" - name: pytest run: pytest @@ -64,7 +64,7 @@ jobs: run: | mkdir -p /tmp/sdist && tar xzf dist/*.tar.gz -C /tmp/sdist --strip-components=1 cd /tmp/sdist - pip install ".[dev,gateway,otel]" + pip install ".[dev,gateway,otel,identity]" pytest -q - name: The quick start works from the wheel diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 22aae211..a7214fba 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -63,7 +63,7 @@ jobs: # With the extras: without them the gateway, ACS, elicitation and OTel modules # skip, and this gate guards an upload to a version number that can never be # reused. It should be no weaker than the one on main. - pip install ".[dev,gateway,otel]" + pip install ".[dev,gateway,otel,identity]" pytest -q - name: The quick start works from the wheel diff --git a/CHANGELOG.md b/CHANGELOG.md index d105951f..f56fbe08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,49 @@ any change to one appears here. ## [Unreleased] +Work towards **0.3.0 — Authority**. Version is `0.3.0.dev0`; nothing in this section has +shipped, and the only thing in the tree today is the contract. + +### Added + +- **`docs/SPEC-v0.3.md`** — the v0.3 contract, a delta over v0.1 and v0.2. Seven deliverables: + an extended `Principal` and the `IdentityProvider` protocol; the `authority:` section with + grants, patterns and constraints; delegation with structural attenuation; observe mode and + `ctrlrun stats`; a JWT identity provider and the gateway wiring for both; examples and docs; + the release. Tests come from §10 (T60–T93). +- **The `identity` extra**, empty until build-list item 5 adds the JWT verifier and the `pyjwt` + line it needs. `pip install ctrlrun` still installs nothing but `pyyaml` and `click`. + +### Notes on what the contract decides + +Recorded here because each is a decision a reader of the code would otherwise have to +reconstruct: + +- **Authority is opt-in as a section and fail-closed once present.** A file with no + `authority:` key behaves exactly as v0.2. With one, every principal needs a grant; no grant + means DENY. +- **Claims are receipt data, not action identity.** The canonical form of an Action is + unchanged, so an approval survives a token rotation. +- **Omission never means unlimited.** A delegated grant that drops a dimension its parent + constrains is rejected, not treated as unconstrained. +- **Observe mode is top-level only.** A partially-enforced configuration is the failure mode + that rule exists to prevent. +- **Policy still cannot see the principal, and a grant carries no decision.** Authority is a + second, independent axis that permits or denies; the two results combine as the stricter of + the pair. How much autonomy an action has stays the same for every principal. +- **A delegation may change who acts, not how many.** Its subject must name a concrete agent — + present, no wildcard — and a user its parent's pattern admits wherever the parent names one. + Otherwise one delegation hands the grant to every agent, or strips the human it was bound to, + and omitting the key reaches the same place as an asterisk. +- **Revocation and expiry are live; an edit to the document is not.** Grants are read when the + document is loaded. `ctrlrun revoke` is the runtime kill switch and it covers delegations + only; there is no hot reload and no runtime revoke for a root grant. + +### Deprecated + +- Nothing new. `--principal-from-client-info`, deprecated in 0.2.0, is **removed** in 0.3.0 by + build-list item 1; SPEC-v0.3 §8.1 has the replacement. + ### Fixed - **The gateway compared mirrored header values by re-parsing them, and Python's parser is diff --git a/docs/SPEC-v0.3.md b/docs/SPEC-v0.3.md new file mode 100644 index 00000000..6508bcf2 --- /dev/null +++ b/docs/SPEC-v0.3.md @@ -0,0 +1,2627 @@ +# CTRLRun v0.3 Specification + +This is a **delta over [`SPEC-v0.1.md`](SPEC-v0.1.md) and [`SPEC-v0.2.md`](SPEC-v0.2.md)**. +Everything in both still holds; this document states only what v0.3 adds or changes. A +reference to an earlier contract is written `v0.1 §5.4` or `v0.2 §6.5`; a bare `§5` is a +section of this document. Section numbers exist in all three, so the prefix is not decoration +— an unprefixed reference to an earlier spec is a defect. + +Tests are derived from §10. Public names added here are frozen in §11. Anything not in this +document or in v0.1/v0.2 is out of scope for v0.3. + +Words: MUST / MUST NOT / SHOULD are used in the RFC 2119 sense. + +v0.3 answers the question v0.1 and v0.2 could not: **who is acting, and what are they entitled +to?** Until now a policy could see the action and nothing else — `v0.1 §3.2` refuses +`agent_eq` and every other principal-addressing condition at load, deliberately, and that rule +survives this release unchanged (§4.7). The principal was attribution on a receipt. It becomes +an authorization input here, in a second and separate axis that policy never learns to read. + +Three rules govern everything below. + +**Authority is opt-in, and then fail-closed.** A configuration with no `authority:` section +behaves exactly as v0.2 did. The moment an `authority:` section exists, every principal needs +a grant and every action must pass *both* authority and policy. There is no half-way, no +`default: allow`, and no flag that restores one. + +**Attenuation is structural.** A delegated grant is valid only if it is provably a subset of +its parent — at the moment it is created *and* again every time it is evaluated. Omitting a +dimension the parent constrains is not "unconstrained"; it is a rejected delegation (§5.4). + +**Identity is consumed, never invented.** Principals come from an `IdentityProvider`. CTRLRun +verifies tokens it is handed and maps verified claims onto a `Principal`. It defines no +identity format, issues no credential, and runs no authorization server. + +--- + +## 1. Scope + +v0.3 delivers seven things, one build-list item each, plus a release. The `#` column is the +build-list position. + +| # | Deliverable | Ships in | Section | +|---|---|---|---| +| 1 | Extended `Principal`, `IdentityProvider`, static and header providers | core | §2, §3 | +| 2 | The `authority:` section: `Grant`, `Authority`, evaluation, the AND with policy | core | §4 | +| 3 | Delegation with attenuation, revocation, the `delegations` table | core | §5 | +| 4 | Observe mode and `ctrlrun stats` | core | §6 | +| 5 | `JWTIdentityProvider`; gateway identity and authority wiring | `ctrlrun[identity]` | §3.4, §8 | +| 6 | Demo scenario 5, `examples/authority*`, `docs/authority.md` | core | §1.2 | +| 7 | Release 0.3.0 | — | — | + +The dependency rule of `v0.2 §1.1` is unchanged and binding: `pip install ctrlrun` MUST +continue to install nothing but `pyyaml` and `click`. The whole authority model — grants, +delegation, containment, observe mode — is core, because it is stdlib plus the YAML parser +already present. Only token verification needs a third party, so only `JWTIdentityProvider` +lives in an extra (`ctrlrun[identity]`), imported lazily, raising `MissingDependency` naming +the install command when absent. `import ctrlrun` MUST NOT import `jwt`, `httpx` or any +`opentelemetry` module. T30 already asserts this against `sys.modules`; §10's T92 extends it. + +### 1.1 What this release opts into, and what it refuses to claim + +v0.3 consumes identities that other systems issue. It is worth being exact about which, because +"aligns with" is the kind of sentence that ages into a lie. + +**What CTRLRun consumes.** + +- A **JSON Web Token** (RFC 7519) presented in an HTTP header, verified against a **JWKS** + (RFC 7517) or a static public key, with `exp`, `nbf`, `aud` and `iss` checked, and with + configurable claim names mapped onto `Principal.agent` and `Principal.user` (§3.4). This is + the only credential format v0.3 verifies. +- Anything that has already been verified by something else and handed over in a trusted + header, via `HeaderIdentityProvider` — with the threat that implies stated in full (§3.3). + +**What CTRLRun does not do.** It issues no token, mints no identity, runs no authorization +server, performs no OAuth flow, and defines no new identity format or claim name. There is no +`ctrlrun` claim, no CTRLRun identity document, and no registry of agent identifiers. A +`Principal` is a *reading* of somebody else's credential. + +**What CTRLRun does not claim.** No conformance, compliance, certification or alignment with +any standard, in this document, in the README, in a docstring, or in CLI output. `ROADMAP.md`'s +standards rule holds: *integrate first, map second, never claim compliance* — a standard +appears in a mapping doc only after code touches it and a test proves the guarantee. §1.3 +records what was read while writing this, so the reading is auditable and its limits visible. + +### 1.2 Examples, demo and documentation (item 6) + +- A fifth `ctrlrun demo` scenario, **authority escalation**: the chain of §5.6, in-process, no + network, printing a fifth `BLOCKED` line. +- `examples/authority-escalation/` — a standalone script telling the same story, with the + `else: raise SystemExit(...)` guard every example carries: a demonstration that quietly + starts succeeding is worse than none, because it keeps printing the line that says the guard + worked. +- `examples/authority/` — two configurations: a payments delegation chain and a DevOps chain. +- `docs/authority.md` — grants, delegation and the omission rule (§5.4) in plain language. +- `docs/THREAT_MODEL.md` gains v0.3's boundary: delegation escalation, expired and revoked + authority, and token forgery are in scope; a compromised identity provider is not. + +The nine sector templates under `examples/policies/` stay on v0.1 primitives, declare +`schema: ctrlrun.policy/v1`, and say so on their face. They gain no `authority:` section: a +grant names real principals in a real organization, and a template that ships plausible ones +invites an operator to adopt them. + +### 1.3 What was read + +Read on **2026-09-04** unless a document states otherwise. Dates are the dates the documents +themselves carry. + +#### NIST — the questions, not the answers + +NIST has published **nothing normative on agent identity**. The one document dedicated to the +subject is a concept paper: *Accelerating the Adoption of Software and AI Agent Identity and +Authorization*, NCCoE, **Initial Public Draft, published 2026-02-05**, comments closed +2026-04-02 (). +It contains no RFC 2119 keyword anywhere in its eleven pages, and its Note to Reviewers asks, +among others, *"What are the mechanisms for an agent to prove its authority to perform a +specific action?"* and *"How do we handle delegation of authority for 'on behalf of' +scenarios?"* — the questions this specification answers, still open on NIST's side. Its stated deliverable is a practice +guide, not a standard. It names MCP, OAuth 2.0/2.1, OpenID Connect, SPIFFE/SPIRE, SCIM and NGAC +as *candidates*, profiles none, and requires none. + +The adjacent finals do not fill the gap. **SP 800-63-4** (final, 2025-07-31, +) is digital identity for natural persons. +**SP 800-207** (Zero Trust Architecture, August 2020, +) has a subject model but nothing on machine +delegation. **NIST IR 8587**, *Protecting Tokens and Assertions from Forgery, Theft, and Misuse*, +is an Initial Public Draft (published 2025-12-22, +), and its verifier obligations informed §3.4 without +being citable as a requirement. Its §4.2.1.2 is the one NIST sentence that addresses a +component in CTRLRun's position — *"Policy enforcement points (e.g., at the application level) +that rely on access tokens and identity assertions MUST confirm the validity, scope, source, +and integrity of access tokens before granting access to resources"* — and it is draft +guidance, aimed at agencies and cloud service providers, that mentions neither AI agents nor +delegation. A NIST blog post of **2026-08-27** +() +observes that *"other specifications, such as Transaction Tokens, are looking at ways to +promulgate authorization context across human and agentic call chains to help ensure that +authorization is attenuated as it's delegated"* — a description of IETF activity, not a +requirement. + +**What v0.3 takes from this:** the vocabulary, and the confirmation that the shape of the +problem is not yet settled by anyone. Nothing to align to, so nothing is claimed. `Principal` +does not become a NIST anything. + +#### MCP — audience-bound tokens, and a warning label on the only fields that look like identity + +Read at revision **2026-07-28** ( +and its `security-considerations` page), which is the revision `SPEC-v0.2` is written against. + +MCP defines **no** principal, actor, delegation, attenuation or authority primitive. Its +authorization section makes the MCP server an OAuth 2.1 **resource server** and binds it with +four MUSTs a verifier inherits: validate the access token; validate that it was *issued for this +server as the audience*, per RFC 8707 §2; accept only tokens valid for its own resources; and +**never pass through the token it received** to an upstream API. Discovery is RFC 9728 protected +resource metadata (April 2025). MCP pins OAuth 2.1 `draft-ietf-oauth-v2-1-13` on that page — +inconsistently: its Refresh Tokens section links `-14`. The live document was +`draft-ietf-oauth-v2-1-16`, dated 2026-09-03, when this was read, so "OAuth 2.1 draft-N" is +stale the moment it is written down; every citation of it here carries a revision *and* a +date. + +The two fields that look like identity are both dead ends, and the specification says so on +its reserved-`_meta` page +(): +`io.modelcontextprotocol/clientInfo` and `serverInfo` are *"self-reported by the sender and are +not verified by the protocol… Implementations SHOULD NOT use them to change the behavior of the +client or server, and SHOULD NOT rely on them for security decisions."* That sentence is why +`--principal-from-client-info` is removed here (§8.1). And there is no machine-readable +"tool X requires scope Y" anywhere: scopes are opaque strings challenged through +`WWW-Authenticate`, clients *"MUST NOT assume any particular set relationship"* between the +challenged scopes and `scopes_supported`, and the step-up flow computes the **union** of previous +and challenged scopes — the opposite of attenuation. + +**What v0.3 takes from this:** nothing it can map an authority model onto. MCP supplies +audience-bound bearer tokens and a transport. Per-action authority is CTRLRun's own ground, which +is exactly why the gateway carries a policy and an `authority:` file rather than deriving +entitlements from the protocol. + +#### OAuth and IETF — stable ground for verification, drafts for everything agent-shaped + +Stable, and what §3.4 is built on: **RFC 7519** (JWT), **RFC 7517** (JWK/JWKS), **RFC 8725** +(BCP 225, JWT Best Current Practices — the algorithm-confusion and cross-JWT-confusion classes +§3.4 refuses), **RFC 9068** (JWT profile for OAuth access tokens, October 2021), **RFC 9700** +(BCP 240, Best Current Practice for OAuth 2.0 Security, 2025 — the current baseline for +audience restriction and bearer-token handling, and the reason this list is not the 2020-era +one), **RFC 8707** (Resource Indicators, February 2020), **RFC 9728** (Protected Resource +Metadata, April 2025). Also stable and deliberately *not* used: **RFC 8693** (Token Exchange, +January 2020), **RFC 9449** (DPoP, September 2023) and **RFC 8705** (mTLS-bound tokens, +February 2020) — all three are things an issuer or a client does, and v0.3 is neither. + +RFC 8693 is worth one more sentence, because it is the RFC someone will expect §5 to be built +on. Its §4.1 requires a consumer to *"only consider the token's top-level claims and the party +identified as the current actor by the `act` claim"*, with nested prior actors informational. +CTRLRun's delegation chain is therefore **not** carried in a token and not read from one: it +lives in CTRLRun's own store (§5.2), and every link is re-checked there (§5.6). A library that +claimed to verify a delegation chain out of an `act` claim would be non-conformant to the one +Final RFC it would be citing. + +Everything specific to *agent* identity is a draft, and most of it has no IETF standing at all. +`draft-ietf-oauth-transaction-tokens-11` (2026-07-30, OAuth WG, "waiting for write-up") and +`draft-ietf-oauth-identity-chaining` (rev 17, 2026-07-19, in the RFC Editor queue when read — +a state that acquires an RFC number without warning, so treat "there is no RFC" as an +observation dated 2026-09-04) are working-group documents; +`draft-ietf-oauth-identity-assertion-authz-grant-04` (2026-05-21, expires 2026-11-22) is the +ID-JAG that MCP's enterprise-managed-authorization extension builds on. Beyond those, +`draft-klrc-aiagent-auth-03` (2026-07-06), +`draft-niyikiza-oauth-attenuating-agent-tokens-01` (2026-06-15, expires 2026-12-17), +`draft-mcguinness-oauth-actor-profile-00` (2026-04-30) and +`draft-mishra-oauth-agent-grants-02` (2026-08-30) are **individual submissions**: no stream, no +working group, no intended RFC status. An `agentproto` BOF exists with group state `bof` and no +charter. OpenID's **Authorization API 1.0** reached Final +(2026-01-11, ); its Access Request and +Approval Profile (ARAP) is an AuthZEN **Working Group Draft**, retrieved 2026-09-04 — its title +page carries a build date that moves, so it is cited by stage and retrieval date rather than by +a version. + +**What v0.3 takes from this:** JWT verification, and nothing else. A library that put a +six-month-old individual draft in its wire format would be shipping somebody's unreviewed idea to +production. `JWTIdentityProvider` verifies RFC 7519 tokens against RFC 7517 keys; the `authority:` +section is CTRLRun's own YAML, deliberately, and does not pretend to be a token profile. + +#### SPIFFE — identity, and explicitly not authorization + +Read from the `spiffe/spiffe` standards directory. **These documents carry no version numbers +and no dates** — each opens with a stability banner and nothing more — so the only durable +identifier is a commit, and this read is pinned at `JWT-SVID.md` and `X509-SVID.md` at commit +`21896ac` (2026-07-01), and `SPIFFE-ID.md` and `SPIFFE_Workload_API.md` at `665a28f` +(2026-07-01). All four are marked Stable. A SPIFFE ID is `spiffe://trust-domain/path`, an RFC 3986 URI. A JWT-SVID +() requires `sub` (the SPIFFE +ID), `aud` and `exp`; restricts `alg` to nine asymmetric values and requires validators to reject +anything else; and *"does not introduce any new claims."* + +It carries **no scope, role, group, permission or entitlement claim**. Custom claims are +permitted, and SPIRE can mint them — but `SPIFFE-ID.md` §4.1.1 warns that role and access-policy +assertions are *"more likely to change between the time of SVID issuance and the time of +validation"* and that operators should *"err on the side of caution and exclude"* volatile +assertions; its §4.1.2 warns that the same attribute name means different things in different +trust domains; and `SPIFFE_Workload_API.md` §6.2.3 says a `ValidateJWTSVID` implementation *"MAY filter +non-SPIFFE claims"* before a consumer ever sees them. A grant that depended on a SPIFFE custom claim could be silently stripped +in transit. + +**What v0.3 takes from this:** confirmation of the split this specification is built on. A +JWT-SVID is a fine `Principal` source — it is a signed JWT with `sub`, `aud` and `exp`, which is +exactly what §3.4 verifies — and it is not, and does not try to be, a statement of entitlement. +That is the `authority:` section's job. v0.3 ships no SPIFFE-specific code; it needs none. + +#### Attenuated delegation — where §5 sits in the prior art + +Six families were read: macaroons (Birgisson, Politz, Erlingsson, Taly, Vrable and Lentczner, +NDSS, February 2014, ); Biscuit (Eclipse project, Incubating, +creation review 2025-02-05); UCAN 1.0.0 (, self-declared 1.0.0, +no dated release); ZCAP-LD (W3C CCG **Community Group draft** v0.4.0-draft, not Recommendation +track); SPKI/SDSI, **RFC 2693**, September 1999, **Experimental**; and +`draft-niyikiza-oauth-attenuating-agent-tokens-01`, an individual submission with no IETF +standing. None is a standard CTRLRun could implement; every one is a source of design pressure. + +They split on one question — *does anything ever compare a child grant to its parent?* + +| Family | Compared at creation | Compared at evaluation | Mechanism | +|---|---|---|---| +| Macaroons | no | no | HMAC chain; caveats are append-only, conjunction at the target | +| Biscuit | no | no | append-only signed blocks with block scoping | +| UCAN 1.0.0 | normatively required, **not verified** | conjunction, time intersection, command prefix | policy array | +| ZCAP-LD | yes | yes | field-by-field "not less restrictive" | +| SPKI/SDSI (RFC 2693) | yes | yes | 5-tuple reduction, delegation bit | +| `draft-niyikiza-…-01` | yes | yes | typed subsumption per link | + +§5's rule — provably contained at creation **and** re-checked at evaluation — sits with +ZCAP-LD, SPKI and that draft. It is a **different** guarantee from the one macaroons and UCAN +give, not a strictly stronger one: they make widening unrepresentable, which is stronger, by +requiring a token that can only ever have restrictions appended; CTRLRun compares two grants +instead, which is weaker against a forged record and stronger against a *changing parent*. That +is the trade this codebase is forced into. A grant here is a YAML document an operator edits and +a delegation is a row in a store, so there is no signature chain to make widening +unrepresentable — and the parent can narrow after the child exists, which only a comparison can +catch (§5.6). The confused-deputy literature (Hardy, 1988; Miller, Yee and Shapiro, *Capability +Myths Demolished*, 2003) is where the rule that a delegation names its own limits rather than +inheriting them comes from. + +Two of §5's dimensions have essentially no prior art: **environment containment** — macaroons +defer to application-defined caveats, UCAN policy sees only invocation arguments, ZCAP-LD has no +environment field — and **numeric containment**, whose nearest standards-adjacent expression is +RFC 2693's `(* range …)`, Experimental and from 1999. Those two are stated here as CTRLRun's own +invention, not as an implementation of anything. + + +--- + +## 2. Principal + +### 2.1 Model + +`v0.1 §2.1`'s `Principal` grows three optional fields. Nothing is removed and nothing is +renamed. + +```python +_NO_CLAIMS: Final = MappingProxyType({}) # shared; a dataclass default must be immutable + +@dataclass(frozen=True) +class Principal: + agent: str # required, e.g. "refund-agent" + user: str | None = None # human on whose behalf, if any + claims: Mapping[str, str | int | bool] = _NO_CLAIMS # verified, from the provider (§3) + issuer: str | None = None # who verified it, e.g. a JWT `iss` + expires_at: datetime | None = None # when the credential stops being valid +``` + +`Principal` stays `frozen=True` and stays **unhashable**, as it is today: `Mapping` is not +hashable, and a dataclass whose default were a bare `{}` would not even reach class definition. +Nothing in v0.1 or v0.2 hashes a `Principal` — `Action.__hash__` is `hash(action_id)` (`v0.1 +§2.1`) — so this costs nothing; it is stated because a reader of the dataclass will wonder. + +`agent` and `user` keep every rule of `v0.1 §2.1`: non-empty, `user` either `None` or +non-empty, empty string → `InvalidArgument`. + +**`claims`** is what the identity provider verified and chose to carry. It is snapshotted at +construction into a read-only mapping, exactly as `Action.arguments` is (`v0.1 §2.2`), so a +caller holding the original object cannot change a constructed `Principal`. Keys MUST be +non-empty `str`. Values MUST be `str`, `int` or `bool` — **no `float`**, for the reason of +`v0.1 §2.3`; no `None`; no lists or mappings. A claim whose value is a structure is flattened +or dropped by the provider that read it, not stored here. Anything else → `InvalidArgument`. +`bool` is not `int` (`v0.1 §3.2`): `True` and `1` are different claim values. + +**`issuer`** is `None` or a non-empty `str` — the provider's own name for whoever it verified +against, so a receipt records not just who was acting but who said so. + +**`expires_at`** is `None` or a timezone-aware `datetime`. A naive `datetime` → +`InvalidArgument`: a credential whose expiry is in an unstated timezone is a credential whose +expiry cannot be checked, and guessing UTC would silently extend or shorten it. `None` means +the provider stated no expiry, which is not the same as "does not expire" — it is "this +provider does not know", and §2.3 says what follows. + +### 2.2 Claims are not part of the action hash + +**The canonical form of an Action is unchanged.** It remains exactly what `v0.1 §2.2` defines, +including `"principal": {"agent": ..., "user": ...}` and nothing more, under the same schema +tag `ctrlrun.action/v1`. `claims`, `issuer` and `expires_at` MUST NOT appear in it, at any +depth, under any key. + +This is not an oversight to be tidied up later. An approval binds to an action hash (`v0.1 +§4.2 A1`), and a hash that included claims would change when a token rotates — a new `jti`, a +refreshed `exp`, a claim the identity provider started emitting last Tuesday. The human +approved a €2,000 refund by `refund-agent` on behalf of `alice`; they did not approve a token +serial number. Putting claims in the hash would invalidate that approval for a reason no human +could see and no agent could fix, and the agent's correct response — propose it again with the +new token — would be indistinguishable from the mutation attack `v0.1 §4.2` exists to catch. + +Claims are **receipt data** (§2.4) and **evidence**. They are not action identity. Any change +to this needs an explicit schema version bump on the canonical form (`v0.1 §2.2`) and a test +proving hashes recorded under the old one still verify. + +The same reasoning applies to authority: §4 evaluates a grant against the principal's `agent` +and `user`, which are in the hash, and never against a claim (§4.2). An authority decision that +depended on a rotating claim would move under a pending approval. + +### 2.3 An expired principal is refused before anything else + +If `principal.expires_at` is not `None` and `now > principal.expires_at`, the action is +refused. Specifically, `Control.execute` MUST, before it evaluates authority and before it +evaluates policy: + +- append `ACTION_PROPOSED`, then `ACTION_DENIED` with `data.reason = "principal_expired"`; +- write a receipt with `result: "denied"`, `decision: "deny"`, + `decision_reason: "principal_expired"`; +- append **no** `POLICY_EVALUATED`, **no** `AUTHORITY_RESOLVED`, and create **no** approval + request; +- raise `IdentityError`. + +There is a principal here — an expired one is still a name to attribute the refusal to — so +unlike a call outside `context()` (`v0.1 §2.1`) this belongs in the evidence log. And it is +refused *before* the approval gate for the same reason authority is (§4.3): a decision that can +never be honoured must not spend a human's attention. + +`expires_at is None` is not an expiry check that passes; it is the absence of one. A deployment +that requires every principal to carry an expiry enforces that in its provider, which is the +component that knows what it verified. + +`IdentityError`, not `ActionDenied`: an agent loop's `except ActionDenied` is written to handle +a policy saying no, and a credential that stopped being valid is not that. This is the same +distinction `v0.1 §5.1` draws for `EffectKeyError`. + +**In observe mode this check is evaluated and recorded, not enforced** (§6.2). §6.3 says what +the receipt then holds. + +### 2.3.1 An expiry that falls inside an action already under way + +`expires_at` is checked **once**, where §2.3 says, and the answer holds for the rest of that +attempt. Three moments could re-ask it, and each is decided here rather than left to an +implementer: + +| Moment | Expired principal | Why | +|---|---|---| +| Committing, failing or marking ambiguous an outcome | **not refused** | The effect has already happened at the remote. Refusing the write would strand a real-world effect in `AMBIGUOUS` for a clock reason, which is the one mistake `v0.1 §5.5` exists to prevent | +| Extending a lease (`v0.2 §6.9.4`) | **refused** | An extension is a request to keep holding authority the credential no longer carries. Refusing lets the lease lapse, and a lapsed lease is `AMBIGUOUS` (`v0.1 §5.3 E3`) — the safe state, reached by the ordinary path | +| `Control.resume` (`v0.2 §6.9`) | **evaluated and recorded, not re-decided** | Exactly what `v0.2 §6.9.2` chose for policy on the same leg, for the same reason: the reservation is held and the remote may already be acting on it | + +A refused lease extension appends `ACTION_DENIED` with `data.reason = "principal_expired"` and +raises `IdentityError`; it does not move the effect record, which the expiring lease will do on +its own. The resumed leg records the expiry in its receipt (`decision_reason` stays whatever the +resumption reached) and appends `ACTION_DENIED` for nothing — there is nothing to deny. + +### 2.4 Where claims appear + +- **Receipts.** `principal` gains `claims`, `issuer` and `expires_at` (§12). +- **The stored Action.** `StateStore`'s serialized Action — the one `Control.resume` rehydrates + a continuation from, and the one an approval record carries — MUST carry the **full** + principal, not just `{agent, user}`. Without this, every receipt written by `Control.resume` + reports `claims: {}`, `issuer: null`, `expires_at: null`, and §2.1's load-bearing distinction + between "the provider stated no expiry" and "the store dropped it" is silently lost — on the + only receipt an MCP multi-round-trip or ACS action ever gets. This is a **value** change + inside an existing `TEXT` column, not a schema migration: rows written by 0.2 carry the two + old keys and parse back with `v0.1 §2.1`'s defaults. +- **Events.** No event carries claims. Events are a timeline; the receipt is the record. +- **`ctrlrun inspect`.** The header block prints the issuer and expiry, and the claim names — + the *names*, sorted, not the values. Values reach `--json`. +- **The OTel sink.** Claim values are withheld exactly as argument values are (`v0.2 §8`): + span attributes carry `ctrlrun.principal.issuer` and the sorted claim names, and nothing + else, unless the operator asked for arguments. They stay in CTRLRun's own `ctrlrun.*` + namespace rather than adopting OpenTelemetry's `user.*` registry, and the reason is better + than preference: every attribute in that registry (`user.id`, `user.name`, `user.email`, + `user.roles`, …) is marked **Development** stability, so adopting it would tie a receipt- + adjacent export to names that may break. + +A claim can hold an employee number, a case identifier, a licence, a customer id. Receipts are +evidence and are meant to be read; spans are exported to a third party by default. The two +places make opposite trade-offs deliberately. + +The `ctrlrun inspect` and OTel changes above ship with **build-list item 1**, alongside the +`Principal` fields they display; `ctrlrun.inspection/v1` becomes `v2` there (§12.2). + +--- + +## 3. `IdentityProvider` + +### 3.1 The protocol + +```python +@dataclass(frozen=True) +class IdentityContext: + action: str # the action name about to be proposed + environment: str + headers: Mapping[str, str] = {} # lowercased names; empty outside the gateway + agent: str | None = None # what context() supplied, if anything — a hint + user: str | None = None + + +class IdentityProvider(Protocol): + def resolve(self, context: IdentityContext) -> Principal | None: ... + + +Control(..., identity: IdentityProvider | None = None) +``` + +`resolve` is called once per action, before the `Action` is constructed — it has to be, since +an Action cannot exist without a principal (`v0.1 §2.1`). + +**Where it runs, and where it does not.** There are exactly two places that build an `Action`, +and they are the two that resolve: `@protect`'s wrapper, and the gateway. `Control.execute` and +`Control.evaluate` take an already-constructed `Action` (`v0.1 §8`, unchanged), so a caller that +builds an `Action` by hand and passes it to `Control.execute` supplies its own `Principal` and +the provider never runs. + +That is a real limit and it is stated rather than papered over: it is the same limit as calling +the wrapped function directly, which `docs/THREAT_MODEL.md` already records as out of scope +("Bypassing the decorator entirely"). Code inside the CTRLRun process is inside the trust +boundary. Code *outside* it — every gateway client, which is where an untrusted caller actually +is — has no such path, because the gateway builds the Action and the client never touches +`Control`. v0.3 does **not** add a re-resolution or a principal-disagreement check to +`Control.execute`: it would be a check against the wrong threat, and it would break every +existing caller that builds Actions for tests and tools. + +**The environment**, when a provider answers and no `context()` is active, is `"production"` — +`v0.1 §2.1`'s default — for both `IdentityContext.environment` and the `Action`. The gateway +supplies `--environment` instead (`v0.2 §6.5`). + +`IdentityContext.headers` is a mapping with **lowercased** header names, because HTTP header +names are case-insensitive and a provider that had to guess the casing would be a provider that +sometimes worked. In-process it is empty; the gateway fills it (§8). + +**A repeated header is a refusal, never a collapse.** `Mapping[str, str]` holds one value per +name, so something must decide what a header appearing twice becomes — first, last, or +comma-joined — and under an authority model that decision picks the principal. It is not left +open: the gateway MUST refuse a request in which **any header its identity configuration reads** +(`--principal-header`, `--user-header`, `--identity-jwt-header`) appears more than once, before +`IdentityContext` is built — `-41007` `ctrlrun.no_principal`, HTTP 403, no receipt and no events, +and a warning naming the header. A proxy that *appends* rather than overwrites is a common +default (§3.3), and joining its value to the client's is how a client chooses its own +principal. + +`agent` and `user` are what an active `context()` supplied. They are a **hint**, not an +identity: a provider is free to ignore them, and both shipped providers do. They are passed at +all so that a custom provider can implement "look up the token for this agent" without a second +channel. + +### 3.2 Which wins: the provider, where it answers + +An action's principal is decided as follows, and this order is not configurable. + +| `identity` | `resolve()` | active `context()` | principal | +|---|---|---|---| +| not set | — | yes | the context's, exactly as v0.1 | +| not set | — | no | `no_principal` — refused (`v0.1 §2.1`) | +| set | returns a `Principal` | either | **the provider's** | +| set | returns `None` | yes, **no `authority:`** | the context's | +| set | returns `None` | yes, **`authority:` loaded** | `no_principal` — refused (see below) | +| set | returns `None` | no | `no_principal` — refused | +| set | raises | either | `IdentityError` — refused, and the context does **not** fill in | + +**The provider wins where it answers.** A verified identity must not be overridable by a +statement in the code the agent influences, because §4 makes the principal an authorization +input and a self-asserted name cannot be one. This is the same sentence that removes +`--principal-from-client-info` (§8.1), applied in-process. + +**`None` is a decline, not a refusal — until authority is switched on.** A provider that has +nothing to say leaves the v0.1 path intact, so installing a provider never breaks code that +already uses `context()`. A provider that *raises* has been given something and rejected it; +falling back to a self-asserted name there would turn a rejected token into a successful action, +which is the one outcome this whole section exists to prevent. + +**Once an `authority:` section is loaded, a decline is not backfilled.** Omitting a credential +reaches the same destination as forging one, by an easier route: agent code already running +inside `with context("finance-agent")` that installs a `JWTIdentityProvider` would execute every +un-credentialed call as `finance-agent`, with `finance-agent`'s grants — the token becomes +optional in the release that makes the principal an authorization input. So where an +`authority:` section exists, `resolve()` returning `None` is `ActionDenied(reason="no_principal")` +whether or not a `context()` is active, and §9 carries the row. + +This is not a flag and there is nothing to configure: it follows from the opt-in rule (§4.1). No +`authority:` section → v0.2 behaviour, decline included. An `authority:` section → the provider +is the only source of a principal. A deployment that wants both a provider and `context()` +principals is a deployment that has not decided who is acting, and under authority that is not a +question the library may answer on its own. + +**A disagreement is a warning, not a silent substitution.** Where a provider answers and a +`context()` is also active with a different `agent` or `user`, `Control` MUST log a warning on +the `ctrlrun` logger naming both principals and which is in force — at most once per `Control`, +because a warning that repeats per call is a warning nobody reads. This is `v0.2 §3.2`'s +template-mismatch rule applied to identity. + +`# SPEC:` The item-0 brief suggested that an explicitly nested `context()` should take +precedence over the provider, and asked for the choice to be documented. This document chooses +the other way, for the reason above; T65 tests every row of the table so the choice is pinned +rather than assumed. + +**Exceptions.** A provider raising `IdentityError` propagates it unchanged. A provider raising +any other `Exception` is logged on the `ctrlrun` logger and re-raised as `IdentityError` with +the original chained (`raise ... from exc`), so a caller has one exception type to catch and +still has the cause. A `BaseException` that is not an `Exception` propagates untouched, for the +reason in `v0.1 §5.5`. + +**No receipt, no events.** A refusal at the identity gate happens before an Action exists, so +there is nothing to attribute a receipt to. `no_principal` keeps `v0.1 §2.1`'s shape exactly — +a warning naming the action, nothing in the evidence log — and a raising provider is recorded +the same way. §2.3's `principal_expired` is the one identity refusal that *does* write a +receipt, because by then a `Principal` exists. + +### 3.3 The two core providers + +```python +StaticIdentityProvider(agent: str, user: str | None = None, *, + claims: Mapping[str, str | int | bool] = {}, + issuer: str | None = None, + expires_at: datetime | None = None) + +HeaderIdentityProvider(agent_header: str, *, user_header: str | None = None, + issuer: str | None = None) +``` + +**`StaticIdentityProvider`** answers with the same `Principal` every time. It is for +development, tests and single-tenant demonstrations. It MUST log a warning on the `ctrlrun` +logger at construction — **once per instance**, not per call — saying that it asserts an +identity nobody verified and naming the agent it asserts. A library that prints on every action +is a library whose warnings get filtered. + +**`HeaderIdentityProvider`** reads `agent_header` from `IdentityContext.headers` (lowercased +comparison). An absent header, or one whose value is empty or whitespace, → `None`: a decline, +so §3.2's table applies. `user_header` is optional and follows the same rule, contributing +`user=None` when absent. It carries no claims: a header is a name, and a provider that +manufactured claims from headers would be inventing verified data. + +**The threat, stated plainly.** *A trusted header is worth exactly what the thing that sets it +is worth.* `HeaderIdentityProvider` is correct behind a proxy that authenticates the caller and +**overwrites** the header on every request. It is worthless anywhere else: if the agent can set +the header, the agent chooses its own authority, and §4 is decoration. Two specific failures: + +- A proxy that *adds* the header when absent but passes it through when present lets a client + supply its own. The proxy MUST overwrite unconditionally. A proxy that *appends* — a common + default for header-injection directives — produces two values, which §3.1 refuses outright + rather than collapsing. +- A gateway reachable directly, bypassing the proxy, has no header discipline at all. `v0.2 + §6.1`'s loopback default and `--allow-remote` exist for this; they are necessary and not + sufficient. + +RFC 7239 §8.1 says it about the header it standardizes, and it is the same sentence: a +forwarding header *"cannot be relied upon to be correct, as it may be modified, whether +mistakenly or for malicious reasons, by every node on the way to the server, including the +client making the request"*, with the only mitigation being a whitelist of trusted proxies. + +`HeaderIdentityProvider` MUST log a warning at construction naming the header and saying the +above in one line. `docs/THREAT_MODEL.md` carries the long form. + +### 3.4 `JWTIdentityProvider` (item 5, `ctrlrun[identity]`) + +```python +# ctrlrun.jwt_identity — lazily importable, not re-exported at package import +JWTIdentityProvider(*, + # exactly one key source + jwks_url: str | None = None, + public_key: str | None = None, # PEM text, or a path to a PEM file + secret: str | None = None, # the shared secret, for an HS* deployment + algorithms: Sequence[str], # required; no default, no wildcard + issuer: str, # required, matched exactly + audience: str, # required + token_type: str | None, # required to be passed; see below + header: str = "authorization", + agent_claim: str = "sub", + user_claim: str | None = None, + claim_names: Sequence[str] = (), # which verified claims reach Principal.claims + leeway: timedelta = timedelta(seconds=60), + jwks_min_refresh_interval: timedelta = timedelta(seconds=30), + http_timeout: timedelta = timedelta(seconds=5), + clock: Callable[[], datetime] = ...) +``` + +It reads a bearer token from `header` (`Authorization: Bearer `; the scheme is matched +case-insensitively, and a header with no scheme is refused rather than guessed), verifies it, +and returns a `Principal`. Absent header → `None` (a decline, §3.2). Present and invalid → +`IdentityError` (a refusal). + +`clock` is injected for the same reason every other timed component in this codebase takes one: +so `exp`, `nbf` and the JWKS refresh window can be tested exactly rather than raced. + +**Configuration is refused before any token is seen.** Each of these is `InvalidArgument` at +construction: + +- More or fewer than one of `jwks_url`, `public_key`, `secret`. +- A symmetric algorithm (`HS*`) with `jwks_url` **or** `public_key`. This is key confusion in its + plainest form — RS256→HS256 is literally "HMAC the token using the PEM of the public key as + the secret" — and it is refused at the *configuration* end, which is the end that can be + refused. The token end is already covered by the `algorithms` allow-list, and a test that only + exercised the token end would be a negative test against behaviour the library refuses anyway. + A deployment that genuinely uses `HS*` puts its key in `secret`, where it cannot be confused + with a public one. +- An asymmetric algorithm with `secret`. +- An empty `algorithms`, or one containing `none` in any casing. +- `token_type` not passed at all. It has no default: see below. + +`public_key` is PEM text when it begins `-----BEGIN`, and a filesystem path otherwise. Stated, +because the gateway flag is `--identity-jwt-public-key PATH` and the constructor takes both. + +**Verification MUST:** + +- **Reject `alg: none` and every unlisted algorithm.** The provider selects the verification + algorithm from its own configured list and MUST NOT read it from the token header for that + purpose; a token whose `alg` is not in the list is refused before any signature check. This is + the algorithm-confusion class of RFC 8725 §3.1, whose rule is that a library "MUST enable the + caller to specify a supported set of algorithms and MUST NOT use any other algorithms". +- **Check `typ`.** `token_type` is a required argument with no default. A string requires the + token's `typ` header to equal it, compared case-insensitively with a leading `application/` + stripped (RFC 7519 §5.1) — `at+jwt` for an OAuth access token per RFC 9068 §4, `JWT` for a + JWT-SVID. Passing `None` explicitly means "this issuer sets no `typ`" and is permitted, and + MUST log a warning at construction naming what it gives up. + + This is not ceremony. Without it, an **OIDC ID token** from the same issuer, signed with the + same key, carrying `aud = `, is accepted by a provider that checks only `iss`, + `aud` and `exp` — whenever the operator configured `audience` to that client id. That is the + cross-JWT confusion attack of RFC 8725 §2, and that document's §3.11 and §3.12 answer it with + explicit typing + and validation rules "written such that they are mutually exclusive, rejecting JWTs of the + wrong kind". A required argument is how the operator is made to choose. +- **Check `exp` and `nbf`** with `leeway` (default 60 s, a bound on clock skew and nothing + more), and **`iss`** exactly. +- **Check `aud` by membership, on either wire shape.** RFC 7519 §4.1.3 permits `aud` to be a + single string *or* an array of strings, and real issuers emit both — SPIFFE's own JWT-SVID + prose says "one or more values" while its published JSON Schema types it as a string. The + provider MUST normalize to a list of strings and require the configured `audience` to be an + element of it, compared exactly. It MUST NOT compare the configured value against a raw `aud` + with `in`: on the string shape that is substring matching, and it accepts + `https://ctrlrun.example` for a configured `ctrl`. +- **Refuse a token with no `exp`.** A credential with no expiry cannot be revoked by waiting, + and v0.3 has no revocation channel (below). +- Set `Principal.expires_at` from `exp`, `Principal.issuer` from `iss`, `Principal.agent` from + `agent_claim` and `Principal.user` from `user_claim` when configured. Claim names are **flat + keys**, not dotted paths: a claim called `a.b` is the key `"a.b"`, and no nesting is + traversed. A missing, empty, or non-string `agent_claim` → `IdentityError`, never coerced. +- Copy only the claims named in `claim_names` into `Principal.claims`, dropping any whose value + is not `str`, `int` or `bool` (§2.1) with a debug log. An allow-list, not everything: a token + carries fields nobody meant to publish into a receipt. + +**JWKS handling.** Keys are fetched from `jwks_url` over HTTPS and cached in memory. + +- A token whose `kid` is not in the cache triggers **at most one** refresh; if the `kid` is still + unknown after it, the token is refused. +- The refresh is rate-limited so a stream of tokens carrying unknown `kid`s cannot turn this + process into a load generator aimed at the issuer: one refresh per + `jwks_min_refresh_interval` (default 30 s), and a token arriving inside that window is refused + without a fetch. +- **A JWK Set containing two entries with the same `kid` is refused**, and the token with it. + RFC 7517 §4.5 says only that distinct keys *SHOULD* use distinct `kid` values, so a duplicate + is legal and "take the first match" would be a silent trust decision about which key signs. +- **A key whose `use` is present and is not `sig` is ignored**, as is one whose `key_ops` + excludes verification. A JWK Set commonly carries encryption keys, and verifying a signature + against one is a defect. +- **A key carrying its own `alg` constrains itself**: the configured allow-list *and* the key's + `alg` must both admit the token. +- A failed fetch is `IdentityError`, never a fallback to an empty key set and never a + cached-forever key. `http_timeout` bounds the fetch, and the provider MUST NOT follow a + redirect to a non-HTTPS URL. + +**Two limits, stated so nobody configures around them.** + +*Multi-tenant issuers are out of scope.* `issuer` is matched as an exact string. The largest +real-world issuers publish a **templated** issuer — `https://login.microsoftonline.com/{tenantid}/v2.0` +— where a relying party is required to substitute the token's own tenant claim into the metadata +issuer and then compare, and to check that tenant against an allow-list. v0.3 does not do that, +so a tenant-templated issuer cannot be configured correctly here. This is said plainly because +the fail-open is inviting: pointing `issuer` at a common multi-tenant endpoint without pinning +the tenant makes every tenant on that platform a valid issuer for this gateway. + +*There is no revocation channel.* A token this provider verified stays valid until its `exp`. +Nothing polls, subscribes, or introspects — which is exactly why a token with no `exp` is +refused. A deployment that needs faster propagation than its token lifetime uses a shared-signals +mechanism at the identity layer (the OpenID Foundation's Shared Signals Framework, CAEP and RISC +reached Final Specification on 2025-09-02); v0.3 implements none of it and does not pretend to. +Short lifetimes are the whole of the story here. + +The extra is `identity`. `import ctrlrun` MUST NOT import `jwt`; constructing +`JWTIdentityProvider` without the extra installed MUST raise `MissingDependency` naming +`pip install 'ctrlrun[identity]'` (T92). `# SPEC:` the item-5 brief said "ImportError"; the +house rule of `v0.2 §1.1` says `MissingDependency`, which is what an operator can act on. + +**What it is not.** It is not an OAuth client. It performs no authorization-code flow, no +refresh, no token exchange, no introspection, no dynamic client registration. It verifies a +token somebody else obtained. Anything beyond verification belongs to the deployment. + +--- + +## 4. Authority + +### 4.1 The section, and what its presence means + +Authority lives in the same `ctrlrun.yaml` as policy, under a top-level `authority:` key, and +needs `schema: ctrlrun.policy/v3` (§12). + +```yaml +schema: ctrlrun.policy/v3 + +authority: + max_delegation_depth: 3 # optional, default 3 (§5.5) + grants: + - id: head-of-support + subject: { agent: "support-agent", user: "alice@example.com" } + actions: ["stripe.refund", "stripe.refund_partial"] + resources: ["payment:EU-*"] + constraints: { amount_gte: 0, amount_lte: 200000 } + environments: ["production"] + expires_at: 2026-12-31T23:59:59Z + delegable: true + +actions: + stripe.refund: + rules: + - when: { amount_gte: 0, amount_lte: 50000 } + decision: allow + - decision: approve +``` + +**No `authority:` key at all → v0.2 behaviour, exactly.** No authority evaluation, no +`AUTHORITY_*` events, no new receipt fields, no change to any decision, and §3.2's decline row +keeps its v0.2 meaning. Every v0.1 and v0.2 acceptance test MUST pass unchanged against such a +configuration (T66). + +**An `authority:` key present → every action needs a grant.** Including actions the policy +allows outright, including reads, including actions with no effect key. There is no +`default: allow`, no `unlisted: permit`, and no per-action opt-out. An operator who wants an +action ungoverned by authority writes a grant that says so, in the file, where it can be read. + +This is the same shape as `v0.1 §3.4`'s `actions: {}`: an empty `grants: []` is valid and denies +every action. `authority:` present without a `grants` key, or with a `grants` that is not a +list, is a `PolicyError` at load — a section that governs everything must state what it permits, +and inferring "nothing" from a missing key would make a truncated edit look deliberate. + +### 4.2 The grant + +| Key | Required | Type | Meaning | +|---|---|---|---| +| `id` | yes | `str` | Unique within the document; names this grant to `ctrlrun delegate` | +| `subject` | yes | mapping | `agent` and/or `user`, at least one; patterns (§4.4) | +| `actions` | yes | non-empty list | Action-name patterns (§4.4) | +| `resources` | no | non-empty list | Resource patterns (§4.4); omitted → any resource | +| `constraints` | no | mapping | Conditions in policy's `when:` syntax (§4.5); omitted → none | +| `environments` | no | non-empty list | Exact environment names; omitted → any environment | +| `expires_at` | no | timestamp | ISO-8601 with an offset; omitted → does not expire | +| `delegable` | no | `bool` | May a delegation be created from it (§5); default `false` | + +Key sets are **closed** at every level, as `v0.1 §3.1` requires: `authority` accepts +`max_delegation_depth` and `grants`; a grant accepts exactly the keys above; `subject` accepts +`agent` and `user`. Anything else → `PolicyError` at load, so `action:` for `actions:` or +`resource:` for `resources:` fails loudly instead of silently denying everything at runtime. + +**A grant carries no decision.** Authority answers one question — may this principal do this at +all — and policy answers the other, how much autonomy the action has. `# SPEC:` an earlier draft +of this document gave a grant an optional `decision: allow | approve`, so that a delegation could +say "may refund, but always with a human", and so that T70's "3×3" was literally a 3×3. It is +removed, for three reasons that arrived together. It put the approval requirement in two places, +so the gateway's approval pre-check (`v0.2 §6.10`) — which keys off the policy decision — would +have deadlocked on a combination no client could satisfy. It needed a `decision` row in §5.4's +containment table whose default is the *loosest* value, which is the one shape §5.4 exists to +refuse. And it made whether an action needs a human depend on who proposed it, which is the exact +sentence `docs/CLAIMS.md` maps to code (§4.7). An operator who wants "this delegate always needs +a human" gives that delegate its own action name, or writes the rule in policy, which is where +per-action autonomy lives. Making authority able to *raise* a decision is a v0.4 question with a +gateway story attached. + +`id` MUST match `^[A-Za-z0-9][A-Za-z0-9._-]*$` and be unique across the document; a duplicate +is a `PolicyError`, because a delegation names its parent by id and two grants answering to one +name is an ambiguity nobody can resolve later. A grant `id` MUST NOT begin `dlg_`: delegation +ids are minted in that namespace (§5.2) and one namespace addresses both kinds. + +That rule is about a grant **in a document**. A `Grant` handed to `Control.delegate` carries +`id=""` and is assigned one (§5.2); the empty id is legal only on that path and only until the +call returns, and a `Grant` with a non-empty `id` on it is refused. + +**Subject.** `subject.agent` and `subject.user` are §4.4 patterns over a **single segment** — an +agent name and a user name are opaque strings, not dotted paths, so they carry no separator and a +`*` in them matches any run of characters. `subject` MUST declare at least one of `agent` and +`user`. A grant matching every principal is a grant nobody means to write, and `subject: {}` is +far more likely a truncated edit than an intent — the same reading `v0.1 §3.2` gives `when: {}`. +A subject that really does mean "any agent" writes `agent: "*"`, which is legible in review. + +**`subject.user` semantics.** Omitted, it matches any principal including one with no user. +Present, the principal's `user` MUST be non-`None` and match the pattern; a principal with no +user does not match a grant that names one. Fail-closed: a grant scoped to a human is not +satisfied by an agent acting alone. This rule is the whole of who holds a grant, so it is tested +by name in T67b rather than left to a grant that happens not to match. + +**Subject matching addresses `agent` and `user`, and nothing else.** Not claims, not the issuer. +Both fields are part of the action's canonical form (`v0.1 §2.2`), so an authority decision is +stable under the token rotation §2.2 describes; a claim is not. Matching a grant on a claim is +out of scope for v0.3 (§13) and needs an answer to "what does a missing claim mean" that this +release does not have. + +`expires_at` MUST parse as ISO-8601 **with an explicit offset**. A naive timestamp is a +`PolicyError`, for §2.1's reason: an expiry in an unstated timezone cannot be checked. YAML +parses an unquoted `2026-12-31T23:59:59Z` into a `datetime` and an unquoted +`2026-12-31 23:59:59` into a naive one, so both the string form and the parsed form must be +accepted on input and both must be rejected when they carry no offset. The comparison is +`now > expires_at → expired`; a grant is valid up to and including its expiry instant. + +### 4.3 Evaluation, and where it sits + +An action **passes authority** iff at least one grant matches on **all four** of subject, action +name, resource and environment, has **all** its constraints hold, is **unexpired**, and — if it +is a delegation — has a chain that is still valid under §5. + +Authority is evaluated **before** policy. Two reasons, and the second is the load-bearing one: + +1. A denial must not leave a pending approval request behind. Policy reaching `approve` creates + a request (`v0.1 §4.3`); an authority denial afterwards would leave a human staring at a + request for an action that could never run, and a request in the store that something might + later present. +2. It is the cheaper check, and it is the one that can refuse the most actions in a + misconfigured deployment. + +So: an action denied by authority produces `AUTHORITY_DENIED` and **no** `POLICY_EVALUATED` +(T74). It is recorded the way an `effect_key_error` is recorded (`v0.1 §5.1`) — there is a +principal, so the refusal belongs in the evidence log: + +- `ACTION_PROPOSED`, then `AUTHORITY_DENIED` with `data.reason`, then `ACTION_DENIED` with the + same reason; +- a receipt with `result: "denied"`, `decision: "deny"`, `decision_reason` = the reason; +- **no** `POLICY_EVALUATED`, **no** approval request, **no** reservation; +- `AuthorityDenied` raised, carrying `reason` and, where one grant was implicated, `grant_id`. + +An action that **passes** authority appends `AUTHORITY_RESOLVED` with `data.grant_id`, and — for +a delegated grant — `data.delegation_id` and `data.depth`. Policy then runs as it always has, and +the two results combine per §4.6. + +**Order within `Control.execute`,** stated once so it can be tested: `principal_expired` (§2.3) +→ authority → policy → approval → reservation → execution. The effect key resolves earlier still, +before `Control.execute` is entered, exactly as `v0.1 §5.1` requires; an unresolvable template +therefore refuses before authority does, and both are refusals with their own reason, so nothing +is lost. + +**Reasons are a closed set**, and each is distinguishable in evidence. This matters: a guard that +can only fire where a later guard would also fire, with the same observable result, is +documentation rather than defence — removing it changes nothing a caller or a test can see. Every +reason below is asserted **by name** in §10, so removing the check that produces it fails a test +rather than passing one. + +| Reason | Meaning | +|---|---| +| `authority_grant` | **Passed.** A grant matched; `AuthorityResult.grant_id` names which | +| `no_authority` | No grant matched subject, action, resource and environment | +| `authority_constraint` | A grant matched the shape, but a constraint did not hold | +| `authority_expired` | Every grant that matched the shape had passed its own `expires_at` | +| `authority_escalation` | A delegated grant is no longer contained in its chain, or an ancestor has expired or is missing (§5.6) | +| `authority_revoked` | A delegation in the chain has been revoked (§5.7) | + +`authority_grant` exists because a *passing* result also needs a reason: §4.6 records the axis +that produced the decision, and there was otherwise no value to record for the cell where +authority is the one that spoke. `decision_reason` **never** holds a grant id — a grant may +legally be named `no_authority`, and evidence that could be spoofed by naming a grant is not +evidence. The id travels in `AuthorityResult.grant_id` and in `AUTHORITY_RESOLVED.data.grant_id`. + +**Reason precedence, within a grant and across grants.** A single grant may fail more than one +way — expired *and* excluded by a constraint. Evaluation MUST collect every reason a grant failed +for rather than short-circuiting on the first, and then report, across all grants, the first +reason present in this fixed order: + +`authority_escalation` → `authority_revoked` → `authority_constraint` → `authority_expired` → +`no_authority` + +Fixed and collected rather than short-circuited, so the evidence for one configuration does not +depend on the order grants appear in the document or the order an implementation happens to +check them in. T71b pins it by permuting the document. + +**A delegation's own expiry is `authority_expired`**, like any other grant's. §5.6's +`authority_escalation` covers its *ancestors* — an expired or missing parent — which is a +different fact about a different record, and the two are asserted separately in §10. + +### 4.4 Patterns: globs that cannot cross a separator + +Action names and resources are matched by pattern. The grammar is deliberately smaller than +`fnmatch`, for the same reason `v0.1 §5.1`'s template grammar is smaller than `str.format`: a +pattern here decides who may move money, and containment between two patterns (§5.5) has to be +**decidable**, not approximated. + +**Grammar.** A pattern is a non-empty sequence of segments joined by the type's separator. Each +segment is one of: + +- a **literal** — any run of characters containing no `*`; +- a **prefix wildcard** — a literal followed by exactly one trailing `*` (`EU-*`, or bare `*`, + which is the empty prefix). + +The final segment may instead be `**`, the **deep wildcard**, which matches one or more remaining +segments, separators included. Where a pattern ends in `**`, **every preceding segment MUST be a +literal**: `stripe.**` and `payment:EU:**` are well formed, `stripe.a*.**` and `*.**` are +`PolicyError`s at load. A deep wildcard's prefix is literal so that containment (§5.5) is decided +by comparison rather than by reasoning about two kinds of wildcard at once. + +`**` is permitted only as the last segment, only once, and never as part of a larger segment +(`a**` and `**a` are load errors). There is no `?`, there are no character classes, there is no +leading or infix `*`. A pattern that does not fit this grammar is a `PolicyError` at load. + +**Separators.** Action names separate on `.`; resources separate on `:`. Both come from v0.1: an +action name is dotted (`v0.1 §2.1`), and a resource is an opaque `type:id` (`v0.1 §2.1`). A +subject pattern (§4.2) has no separator and is therefore always one segment. + +**Matching** is segment-wise, case-sensitive, with no Unicode normalization — `v0.1 §3.1` and +`v0.1 §2.3` for both. A pattern with no deep wildcard matches only a value with the same number +of segments. Consequences, and they are the point: + +| Pattern | Matches | Does not match | +|---|---|---| +| `stripe.*` | `stripe.refund` | `stripe.refund.partial`, `stripe` | +| `stripe.**` | `stripe.refund`, `stripe.refund.partial` | `stripe`, `stripes.refund` | +| `payment:EU-*` | `payment:EU-42` | `payment:EU-42:leg2`, `payment:US-42` | +| `*` | `refund` | `stripe.refund` | +| `**` | every action name | — | + +**There is no short spelling for "everything".** `*` matches one segment. Granting every action +is spelled `**`, one token, greppable, and impossible to write by accident. This is deliberate: +the pattern that grants an agent the whole surface of a system should be the one that looks like +it does. + +An action whose `resource` is `None` does **not** match a grant that declares `resources:`. To +grant an action that carries no resource, omit `resources:` from the grant. (Under §5.4 a +*delegation* may not omit it where its parent declares it — omission never widens.) + +### 4.5 Constraints + +`constraints` is a mapping in exactly the syntax of a policy rule's `when:` (`v0.1 §3.2`), and it +MUST be parsed by the same code: `_` where op is one of `eq`, `neq`, `lt`, `lte`, +`gt`, `gte`, `in`; longest-suffix split; operands validated against `v0.1 §2.3`'s types at load; +`float` operands refused; `bool` refused for numeric ops on either side; equality type-strict and +recursive; the reserved names of `v0.1 §3.2` refused. All constraints must hold (AND). A +`constraints: {}` or `constraints:` (null) is a `PolicyError`, as `when: {}` is. + +One shared implementation, not two. A second condition evaluator would be a second place for +`True` to start comparing equal to `1`. §11 names the surface that is promoted from private to +public so the sharing is real rather than aspirational. + +**Constraints address the action's arguments**, and only arguments — the same rule and the same +reserved-name refusal as policy. v0.3 adds `claims`, `issuer` and `expires_at` to +`RESERVED_ARGUMENTS`, so `claims_eq:` is a load error rather than a condition that silently +addresses an argument nobody has. Before v0.3 those names meant nothing; now they name fields of +a `Principal`, and `v0.1 §3.2`'s rule is that a name with two candidate meanings is refused until +the author renames. This is a **breaking change**, it is **not** gated on the schema version, and +§12.1 says exactly what it breaks. + +**An absent argument makes a constraint false, and logs a warning** — `v0.1 §3.2` unchanged. In +policy a false condition falls through to the next rule; in authority it means this grant does +not match, and if no other does, the action is denied with `authority_constraint`. The direction +is the same: a condition that cannot be evaluated never matches. + +**Only integer arguments can be bounded, and that decides how money must be represented.** +`v0.1 §2.3` blesses two representations for money — integer minor units (`amount=200000`) or +decimal strings (`"2000.00"`) — and `v0.1 §3.2` restricts `lt`, `lte`, `gt` and `gte` to `int` +operands. Authority inherits both rules, and the consequence is worth stating rather than leaving +to be rediscovered: **a deployment that represents money as decimal strings cannot express an +amount ceiling in a grant at all**, and therefore cannot attenuate the one dimension this whole +model is sold on. Integer minor units are the only representation authority can bound. + +The fail-open here is already closed, which is why this is a documentation duty and not a change: +a decimal-string operand is a `PolicyError` at load (`v0.1 §3.2`), so lexicographic comparison — +under which `"1000.00" < "500.00"` and a child would be judged *stricter* than its parent — can +never reach §5.4's containment check. What is left is an operator who writes `amount_lte: +"2000.00"` and gets a load error whose message should name the reason. It does. + +### 4.6 The AND with policy: the stricter of the two + +Authority yields `pass` or `deny`. Policy yields `allow`, `approve` or `deny`, exactly as it +always has. Order the decisions by strictness — **allow < approve < deny** — and the action's +decision is the **stricter** of the two: + +| | policy `allow` | policy `approve` | policy `deny` | +|---|---|---|---| +| **authority passes** | allow | approve | deny | +| **authority denies** | deny | deny | deny | + +Neither axis can loosen the other. Authority cannot make a denied action allowed; policy cannot +make an unauthorized action permitted. The bottom row is reached without evaluating policy at all +(§4.3), so its three cells share an outcome and differ in their event trace. T70 parametrizes the +outcomes; T74 pins the trace. + +`# SPEC:` the item-2 brief asks T70 to "parametrize the 3×3". With a grant carrying no decision +(§4.2) authority is binary and the table is 2×3, so T70 parametrizes six cells and asserts the +recorded reason in each. The brief's third row was the grant-level `decision:` this document +removed, and the reasoning is in §4.2. + +**`decision_reason`** names the axis that produced the decision: an authority reason from §4.3 +where authority is the stricter axis, and a policy reason (`rule[1]`, `decision`, +`no_matching_rule`, `unknown_action`) otherwise. Where the two tie — authority passes and policy +allows, or authority passes and policy approves — the **policy** reason is recorded, because it +is the one an operator edits per action; `authority_grant` and the grant id are in +`AUTHORITY_RESOLVED` either way. + +**`Control.evaluate` returns the combined decision.** `v0.1 §8` freezes +`Control.evaluate(action) -> Evaluation` as the public "what will happen to this action" query, +and it would stop answering that question if it reported the policy axis alone while +`Control.execute` acted on the combination. Its signature and `Evaluation`'s two fields are +unchanged; it reads the store to resolve delegations and still writes nothing, so "no side +effects" holds. §11 records the change. + +Everything that pre-checks a decision MUST use the combined one — `Control.evaluate`, the +gateway's approval path (§8.3), and `ctrlrun.acs`'s hook. Reading `Policy.evaluate` directly for +a decision is a defect after v0.3, and the two places in shipped code that do it are named in +§8.3 so item 5 cannot miss them. + +**Where several grants match**, the action passes on the first that does; a grant is a +permission, and holding two permissions must never be worse than holding one. Denial arises from +the absence of a matching grant, never from the presence of a stricter one. +`AuthorityResult.grant_id` names the grant that produced the pass, and the result MUST NOT depend +on the order grants appear in the document — evaluation over a set of matching grants has one +answer, and T70b pins it by permuting the file. + +### 4.7 What does not change + +**Policy still cannot see the principal.** `Policy.evaluate(action)` passes the action's name and +arguments to rule evaluation and nothing else; `agent_eq`, `user_eq` and every other reserved +name are still refused at load (`v0.1 §3.2`). `docs/CLAIMS.md` maps the README's *"Autonomy +belongs to the action, not the agent"* to exactly that fact, and after §4.2 removed the +grant-level decision the sentence stays true of behaviour as well as of a function signature: +**how much autonomy an action has is the same for every principal.** What differs per principal +is whether they may propose it at all. + +That separation is not tidiness. Per-action autonomy is a property of the action's consequence — +a €20,000 refund needs a human whoever proposes it. Entitlement is a property of the actor. +Collapsing them into one rule table produces a file where nobody can answer either question, and +it is what makes the README sentence a claim about the product rather than about an argument +list. + +### 4.8 The `Authority` object + +```python +@dataclass(frozen=True) +class Subject: + agent: str | None = None # a §4.4 single-segment pattern + user: str | None = None # a §4.4 pattern; None matches any user, including none + + def __post_init__(self) -> None: ... # both None → InvalidArgument (§4.2) + +@dataclass(frozen=True) +class Grant: + id: str + subject: Subject + actions: tuple[str, ...] # §4.4 patterns, separator "." + resources: tuple[str, ...] | None = None # §4.4 patterns, separator ":"; None → any + constraints: Mapping[str, Condition] = _NO_CONSTRAINTS # keyed by the raw condition key + environments: tuple[str, ...] | None = None # exact names; None → any + expires_at: datetime | None = None + delegable: bool = False + + +@dataclass(frozen=True) +class AuthorityResult: + passed: bool + reason: str # a §4.3 reason — never a grant id + grant_id: str | None = None + delegation_id: str | None = None + depth: int = 0 + + +class Authority: + @classmethod + def from_yaml(cls, text: str, *, source: str = "") -> Authority: ... + grants: Mapping[str, Grant] + max_delegation_depth: int + def evaluate(self, action: Action, *, now: datetime, store: StateStore) -> AuthorityResult: ... + def plan_delegation(self, parent_id: str, grant: Grant, *, by: Principal, + store: StateStore, now: datetime) -> Delegation: ... # §5.3 + def plan_revocation(self, delegation_id: str, *, by: str | None, + store: StateStore, now: datetime) -> Delegation: ... # §5.7 + + +Control(..., authority: Authority | None = None) +Control.delegate(parent_id: str, grant: Grant, *, by: Principal) -> Delegation +Control.revoke(delegation_id: str, *, by: str | None = None) -> None +``` + +`Subject.__post_init__` refuses both fields `None`, so the constructor refuses exactly what the +loader refuses. `Control.delegate` is public API and takes a `Grant` built in Python, not YAML; +without this a holder of a narrow grant could mint a child addressed to every principal in the +deployment — widening the population rather than the powers. "Any agent" is spelled +`Subject(agent="*")`, which is greppable — and §5.4 forbids it in a *delegation* regardless. + +`Grant.constraints` is keyed by the **raw condition key** (`"amount_lte"`), which is injective +given `v0.1 §3.2`'s longest-suffix split, and valued by the `Condition` §11 promotes from +`policy.py`. §5.4 needs exactly that keyed lookup. `_NO_CONSTRAINTS` is a shared immutable empty +mapping, for §2.1's reason. + +`store` is **required** on `evaluate`. §5.6 re-checks a delegation's whole chain on every +evaluation and that walk reads the store; an optional store would give an implementation a +fail-open reading in which a revoked delegation evaluates as valid, and "a chain of any depth is +cut by one write" would stop being true. An `Authority` with no delegations reads nothing from +it, which costs nothing and says so. + +**`Authority` is pure, and `Control` writes.** `evaluate`, `plan_delegation` and +`plan_revocation` read the store and never write to it; each `plan_*` returns the record it would +write, or raises `AuthorityEscalation`. `Control.delegate` and `Control.revoke` perform the write, +append the events of §7, and fan out to every registered `EventSink` — because `v0.2 §4.1` +requires sinks to be called by `Control` with the `event_id` the store assigned, and +`ARCHITECTURE.md` §6 makes `Control` the only module that composes the others. `ctrlrun delegate` +and `ctrlrun revoke` (§5.7) go through `Control`, not around it. + +The alternative — `Authority` writing and appending for itself — would make `authority.py` a +second module composing storage and evidence, and would leave the highest-privilege operations in +the release as the only ones invisible to the export path. T75 and T78 assert that the delegation +events reached a registered sink, not merely the `events` table. + +--- + +## 5. Delegation with attenuation + +### 5.1 What a delegation is + +A **delegation** is a grant created at runtime, by a principal who already holds one, naming a +parent it must not exceed. Human grants €100,000 to a finance agent; the finance agent grants +€25,000 of it to a support agent; the support agent asks for €50,000 and is denied. That chain +is §5.6, it is `ctrlrun demo` scenario 5, and it is the reason this section exists. + +A grant in the YAML file is a **root grant**: written by an operator, in a file the operator +controls, at depth 0. A delegation is created by `Control.delegate` or by `ctrlrun delegate`, at +depth `parent.depth + 1`, and lives in the store. + +### 5.2 The record and where it lives + +A new `delegations` table, in both stores. It is a **new table**, so `CREATE TABLE IF NOT EXISTS` +adds it to a database that already exists and v0.3 needs no migration story — the same reason +`v0.2 §2.2` refused to add a column to `effects`. No existing table gains a column in v0.3; T87 +depends on that. + +```sql +delegations( + delegation_id TEXT PRIMARY KEY, -- "dlg_" + 32 hex + parent_id TEXT NOT NULL, -- a root grant's id, or another delegation_id + depth INTEGER NOT NULL, -- recorded, never trusted (§5.5) + grant_json TEXT NOT NULL, -- the child grant, serialized per below + created_by TEXT NOT NULL, -- "agent" or "agent/user" + created_via TEXT NOT NULL, -- "api" | "cli" (§5.7) + created_at TEXT NOT NULL, + revoked_at TEXT, -- NULL while live + revoked_by TEXT +); +CREATE INDEX IF NOT EXISTS delegations_by_parent ON delegations(parent_id); +``` + +**The store persists rows, not grants.** `StateStore` reads and writes a `DelegationRecord` whose +fields are the columns above — `grant_json` stays a string — and `Authority` is what parses a +`Grant` out of it. This keeps `ARCHITECTURE.md` §6's dependency direction intact: `state.py` does +not import `authority.py`, and therefore does not transitively acquire `policy.py`. `Delegation` +is the parsed form and lives in `authority.py`: + +```python +@dataclass(frozen=True) +class Delegation: + delegation_id: str + parent_id: str + depth: int + grant: Grant + created_by: str + created_via: Literal["api", "cli"] + created_at: datetime + revoked_at: datetime | None = None + revoked_by: str | None = None +``` + +**Who mints the id.** `Control.delegate` mints `"dlg_" + secrets.token_hex(16)` — the same shape +as `act_` and `ctr_` (`v0.1 §2.1`, §6.1) — and returns a `Delegation` whose `grant.id` is set to +it. A `Grant` passed to `Control.delegate` MUST carry `id=""`, and an `id:` key in a +`ctrlrun delegate --file` document is a `PolicyError` naming the rule: *the id is assigned, not +chosen*. Creation is deliberately **not** idempotent — two identical calls make two delegations, +each revocable on its own — because a delegation is an act, and collapsing two acts into one +record would lose which one a receipt refers to. + +`grant.id` for a delegation therefore **is** its `delegation_id`: one namespace addresses both +kinds and `--parent` takes either. §4.2 keeps root grant ids out of the `dlg_` namespace. + +**`grant_json`** is the grant as JSON with `sort_keys=True`, `separators=(",", ":")`, +`ensure_ascii=False`: `subject` as an object of two nullable strings, `actions`/`resources`/ +`environments` as arrays of strings or `null`, `constraints` as an object mapping each raw +condition key to its operand, `delegable` as a boolean, and `expires_at` as ISO-8601 **retaining +the offset it was written with** — not normalized to UTC, so a grant round-trips to an equal +`Grant` and T71's "exactly `expires_at` still passes, one microsecond later does not" does not +depend on which path wrote it. + +The word *canonical* is deliberately not used: this is a storage encoding, nothing hashes it, and +`v0.1 §2.3`'s canonical form is a versioned security primitive that a change here must not be +read as touching. Reading a grant back MUST validate it exactly as loading one from YAML does — a +store is not more trusted than a file, and a delegation whose stored form no longer parses is an +`AuthorityDenied` naming it, never a grant with a field quietly defaulted. + +### 5.3 Who may delegate + +`Control.delegate(parent_id, grant, *, by)` MUST refuse unless **all** hold. The six checks are +performed **in the order listed**, and the first that fails is the refusal's reason — fixed, for +§4.3's reason: the evidence for one attempted creation must not depend on the order an +implementation happens to check things in. + +1. `parent_id` names a root grant or a live delegation. Otherwise `unknown_parent`. +2. The parent's `delegable` is `true`. Otherwise `parent_not_delegable`. +3. The parent is unexpired at `now`, and no delegation in its chain is revoked or expired. + Otherwise `parent_not_valid`. +4. `by` — the principal creating the delegation — **matches the parent grant's subject** (§4.2's + matching, including the `user` rule). Otherwise `not_the_subject`. You may only delegate + authority you hold; a process that could delegate from a grant addressed to someone else would + make the subject field decorative. +5. `parent.depth + 1 <= max_delegation_depth` (§5.5). Otherwise `max_depth`. +6. The child grant is contained in the parent on every dimension of §5.4. Otherwise + `containment`, and `data.dimension` names the row. + +Every refusal raises `AuthorityEscalation(reason=...)`, writes no delegation record, and appends +`DELEGATION_REJECTED` with `data.reason` and `data.parent_id`. **`data.dimension` is present only +for a rule-6 refusal**, because it names a §5.4 row and the other five are not rows; §7 says the +same. A successful creation appends `DELEGATION_CREATED` with `data.delegation_id`, +`data.parent_id`, `data.depth`, `data.created_by` and `data.created_via`. + +`AuthorityEscalation`'s reasons are creation-time reasons and are a **different vocabulary** from +§4.3's evaluation-time deny reasons. A test asserting `authority_escalation` on a creation is +asserting a value that creation never produces. + +### 5.4 Containment: what child ⊆ parent means, dimension by dimension + +Checked at creation, and checked again at every evaluation (§5.6). Each row is separately +testable and separately mutable; T76 breaks each one alone. + +| Dimension | Child is contained iff | +|---|---| +| `subject` | The child's subject contains **no wildcard**, and where the parent declares `user`, the child MUST declare a `user` that the parent's pattern matches. The child's `agent` is otherwise unconstrained | +| `actions` | **Every** child pattern is contained in **some** parent pattern (§5.5) | +| `resources` | Parent omits → child may declare anything, or omit. Parent declares → child MUST declare, and every child pattern is contained in some parent pattern | +| `constraints` | For **every** `(argument, op)` the parent declares, the child declares the **same** `(argument, op)` with an operand at least as strict. The child may add constraints the parent does not have | +| `environments` | Parent omits → child may declare anything, or omit. Parent declares → child MUST declare, and the child's set is a subset of the parent's (exact strings) | +| `expires_at` | Parent has none → child may have any, including none. Parent has one → child MUST have one, no later than the parent's | +| `delegable` | Not a containment row. §5.3 rule 2 governs creation and §5.6 rule 6 governs evaluation | + +**Why `subject` is a row at all, given that delegation exists to change who acts.** It is a row +because two things that look like "naming the grantee" are actually widening: + +- *Dropping the parent's `user`.* A parent `{agent: finance-agent, user: alice}` is authority to + act **for alice**. A child `{agent: finance-agent}` matches finance-agent acting alone, with no + human attached — §4.2 spends a paragraph making that impossible for the parent, and one + delegation would undo it. So where the parent names a user, the child must too, and the child's + user pattern must be one the parent's admits. Omission is rejection, like every other omission. +- *A wildcard grantee.* `subject: {agent: "*"}` in a delegation hands the parent's authority to + every agent in the deployment — and, since §5.3 rule 4 checks the *child's* subject for the next + hop, hands every one of them the right to delegate it onward. `max_delegation_depth` bounds + chain length, not grantee population, and nothing else would bound it. A wildcard subject is an + operator's decision in a reviewed file (§4.2); it is exactly what a runtime-created grant must + not be able to write. + +What is *not* constrained is the child's `agent` name. It may be any concrete agent, which is the +whole point of the chain in §5.6. + +**Operand strictness**, per operator, for the `constraints` row: + +| Op | Child is at least as strict iff | +|---|---| +| `lt`, `lte` | child operand ≤ parent operand | +| `gt`, `gte` | child operand ≥ parent operand | +| `eq` | child operand equals the parent's, type-strictly (`v0.1 §3.2`) | +| `neq` | child operand equals the parent's, type-strictly | +| `in` | the child's list is a subset of the parent's, compared type-strictly | + +A `when:` mapping can carry at most one condition per `(argument, op)` — it is a mapping — so +"the same `(argument, op)`, at least as strict" is well defined and there is nothing to combine. +A child that constrains `amount_lt` where the parent constrains `amount_lte` has not discharged +the parent's constraint: it may hold it *as well*, but the parent's key must be present. +Requiring the same operator keeps containment decidable and keeps the failure legible in a +rejection message; a solver that reasoned across operators would be a second policy engine. + +**On `neq`, before somebody flags it as inverted.** The classic error in this area is that a +*deny-list* attenuates by growing, so a child's exclusion set must be a **superset** of its +parent's — the direction opposite to every other row. It does not arise here: `v0.1 §3.2`'s +operator set has no deny-list operator (there is no `not_in`, no `contains`), and a `when:` +mapping holds at most one `neq` per argument, so a child cannot express "the parent's exclusions +plus more" even in principle. Equality is the correct and only expressible rule. This is stated +because a reviewer who knows the prior art will otherwise read the row as an oversight. + +**Omission is never "unlimited".** This is the rule the whole section exists for, and it is T81. +A parent constrained to `amount_lte: 25000`, and a child that simply does not mention `amount`, +is **rejected** — not treated as inheriting the parent's limit, and certainly not as +unconstrained. Inheritance would be worse than rejection, because a child that silently inherits +looks, in the file and in the receipt, like a child that was authorized for what it says. A +delegation states its own limits or it is not a delegation. + +The asymmetry with §4.2 — where a *root* grant that omits `resources` is unconstrained on +resources — is deliberate and worth saying out loud. A root grant is written by an operator, in a +file under review, and its omissions are that operator's decision. A delegation is created at +runtime by a principal the threat model does not trust, and its omissions are exactly what an +attacker would write. + +**Where containment cannot be decided, it fails.** The pattern grammar of §4.4 is small enough +that every pair is decidable, and a pattern outside it is refused at load. If a future grammar +extension makes some pair undecidable, the answer is "not contained". + +### 5.5 Pattern containment, chain depth, and the shape of the walk + +Pattern `C` is contained in pattern `P` (same separator) iff: + +- `P` is `**` — it contains every pattern; or +- `P` ends in a deep wildcard, `P = Q.**` (where §4.4 makes every segment of `Q` a literal): then + `C` is contained iff `C` has **more segments than `Q`** and its first `len(Q)` segments equal + `Q`'s. `Q.**` does **not** contain `Q` itself — the deep wildcard matches one or more further + segments; or +- otherwise `C` has no deep wildcard, `C` and `P` have the **same number of segments**, and each + segment of `C` is contained in the corresponding segment of `P`, where: + - literal ⊆ literal iff equal; + - literal `L` ⊆ prefix wildcard `A*` iff `L` starts with `A`; + - prefix wildcard `B*` ⊆ prefix wildcard `A*` iff `B` starts with `A`; + - prefix wildcard ⊆ literal: never. + +**`**` counts as one segment for every count above.** So `stripe.**` has two segments, and +`stripe.**` ⊆ `stripe.**` holds by the second clause (2 > 1). This is load-bearing rather than +pedantic: **every well-formed pattern contains itself**, and T76's whole construction — vary one +dimension, hold the rest identical — silently depends on it. A containment relation under which +`stripe.**` failed to contain `stripe.**` would fail eight of T76's rows on the dimension that +was *not* under test, and the false red would be debugged as a containment bug. T72b asserts +`C ⊆ C` directly. + +So `stripe.refund` ⊆ `stripe.*` ⊆ `stripe.**` ⊆ `**`, and `payment:EU-42` ⊆ `payment:EU-*` ⊆ +`payment:*`, and `stripe.*` ⊄ `stripe.refund`, and `stripe.refund.partial` ⊄ `stripe.*`, and +`stripe` ⊄ `stripe.**`. + +**Chain depth is recomputed, never trusted.** `authority.max_delegation_depth` is a non-negative +`int`, default **3**. A root grant is depth 0; a delegation of it is depth 1. A creation whose +resulting depth exceeds the maximum is refused with `max_depth`. The `depth` column (§5.2) is +recorded for reading, and evaluation MUST derive the depth by **walking to the root**, so a row +edited directly in the database cannot assert its way to a shorter chain. + +The walk MUST be bounded at `max_delegation_depth + 1` steps and MUST refuse a chain that revisits +a `delegation_id` it has already seen. A cycle is not reachable through `Control.delegate` — a +parent exists before its child — but it is reachable with `sqlite3` and a text editor, and an +unbounded walk over one would hang the process rather than deny the action. Exceeding the bound, +or revisiting an id, is `authority_escalation`. + +`max_delegation_depth: 0` is valid and means no delegation may be created at all — the legible way +to switch the feature off in a deployment that only wants root grants. A negative value is a +`PolicyError`. + +### 5.6 Re-checking at evaluation, and the signature scenario + +At evaluation, a delegated grant is valid only if, walking from it to the root: + +1. every ancestor still exists → else `authority_escalation`, with `data.missing_parent_id` + naming the one that does not; +2. no ancestor, and not itself, is revoked (§5.7) → else `authority_revoked`; +3. no ancestor has expired at `now` → else `authority_escalation` (the delegation's *own* expiry + is `authority_expired`, §4.3); +4. every parent→child step still satisfies §5.4 → else `authority_escalation`, with + `data.dimension`; +5. the chain's depth is still within `max_delegation_depth`, and the walk is acyclic (§5.5) → + else `authority_escalation`; +6. every ancestor is still `delegable` → else `authority_escalation`. + +Rule 6 exists because `delegable` is not a §5.4 row and rule 4 would therefore never see it. An +operator who sets `delegable: false` on a root grant is shutting down a chain they believe is +compromised; without rule 6 that edit would have no effect on any existing delegation beneath it, +and the prose would be promising containment the table did not deliver. + +Re-checking is not belt-and-braces. It is what makes revocation transitive, what makes an expiring +parent stop authorizing without anyone finding its children, and what makes a narrowed root grant +narrow everything beneath it. A containment check performed only at creation would leave every +delegation exactly as wide as the file used to be, which is the shape of every stale-permission +incident there has ever been. + +**What re-checking cannot do, stated plainly.** An `Authority` is built when the document is +loaded — `Authority.from_yaml`, `Control.from_file()` — and v0.3 does **not** hot-reload it. +Revocation and expiry are live, because they are read from the store and from the clock on every +evaluation. An **edit to the file** is not: narrowing a root grant's `amount_lte`, bringing its +`expires_at` forward, removing `delegable`, lowering `max_delegation_depth` or deleting a grant +takes effect when the process next loads the document, which for `ctrlrun gateway` means a +restart. Every "MUST take effect" in this section is scoped to that. + +The operational consequence is the one worth knowing: **the runtime kill switch is +`ctrlrun revoke`, and it covers delegations only.** There is no runtime revocation for a root +grant. A compromised root grant is answered by editing the file and restarting the process, or by +revoking the delegations beneath it. Hot reload, `SIGHUP` and a root-grant revoke are all recorded +as out of scope in §13 rather than implied by prose here. + +T77b and T79 therefore each run against **one long-lived `Control`** for the live half — parent +expiry, revocation, depth — and reload explicitly for the file half, so neither can pass by +rebuilding the world between the two halves. + +**The signature scenario.** + +``` +root grant head-of-finance amount_lte 10000000 delegable: true depth 0 + └─ dlg_1 finance-agent amount_lte 2500000 delegable: true depth 1 + └─ dlg_2 support-agent amount_lte 200000 delegable: false depth 2 +``` + +`support-agent` requesting a €50,000 refund (`amount=5000000`) matches `dlg_2` on subject, +action, resource and environment, and fails its `amount_lte` — `authority_constraint`. Requesting +€1,500 (`amount=150000`) passes authority, and policy then decides how much autonomy it has. + +The creation half of the story needs two different attempts, because two different guards refuse +them and a single one would hide whichever ran second: + +- `finance-agent` attempting to delegate €50,000 to `support-agent` under its own €25,000 parent + is refused at §5.3 rule 6 — `containment`, `data.dimension = "constraints"`. This is the guard + the containment machinery exists for. +- `support-agent` attempting to delegate anything at all is refused at §5.3 rule 2 — + `parent_not_delegable` — whatever the amount, because `dlg_2` is not delegable. + +T80 asserts both, by reason, so neither can stand in for the other. An escalating creation never +becomes a record that a later evaluation has to catch. + +### 5.7 Revocation, and the two CLI commands + +``` +ctrlrun delegate --parent --file --as [/] [--json] +ctrlrun revoke [--by ] +``` + +Both go through `Control` (§4.8), so both write through the store and fan out to every registered +sink. + +`ctrlrun revoke` sets `revoked_at` and `revoked_by` and appends `DELEGATION_REVOKED`. Revocation +is **not** reversible in v0.3 — there is no `unrevoke` — because the operation whose safety +matters is the one taken in a hurry, and an operator who revoked the wrong delegation creates a +new one, which leaves a record of both acts. + +Revocation is **transitive by structure**: a revoked delegation breaks the chain for everything +beneath it, because §5.6 rule 2 walks to the root on every evaluation. Nothing is rewritten and no +children are visited at revoke time; a chain of any depth is cut by one write. + +Revoking an already-revoked delegation is idempotent: it logs, appends no second event, and exits +0. Revoking an unknown id exits non-zero with a message on stderr and prints nothing on stdout, as +`ctrlrun inspect` does for an unknown action (`v0.2 §5`). + +`ctrlrun delegate` reads a one-grant YAML document with the keys of §4.2 minus `id` (§5.2), +validates it, runs every check of §5.3, and prints the new `delegation_id`. Its refusals exit +non-zero and name the rule that failed. + +**`--as` is an assertion, and the record says so.** It supplies the creating principal for §5.3 +rule 4, and there is no default, because the default would be "whoever the store belongs to", +which is not a principal. But it is free text typed by whoever runs the command, checked against a +grant that same person can read — so at the CLI, rule 4 is satisfied by naming the right principal +rather than by being it. That is why the record carries `created_via` (§5.2): `api` for +`Control.delegate`, where `by` came from wherever the application's identity came from, and `cli` +for this command, where it came from a shell. A reader of the evidence can tell an act from an +assertion, which is the whole reason the field exists. + +CLI delegation is an operator act inside the trust boundary, exactly as `ctrlrun approve` is +(`docs/THREAT_MODEL.md`: "A compromised approver … is out of scope"). §13 records that v0.3 does +not authenticate the delegator any more than it authenticates the approver, and item 6 puts the +same sentence in the threat model. + +--- + +## 6. Observe mode + +### 6.1 What it is for + +Nobody puts an enforcement kernel in front of a live agent fleet and turns it on. Observe mode is +the rollout path: run the real decisions against real traffic, record what *would* have been +blocked, and read the numbers before anything is enforced. + +```yaml +schema: ctrlrun.policy/v3 +mode: observe # or enforce; enforce is the default +``` + +`mode` is **top-level and nothing else**. A `mode:` key inside an action entry, inside a rule, +inside a grant, or anywhere in the `authority:` section is a `PolicyError` at load naming the path +(T84), and so is a `mode:` in a `--authority` document (§8.3). A partially-enforced configuration +— some actions observed, some enforced — is the failure mode this rule exists to prevent: it +produces a deployment where nobody can say whether an action was permitted or merely watched, and +where turning enforcement "on" is a per-action archaeology exercise. There is one switch and it +governs the process. + +The value MUST be exactly `observe` or `enforce`. Anything else, including `true`, `off` and `0`, +is a `PolicyError`. Absent → `enforce`: the fail-closed default, so a document that predates this +key enforces, and a typo in the key name (`Mode:`) is caught by the closed top-level key set of +`v0.1 §3.1` rather than silently disabling every guarantee. + +### 6.2 What observe mode does, and what it still refuses + +**Everything is evaluated. Nothing about an action is enforced. Everything is recorded.** + +In observe mode, an action that would have been denied, or would have needed a human, or would +have been refused as a duplicate, **executes**, and `Control.execute` **returns a receipt** rather +than raising: no `ActionDenied`, no `AuthorityDenied`, no `ApprovalRequired`, no `ApprovalMismatch`, +no `DuplicateEffect`, no `AmbiguousEffect`. A caller that saw an exception would not be observing. +Evaluation is otherwise identical to enforce mode, and that identity is the point: an observe-mode +deployment that took shortcuts would be measuring something other than what enforcement will do. + +Specifically, in observe mode: + +- The identity provider runs; `principal_expired` (§2.3) is evaluated and recorded, not enforced. +- Authority is evaluated in full — grants, constraints, chains, revocation — and recorded. +- Policy is evaluated in full and recorded. `AUTHORITY_RESOLVED` / `AUTHORITY_DENIED` and + `POLICY_EVALUATED` are appended exactly as in enforce mode, including §4.3's rule that a denial + by authority skips policy. +- The effect key is resolved and the reservation is **attempted**. A refusal — duplicate, + in-progress, ambiguous — is recorded as `would_have.blocked_reason` and then ignored, and the + executor runs anyway. +- A receipt is written for every action, with `result: "observed"` (§6.3). + +**Where the reservation succeeded**, the executor's outcome maps through `v0.1 §5.5` unchanged and +this attempt's effect record is written: `COMMITTED`, `FAILED` or `AMBIGUOUS`. Observe mode records +what happened; it does not stop recording it. + +**Where the reservation was refused**, the executor still runs and **no effect record is written**. +The record belongs to the attempt that holds the key, and observe mode does not make a second +attempt its owner. Two reasons, and the second is not negotiable: + +- The store would refuse it anyway. `commit_effect`, `fail_effect` and `mark_ambiguous` admit only + the `action_id` that holds the record (`v0.1 §5.3 E1`), so writing here would need a new + transition that lets a non-holder write — in the one mode where the guard is already relaxed. +- A record already in `AMBIGUOUS` would be moved to `COMMITTED` by a machine. `v0.1 §5.2` reserves + that for a human, and `v0.2 §2.2` amends it to admit exactly one other authority, the + `reconcile` hook. Observe mode is neither, and a rollout mode that silently closes an incident a + human is still adjudicating is worse than the enforcement it stands in for. + +The reservation *attempt* keeps its own effects: an expired lease still moves a record to +`AMBIGUOUS` (`v0.1 §5.4`), because that is the reservation speaking rather than this attempt's +outcome. The refused attempt's outcome is not lost — it is on its own receipt, as `execution` and +`would_have.blocked_reason` (§6.3). + +**No approval request is created.** A policy reaching `approve` is recorded as +`would_have.blocked_reason: "approval_required"` and the action runs. Observe mode MUST NOT page a +human about an action it is about to execute regardless of the answer, and a pending request +nobody answers is not evidence — it is a queue that fills up. No `APPROVAL_REQUESTED` event, no +record in the store, no `ApprovalRequired` raised. + +**The `reconcile` hook is not called.** `v0.2 §2.3`'s blocking trigger fires "at the moment an +existing `AMBIGUOUS` record refuses a new attempt's reservation"; in observe mode that refusal is +ignored, so there is nothing being unblocked and nothing to ask about. Calling it would make a +real request to a remote on behalf of a mode that enforces nothing, and its `"committed"` answer +would move a record — the same collapse the previous paragraph refuses, arriving by another door. +Eager reconciliation (`reconcile_eagerly=True`) is likewise not called for an attempt that held no +reservation; for one that *did* reserve, both triggers behave exactly as in enforce mode, because +that attempt owns its record. + +**A `Suspended` executor holds nothing it did not reserve.** If the executor raises `Suspended` +(`v0.2 §6.9`) on an attempt whose reservation was refused, there is no lease to extend and no +continuation to hold: the signal propagates to the caller unchanged and no continuation is +recorded, which is `v0.2 §6.9.3`'s no-effect-key path reached for a different reason. An attempt +that *did* reserve suspends exactly as in enforce mode. + +**What observe mode does not suspend.** It suspends *decisions about an action* and *effect-state +refusals*. It does not suspend the refusals that mean CTRLRun cannot construct or identify the +action at all, and it does not suspend the creation of durable authority: + +| Still refuses in observe mode | Why | +|---|---| +| `no_principal` (`v0.1 §2.1`) | There is no Action to observe, and nothing to attribute a receipt to | +| A raising identity provider (§3.2) | Same: no principal was produced | +| `effect_key_error` (`v0.1 §5.1`) | The action has no identity, so "would it have been a duplicate" has no answer | +| `unrepresentable_argument` (`v0.2 §6.6`) | The Action cannot be constructed | +| `AuthorityEscalation` at delegation creation (§5.3) | A delegation is a durable authority record, not a decision about an action — and T87 makes records created under observe survive the switch to enforce | +| `PolicyError` at load | There is no configuration to observe with | + +The line is drawn where it is because everything above is either a wiring bug in the deployment or +an act of authority, not a decision about an action. Observing a wiring bug means running an +action CTRLRun could not describe, and a receipt that cannot say what ran is not evidence. + +T82b asserts the first four rows and the delegation row by name. The last row is **unfalsifiable +and carries no test**: an implementation cannot know the mode without loading the configuration, +so counting it as coverage would be a negative test against something the environment already +prevents. It is in the table because a reader will look for it. + +### 6.3 The receipt in observe mode + +```json +{ + "schema": "ctrlrun.receipt/v2", + "result": "observed", + "execution": "committed", + "would_have": { + "decision": "deny", + "reason": "authority_constraint", + "blocked_reason": "authority_constraint" + } +} +``` + +- **`result`** is `"observed"` for every receipt an observe-mode run *observed* — including + actions nothing would have blocked, so a reader never has to infer from the absence of a field + that a receipt describes an unenforced run. A receipt written by one of §6.2's **still-refuses** + rows is **not** such a receipt: it says `result: "denied"`, with `execution: null` and + `would_have: null`, because the action genuinely was stopped and there is nothing counterfactual + to record. `would_have` being present on every observed run and absent on every refused one is + what keeps the "never infer from absence" property true in both directions. +- **`execution`** is what the executor actually did — `committed`, `failed` or `ambiguous` — or + `null` where it never ran. In enforce mode `execution` is always `null`; `result` already + carries it, and duplicating it would give two fields that can disagree. +- **`would_have.decision`** is the combined decision of §4.6 — what enforce mode would have + reached. +- **`would_have.reason`** is the `decision_reason` that decision came with. +- **`would_have.blocked_reason`** is `null` when the action would have run unimpeded, and + otherwise names what would have stopped it: `approval_required`, `duplicate`, `in_progress`, + `ambiguous`, `approval_mismatch`, `principal_expired`, `unknown_action`, `no_matching_rule`, or + one of §4.3's authority reasons. It is separate from `decision` because "the policy said allow + and the effect was already committed" is a real and common answer, and a single field could not + hold both halves. + +`decision` and `decision_reason` at the top level keep their enforce-mode meaning: the decision +that was *reached*. `would_have` says what would have been *done* with it. The pair is not a +duplicate — one is the combined §4.6 result, the other is the consequence — and where they look +alike it is because nothing intervened between them. + +### 6.4 `ctrlrun stats` + +``` +ctrlrun stats [--since ] [--json] +``` + +Counts, from the **local store and nothing else** — no network, no aggregation service, no upload. +This is a command that reads receipts out of the SQLite file the process it is diagnosing has been +writing. + +``` +CTRLRun — 2026-09-01T00:00:00Z .. 2026-09-04T09:11:00Z (observe mode) + +actions 1284 +would have been denied 37 (2.9%) + no_authority 21 + authority_constraint 9 + unknown_action 7 +would have needed approval 64 +would have been blocked 12 + duplicate 9 + ambiguous 3 +ambiguous outcomes 3 +``` + +Every number comes from one field: **`would_have.blocked_reason`**, plus `would_have.decision` for +the denial count and `execution` for the ambiguous count. That is why §6.3 defines +`blocked_reason` as a closed vocabulary rather than free text — a bucketed count over a string +nobody constrained is a report that quietly stops adding up. + +**In enforce mode the same command reports what actually happened**, and it reports less. There is +no `would_have` object on an enforce-mode receipt, so `ctrlrun stats` counts `denied` receipts by +`decision_reason` and `ambiguous` receipts by `result`, and it **omits the duplicate/ambiguous +breakdown**, because an enforce-mode `blocked` receipt carries the policy reason and keeps the +distinction only inside `error` as exception text (`v0.1 §6.1`). The command says so in its footer +rather than printing a line it cannot substantiate. `# SPEC:` adding a structured `blocked_reason` +to enforce-mode receipts would close this, and it is deliberately not done in v0.3: it would put a +counterfactual field on a receipt describing something that was not counterfactual. + +`--since` compares **`finished_at`**, inclusive at the boundary, and accepts an absolute ISO-8601 +timestamp with an offset or a relative `` where unit is exactly one of `m` (minutes), `h` +(hours) or `d` (days). No months, no weeks, no bare numbers; anything else exits non-zero naming +the accepted forms. Absent → everything in the store. + +`--json` emits one object under `schema: "ctrlrun.stats/v1"` with the same numbers, so the output +can be diffed between runs. + +An action is counted once, by its receipt. Actions still awaiting a human have no receipt (`v0.1 +§6.1`) and are not counted; the command says so in its footer rather than leaving the arithmetic +unexplained. An empty store prints zeros and exits 0. + +### 6.5 The banner, and `ctrlrun verify` + +**Every CLI command that loads the operator's policy MUST print, to stderr, before anything +else:** + +``` +OBSERVE MODE — nothing is enforced +``` + +To stderr, so a `--json` stdout stays machine-readable and a pipeline cannot silently swallow the +warning. On every invocation, not once per day and not behind a flag: a deployment that has been +in observe mode for six months is exactly the deployment this line is for. + +`# SPEC:` the item-0 brief says "on every command". A command that loads no policy cannot know the +mode, so the rule is "every command that loads the operator's policy", and the two sets are named +here so the carve-out cannot silently grow: + +| Loads the policy in v0.3, and prints the banner | Does not load it, and does not print | +|---|---| +| `gateway`, `stats`, `delegate`, `revoke`, `verify` | `init`, `demo`, `approve`, `deny`, `receipts`, `effects`, `resolve`, `inspect` | + +The right-hand column is not an oversight, and v0.3 does **not** move any command across it. +`state_path()` exists so the CLI can find the store an agent is using *without loading the policy* +(`v0.1 §8`); making `ctrlrun receipts` or `ctrlrun inspect` load one would turn a missing or +malformed policy into a failure of the evidence commands, and break every deployment that points +`$CTRLRUN_STATE` at a store with no policy beside it. Reading evidence must not depend on the +configuration that produced it. + +The commands in the left-hand column already need the policy — the gateway to serve it, `stats` to +know which mode it is reporting, `delegate` and `revoke` to load the `authority:` section. Where +one of them cannot load it, that is a `PolicyError` and the command exits non-zero, exactly as +`Control.from_file()` does (`v0.1 §3.4`); the banner is not printed, because there is no mode to +report. T85 asserts both columns by name. + +**`ctrlrun verify`** is added as a stub that refuses: it prints, to stderr, that verification +lands in v0.4 and exits **2**. It exists in v0.3 for one reason — observe mode's whole purpose is +to lead somewhere, and the command an operator will reach for next should not be a +`No such command` error that suggests they mistyped. It runs nothing, checks nothing, and claims +nothing. + +### 6.6 Switching modes + +Switching `observe` → `enforce` is a one-line edit and requires **no state migration**: the same +store, the same tables, the same effect records, the same delegations (T87). An effect that +reached `COMMITTED` while observing is a committed effect, and the first enforced action on that +key is refused as a duplicate — correctly, because it *was* one. Effect records that observe mode +declined to write (§6.2) were never this deployment's to write, so nothing is missing. + +The corollary is worth stating: **observe mode is not a dry run.** It executes. Effects land at +remotes, and the records of them are real. It is a way to learn what enforcement would cost, not a +sandbox. + +--- + +## 7. Events + +Five types join the closed set of `v0.1 §6.2` and `v0.2 §2.5`: + +``` +AUTHORITY_RESOLVED +AUTHORITY_DENIED +DELEGATION_CREATED +DELEGATION_REVOKED +DELEGATION_REJECTED +``` + +| Type | `data` | +|---|---| +| `AUTHORITY_RESOLVED` | `grant_id`, and for a delegated grant `delegation_id` and `depth` | +| `AUTHORITY_DENIED` | `reason` (§4.3); `grant_id` / `delegation_id` where one grant was implicated; `dimension` for a §5.6 rule-4 failure; `missing_parent_id` for a §5.6 rule-1 failure | +| `DELEGATION_CREATED` | `delegation_id`, `parent_id`, `depth`, `created_by`, `created_via` | +| `DELEGATION_REVOKED` | `delegation_id`, `revoked_by` | +| `DELEGATION_REJECTED` | `reason` and `parent_id`; `dimension` **only** for a §5.3 rule-6 refusal | + +`AUTHORITY_RESOLVED` is appended for **every** action that passes authority, not only for +delegated ones. Evidence has to record that CTRLRun checked and found a grant, or a deployment +with a permissive grant is indistinguishable from one with no authority section at all — the same +argument `v0.2 §2.5` makes for appending `RECONCILIATION_RESOLVED` on `"unknown"`. + +`dimension` is present only where a §5.4 row failed. The other refusals of §5.3 — `unknown_parent`, +`parent_not_delegable`, `parent_not_valid`, `not_the_subject`, `max_depth` — name no row, and +inventing a dimension for them would make two distinguishable guards report the same shape. + +**`Event.action_id` becomes `str | None`.** The three delegation events are not about an action: +they are about an authority record, created and revoked outside any action's life. They carry +`action_id: null` and name the delegation in `data.delegation_id`. The alternative — inventing a +synthetic `action_id` — would put a value in a field that every reader takes to name a real +proposal. No column is added to the `events` table for this; `action_id` is already nullable in +its DDL, and adding a column would need the migration story v0.3 does not have (§5.2). + +**What a sink does with an action-less event.** `v0.2 §4.1` requires every `Event` to reach every +sink, and `Control` appends and fans out all five of these (§4.8), so they do. `JSONLEventSink` +writes them with `"action_id": null`, unchanged. `OTelEventSink` MUST emit a **standalone span** +named for the event type, carrying `ctrlrun.delegation_id` and `ctrlrun.parent_id`, rather than +looking for an open span it will never find and dropping the event — which is what the v0.2 sink +would do by default, silently, leaving an operator whose evidence pipeline is OTel to watch a +delegation chain appear and be revoked with nothing in the trace. The highest-privilege operations +in the release must not be the only ones missing from the export path. + +`ctrlrun inspect ` is unaffected: it selects by `action_id`, and rows with `NULL` never +match. A delegation's history is read from the events file and `ctrlrun stats`; there is no +`ctrlrun delegations` in v0.3 (§13). + +--- + +## 8. Gateway + +### 8.1 `--principal-from-client-info` is removed + +The flag is **gone**, not deprecated further. `ctrlrun gateway --principal-from-client-info` MUST +exit non-zero with a message naming its replacement: + +``` +--principal-from-client-info was removed in 0.3. Use --principal-header NAME, set by a +proxy that authenticates the caller and overwrites the header on every request. +``` + +A hard error, not a silent ignore and not a warning-and-continue. The flag chose the gateway's +principal; a gateway that started anyway would be running with a *different* principal than the +operator asked for, and under v0.3 the principal is an authorization input. + +`v0.2 §6.5` recorded the expiry condition when it added the flag: it was survivable only while a +policy could not address the principal at all, so an unauthenticated one misattributed evidence +and could not widen an outcome. §4 ends that, exactly as `v0.2 §6.5` said it would. + +### 8.2 Identity selection + +`--principal AGENT` and `--principal-header NAME` remain, and are now understood as constructors: +`--principal` builds a `StaticIdentityProvider`, `--principal-header` builds a +`HeaderIdentityProvider` (with `--user-header`). Item 5 adds a third, `--identity-jwt`. + +**Exactly one identity source MUST be given**, and there is still no default (`v0.2 §6.5`). +`--principal`, `--principal-header` and `--identity-jwt` are mutually exclusive; giving two, or +none, exits non-zero naming the three. `--user-header` is accepted only with `--principal-header` +(with `--identity-jwt`, the user comes from `--identity-jwt-user-claim`; with `--principal`, from +nowhere), and every `--identity-jwt-*` flag is accepted only with `--identity-jwt`. A flag that +cannot take effect is a flag the operator believes took effect, so it is an error rather than a +warning. + +``` +--identity-jwt select JWTIdentityProvider (ctrlrun[identity]) +--identity-jwt-jwks-url URL | exactly one of these three +--identity-jwt-public-key PATH | +--identity-jwt-secret-file PATH | +--identity-jwt-algorithms ALG repeatable; required +--identity-jwt-issuer ISS required +--identity-jwt-audience AUD required +--identity-jwt-token-type TYP required; pass "" for "this issuer sets no typ" +--identity-jwt-header NAME default: authorization +--identity-jwt-agent-claim NAME default: sub +--identity-jwt-user-claim NAME optional +--identity-jwt-claim NAME repeatable; which claims reach the receipt +--identity-jwt-leeway SECONDS default: 60 +--identity-jwt-jwks-min-refresh SECONDS default: 30 +``` + +The secret is read from a **file**, never from a flag value: a shared secret on a command line is +in every process listing on the host. + +The gateway fills `IdentityContext.headers` with the request's headers, names lowercased, and +`IdentityContext.action` with the action name it is about to propose. §3.1's repeated-header rule +applies before that mapping is built. + +A refusal at the identity gate returns `-41007` `ctrlrun.no_principal`, HTTP 403, no receipt and +no events — `v0.2 §6.5` unchanged. A token that was present and invalid returns the same code with +a distinct message; the client learns that its credential was rejected and learns nothing about +why, which is the correct amount to tell an unauthenticated caller. + +### 8.3 Authority at the gateway + +``` +--authority PATH load the authority: section from a separate YAML document +``` + +Authority is normally the `authority:` section of the policy file. `--authority` loads it from a +separate document instead, because the person who writes grants and the person who writes +per-action autonomy are often not the same person, and a gateway is where that separation shows up +first. + +**That document is not a policy document.** Its top-level key set is closed at exactly `schema` +and `authority`: `actions:` or `mode:` in it is a `PolicyError` at load naming the file and the +key, and `v0.1 §3.1`'s requirement that a policy document carry `actions:` does not apply to it. +Two sources for `mode:` would be two switches, which is the one thing §6.1 says there is not. + +If **both** the policy file and `--authority` declare `authority:`, the gateway MUST refuse to +start, naming both paths. Two sources for one section is the ambiguity `v0.1 §5.1` refuses for +`{resource}`: pick one, or say which. + +**The approval gate uses the combined decision.** `v0.2 §6.10`'s approval flow keys off the +action's decision, and since §4.6 that is the combined one. Two places in shipped v0.2 code read +`Policy.evaluate` directly to decide whether to look for a granted approval — the gateway's +`tools/call` path and `ctrlrun.acs`'s request hook — and both MUST be changed to use the combined +result, and to evaluate authority first so §4.3's ordering holds on the gateway path too. They are +named here so item 5 cannot miss them. + +**A denial outside the grant is `-41012`.** A tool call outside the principal's grant returns +JSON-RPC error **`-41012` `ctrlrun.unauthorized`**, HTTP 403, with the same `data` shape as +`-41001` (`reason`, `action_id`) where `reason` is the §4.3 reason, and the **upstream is never +called** (T91). A denied receipt and the events of §4.3 are written, because there is a principal. + +This narrows `v0.2 §6.10`'s table: its `DENY → -41001 ctrlrun.denied` row now means a **policy** +denial. Because §11 makes `AuthorityDenied` a subclass of `ActionDenied`, the gateway MUST catch +`AuthorityDenied` **before** `ActionDenied`, or the new code is unreachable and every authority +denial reports `-41001` while every test that asserts only "it was refused" stays green. T91 +asserts both halves — an authority denial returning `-41012` and a policy denial under the same +`authority:` section still returning `-41001` — so the discrimination is pinned rather than +assumed. + +`# SPEC:` the item-5 brief said "JSON-RPC error ctrlrun.denied reason no_authority", i.e. reuse +`-41001`. A distinct code earns its keep because the two refusals are answered differently by a +client: `-41001` means this action is not permitted to anyone in this configuration, and `-41012` +means it is not permitted to *you* — the second is worth a different message and, in a +multi-tenant deployment, a different alert. + +**Startup output.** The gateway already prints every action in its policy with no `effect:` +(`v0.2 §3.2`). It now also prints, in the same startup block: + +- the identity provider in force, by name, and for `HeaderIdentityProvider` the header it trusts + and the one-line warning of §3.3; +- whether an `authority:` section is loaded and how many grants it holds — or, when there is none, + the single line `no authority: section — every principal is unrestricted`, so an operator who + believes they configured authority finds out on the line that starts the process; +- in observe mode, `OBSERVE MODE — nothing is enforced`, on its own line, before everything. + +**In observe mode the gateway forwards everything.** It returns no `-41001`, no `-41012`, no +`-41002`; the client sees the upstream's response, and the would-have decision lives in the +receipt. A gateway that returned an error while claiming not to enforce would be enforcing. + +--- + +## 9. Fail-closed table for v0.3 + +`v0.1 §3.4` and `v0.2 §6.11` hold in full, and so does every row below, **in enforce mode**. + +`mode: observe` (§6) is the one switch that changes this, and it changes it wholesale rather than +per row: under `mode: observe` every row marked ⚠ is evaluated and recorded as +`would_have.blocked_reason` (§6.3) instead of refused, and the action executes. The rows **not** +marked ⚠ refuse in both modes, and they are §6.2's table. That is what "there is no half-way" +means — one switch, all of it or none of it, never a per-row or per-action opt-out. **No row here +is individually configurable**, and there is no flag that makes any single one of them permissive. + +| Condition | Result | ⚠ | +|---|---|---| +| `identity` set, `resolve()` returns `None`, no `context()` | `ActionDenied(reason="no_principal")`; no receipt, no events (`v0.1 §2.1`) | | +| `identity` set, `resolve()` returns `None`, `context()` active, **`authority:` loaded** | `ActionDenied(reason="no_principal")`; the context does not fill in (§3.2) | | +| `identity` set, `resolve()` raises | `IdentityError`; no receipt, no events; the context does not fill in | | +| Any configured identity header repeated in one request | `-41007`, HTTP 403; no receipt, no events (§3.1) | | +| `principal.expires_at` in the past | `IdentityError`, `decision_reason="principal_expired"`; denied receipt; policy never runs | ⚠ | +| `principal.expires_at` passes during an action: lease extension | refused; the lease lapses and the record becomes `AMBIGUOUS` (§2.3.1) | ⚠ | +| JWT: more or fewer than one of `jwks_url` / `public_key` / `secret` | `InvalidArgument` at construction | | +| JWT: `HS*` with `jwks_url` or `public_key`, or an asymmetric alg with `secret` | `InvalidArgument` at construction | | +| JWT: `token_type` not passed | `InvalidArgument` at construction | | +| JWT: `alg` absent from the configured list, or `none` | `IdentityError`; the signature is never checked | | +| JWT: `typ` present and not the configured `token_type` | `IdentityError` | | +| JWT: `exp` absent, or expired beyond leeway; `nbf` in the future | `IdentityError` | | +| JWT: `iss` mismatch, or `audience` not among `aud` | `IdentityError` | | +| JWT: `kid` still unknown after one refresh, or inside the refresh window | `IdentityError` | | +| JWT: two JWKS entries share a `kid` | `IdentityError`; never first-match (§3.4) | | +| JWT: JWKS fetch fails | `IdentityError`; never an empty key set, never a cached-forever key | | +| `authority:` present, no grant matches | `AuthorityDenied(reason="no_authority")`; denied receipt; no approval request | ⚠ | +| `authority:` present, a constraint fails | `AuthorityDenied(reason="authority_constraint")` | ⚠ | +| `authority:` present, every matching grant expired | `AuthorityDenied(reason="authority_expired")` | ⚠ | +| Delegated grant no longer contained in its parent | `AuthorityDenied(reason="authority_escalation")` | ⚠ | +| A delegation in the chain is revoked | `AuthorityDenied(reason="authority_revoked")` | ⚠ | +| A parent grant has been deleted from the document | `AuthorityDenied(reason="authority_escalation")`, `missing_parent_id` | ⚠ | +| An ancestor is no longer `delegable` | `AuthorityDenied(reason="authority_escalation")` (§5.6 rule 6) | ⚠ | +| A chain walk revisits a delegation, or exceeds the depth bound | `AuthorityDenied(reason="authority_escalation")` (§5.5) | ⚠ | +| Delegation creation: any §5.4 dimension violated | `AuthorityEscalation(reason="containment")`; no record; `DELEGATION_REJECTED` | | +| Delegation creation: parent unknown / not `delegable` / not valid | `AuthorityEscalation` naming which (§5.3) | | +| Delegation creation: creator is not the parent's subject | `AuthorityEscalation(reason="not_the_subject")` | | +| Delegation creation: depth would exceed the maximum | `AuthorityEscalation(reason="max_depth")` | | +| Delegation creation: child subject carries a wildcard, or drops the parent's `user` | `AuthorityEscalation(reason="containment")`, `dimension="subject"` | | +| A stored delegation whose `grant_json` no longer parses | `AuthorityDenied`; never a grant with a field defaulted (§5.2) | ⚠ | +| `authority:` present but malformed, or `grants` missing | `PolicyError` at load; no `Control` | | +| A pattern outside §4.4's grammar | `PolicyError` at load | | +| `mode:` anywhere but the top level of the policy document | `PolicyError` at load | | +| `mode:` not exactly `observe` or `enforce` | `PolicyError` at load | | +| `authority:` or `mode:` in a `v1`/`v2` document | `PolicyError` at load, naming the key and `ctrlrun.policy/v3` | | +| `actions:` or `mode:` in an `--authority` document | `PolicyError` at load, naming the file and the key | | +| Gateway: `--principal-from-client-info` | exit non-zero, naming `--principal-header` | | +| Gateway: two identity sources, or none | exit non-zero, naming the three | | +| Gateway: authority declared in two places | refuses to start, naming both | | + +--- + +## 10. Acceptance tests + +Each MUST exist as a pytest test with the given ID in its name, as `v0.1 §7` and `v0.2 §10` +require. All MUST pass for v0.3, and every test of `v0.1 §7` and `v0.2 §10` MUST still pass. + +The item-0 brief scoped this section as T60–T85; items 4 and 5 add T86–T92 and item 6 adds T93, so +the range is **T60–T93**. Numbers are stable and never reused; the range names its endpoints, and +suffixed ids (T65b, T74b, T82c…) fill in between them where a rule earned its own test after the +brief was written. + +**Every test that asserts a refusal asserts it by name** — the `reason`, not merely the exception +class — wherever §4.3, §5.3 or §6.3 defines a vocabulary. Five deny reasons that all raise +`AuthorityDenied` are five guards a test asserting only the type cannot tell apart. + +### Item 1 — Principal and identity (§2, §3) + +#### T60 — Claims are receipt data, not action identity +Two Actions identical but for `principal.claims` — one with none, one with a full mapping — have +the **same** `action_hash`, and `canonicalize()` returns identical bytes. The claims appear in the +receipt's `principal` object. An approval granted against the first authorizes the second. +Changing `agent` or `user` still changes the hash (`v0.1 §7 T7`). + +#### T60b — `Principal` validates what §2.1 says it validates +A `float` claim value, a `None` claim value, a list or mapping claim value, a non-string or empty +claim key, and a naive `expires_at` each raise `InvalidArgument`. `claims` is snapshotted: mutating +the mapping passed in does not change the constructed `Principal`, and `True` and `1` are distinct +claim values. + +#### T60c — The stored Action carries the whole principal +An action whose principal carries claims, an issuer and an expiry is suspended (`v0.2 §6.9`) and +resumed. The receipt written by `Control.resume` carries all three, unchanged. A row written in +the v0.2 shape parses back with `v0.1 §2.1`'s defaults rather than raising. + +#### T61 — An expired principal is refused before policy +A principal whose `expires_at` is in the past, on an action the policy would `approve`. Then +`IdentityError` is raised, the receipt is `denied` with `decision_reason="principal_expired"`, the +store holds **no** approval request, and the executor never ran. The `else` branch — the action +having executed — fails the test. Asserted as an ordering, not an absence: the same action with a +valid principal produces `AUTHORITY_RESOLVED` and `POLICY_EVALUATED`, and the expired one produces +neither, so the assertion is a difference between two runs rather than a claim that nothing +happened. + +#### T61b — An expiry that falls mid-action +Parametrized over §2.3.1: a principal that expires after `EXECUTION_STARTED` still commits (the +record reaches `COMMITTED`, no exception); a lease extension under the same expired principal is +refused with `IdentityError` and the record is left for the lapsing lease to move; a +`Control.resume` under it records the expiry and does not re-decide, and the resumed leg produces a +receipt. + +#### T62 — A provider returning `None` with no context is `no_principal` +`ActionDenied(reason="no_principal")`, a warning naming the action on the `ctrlrun` logger, and the +store holds no receipt and no events — `v0.1 §2.1` unchanged. + +#### T63 — `StaticIdentityProvider` warns once +Constructing it logs one warning naming the agent. Resolving one hundred actions logs no further +warning. The count is asserted exactly, not as "≥ 1": a test that checked only for presence would +pass a provider that warned on every call. + +#### T64 — `HeaderIdentityProvider` with a missing header declines +Missing header → `None`. Empty header → `None`. Whitespace-only header → `None`. Present header → +a `Principal` with that agent. Header name matching is case-insensitive. `user_header` present and +absent, and its construction warning, asserted the same way as T63. + +#### T65 — Provider and `context()` precedence, every row +Parametrized over §3.2's table, including both `resolve() → None` rows: with an `authority:` +section loaded, the decline is `ActionDenied(reason="no_principal")` and the executor does not run; +without one, the context fills in and it does. The provider-answers row asserts the resulting +`Principal` **and** that a disagreeing `context()` logged one warning naming both. The +provider-raises row asserts `IdentityError`, that the executor's call count is 0, and that no +receipt was written — three behaviours, because asserting only the exception class cannot tell a +refusal from a refusal that already ran the action. + +#### T65b — `--principal-from-client-info` is gone +`ctrlrun gateway --principal-from-client-info ...` exits non-zero, and stderr names +`--principal-header`. The gateway does not start; no socket is bound. + +#### T65c — A repeated identity header is refused +Through the gateway, with `--principal-header X-Principal`: a request carrying that header twice +returns `-41007`, HTTP 403; the upstream request count is 0; no receipt and no event exist. Repeated +for `--user-header` and for the `--identity-jwt` header. The recorder is checked to prove it would +have seen a forwarded request had one been made. + +### Item 2 — Authority (§4) + +#### T66 — No `authority:` section is v0.2, exactly +`Control.from_file()` on a document with no `authority:` key leaves `Control.authority is None`, +and no `AUTHORITY_*` event is ever appended by any test in the suite. The mechanical half is a +session-scoped assertion, not a re-run of the suite: an autouse fixture records every event type +appended anywhere in the run, and this test asserts the five §7 types appear only in tests that +built an `authority:` section. That is what "every v0.2 test passes unchanged" can actually mean +inside one pytest process — the suite already runs, and a second copy of it would prove nothing the +first does not. + +#### T67 — A principal with no grant is denied +`authority:` present, a grant for a different agent. Then `AuthorityDenied(reason="no_authority")`, +a `denied` receipt with `decision="deny"` and `decision_reason="no_authority"`, no approval request +in the store, and the executor never ran. + +#### T67b — Subject matching, by name +Parametrized over §4.2: a grant naming a user against a principal with none → denied +`no_authority`; against a different user → denied; a grant omitting `user` against a principal that +has one → passes; `agent: "*"` against a dotted agent name; a `subject.agent` prefix wildcard +against a name sharing only the prefix → denied. Each asserts the reason, so deleting the +`user is not None` check fails a test rather than silently letting an agent inherit a human's +grant. + +#### T68 — A matching grant passes to policy +Subject, action, resource and environment all match and the constraints hold. Then +`AUTHORITY_RESOLVED` names the grant, `POLICY_EVALUATED` follows it, and the action executes. + +#### T69 — A failed constraint denies what policy would allow +Policy says `allow`. The grant's `amount_lte` excludes the action. Then +`AuthorityDenied(reason="authority_constraint")` — by reason, so removing the constraint check +cannot pass as `no_authority`. + +#### T69b — Only integers can be bounded +A grant whose constraint operand is a decimal string (`amount_lte: "2000.00"`) is a `PolicyError` +at load whose message names the representation rule (§4.5). No `Control` is constructed. + +#### T70 — Stricter wins, all six cells +Parametrized over authority ∈ {passes, denies} × policy ∈ {allow, approve, deny}, asserting the +combined decision of §4.6's table in every cell and asserting the recorded `decision_reason` names +the axis §4.6 says it should. `Control.evaluate` is asserted to return the same decision as the +one `Control.execute` acts on, in every cell. + +#### T70b — Several matching grants, and order does not matter +Two grants match the same action; the action passes, `AUTHORITY_RESOLVED.grant_id` names one of +them, and swapping the two in the document yields the identical decision and the identical +`grant_id`. + +#### T71 — An expired grant denies +The only matching grant's `expires_at` is in the past under a fake clock. +`AuthorityDenied(reason="authority_expired")`. Advancing the clock to exactly `expires_at` still +passes; one microsecond later does not. + +#### T71b — Reason precedence, collected and fixed +One configuration in which three grants match on shape and fail respectively for +`authority_expired`, `authority_constraint` and `authority_revoked`; the reported reason is +`authority_revoked`. A single grant that fails both by expiry and by constraint reports +`authority_constraint`. Permuting the grants in the document yields the identical reason and the +identical `AUTHORITY_DENIED` data — the only assertion that can catch a sort that fell back to +file order. + +#### T72 — Pattern matching does not cross a separator +Parametrized over §4.4's table: `stripe.*` matches `stripe.refund` and not `stripe.refund.partial`; +`payment:EU-*` matches `payment:EU-42` and not `payment:EU-42:leg2` and not `payment:US-42`; `*` +matches `refund` and not `stripe.refund`; `**` matches everything; `stripe.**` matches +`stripe.refund.partial` and not `stripe`. Plus, each a `PolicyError` at load: `?`, `[abc]`, a +leading `*`, an infix `*`, `a**`, `**a`, a non-final `**`, and — the case §4.4's grammar has to +exclude explicitly — a wildcard segment before a deep wildcard (`stripe.a*.**`, `*.**`). + +#### T72b — Pattern containment, in both directions +Parametrized directly over §5.5's relation, separately from T72's matching relation: `C ⊆ C` for +every well-formed pattern **including** `stripe.**` and `**`; `stripe.refund ⊆ stripe.* ⊆ +stripe.** ⊆ **`; `stripe.* ⊄ stripe.refund`; `stripe.refund.partial ⊄ stripe.*`; `stripe ⊄ +stripe.**`; `payment:EU-42 ⊆ payment:EU-* ⊆ payment:*`; `payment:EU-* ⊄ payment:EU-4*`; prefix +wildcard ⊄ literal. Containment is a different function from matching, with counterintuitive +clauses, and an implementation of it as `fnmatch` or `startswith` passes T75 and T76 while breaking +attenuation. + +#### T73 — A malformed authority section fails at load +Parametrized: `authority:` with no `grants`; `grants` not a list; a grant with no `subject`; +`subject: {}`; a grant with no `actions`; `actions: []`; an unknown key at each of the three levels; +a duplicate `id`; an `id` beginning `dlg_`; a naive `expires_at`; `constraints: {}`; a condition +naming a reserved argument; a `float` operand; `max_delegation_depth: -1`. Each raises `PolicyError` +at load and **no `Control` is constructed** — asserted, not assumed. + +#### T73b — `Subject` refuses in Python what the loader refuses in YAML +`Subject()` and `Subject(agent=None, user=None)` raise `InvalidArgument` (§4.8), so +`Control.delegate` cannot be handed a grant addressed to every principal. + +#### T74 — Authority is evaluated before policy +An action denied by authority produces `ACTION_PROPOSED`, `AUTHORITY_DENIED`, `ACTION_DENIED` and +**no** `POLICY_EVALUATED`, in that order by `event_id`. + +#### T74b — `claims`, `issuer` and `expires_at` are reserved in conditions +`claims_eq:`, `issuer_eq:` and `expires_at_gt:` in a policy rule and in a grant's `constraints` each +raise `PolicyError` at load, with a message telling the author to rename the argument — and in a +`ctrlrun.policy/v1` document as well as a `v3` one, because §4.5's reservation is not schema-gated +and §12.1 says so. + +### Item 3 — Delegation (§5) + +#### T75 — A contained child is accepted +Narrower on every dimension at once. `DELEGATION_CREATED` is appended **and reaches a registered +`EventSink`**, not merely the `events` table; the record is readable from both stores; the id is +the `dlg_`-prefixed one `Control.delegate` minted, and a `Grant` carrying an `id` is refused. An +action within the child's limits passes authority naming the delegation and its depth. Calling +`delegate` twice with the same grant makes two records with different ids. + +#### T75b — Only the parent's subject may delegate +`Control.delegate(..., by=)` raises +`AuthorityEscalation(reason="not_the_subject")`, writes no record, and appends +`DELEGATION_REJECTED`. Including the `user` rule: a parent whose subject names a user is not +satisfied by the same agent acting with no user. Repeated through `ctrlrun delegate --as`, which +additionally records `created_via="cli"` while the API path records `"api"`. + +#### T76 — Each containment dimension, violated alone +Parametrized, one dimension at a time, everything else contained: a wider `actions` pattern; a wider +`resources` pattern; each constraint operator loosened (`lte` raised, `gte` lowered, `eq` changed, +`neq` changed, `in` widened); an `environments` entry the parent lacks; an `expires_at` later than +the parent's; a child subject carrying a wildcard; a child subject dropping the parent's `user`. +Each → `AuthorityEscalation(reason="containment")`, `DELEGATION_REJECTED` whose `data.dimension` +names the row, and no record written. + +#### T76b — The creation-time rules that are not containment +Parametrized over §5.3 rules 1–5, each asserting the reason **by name**, that +`store.get_delegation` returns nothing for the id that was not created, that exactly one +`DELEGATION_REJECTED` was appended, and that `data.dimension` is **absent**: an unknown +`parent_id` → `unknown_parent`; a parent with `delegable: false` → `parent_not_delegable`; an +expired parent, a parent whose own parent is revoked, and one whose own parent is expired → +`parent_not_valid`; a depth beyond the maximum → `max_depth`. Rule 3 has no other test, and +without this one it is a guard whose deletion §5.6 would silently cover. + +#### T77 — Valid at creation, invalid later +A child valid at creation; then the parent's `expires_at` passes under the fake clock. The child +then denies with `authority_escalation`. The delegation record is untouched — the check is at +evaluation, not a rewrite. + +#### T77b — A narrowed parent narrows its children, and a non-delegable one stops them +A child at `amount_lte: 25000` under a parent at `100000`; the document is edited to +`amount_lte: 5000` and reloaded. An action at `10000` then denies with `authority_escalation`, and +one at `4000` passes. Repeated for the parent's `delegable` going `true → false`, which denies via +§5.6 rule 6. Nothing rewrote the delegation record. The live half of T77/T78/T79 runs against one +long-lived `Control` that is never rebuilt, so a re-check that only happened at construction fails +red. + +#### T77c — A deleted parent is not `no_authority` +A depth-2 chain whose root grant is removed from the reloaded document. `AuthorityDenied` with +`reason == "authority_escalation"`, asserted **not** to be `no_authority`, and the +`AUTHORITY_DENIED` event names the missing parent in `data.missing_parent_id`. + +#### T77d — A cyclic or over-deep chain terminates +A `delegations` table mutated directly so that two rows are each other's parent. Evaluation denies +with `authority_escalation` within the §5.5 bound and does not hang; the test bounds its own +iteration so a broken bound fails red rather than timing out CI. + +#### T78 — A revoked parent denies +`ctrlrun revoke` on a mid-chain delegation appends `DELEGATION_REVOKED` **and reaches a registered +sink**. A grandchild's action then denies with `AuthorityDenied(reason="authority_revoked")` — both +asserted: the event at revoke time, the reason at evaluation time. Re-revoking is idempotent and +appends no second event. + +#### T79 — Depth beyond the maximum is rejected +With `max_delegation_depth: 3`, depths 1, 2 and 3 are created and depth 4 raises +`AuthorityEscalation(reason="max_depth")`. Then, with the document lowered to 2 and reloaded, the +existing depth-3 chain denies at evaluation. Depth is asserted to be derived by walking, not read +from the column: a row whose `depth` is edited to 0 still denies. +`max_delegation_depth: 0` refuses every creation. + +#### T80 — The signature scenario +Human grant €100,000 `delegable` → finance agent €25,000 → support agent €2,000. The support agent +requesting €50,000 is denied with `authority_constraint` and the fake remote is never called; +requesting €1,500 passes authority and reaches policy. Two separate creation attempts, because two +different guards refuse them: `finance-agent` delegating €50,000 under its €25,000 parent → +`containment` with `data.dimension == "constraints"`; `support-agent` delegating anything at all → +`parent_not_delegable`. Asserting one would hide the other. + +#### T81 — Omission is not "unlimited" +A parent with `amount_lte: 25000`; a child with **no** `amount` constraint. The delegation is +**rejected at creation** — not accepted-and-inherited, not accepted-and-unconstrained. Repeated for +`resources`, `environments`, `expires_at`, and the parent's `user`. The `else` branch, in which the +delegation was created, fails the test with the delegation id it should not have got. + +### Item 4 — Observe mode (§6) + +#### T82 — Observe executes what enforce would deny +`mode: observe`, an action the policy denies. The executor runs, `Control.execute` returns rather +than raising, the receipt is `result="observed"`, `execution="committed"`, +`would_have.decision="deny"`. The same configuration under `mode: enforce` raises `ActionDenied` and +the executor does not run — both halves in one test, so "observe executes" cannot pass by the action +having been allowed. + +#### T82b — What observe mode still refuses +Parametrized over §6.2's falsifiable rows under `mode: observe`: `no_principal`, a raising identity +provider, `effect_key_error`, `unrepresentable_argument` through the gateway, and an escalating +`Control.delegate`. Each raises its enforce-mode exception, the executor's call count is 0, and the +receipt-and-event shape matches the enforce-mode one; the delegation row additionally asserts no +row was written. Without this test, one `if observe:` wrapped around the execute path passes every +other observe test while violating the whole table. + +#### T82c — Observe mode asks no human +`mode: observe` with a policy rule reaching `approve`. The executor ran exactly once, +`result == "observed"`, `would_have.blocked_reason == "approval_required"`, the store holds zero +approval requests, and no `APPROVAL_REQUESTED` was appended. The `else` branch — `ApprovalRequired` +having been raised — fails the test. + +#### T83 — A duplicate is recorded and still runs +Two executions of the same effect key in observe mode. The second has +`would_have.blocked_reason="duplicate"` and still executes; the fake remote's call count is 2. The +effect record is unchanged by the second attempt — same `attempt`, same state, same `action_id`. +Repeated with the prior record in `AMBIGUOUS`: the second attempt executes, +`would_have.blocked_reason == "ambiguous"`, and the record is **still `AMBIGUOUS`** afterwards, with +the `else` branch failing the test. Repeated with a `reconcile` hook installed, asserting the hook +was never called. + +#### T84 — `mode` is top-level only +`mode:` inside an action entry, inside a rule, inside a grant, inside `authority:`, and in an +`--authority` document each raise `PolicyError` at load naming the path. `mode: true`, `mode: "off"` +and `mode: 0` each raise `PolicyError`. An absent `mode` is `enforce`. + +#### T85 — The banner, and `ctrlrun verify` +Parametrized over §6.5's two columns **by name**: each command in the left column prints +`OBSERVE MODE — nothing is enforced` to stderr under an observe configuration and prints nothing of +the sort under an enforce one; each command in the right column prints it under neither, and still +works when no policy file exists at all. `--json` stdout stays parseable. `ctrlrun verify` exits +**2** in both modes with a message naming v0.4. + +#### T86 — `ctrlrun stats` +Counts would-have-denied (broken down by reason), would-have-needed-approval, would-have-been- +blocked (by duplicate and by ambiguous), and ambiguous outcomes, from a store seeded with known +receipts. In enforce mode the same store reports denials and ambiguous outcomes and omits the +duplicate/ambiguous breakdown, with the footer saying so. `--since` with an absolute timestamp and +with `24h` each filter on `finished_at`, and a receipt exactly on the boundary is included; `7d` +and `30m` parse, and `1w`, `2mo` and a bare `5` exit non-zero naming the accepted forms. `--json` +matches the human output number for number. An empty store prints zeros and exits 0. No network is +reachable during the test. + +#### T87 — Observe → enforce needs no migration +A store written in observe mode, then the same store used by a `Control` built from the same file +with `mode: enforce`. Every table opens, the effect committed under observe is refused as a +duplicate under enforce, and the delegations created under observe still evaluate. + +### Item 5 — JWT identity and the gateway (§3.4, §8) + +Keys are generated in the test; nothing reaches the network. The JWKS is served from a local +fixture, and the static-key cases assert that no outbound connection is attempted. + +#### T88 — A valid token becomes a Principal +Signed with a locally generated key, `exp` in the future, correct `iss`, `aud` and `typ`. The +resulting `Principal` carries the mapped `agent`, `user`, `issuer` and `expires_at`, and exactly +the claims named in `claim_names` — no more, asserted by the mapping's key set. Repeated with `aud` +as a single string and as an array containing the configured audience among others. + +#### T88b — Configuration is refused before any token is seen +Parametrized over §3.4's construction rules, each `InvalidArgument`: zero, two and three key +sources; `algorithms=["HS256"]` with `public_key`; `algorithms=["HS256"]` with `jwks_url`; +`algorithms=["RS256"]` with `secret`; an empty `algorithms`; `algorithms=["none"]` and `["NONE"]`; +`token_type` not passed. The `HS*`-with-`public_key` case is the one that matters: it is the +configuration end of key confusion, and the token end is already covered by the allow-list, so a +test that only exercised the token end would prove nothing the library was not already doing. + +#### T89 — Every invalid token is refused, by cause +Parametrized, each asserting `IdentityError` **and** a distinguishing observable — the message +token, and through the gateway an upstream request count of 0: expired; `nbf` in the future; wrong +`aud`; a configured audience that is a substring of an `aud` value but not an element of it; wrong +`iss`; bad signature; `alg: none`; an `alg` outside the configured list; a `typ` that is not the +configured `token_type`; an **OIDC ID token** from the same issuer and key whose `aud` is the +configured audience and whose `typ` is `JWT` rather than `at+jwt` — the cross-JWT confusion case, +which passes every other check; a token with no `exp`; a token whose `agent_claim` is missing, +empty, or not a string. + +#### T90 — JWKS handling +A token with an unknown `kid` triggers exactly one fetch; still unknown → `IdentityError`. A second +such token inside `jwks_min_refresh_interval` triggers **no** fetch and is refused. After the +interval, one more fetch. The fetch count is asserted at every step against an injected clock, and +the loop is bounded so a broken rate limit fails red rather than hanging. Plus: a JWK Set with two +entries sharing a `kid` refuses the token; a key with `use: enc` is ignored and a token signed with +it is refused; a key whose own `alg` excludes the token's is refused even when the configured +allow-list admits it; a failed fetch is `IdentityError` and the previously cached keys are not +discarded. + +#### T91 — The gateway enforces authority, distinguishably +A `tools/call` outside the principal's grant returns JSON-RPC `-41012` `ctrlrun.unauthorized` with +`data.reason="no_authority"`, HTTP 403, upstream request count 0, and a `denied` receipt. The same +gateway and the same `authority:` section, with a call that a grant matches but a **policy rule +denies**, returns `-41001` `ctrlrun.denied` — so widening the `AuthorityDenied` handler to +`ActionDenied`, or ordering the two `except` clauses the other way, fails a test rather than +returning a plausible code. A call inside the grant reaches the upstream. + +#### T91b — The gateway's approval gate uses the combined decision +Policy `allow`, authority passing, a policy rule reaching `approve` for a larger amount: the +approval flow of `v0.2 §6.10` works unchanged through the gateway with an `authority:` section +loaded — first call `-41002`, `ctrlrun approve`, re-send executes exactly once — proving the +pre-check was not left reading `Policy.evaluate` in isolation. The ACS hook is asserted the same +way. + +#### T91c — In observe mode the gateway forwards everything +The same gateway under `mode: observe`: a call outside the grant returns the upstream's response, +not `-41012`; the upstream request count is 1; the receipt carries +`would_have.blocked_reason="no_authority"`. + +#### T92 — The extra says so when it is missing +In a subprocess whose `sys.modules` blocks `jwt`, `import ctrlrun` succeeds and no +`opentelemetry`, `httpx` or `jwt` module is imported; constructing `JWTIdentityProvider` raises +`MissingDependency` whose message contains `pip install 'ctrlrun[identity]'`. T30's assertion list +grows to include `jwt`. + +### Item 6 — Demo, examples, docs + +#### T93 — Five scenarios, five refusals +`ctrlrun demo` exits 0, prints five scenario headings and five `BLOCKED` lines, runs in under a +second with no network, and writes receipts for all five. Every example under `examples/`, +including the two new ones, exits 0 offline, keeps its state under its own directory, is +repeatable, and has every file it needs tracked by git (`v0.2 §10 T31` extended). + +--- + +## 11. Public API and CLI additions (frozen for v0.3) + +```python +# ctrlrun/__init__.py — added +from .identity import ( + IdentityContext, + IdentityProvider, + StaticIdentityProvider, + HeaderIdentityProvider, +) +from .authority import Authority, Grant, Subject, Delegation, AuthorityResult +from .policy import Condition, parse_conditions # promoted; see below +from .state import DelegationRecord +from .errors import AuthorityDenied, AuthorityEscalation, IdentityError +# lazily importable, not re-exported at package import: +# ctrlrun.jwt_identity.JWTIdentityProvider (ctrlrun[identity]) +# ctrlrun.otel.OTelEventSink (ctrlrun[otel]) +# ctrlrun.gateway.serve (ctrlrun[gateway]) +``` + +```python +@dataclass(frozen=True) +class Principal: # extended, v0.1 §2.1 + agent: str + user: str | None = None + claims: Mapping[str, str | int | bool] = _NO_CLAIMS + issuer: str | None = None + expires_at: datetime | None = None + +Control(..., identity: IdentityProvider | None = None, + authority: Authority | None = None) +Control.authority -> Authority | None +Control.delegate(parent_id: str, grant: Grant, *, by: Principal) -> Delegation +Control.revoke(delegation_id: str, *, by: str | None = None) -> None + +Authority.from_yaml(text, *, source="") -> Authority +Authority.grants -> Mapping[str, Grant] +Authority.max_delegation_depth -> int +Authority.evaluate(action, *, now, store) -> AuthorityResult +Authority.plan_delegation(parent_id, grant, *, by, store, now) -> Delegation +Authority.plan_revocation(delegation_id, *, by, store, now) -> Delegation + +Policy.mode -> Literal["observe", "enforce"] + +StateStore.put_delegation(record: DelegationRecord) -> None +StateStore.get_delegation(delegation_id: str) -> DelegationRecord | None +StateStore.delegations_for(parent_id: str) -> tuple[DelegationRecord, ...] +StateStore.delegations(*, include_revoked: bool = False) -> tuple[DelegationRecord, ...] +StateStore.revoke_delegation(delegation_id: str, *, by: str | None, at: datetime) -> bool +``` + +**Promoted.** `policy._Condition` becomes `policy.Condition`, and the parser behind a `when:` +mapping becomes `policy.parse_conditions(mapping, *, where: str) -> Mapping[str, Condition]`, +keyed by the raw condition key. `Grant` is public and `Grant.constraints` is made of them, so the +types have to be nameable; and §4.5 requires authority and policy to share one condition +evaluator rather than growing a second. `Condition` keeps `argument`, `op`, `operand` and +`matches`; nothing about its behaviour changes. + +**`DelegationRecord` is the store's row, `Delegation` is the parsed grant.** `state.py` reads and +writes the columns of §5.2 with `grant_json` as a string, and `authority.py` parses a `Grant` out +of it. Stated because the obvious alternative — typing the store on `Delegation` — would make +`state.py` import `authority.py` and transitively `policy.py`, which is the edge +`ARCHITECTURE.md` §6 puts in `state.py`'s "must not know about" column. `delegations()` exists +because §5.6 walks **upward** from a delegation to its root, and a chain whose root grant has been +deleted from the document cannot be found by walking downward from the ids that are still in it. + +**Changed.** + +- `Control.evaluate(action)` returns the **combined** §4.6 decision, not the policy axis alone. + Its signature and `Evaluation`'s two fields are unchanged; it reads the store and still writes + nothing. Everything that pre-checks a decision — the gateway's `tools/call` path, + `ctrlrun.acs`'s request hook — MUST use it or the same combination, never `Policy.evaluate` in + isolation (§8.3). +- `Event.action_id` becomes `str | None` (§7); the three delegation events carry `None`, and + `OTelEventSink` emits a standalone span for them rather than dropping them. +- `Receipt` gains `execution` and `would_have`, and its `principal` object gains `claims`, + `issuer` and `expires_at` (§12.2). `ReceiptResult` gains `OBSERVED = "observed"`. +- The Action serialized into the store carries the full principal (§2.4). +- `RESERVED_ARGUMENTS` gains `claims`, `issuer` and `expires_at` (§4.5) — a **breaking** change + for a protected function taking an argument by one of those names, in documents of every + schema version (§12.1). + +**Removed.** `ctrlrun gateway --principal-from-client-info`, and the +`GatewayConfig.principal_from_client_info` field behind it (§8.1). + +Five event types join the closed set: `AUTHORITY_RESOLVED`, `AUTHORITY_DENIED`, +`DELEGATION_CREATED`, `DELEGATION_REVOKED`, `DELEGATION_REJECTED` (§7). + +One JSON-RPC error code joins the frozen list of `v0.2 §11`: **`-41012` `ctrlrun.unauthorized`**, +with `-41001`'s `data` shape, and `v0.2 §6.10`'s `DENY` row narrowed to policy denials (§8.3). + +Errors: + +```python +class IdentityError(CTRLRunError): ... # a credential was offered and rejected +class AuthorityDenied(ActionDenied): ... # reason: §4.3; grant_id, delegation_id +class AuthorityEscalation(CTRLRunError): ... # reason: §5.3; a delegation that may not exist +``` + +`AuthorityDenied` subclasses `ActionDenied`: an authority denial *is* the action being denied, and +an agent loop's existing `except ActionDenied` should keep working. `IdentityError` and +`AuthorityEscalation` subclass `CTRLRunError` directly, for `v0.1 §5.1`'s reason: neither is a +policy saying no, and neither should be swallowed by a handler written for one. `IdentityError` is +raised where a credential was offered and found wanting; a *missing* principal stays +`ActionDenied(reason="no_principal")`, unchanged since v0.1. The two reason vocabularies — +`AuthorityDenied`'s evaluation reasons and `AuthorityEscalation`'s creation reasons — are disjoint +and are never used interchangeably (§5.3). + +CLI: + +``` +ctrlrun stats [--since ISO|30m|24h|7d] [--json] +ctrlrun delegate --parent GRANT_ID --file GRANT_YAML --as AGENT[/USER] [--json] +ctrlrun revoke DELEGATION_ID [--by WHO] +ctrlrun verify # exits 2 until v0.4 + +ctrlrun gateway ... + [--authority PATH] + [--identity-jwt (--identity-jwt-jwks-url URL + | --identity-jwt-public-key PATH + | --identity-jwt-secret-file PATH)] + [--identity-jwt-algorithms ALG]... [--identity-jwt-issuer ISS] + [--identity-jwt-audience AUD] [--identity-jwt-token-type TYP] + [--identity-jwt-header NAME] [--identity-jwt-agent-claim NAME] + [--identity-jwt-user-claim NAME] [--identity-jwt-claim NAME]... + [--identity-jwt-leeway SECONDS] [--identity-jwt-jwks-min-refresh SECONDS] + # --principal-from-client-info: REMOVED (§8.1) +``` + +Extras in `pyproject.toml` gain `identity`. `dependencies` is unchanged: `pyyaml` and `click`. + +**Module map** (`ARCHITECTURE.md` §6) gains three rows, and the dependency direction is unchanged +— downward only, with `Control` the only module that composes the others: + +| Module | Owns | Must not know about | +|---|---|---| +| `identity.py` | `IdentityProvider`, `IdentityContext`, static and header providers | policy, authority, storage | +| `authority.py` | `Grant`, `Subject`, `Authority`, matching, containment, delegation planning | approvals, effect state, executors, sinks | +| `jwt_identity.py` | `JWTIdentityProvider` — `ctrlrun[identity]`, lazy | everything but `identity.py` | + +`authority.py` imports the condition parser and evaluator from `policy.py` (§4.5), which is the +same exception `v0.2` made for `policy.py` importing the template grammar from `effect.py`: the +alternative is a second copy of security-critical code. `policy.py` does not import +`authority.py`, so there is no cycle, and policy still cannot see a principal (§4.7). +`authority.py` reads the store through the `StateStore` protocol and **writes nothing and appends +nothing** — `Control` does both (§4.8) — so it does not become a second composer. + +--- + +## 12. Schema versions + +### 12.1 `ctrlrun.policy/v3` + +`authority:` and `mode:` require `schema: ctrlrun.policy/v3`. A `v1` or `v2` document using either +is a `PolicyError` naming the key and the required schema — the exact shape `v0.2 §3.1` uses for +`effect:` under `v1`, and for the same reason: a reader that ignored an `authority:` section would +run every action with no authority check at all, and a reader that ignored `mode: observe` would +enforce a configuration that was deployed to observe. The schema string is the only thing standing +between those outcomes. + +v0.3 loads `ctrlrun.policy/v1`, `v2` and `v3`. A `v1` file loads as it did in v0.1 and a `v2` file +as it did in v0.2, **with one exception, and it is not schema-gated**: a condition naming `claims`, +`issuer` or `expires_at` is refused at load in a document of any version (§4.5). The reservation +lives in the condition-key splitter, which runs for every condition in every document, and gating +it on `v3` would leave the same name meaning two things in two files — which is the ambiguity +`v0.1 §3.2` refuses in the first place. The cost is real and is stated rather than hedged: a +`v1` policy whose protected function takes an argument called `claims` stops loading under v0.3, +and `Control` cannot be constructed until the argument is renamed. The load error says so. T74b +asserts it against a `v1` document, so the breakage is a tested decision rather than a surprise. + +The top-level key set grows to `schema`, `actions`, `mode`, `authority`, and stays closed. An +`--authority` document has its own, smaller closed set (§8.3). + +`ctrlrun init` keeps writing `v1`: the smallest working policy is still the right starting point, +and adding authority means changing the schema line, which is a visible act of opting in. + +### 12.2 `ctrlrun.receipt/v2` + +The receipt schema bumps. `v0.2` deliberately did not bump it — nothing changed. v0.3 changes two +things a reader can trip over: + +- **`result` gains a value.** `"observed"` is not in `v0.1 §6.1`'s set. That alone is worth a + version. +- **New fields**: `execution`, `would_have`, and `principal.claims` / `principal.issuer` / + `principal.expires_at`. + +Every field of `ctrlrun.receipt/v1` is present in `v2` with the same meaning. Receipts already +written keep their `v1` tag; the schema string is per document, and nothing rewrites history. + +**What actually breaks, stated without the hedge.** "A reader that tolerates unknown keys and +unknown `result` values needs no change" is true and nearly useless, because the most important +reader does not tolerate them: `Receipt.from_dict` parses `result` into a closed `StrEnum`, and +`SQLiteStateStore` reads every stored receipt through it. So: + +- A CTRLRun **≤ 0.2** process running `ctrlrun receipts` or `ctrlrun inspect` against a store or + JSONL file that a 0.3 **observe-mode** process wrote raises on the unknown `result` value. Two + processes sharing one store is the intended deployment (`v0.2 §6.1`), so this is not a corner + case: **upgrade every reader before switching any writer to `mode: observe`.** An enforce-mode + 0.3 writer emits no `observed` receipt and is safe to mix. +- An external reader that keys on `result` must read `execution` as well, or it counts every + observed execution as if nothing ran. + +`ctrlrun.action/v1` is **unchanged** (§2.2). `ctrlrun.inspection/v1` becomes `v2`, gaining the +principal's issuer, expiry and claim names and an `authority` block carrying the +`AUTHORITY_RESOLVED` / `AUTHORITY_DENIED` data for the action; it ships with item 1 and item 2 +respectively. `ctrlrun.stats/v1` is new (§6.4). + +--- + +## 13. Explicitly out of scope for v0.3 + +Everything in `v0.1 §9` and `v0.2 §12` that v0.3 does not deliver, and specifically: + +- **Issuing anything.** No token minting, no OAuth flows, no authorization server, no dynamic + client registration, no token exchange, no introspection, no revocation lists. v0.3 verifies + what it is handed (§1.1). +- **A revocation channel for a credential.** A verified token is valid until its `exp`, which is + why one without an `exp` is refused. Shared-signals mechanisms exist and v0.3 implements none of + them (§3.4). +- **Multi-tenant, tenant-templated issuers.** `issuer` is an exact string (§3.4). +- **Matching a grant on a claim.** Subjects address `agent` and `user` (§4.2). Claim-based + subjects need an answer to "what does a missing claim mean" and a claim-name convention; + neither exists yet. +- **Authority raising a decision.** A grant permits or it does not; it cannot require an approval + that policy did not (§4.2). That needs a gateway story (§8.3) and a second look at the README + claim §4.7 defends. +- **Hot reload.** An `Authority` is built when the document is loaded. Editing the file takes + effect on the next load; there is no `SIGHUP`, no re-stat, no TTL (§5.6). +- **Runtime revocation of a root grant.** `ctrlrun revoke` covers delegations. A root grant is + withdrawn by editing the document and reloading (§5.6). +- **Unrevoking**, and **listing delegations**: no `ctrlrun delegations`, no UI, no dashboard + (§5.7, §7). +- **Authenticating the delegator.** `ctrlrun delegate --as` is an assertion, recorded as one + (§5.7) — the same position `v0.1 §4.1` takes on the approver, who is also still unauthenticated. +- **Data scope.** Authority governs *which actions on which resources*, not which records, fields + or purposes. That is `VISION.md` §5's resource/data scope and it is not v0.3. +- **Consequence taxonomy, the CONTROL registry, separation of duties, multi-approver workflows, + M-of-N, break-glass.** Unchanged from v0.2. +- **Authority propagation across agent hops / A2A.** A delegation is created deliberately and + recorded; it is not derived from a protocol hop. That is v0.7. +- **`ctrlrun verify`.** A stub that exits 2 (§6.5), and nothing more, until v0.4. +- **A structured `blocked_reason` on enforce-mode receipts** (§6.4). +- **Migrations.** v0.3 adds one new table and no column to any existing one, on purpose (§5.2). A + schema migration story is still v0.6. +- **Multi-host reservation, Postgres, signed receipts, framework adapters, compensation.** + Unchanged. +- **Any compliance or standards claim**, including mapping tables, badges, and "aligned with" + language, in this document, the README, docstrings or CLI output (§1.1). +- Anything in `VISION.md` beyond §5's authority-grant shape, which is design input here and + nothing more. diff --git a/pyproject.toml b/pyproject.toml index 02fe523c..fd50692a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "ctrlrun" -version = "0.2.0" +version = "0.3.0.dev0" description = "Make consequential AI-agent actions safe to execute." readme = "README.md" requires-python = ">=3.11" @@ -56,6 +56,14 @@ otel = [ "opentelemetry-sdk>=1.20", "opentelemetry-exporter-otlp-proto-http>=1.20", ] +# SPEC-v0.3 §3.4 — `JWTIdentityProvider` is the only part of the authority model that needs a +# third party: verifying somebody else's signature. Everything else — grants, containment, +# delegation, observe mode — is stdlib and the YAML parser that is already a dependency. +# +# Empty until build-list item 5, which adds the verifier and the `pyjwt` line it needs. The +# extra is declared now so `pip install 'ctrlrun[identity]'` names something real for the whole +# of the 0.3 development series, and so CI installs it without a second edit. +identity = [] [tool.setuptools.packages.find] where = ["src"] From 93605e49ce408a0a2b3205d1d180dde6025e8734 Mon Sep 17 00:00:00 2001 From: arpan Date: Fri, 4 Sep 2026 10:22:29 +0530 Subject: [PATCH 2/3] spec: format the v0.3 code blocks ruff format reformats Python code blocks inside Markdown, and the v0.3 blocks are valid Python, so it does. The v0.1 and v0.2 spec blocks escape it only because they carry unparseable placeholders (action_id: str = ), which is luck rather than a convention. Aligned trailing comments go; nothing else changes. --- docs/SPEC-v0.3.md | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/docs/SPEC-v0.3.md b/docs/SPEC-v0.3.md index 6508bcf2..4798e1bd 100644 --- a/docs/SPEC-v0.3.md +++ b/docs/SPEC-v0.3.md @@ -292,15 +292,16 @@ invention, not as an implementation of anything. renamed. ```python -_NO_CLAIMS: Final = MappingProxyType({}) # shared; a dataclass default must be immutable +_NO_CLAIMS: Final = MappingProxyType({}) # shared; a dataclass default must be immutable + @dataclass(frozen=True) class Principal: - agent: str # required, e.g. "refund-agent" - user: str | None = None # human on whose behalf, if any - claims: Mapping[str, str | int | bool] = _NO_CLAIMS # verified, from the provider (§3) - issuer: str | None = None # who verified it, e.g. a JWT `iss` - expires_at: datetime | None = None # when the credential stops being valid + agent: str # required, e.g. "refund-agent" + user: str | None = None # human on whose behalf, if any + claims: Mapping[str, str | int | bool] = _NO_CLAIMS # verified, from the provider (§3) + issuer: str | None = None # who verified it, e.g. a JWT `iss` + expires_at: datetime | None = None # when the credential stops being valid ``` `Principal` stays `frozen=True` and stays **unhashable**, as it is today: `Mapping` is not @@ -2386,7 +2387,7 @@ from .identity import ( HeaderIdentityProvider, ) from .authority import Authority, Grant, Subject, Delegation, AuthorityResult -from .policy import Condition, parse_conditions # promoted; see below +from .policy import Condition, parse_conditions # promoted; see below from .state import DelegationRecord from .errors import AuthorityDenied, AuthorityEscalation, IdentityError # lazily importable, not re-exported at package import: @@ -2469,9 +2470,13 @@ with `-41001`'s `data` shape, and `v0.2 §6.10`'s `DENY` row narrowed to policy Errors: ```python -class IdentityError(CTRLRunError): ... # a credential was offered and rejected -class AuthorityDenied(ActionDenied): ... # reason: §4.3; grant_id, delegation_id -class AuthorityEscalation(CTRLRunError): ... # reason: §5.3; a delegation that may not exist +class IdentityError(CTRLRunError): ... # a credential was offered and rejected + + +class AuthorityDenied(ActionDenied): ... # reason: §4.3; grant_id, delegation_id + + +class AuthorityEscalation(CTRLRunError): ... # reason: §5.3; a delegation that may not exist ``` `AuthorityDenied` subclasses `ActionDenied`: an authority denial *is* the action being denied, and From ede2a3c41b74b181705767ef5e7d25026eeda4ee Mon Sep 17 00:00:00 2001 From: arpan Date: Fri, 4 Sep 2026 10:39:28 +0530 Subject: [PATCH 3/3] =?UTF-8?q?spec:=20close=20two=20holes=20the=20review?= =?UTF-8?q?=20pass=20found=20in=20v0.3=20=C2=A74.6=20and=20=C2=A75.4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both are defects this document introduced, not carried over. §4.6 contradicted itself on which grant is named when several match. It said the action "passes on the first that does" -- order-dependent -- and then said the result MUST NOT depend on the order grants appear in the document, with T70b asserting an identical grant_id after permuting the file. Both cannot hold. Any matching grant authorizes the action, so which one is named needs its own rule, and evidence must not turn on incidental file order for the same reason §4.3 fixes the deny-reason precedence. AuthorityResult.grant_id is now the matching grant whose id sorts first by codepoint, which is a property of the set rather than of the document. §5.4's subject row banned a wildcard in a delegation's subject but did not require agent to be present, and §4.2 makes an absent subject.agent match any agent. So a child of {agent: finance-agent, user: alice} could declare {user: alice@example.com} with no agent key, carry no wildcard, satisfy the user rule, pass containment, and hand the grant to every agent acting for alice. That is the grantee-population widening the row was added to prevent, reached by omission instead of by an asterisk -- and §5.4's own rule already answers it: omission is never unlimited. A delegation's subject must now declare a concrete agent. Also: §9 gained the row for §5.6 rule 3, an expired ancestor, which T77 tests and the table had no line for; and §6.3 said blocked_reason may be one of §4.3's authority reasons, which since authority_grant joined that set would have admitted a pass reason into a field that records what stopped an action. T76 and T81 gain the omitted-agent case; T70b asserts the sorted id rather than whichever grant the implementation happened to reach first. --- docs/SPEC-v0.3.md | 60 +++++++++++++++++++++++++++++------------------ 1 file changed, 37 insertions(+), 23 deletions(-) diff --git a/docs/SPEC-v0.3.md b/docs/SPEC-v0.3.md index 4798e1bd..9dc62c42 100644 --- a/docs/SPEC-v0.3.md +++ b/docs/SPEC-v0.3.md @@ -1047,12 +1047,15 @@ gateway's approval path (§8.3), and `ctrlrun.acs`'s hook. Reading `Policy.evalu a decision is a defect after v0.3, and the two places in shipped code that do it are named in §8.3 so item 5 cannot miss them. -**Where several grants match**, the action passes on the first that does; a grant is a -permission, and holding two permissions must never be worse than holding one. Denial arises from -the absence of a matching grant, never from the presence of a stricter one. -`AuthorityResult.grant_id` names the grant that produced the pass, and the result MUST NOT depend -on the order grants appear in the document — evaluation over a set of matching grants has one -answer, and T70b pins it by permuting the file. +**Where several grants match**, the action passes; a grant is a permission, and holding two +permissions must never be worse than holding one. Denial arises from the absence of a matching +grant, never from the presence of a stricter one. + +Which grant is *named* then needs a rule, because any of them authorizes the action and evidence +must not turn on incidental file order — the same standard §4.3 applies to deny reasons. +`AuthorityResult.grant_id` is therefore the matching grant whose `id` sorts first by simple +codepoint order, which is a property of the set rather than of the document. Reordering the +grants in the file changes neither the decision nor the id, and T70b pins both by permuting it. ### 4.7 What does not change @@ -1266,7 +1269,7 @@ testable and separately mutable; T76 breaks each one alone. | Dimension | Child is contained iff | |---|---| -| `subject` | The child's subject contains **no wildcard**, and where the parent declares `user`, the child MUST declare a `user` that the parent's pattern matches. The child's `agent` is otherwise unconstrained | +| `subject` | The child MUST declare a concrete `agent` — present, and containing no wildcard — and, where the parent declares `user`, a concrete `user` the parent's pattern matches. Which concrete agent it names is otherwise unconstrained | | `actions` | **Every** child pattern is contained in **some** parent pattern (§5.5) | | `resources` | Parent omits → child may declare anything, or omit. Parent declares → child MUST declare, and every child pattern is contained in some parent pattern | | `constraints` | For **every** `(argument, op)` the parent declares, the child declares the **same** `(argument, op)` with an operand at least as strict. The child may add constraints the parent does not have | @@ -1282,15 +1285,22 @@ because two things that look like "naming the grantee" are actually widening: human attached — §4.2 spends a paragraph making that impossible for the parent, and one delegation would undo it. So where the parent names a user, the child must too, and the child's user pattern must be one the parent's admits. Omission is rejection, like every other omission. -- *A wildcard grantee.* `subject: {agent: "*"}` in a delegation hands the parent's authority to - every agent in the deployment — and, since §5.3 rule 4 checks the *child's* subject for the next - hop, hands every one of them the right to delegate it onward. `max_delegation_depth` bounds - chain length, not grantee population, and nothing else would bound it. A wildcard subject is an - operator's decision in a reviewed file (§4.2); it is exactly what a runtime-created grant must - not be able to write. - -What is *not* constrained is the child's `agent` name. It may be any concrete agent, which is the -whole point of the chain in §5.6. +- *An unbounded grantee.* `subject: {agent: "*"}` in a delegation hands the parent's authority + to every agent in the deployment — and, since §5.3 rule 4 checks the *child's* subject for the + next hop, hands every one of them the right to delegate it onward. `max_delegation_depth` + bounds chain length, not grantee population, and nothing else would bound it. A wildcard + subject is an operator's decision in a reviewed file (§4.2); it is exactly what a + runtime-created grant must not be able to write. + + **Omitting `agent` is the same escalation spelled differently**, and it is why the row demands + the key rather than merely banning the character. §4.2 makes an absent `subject.agent` match + *any* agent, so a child of `{agent: finance-agent, user: alice}` declaring only + `{user: alice@example.com}` carries no wildcard, satisfies the `user` rule, and still hands + the grant to every agent acting for alice. §5.4's own rule decides it: omission is never + "unlimited". + +What is *not* constrained is *which* concrete agent the child names. It may be any of them, +which is the whole point of the chain in §5.6. **Operand strictness**, per operator, for the `constraints` row: @@ -1633,7 +1643,7 @@ prevents. It is in the table because a reader will look for it. - **`would_have.blocked_reason`** is `null` when the action would have run unimpeded, and otherwise names what would have stopped it: `approval_required`, `duplicate`, `in_progress`, `ambiguous`, `approval_mismatch`, `principal_expired`, `unknown_action`, `no_matching_rule`, or - one of §4.3's authority reasons. It is separate from `decision` because "the policy said allow + one of §4.3's five **deny** reasons — never `authority_grant`, which records a pass. It is separate from `decision` because "the policy said allow and the effect was already committed" is a real and common answer, and a single field could not hold both halves. @@ -1957,6 +1967,7 @@ is individually configurable**, and there is no flag that makes any single one o | `authority:` present, every matching grant expired | `AuthorityDenied(reason="authority_expired")` | ⚠ | | Delegated grant no longer contained in its parent | `AuthorityDenied(reason="authority_escalation")` | ⚠ | | A delegation in the chain is revoked | `AuthorityDenied(reason="authority_revoked")` | ⚠ | +| An ancestor of a delegated grant has expired | `AuthorityDenied(reason="authority_escalation")` (§5.6 rule 3) | ⚠ | | A parent grant has been deleted from the document | `AuthorityDenied(reason="authority_escalation")`, `missing_parent_id` | ⚠ | | An ancestor is no longer `delegable` | `AuthorityDenied(reason="authority_escalation")` (§5.6 rule 6) | ⚠ | | A chain walk revisits a delegation, or exceeds the depth bound | `AuthorityDenied(reason="authority_escalation")` (§5.5) | ⚠ | @@ -1964,7 +1975,7 @@ is individually configurable**, and there is no flag that makes any single one o | Delegation creation: parent unknown / not `delegable` / not valid | `AuthorityEscalation` naming which (§5.3) | | | Delegation creation: creator is not the parent's subject | `AuthorityEscalation(reason="not_the_subject")` | | | Delegation creation: depth would exceed the maximum | `AuthorityEscalation(reason="max_depth")` | | -| Delegation creation: child subject carries a wildcard, or drops the parent's `user` | `AuthorityEscalation(reason="containment")`, `dimension="subject"` | | +| Delegation creation: child subject carries a wildcard, omits `agent`, or drops the parent's `user` | `AuthorityEscalation(reason="containment")`, `dimension="subject"` | | | A stored delegation whose `grant_json` no longer parses | `AuthorityDenied`; never a grant with a field defaulted (§5.2) | ⚠ | | `authority:` present but malformed, or `grants` missing | `PolicyError` at load; no `Control` | | | A pattern outside §4.4's grammar | `PolicyError` at load | | @@ -2104,9 +2115,10 @@ the axis §4.6 says it should. `Control.evaluate` is asserted to return the same one `Control.execute` acts on, in every cell. #### T70b — Several matching grants, and order does not matter -Two grants match the same action; the action passes, `AUTHORITY_RESOLVED.grant_id` names one of -them, and swapping the two in the document yields the identical decision and the identical -`grant_id`. +Two grants match the same action; the action passes and `AUTHORITY_RESOLVED.grant_id` is the +one whose `id` sorts first (§4.6), not the one written first. Swapping the two in the document +yields the identical decision and the identical `grant_id` — the only assertion that can catch +an implementation that returned whichever grant it happened to reach first. #### T71 — An expired grant denies The only matching grant's `expires_at` is in the past under a fake clock. @@ -2179,7 +2191,8 @@ additionally records `created_via="cli"` while the API path records `"api"`. Parametrized, one dimension at a time, everything else contained: a wider `actions` pattern; a wider `resources` pattern; each constraint operator loosened (`lte` raised, `gte` lowered, `eq` changed, `neq` changed, `in` widened); an `environments` entry the parent lacks; an `expires_at` later than -the parent's; a child subject carrying a wildcard; a child subject dropping the parent's `user`. +the parent's; a child subject carrying a wildcard; a child subject omitting `agent` altogether; +a child subject dropping the parent's `user`. Each → `AuthorityEscalation(reason="containment")`, `DELEGATION_REJECTED` whose `data.dimension` names the row, and no record written. @@ -2239,7 +2252,8 @@ different guards refuse them: `finance-agent` delegating €50,000 under its € #### T81 — Omission is not "unlimited" A parent with `amount_lte: 25000`; a child with **no** `amount` constraint. The delegation is **rejected at creation** — not accepted-and-inherited, not accepted-and-unconstrained. Repeated for -`resources`, `environments`, `expires_at`, and the parent's `user`. The `else` branch, in which the +`resources`, `environments`, `expires_at`, the parent's `user`, and the child's own `agent` — +the last two being the cases where an omitted key means "anyone" rather than "nothing". The `else` branch, in which the delegation was created, fails the test with the delegation id it should not have got. ### Item 4 — Observe mode (§6)