diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0b3cc24..10ffcd5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -120,12 +120,12 @@ jobs: set -eu echo "authority: $AUTHORITY ($AUTHORITY_NA not applicable)" echo "templates: $TEMPLATES ($TEMPLATES_NA not applicable)" - test "$AUTHORITY" = "verified 11/11" + test "$AUTHORITY" = "verified 13/13" # G13 is N/A on SQLite, the action's default store: SQLite has no clock of its own - # to diverge from (SPEC-v0.7 §8.9). + # to diverge from (SPEC-v0.7 §8.9). G16 is graded on both documents (§8.9). test "$AUTHORITY_NA" = "1" - test "$TEMPLATES" = "verified 6/6" - test "$TEMPLATES_NA" = "6" + test "$TEMPLATES" = "verified 7/7" + test "$TEMPLATES_NA" = "7" test -s verify-badge.json test -s verify-report.json test -s verify-report.xml diff --git a/CHANGELOG.md b/CHANGELOG.md index e0f5819..8c7ae0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,73 @@ any change to one appears here. `InvalidArgument`. Verify gains G14, with a note beneath the table: a token is unique only as far as the operator's effect keys are, and a kernel that sees one store cannot check that two stores sharing a provider account never produce one effect-key string for two different effects. +- **Precondition fingerprints** (`docs/SPEC-v0.7.md` §6, §7). `@protect(..., preconditions=provider)` + and `Control.execute(..., preconditions=provider)`, where the provider takes the `Action` and + returns a mapping of the state an approval depends on. A precondition fingerprint **narrows** + the window between a human's approval and the action's execution; it does not close it. + Under `APPROVE` the provider is called when the approval is requested, and the result is kept only + as a `sha256:` fingerprint on the request (`ApprovalRequest.precondition_fingerprint`, stored in the + new `approvals.precondition_fingerprint` column). On the presenting pass it is called again, + strictly before the store call that consumes the approval, and the action is refused with + `ApprovalMismatch` and a reason of its own: `precondition_changed` where the two fingerprints + differ, `precondition_missing` where only one side has one (a store that lost the column, or the + gateway and the ACS hook, which name no provider), and `precondition_unavailable` where the + provider raises or returns something that is not a canonicalizable mapping. Every refusal reserves + nothing and leaves the approval granted. On the request pass a provider that fails refuses the + action with `ActionDenied(reason="precondition_unavailable")` before any human is asked, and a + fingerprint that is computed and then **not recorded** (a store without the column, a third-party + `ApprovalProvider` building its own request) refuses with + `ActionDenied(reason="precondition_missing")` and **withdraws the request it left behind** where + this call can reach it: denied while it is pending, spent where a grant landed inside the window, + and `not_withdrawn:` in the evidence where neither write was possible (the provider raised + after recording, or the store refused). §6.4 states that bound and its residual. **A withdrawal is + a `deny_approval`**, so `find_denied_request` returns it and the gateway's "no is an answer" + pre-check refuses every call for that action hash until the request expires, as though a human had + said no: fail-closed, bounded by the TTL, and traceable through the approver + `ctrlrun:precondition-not-recorded`. `ALLOW`, `DENY` and `Control.resume` never call the provider; + observe mode compares, records and runs. + The comparison is a network call, so it runs outside the atomic reservation write, and a change + that lands after the comparison and before the reservation is not refused: T261b opens that + window and asserts exactly that. Raw provider output reaches no receipt, event, log line or + table, and a provider's exception is recorded by its type name only. No `skip_preconditions`, + and no timeout parameter: a provider that hangs holds the call and reserves nothing. +- **Migration `0005_precondition_fingerprint`** adds `approvals.precondition_fingerprint`, `NULL` on + every existing row, on SQLite and Postgres. A database built by 0.6.1's own code migrates keeping + every row, and 0.6.1 refuses the migrated database at open naming `0005`. **Stop every 0.6 process + before any 0.7 process opens the store**: a store checks migrations only at open, so a 0.6.1 + process already running would consume a fingerprinted approval with no comparison, *and* would + rehash every `v4` receipt under `v3`'s keys and report a correct chain as altered. The trigger is + the first receipt a 0.7 process writes, not the first caller that passes `preconditions=`, and + nothing in the new process can see the old one. +- **G16 in `ctrlrun verify`**, "a moved fingerprint is refused" before the reservation, under + `ctrlrun.guarantees/v3`. Verify supplies its own provider, because a provider is named in code + that verify does not read, and the report says so beneath the table; `not applicable` only where + no action requires approval. The store conformance suite gains a `precondition-fingerprint` case + and a broken-store fixture that fails it by name. + +### Changed + +- **`ctrlrun.receipt/v4`**, with `precondition_at_request` and `precondition_at_recheck`, and the + first receipt-schema bump that does not report older receipts as altered. A receipt read from a + store is now hashed as the document it was read from (`docs/SPEC-v0.7.md` §6.11, amending + `SPEC-v0.6.md` §6.4's last bullet), so every `v3` receipt a released 0.6 wrote still rehashes to its + stored hash and a chain spanning `v3` and `v4` verifies end to end. A key added to a stored + receipt, a relabelled `schema`, a removed one or an unknown one is `content_altered` at its `seq`, + and no longer something a reader could miss. **Visible**: `to_dict()`, `ctrlrun receipts --json` + and `ctrlrun inspect` render each receipt under its own schema, so a pre-v0.6 receipt shows its + own `v1` or `v2` label and keys where 0.6.1 showed `v3`. Upgrade every reader before any writer: + a `v4` JSONL line handed to 0.6.1 rehashes wrongly. +- **`ctrlrun verify` prints each distinct note once**, where it printed only the first note in the + report, which would have dropped G16's beneath G3's. CI's `verify` job expects `verified 12/12` + and `verified 7/7`. +- **A receipt chain reader no longer stops at a row it cannot hash.** A stored document holding a + value with no canonical form (a float, a lone surrogate) made `verify_chain` raise, so one + tampered row ended the walk: `ctrlrun receipts --verify-chain` exited with no report and a forged + field at another `seq` went unnamed. Such a row is `content_altered` at its `seq`, named by the + refusal's type and never its message. +- **`APPROVAL_CONSUMED` carries what the presenting pass compared**, where a precondition was + compared, so a suspended action's resumed leg, whose receipt is the only one it gets, records the + comparison its first leg made. ### Fixed diff --git a/docs/SPEC-v0.3.md b/docs/SPEC-v0.3.md index e2da15d..7f8dc1c 100644 --- a/docs/SPEC-v0.3.md +++ b/docs/SPEC-v0.3.md @@ -1037,21 +1037,27 @@ checks against the parent's subject but does not authenticate — `--as` is an a as one (§5.7, §13). An unqualified MUST above a table containing its own exceptions would teach an implementer that delegation is authenticated. -| Entry point | Builds an `Action` | Resolves identity | Evaluates authority | -|---|---|---|---| -| `@protect` → `Control.execute` | yes | yes (§3.2) | yes | -| `Control.execute` called directly | no — the caller built it | no; the in-process trust boundary (§3.1) | yes | -| `Control.evaluate` | no | no | yes — returns the combined §4.6 decision | -| `Control.resume` | rehydrated from the store | no — the principal is the held action's | evaluated and recorded, not re-decided (§5.6.1) | -| `Control.delegate` / `Control.revoke` | no — creates authority | checks `by` is unexpired (§5.3 rule 0) | the six checks of §5.3 | -| The gateway's `tools/call` | yes | yes (§8.2) | yes, before the approval gate (§8.3) | -| `ctrlrun.acs`'s request hook | yes | yes (§8.4) | yes, before the approval gate (§8.3) | -| `ctrlrun.verify.run` | no - it drives the rows above | no - it synthesizes principals for a scratch store | no - it asserts that the rows above do | -| An adapter's protected tool -> `@protect` -> `Control.execute` | yes - `@protect` does, from the bound call | yes (§3.2), from the `Control`'s provider | yes, before the approval gate | -| `ctrlrun.adapter.needs_approval` -> `Control.evaluate` | yes - **core** builds it; the adapter supplies neither a principal nor an `Action` | yes (§3.2), by `Control.resolve_principal` | yes - the combined §4.6 decision, and it writes nothing | -| `ctrlrun.adapter.InterruptApprovalProvider.wait` -> `grant_approval` / `deny_approval` | no - it records an answer about an action that already exists | no - the principal was resolved when the request was created | **no**, and `SPEC-v0.5.md` §4.1 argues why: a grant authorizes nothing on its own, and `Control.execute` decides the action again in full before consuming it | -| `ctrlrun mcp-operator`'s read tools | no | **no** - they are consulted for nothing, and `SPEC-mcp-operator.md` §4.1 argues why: a provider that ran on every read would make an expired credential turn `receipts` into a refusal | no - they report what the rows above already decided | -| `ctrlrun mcp-operator`'s write tools -> `grant_approval` / `deny_approval` / `resolve_effect` | no - each answers about an action or an effect that already exists | yes, from the configured `IdentityProvider` and from nothing else; a decline, a raise, an expiry and a principal with no `user` are four distinguishable refusals (`SPEC-mcp-operator.md` §3.3) | **no**, for `SPEC-v0.5.md` §4.1's reason, restated in `SPEC-mcp-operator.md` §4.3: a grant authorizes nothing on its own | +| Entry point | Builds an `Action` | Resolves identity | Evaluates authority | Rechecks preconditions (`SPEC-v0.7.md` §7) | +|---|---|---|---|---| +| `@protect` → `Control.execute` | yes | yes (§3.2) | yes | yes, under `APPROVE`, where the decorator names `preconditions=`, before each store call that consumes the approval; refuses a fingerprinted approval where it names none | +| `Control.execute` called directly | no — the caller built it | no; the in-process trust boundary (§3.1) | yes | yes, as above, where the call passes `preconditions=` | +| `Control.evaluate` | no | no | yes — returns the combined §4.6 decision | **no**: it writes nothing and consumes nothing | +| `Control.resume` | rehydrated from the store | no — the principal is the held action's | evaluated and recorded, not re-decided (§5.6.1) | **no**: `SPEC-v0.6.md` §7.2.3, refusing would strand a reservation the remote may be acting on | +| `Control.delegate` / `Control.revoke` | no — creates authority | checks `by` is unexpired (§5.3 rule 0) | the six checks of §5.3 | **no**: they consume no approval | +| The gateway's `tools/call` | yes | yes (§8.2) | yes, before the approval gate (§8.3) | **no provider**, and it refuses a presented approval that carries a fingerprint (`precondition_missing`) | +| `ctrlrun.acs`'s request hook | yes | yes (§8.4) | yes, before the approval gate (§8.3) | **no provider**, and refuses a fingerprinted approval, as the gateway | +| `ctrlrun.verify.run` | no - it drives the rows above | no - it synthesizes principals for a scratch store | no - it asserts that the rows above do | informational: it drives the first two rows with its own provider for G16 | +| An adapter's protected tool -> `@protect` -> `Control.execute` | yes - `@protect` does, from the bound call | yes (§3.2), from the `Control`'s provider | yes, before the approval gate | yes, as the `@protect` row | +| `ctrlrun.adapter.needs_approval` -> `Control.evaluate` | yes - **core** builds it; the adapter supplies neither a principal nor an `Action` | yes (§3.2), by `Control.resolve_principal` | yes - the combined §4.6 decision, and it writes nothing | **no**, for `Control.evaluate`'s reason | +| `ctrlrun.adapter.InterruptApprovalProvider.wait` -> `grant_approval` / `deny_approval` | no - it records an answer about an action that already exists | no - the principal was resolved when the request was created | **no**, and `SPEC-v0.5.md` §4.1 argues why: a grant authorizes nothing on its own, and `Control.execute` decides the action again in full before consuming it | **no**: it records an answer, and `Control.execute` rechecks before consuming | +| `ctrlrun mcp-operator`'s read tools | no | **no** - they are consulted for nothing, and `SPEC-mcp-operator.md` §4.1 argues why: a provider that ran on every read would make an expired credential turn `receipts` into a refusal | no - they report what the rows above already decided | **no**: they read | +| `ctrlrun mcp-operator`'s write tools -> `grant_approval` / `deny_approval` / `resolve_effect` | no - each answers about an action or an effect that already exists | yes, from the configured `IdentityProvider` and from nothing else; a decline, a raise, an expiry and a principal with no `user` are four distinguishable refusals (`SPEC-mcp-operator.md` §3.3) | **no**, for `SPEC-v0.5.md` §4.1's reason, restated in `SPEC-mcp-operator.md` §4.3: a grant authorizes nothing on its own | **no**: a grant authorizes nothing on its own; the recheck is at consumption | + +The fifth column is added by `SPEC-v0.7.md` §7, which argues every cell, the "no"s as +deliberately as the "yes"es. v0.7 adds no entry point; it adds a check to one, the precondition +recheck on `Control.execute`'s presenting pass, which **narrows** the window between a human's +decision and the effect and does not close it (`SPEC-v0.7.md` §6.7). Recorded here in the same +commit as the code, as §7 requires. The `ctrlrun.verify.run` row is **informational**, added by `SPEC-v0.4.md` §3.9 and §9.4. The three adapter rows are added by `SPEC-v0.5.md` §4.1, which states each cell with its argument; diff --git a/docs/SPEC-v0.7.md b/docs/SPEC-v0.7.md index 1218a82..a0a1ef1 100644 --- a/docs/SPEC-v0.7.md +++ b/docs/SPEC-v0.7.md @@ -1280,6 +1280,7 @@ effect: the reservation is held, the provider hangs, and nobody knows whether to |---|---| | The call names a provider, and it returns a canonicalizable mapping | the request is created carrying the fingerprint; `ApprovalRequired` as today | | The provider raises, returns a non-mapping, or returns something the canonicalizer refuses | **refused before any request exists**: `ActionDenied(reason="precondition_unavailable")`, a `denied` receipt keeping `decision: approve`, `ACTION_DENIED` with the reason; no human is asked | +| The fingerprint is computed and the store hands the request back without it | **refused**: `ActionDenied(reason="precondition_missing")`, and the request is **withdrawn** where this call can reach it, so no later presentation of it compares nothing (§6.4, and its residual); a `denied` receipt keeping `decision: approve` | Every refusal on the presenting pass appends `APPROVAL_INVALIDATED` with `data.reason` naming which, and the two fingerprints it compared, hashes only. The three reasons are distinct because a mismatch and an @@ -1326,6 +1327,48 @@ that can. Both refusals share `precondition_missing`, and are told apart by the event's two fields, one of which is null. +**And a fingerprint that is computed and then not recorded is refused on the request pass, with the +request withdrawn.** Refusing only at presentation was not enough, and an independent review showed +why: where the fingerprint never reaches the record, *neither* side has one at presentation, which is +the first row of §6.2's table, so any call naming no provider consumed the approval with nothing +compared. That is the skip this section forbids, reached through the very causes it lists. So the +request pass reads its own request back, through the object the provider returned **and** through +`get_approval`, and where the fingerprint is not there: + +- the action is refused, `ActionDenied(reason="precondition_missing")`, before any human answers; +- the request the provider already recorded is **withdrawn**, through `deny_approval`, an existing + store method (`v0.6 §9.2` is not amended): `check_consumable` then refuses it for ever, by a + denial's own reason. A grant that landed inside the window is withdrawn by being **spent** instead, + `consume_approval` on an approval nothing reserved for and nothing ran on, because `deny_approval` + answers only a pending request; +- `APPROVAL_INVALIDATED` records the reason, which of the two withdrawals was used, and the + fingerprint that was computed and not recorded. + +**The residual, stated, and it is wider than one race.** `Control` learns that a request exists only +when the provider returns, so anything that happens to the row before that is beyond this refusal: + +- an approval granted **and presented** inside `request()` is spent before there is anything to + withdraw; +- **a provider that records a request and then raises** leaves the same orphan with no race at all, + and `Control` never learns its id. The exception is the provider's and propagates; the kernel logs + a warning naming the action and saying a fingerprint was computed, which is all it can do; +- a store that refuses the withdrawal itself leaves the request answerable. The action is still + refused, with its receipt and its `ACTION_DENIED`, and `APPROVAL_INVALIDATED` says + `not_withdrawn:` rather than claiming a write (§6.2's table, and the reason strings of §9.2). + +So the claim this section makes is bounded: **a request this call can reach is withdrawn, and the +evidence names what was done to it.** Closing the rest needs a store call that records the request and +its fingerprint together, and `StateStore` is frozen (`v0.6 §9.2`); §12.5 records the alternative that +would remove the *cause* rather than close the window, and why v0.7 does not take it. + +**What a withdrawal looks like to everything that reads denials.** It is a `deny_approval`, so +`find_denied_request` returns it and the gateway's *"no is an answer"* pre-check (`v0.2 §6.10`) refuses +every call for that action hash until the request expires, as though a human had said no. That is +fail-closed, bounded by the TTL and traceable through the approver +(`ctrlrun:precondition-not-recorded`), and it exports an in-process misconfiguration to a path that +never asked for a fingerprint, which is the cost of using an existing store method rather than adding +one. + **What that costs at the gateway and the ACS hook, stated with its bound.** Both present the newest granted approval for the action's hash and create a new request only when they find none (`server.py:709-725`, `acs.py:202-218`). Where the newest granted approval carries a fingerprint, every @@ -1372,6 +1415,19 @@ is spent only where its answer can matter**, which is a courtesy to the resource skipped by this: an approval `check_consumable` refuses is refused by the store as well, with nothing consumed. +**The refusal is raised from this read, and nothing is written for it.** Two things follow, and an +independent review found both wanting in the first build. The refusal is not left to the store call: +a `pending` record a human grants between this read and that call would be consumed there with nothing +compared, which is the skip §6.4 forbids, so the verdict this read found is the verdict that is raised. +And **no write goes to the store on this path at all**, not even the lapse of an expired grant: whose +clock decides expiry is `v0.1 §4.2 A3`'s question and the answer stays the **store's**. A `Control` +whose clock runs ahead of its store's would otherwise send a grant the store still calls live to +`consume_approval`, the store would spend it, and the row would say `consumed` while the events said +expired and the receipt said blocked. The row keeps what the store gave it; `APPROVAL_EXPIRED` records +the lapse this clock saw; nothing is reserved and nothing runs; and `check_consumable` refuses that +grant at every later presentation, on whichever clock. Where no precondition is in play this read +decides nothing, and the store call is 0.6.1's, on the store's clock, including its lapse write. + ### 6.7 The residual window, stated The recheck **narrows** the window from the whole of human deliberation down to the span between the @@ -1435,8 +1491,10 @@ account states and PHI stay out of the evidence; only the fingerprint does. The exists in memory for as long as it takes to canonicalize and hash it, and T260 searches every written row, every JSONL line and every captured log record for a sentinel value the provider returned. -The fingerprint goes to three places, all of them hashes: `approvals.precondition_fingerprint`, -`APPROVAL_INVALIDATED`'s data on a refusal, and the receipt's two fields (§6.11). It is not added to the +The fingerprint goes to four places, all of them hashes: `approvals.precondition_fingerprint`, +`APPROVAL_INVALIDATED`'s data on a refusal, `APPROVAL_CONSUMED`'s data where a comparison was made +(§6.11, which is how a resumed leg records the comparison its first leg made), and the receipt's two +fields. This enumeration exists to be complete, so a fifth place is a change to this paragraph. It is not added to the webhook document (`ctrlrun.approval_request/v1` is unchanged), to `ctrlrun inspect`, or to anything a human is shown to decide with: a hash tells a human nothing about the world they are approving, and the human reads the world in their own systems. @@ -1446,7 +1504,11 @@ human reads the world in their own systems. **Two receipt fields**, `precondition_at_request` and `precondition_at_recheck`: the approval's stored fingerprint and the one computed on the presenting pass, `null` where there was none. On a refusal they say which side moved or was missing; on a committed action they are equal, and the receipt records that -the world was checked. The schema becomes **`ctrlrun.receipt/v4`**, and it moves once, in item 5, which +the world was checked. **A resumed leg's receipt says the same**, and that needs one more write: the leg +that consumed the approval records what it compared on its `APPROVAL_CONSUMED` (hashes only, and nothing +at all where nothing was compared), and `Control.resume` reads it back. A suspended action writes no +receipt on its first leg, so the resumed leg's is the only receipt it ever gets, and without this the one +comparison that did happen left no trace in it. The schema becomes **`ctrlrun.receipt/v4`**, and it moves once, in item 5, which is why item 5 is last. **One column**, through migration **`0005_precondition_fingerprint`**: `approvals.precondition_fingerprint @@ -1489,6 +1551,13 @@ document and report every receipt a released 0.6 wrote as `content_altered`. So, branch**, so the hash written to the column and the document written beside it come from one dictionary, and the read-time hash of that document is the write-time hash. + **A store outside this package does not get this**, and saying so here is better than leaving an + implementer to find it: the field is private, `_stored_receipt` is its only writer, and §9.2 adds no + public name for either. A third-party store's read-back receipts are hashed from `to_dict()`, as at + 0.6.1: an untouched `v3` or `v4` row still verifies, and a key added to one of its stored documents + does not show. Whether that setter should be public is the maintainer's to decide on its own merits, + not this item's to settle by adding a name. + Nothing else is affected: no other reader calls `chain_hash()`. The in-memory store keeps the objects it was handed; the JSONL sink and the OTel sink receive `put_receipt`'s fresh return; `ctrlrun receipts`, the reporting payloads, the operator server's tools and verify's counterexample only display; a resumed @@ -1504,7 +1573,23 @@ document and report every receipt a released 0.6 wrote as `content_altered`. So, the reader to get wrong. Without this, a row-writer could add `precondition_at_recheck` to a receipt 0.6.1 wrote; a reader rendering it under `v3`'s keys would leave the key out of the hash, the chain would verify, and a reader that parsed it would show a fabricated field. -- **`from_dict` never raises inside a store read, over the schema or over a key.** `receipts()` builds + + **Including a key whose value has no canonical form.** A float or a lone surrogate made `chain_hash()` + raise out of the walk, so one such row stopped the whole read: `ctrlrun receipts --verify-chain` exited + with no report at all, and a forged field at another `seq` went unnamed. A document this reader cannot + canonicalize is a document nothing here wrote, since `put_receipt` hashes what it serializes, so it is + `content_altered` at its `seq` like any other altered document, named by the canonicalizer's exception + **type** and never its message, which quotes what it refused. The rows that link to it are told that it + has no computable hash. A malformed value of a key a schema *declares*, a float among `controls` say, is + a document that cannot be parsed at all and behaves as it does at 0.6.1, which the next bullet covers. +- **`from_dict` never raises inside a store read over the *schema* or over an added *key*. Over a +malformed **value** of a key a schema declares, it still does, exactly as at 0.6.1**, and that is +stated rather than smoothed over: a float among `controls` makes `_controls_of` raise out of +`receipts()`, so one `UPDATE` blinds `ctrlrun receipts`, `--verify-chain`, `inspect`, `stats` and G11 +at once. v0.7 neither introduces nor widens it, and fixing it needs either a new name in +`CHAIN_BREAKS` -- a closed set and a `v0.6 §6.5` surface -- or a reader that can walk raw rows, which +is a shape this milestone does not have. It is deferred with that blast radius written down, and §12.5 +records it for the roadmap. `receipts()` builds every row with `Receipt.from_json` (`state.py:1211`), so a `from_dict` that raised on one tampered row would raise out of `receipts()` and blind every reader at once: the chain walk, `ctrlrun receipts`, `inspect`, `stats` and G11. An absent or unknown `schema`, or an extra key, is left to the hash to @@ -1537,13 +1622,18 @@ means an added key is neither parsed nor rendered, so the recomputed hash matche anything, unless `Receipt` carried a marker saying "this document had extra keys", which would be a public name added to a frozen record for a check the stored document makes for free. -**No 0.6 process may be running when any caller uses `preconditions=`.** A store checks migrations -only at open (`postgres.py:259`), so a 0.6.1 process already running when `0005` is applied keeps -running against the migrated database: it reads approvals through columns it knows, never sees the -fingerprint, and consumes a fingerprinted approval with no recheck. The kernel cannot detect that -process from the new one, so the rule is operational and stated as one, here and in the upgrade notes -item 6 writes: **stop every 0.6 process before the first caller passes `preconditions=`**. Until then an -approval carries no fingerprint, and a 0.6 process consuming one loses nothing a 0.6.1 deployment had. +**No 0.6 process may be running when a 0.7 process opens the store.** A store checks migrations only at +open (`postgres.py:259`), so a 0.6.1 process already running when `0005` is applied keeps running +against the migrated database, and it meets the schema bump in two ways. It reads approvals through the +columns it knows, never sees the fingerprint, and consumes a fingerprinted approval with no recheck. +And it rehashes every `v4` receipt a 0.7 process writes under `v3`'s keys, so `verify_chain` in that +process reports a correct chain as `content_altered` and its head as mismatched. + +**The trigger is the first receipt a 0.7 process writes, not the first caller that passes +`preconditions=`**, and an independent review measured it: a 0.6.1 reader held open across the +migration misreports a chain written by a 0.7 process that named no provider at all. The kernel cannot +detect that process from the new one, so the rule is operational and stated as one, here and in the +upgrade notes item 6 writes: **stop every 0.6 process before any 0.7 process opens the store.** **Every reader upgrades before any writer switches** (`v0.3 §12.2`). The chain walk, `ctrlrun receipts --verify-chain` and `ctrlrun verify` read `v3` and `v4`, and **a chain spanning both verifies end to end** @@ -2322,7 +2412,7 @@ BLOCKED_ATTEMPT_CEILING: Final = "attempt_ceiling" # joins BLOCKED_BY_STATE |---|---|---| | `ActionDenied.reason`, `EFFECT_RESERVATION_REFUSED.data.reason` | `attempt_ceiling` | §5.5 | | `ApprovalMismatch.reason`, `APPROVAL_INVALIDATED.data.reason` | `precondition_changed`, `precondition_missing`, `precondition_unavailable` | §6.2 | -| `ActionDenied.reason` (request pass) | `precondition_unavailable` | §6.2 | +| `ActionDenied.reason` (request pass) | `precondition_unavailable`, `precondition_missing` | §6.2, §6.4 | | `would_have.blocked_reason` | `attempt_ceiling` | §5.5 | The two `preconditions=` keywords and `clock_skew_threshold=` are keywords on existing callables, not new @@ -2432,6 +2522,9 @@ own, and none of them is configurable. | A stored receipt document with an added key, a changed or removed `schema`, or an unknown one | A hash mismatch: `content_altered` at its `seq`. `receipts()` does not raise, and no reader surfaces an undeclared key (§6.11) | | A presented approval whose fingerprint differs from the recheck | `ApprovalMismatch(reason="precondition_changed")`; nothing reserved; approval `granted` (§6.3) | | A fingerprint on one side only | `ApprovalMismatch(reason="precondition_missing")`; never a skip (§6.4) | +| A fingerprint computed on the request pass and not recorded on the request | `ActionDenied(reason="precondition_missing")`; the request is withdrawn, denied where it is still pending and spent where it was granted inside the window; no human is asked to answer it (§6.4) | +| An approval the read finds unusable where a precondition is in play | The refusal that read found, raised by `Control`, with **nothing written to the store**; expiry stays the store's to decide and to record (§6.6) | +| A stored receipt document whose value has no canonical form | `content_altered` at its `seq`, named by the refusal's type; the walk continues and every other break is still reported (§6.11) | | The provider raises, returns a non-mapping, or returns what `canonical_bytes` refuses | Presenting pass: `ApprovalMismatch(reason="precondition_unavailable")`, nothing reserved. Request pass: `ActionDenied(reason="precondition_unavailable")`, no request created (§6.5) | | An 0.6 binary opening a database migrated by 0.7 | `SchemaMismatch` at open, naming `0005` and both versions (§6.11) | @@ -2916,4 +3009,173 @@ claim to. ### 12.5 Item 5: precondition fingerprints +It narrows; the residual window of §6.7 is T261b's, and nothing written for this item says otherwise. + +**§6.6's read is where a refusal is raised from, and not only where it is found, and it writes +nothing.** The first build read the record, skipped the provider on a refusal verdict, and let +`_take` raise the store's own refusal. That had a hole: a `pending` approval a human grants between +the read and the store call was then consumed with no comparison, which is a skip reached by timing. +So where the read's verdict is a refusal and the precondition question is live (a provider is named, +or the record carries a fingerprint), `Control` raises that verdict itself, from the same pure +`check_consumable` every store applies, with the reason and message the store would give. T262's +pending-race case opens that window with a grant that lands inside the read. + +**The first build made one exception, sending an expired grant to `consume_approval` so the lapse +would be recorded as 0.6.1 records it, and the independent review measured what that costs.** With +`Control`'s clock two minutes ahead of the store's and a minute of life left by the store's, the +store consumed the grant: the row said `consumed`, the events said `APPROVAL_EXPIRED` and +`APPROVAL_INVALIDATED` with no `APPROVAL_CONSUMED`, and the receipt said blocked. Safe, and untrue, +and it quietly moved `v0.1 §4.2 A3`'s question of whose clock decides expiry from the store to +`Control`. So this path now writes nothing at all: the row keeps the status the store gave it, the +lapse this clock saw is in `APPROVAL_EXPIRED`, and `check_consumable` refuses the grant at every +later presentation. Where neither side has a fingerprint the store call is 0.6.1's exactly, on the +store's clock, including its lapse write, and the divergent-clock test pins that too. + +**The read runs on every presenting pass under `APPROVE`**, provider or not: only the record says +whether an approval carries a fingerprint, and one that does, presented by a call naming no +provider, is refused (§6.4). The cost is one `get_approval` per approved action. + +**`precondition_missing` on a call that names a provider fetches first**, so the event's +`precondition_at_recheck` is set and §6.4's "one of which is null" holds for both cases. A provider +that fails there is `precondition_unavailable`: the outage is what an operator fixes first. + +**What `error` holds**, in the event, the receipt's `error` and the log line alike: the provider's +exception by type name; `returned , not a mapping`; or the type name of what `canonical_bytes` +raised, whose message can quote the value it refused. T260 plants a sentinel in a provider's +exception message and finds it nowhere. + +**A fingerprint that is computed and not recorded is refused on the request pass, and the request is +withdrawn.** The review's blocking finding: a store that drops the column and a third-party +`ApprovalProvider` that builds its own `ApprovalRequest` both leave an approval requested with a +fingerprint carrying none, and at presentation *neither* side has one, which is §6.2's first row and +0.6.1's path. Every call naming no provider consumed it with nothing compared, which is exactly the +skip §6.4 forbids. The request pass reads its own request back, through the returned object and +through `get_approval`, and where the fingerprint is not there it refuses and withdraws the request +with the store methods that exist: `deny_approval` while it is pending, `consume_approval` for a +grant that landed inside the window. **The residual is in §6.4**: an approval granted *and presented* +inside `request()` is spent before `Control` knows the request exists, and closing that needs a store +call that records the request and its fingerprint together. `StateStore` is frozen (`v0.6 §9.2`), so +that is a finding for the maintainer and not a method this item adds. The honest test names it. + +**A withdrawal reports what happened to the request, not what was read before trying.** The first +build of it returned the status from the read *before* its own failed `consume_approval`, and said +`consumed` whether it had spent the grant or another caller had. The review drove a presentation that +won that race: it **ran the action**, and the evidence said the request had been withdrawn `granted` +while the row said `consumed`. So the row is read back after a failed write and the answers are +distinct -- `denied`, `spent`, `already_consumed`, `not_withdrawn:` -- and the refusal calls +itself a withdrawal only for the first two, which are this call's own writes. + +**Every exception in the withdrawal is caught, on `_spend_unneeded_approval`'s argument pointed the +other way.** A `sqlite3.OperationalError` out of `deny_approval` used to leave `_presented` with no +`ACTION_DENIED`, no receipt and an answerable unfingerprinted request. There, catching everything is +safe because the action proceeds and there is nothing to protect; here it is safe because the action is +refused whatever the store does, so a wider catch can only add a refusal and its evidence. No other +handler in this file may widen on either argument without making it again. + +**The object-side half of the recorded check was subsumed, and is gone.** It compared the +`ApprovalRequest` the provider returned as well as the record read back. A presentation reads the +store, so a returned object that differs from the row changes nothing a later pass sees, and both +reachable causes (a store without the column, a provider that builds its own request) are visible in +the read-back. Collapsed rather than kept as documentation, which is what `CONTRIBUTING.md`'s first +shape asks for. + +**Rejected for v0.7: making the fingerprint recordable by a third-party provider.** The reachable cause +of §6.4's residual is as much the frozen `ApprovalProvider` protocol as `StateStore`'s method set: +`build_request` is package-internal and the context variable it reads is private, so a provider outside +this package cannot record a fingerprint however careful it is, and the kernel can only detect that +afterwards. Publishing either would remove the cause rather than close the window, which is the better +shape of fix. It is not taken here because it is a public name on a frozen surface (`v0.1 §8`, +`v0.5 §9`) and because the case is **unreachable with all three shipped providers**, which build their +requests through `build_request`; v0.7 refuses the reachable symptom instead, and the decision is +recorded so a later milestone can weigh the name rather than rediscover the argument. + +**Deferred, with its blast radius: a malformed value of a key a schema declares.** `_controls_of` +raises out of `from_dict`, so one `UPDATE` putting a float among a receipt's `controls` blinds +`ctrlrun receipts`, `receipts --verify-chain`, `inspect`, `stats` and G11 together, where the +schema-level and added-key cases are each reported at their `seq` and leave every other row readable. +v0.7 neither introduces nor widens it: 0.6.1 behaves the same. Fixing it needs a new name in +`CHAIN_BREAKS`, which is a closed set on a `v0.6 §6.5` surface, or a reader that walks raw rows, and +neither belongs in an item about preconditions. Item 6 carries it to the roadmap as a named item before +v1.0, with this paragraph as its statement. + +**A provider outage in observe mode costs the duplicate refusal too.** Observe mode records a failed +comparison and runs holding nothing, which is 0.6.1's observe path for any approval mismatch, so +while a provider is down every observed action under `APPROVE` with an effect key runs without +reserving its key: two of them are two unreserved attempts, and neither `would_have.blocked_reason` +says `duplicate`. Enforce mode refuses instead, so nothing is lost there. + +**Everything a provider hands back is inside one `try`.** `isinstance(state, Mapping)` sat outside +it, and `isinstance` reads `__class__`: an object whose `__class__` raises carried its own message +out of `Control` as a raw exception, with no refusal reason and no receipt. + +**`ctrlrun inspect` adds no field.** Its approval entries, the webhook document and the operator +server's pending listing carry no fingerprint (§6.10); `inspect --json` embeds the receipt and the +events, and those carry the fields §6.10 and §6.11 place there, as evidence rather than as the +question put to a human. + +**The three reason strings are private constants in `control.py`.** §9.2 adds no public name for +them and says so; every test asserts the string. + +**Observe mode records a failed comparison the way it records any presented approval that does not +match**: `APPROVAL_INVALIDATED` with the reason and both fields, `would_have.blocked_reason = +"approval_mismatch"`, and the action runs holding no reservation, which is 0.6.1's observe path for +an approval mismatch. No grant is spent. + +**A resumed leg's receipt records the comparison its first leg made.** The first build left +`precondition_at_recheck` null there, on the ground that this leg compares nothing, and the review +found the consequence: the first leg of a suspended action writes no receipt, so the one comparison +that happened left no trace anywhere and §6.11's *on a committed action they are equal* was false of +every resumed leg. The leg that consumes the approval now records what it compared on its +`APPROVAL_CONSUMED` (hashes only, and nothing at all where nothing was compared), and `resume` reads +it back from that event. Where no such event exists, the record's own fingerprint fills +`precondition_at_request` and the recheck field stays null, which is what a leg that compared +nothing should say. + +**Rendering a label this binary does not know.** §6.11 fixes the four known schemas. An unknown +label renders under `v3`'s keys with its own label; an absent one renders with no `schema` key. The +two `v4` fields are rendered only for a `v4` label, since they are read only from a `v4` document. +A `v1` receipt's principal renders as `v1` wrote it, `agent` and `user` only. + +**`put_receipt` writes `v4` whatever schema the receipt was read under.** A `v1` or `v2` key set has +no `seq`, so a read-back receipt written again under its own label would carry no position in its own +document. A receipt written again is a new row this binary writes. + +**The stored document is excluded from equality**, and the store conformance suite's field-by-field +comparison skips fields excluded from equality, because the receipt read back has a stored document +and the one written has none by design. + +**A stored row whose document has no canonical form is `content_altered`, not a raised walk.** The +first build let the canonicalizer's refusal out of `verify_chain`, and the review showed what that +costs: an added key holding a float or a lone surrogate ended the walk, `ctrlrun receipts +--verify-chain` exited with no report at all, and a forged `decision_reason` at another `seq` went +unnamed. The claim that 0.6.1 behaved the same was false for an added key, which 0.6.1 ignored when +hashing. `put_receipt` hashes what it serializes, so a stored document this reader cannot +canonicalize is one nothing here wrote: it is reported at its `seq`, by the refusal's type and never +its message, and the rows that link to it are told it has no computable hash. A malformed value of a +key a schema declares, a float among `controls` say, still raises out of `from_dict` as it did at +0.6.1, and that case is the one §6.11's last bullet already covers. + +**`ApprovalRequest` does not validate the fingerprint's shape.** A malformed stored value compares +unequal and is refused `precondition_changed`; validating at construction would make a tampered row +raise out of `get_approval` and blind every reader of that approval. + +**G16's title is "a moved fingerprint is refused".** "A moved precondition is refused" is +unqualified and §6.7 says why: a precondition that moves after the comparison is not refused. A title +is the shortest sentence this project writes about a guarantee, so the titles are scanned by T268 +with everything else this item writes. + +**`PRECONDITION_NOTE` is not a public name.** It went into `verify.guarantees.__all__` and not into +§9.2, and §9.2 is the list of what v0.7 adds; `scenarios.py` reads it as an attribute, as it reads +every other reason in that module. + +**Verify prints each distinct note once**, where it printed only the first. G16's note is a +different sentence from G3's, and the first rule dropped it on every document that also lacked an +`effect:` template. + +**G16 grades a change before the comparison.** A change after it is not refused by a correct kernel, +so there is nothing there for verify to grade; T261b is where that residual is kept honest. + +**CI's `verify` job now expects `verified 12/12` and `verified 7/7`.** Item 1's G13 moves both again, +and whichever lands second rebases the two lines. + ### 12.6 Item 6: the release diff --git a/src/ctrlrun/approval.py b/src/ctrlrun/approval.py index 6787dfd..e2b9163 100644 --- a/src/ctrlrun/approval.py +++ b/src/ctrlrun/approval.py @@ -8,17 +8,18 @@ from __future__ import annotations +import hashlib import secrets import time -from collections.abc import Callable, Iterable, Iterator +from collections.abc import Callable, Iterable, Iterator, Mapping from contextlib import contextmanager from contextvars import ContextVar from dataclasses import dataclass from datetime import UTC, datetime, timedelta from enum import StrEnum -from typing import Final, Protocol, runtime_checkable +from typing import Any, Final, Protocol, runtime_checkable -from .action import Action +from .action import Action, canonical_bytes from .errors import ( ActionDenied, ApprovalMismatch, @@ -93,6 +94,19 @@ class ApprovalRequest: #: which is a narrower window than the one §7.2 is about and is recorded by the receipt's #: own `policy_hash` either way. policy_hash: str | None = None + #: SPEC-v0.7 §6.2: the precondition fingerprint captured when this request was created, or + #: `None` where the call that created it named no provider. A hash and never the state it + #: was computed from (§6.10): `"sha256:"` over the canonical form of what the operator's + #: provider returned, under the `ctrlrun.precondition/v1` domain tag. + #: + #: Captured at request time for `policy_hash`'s reason (`v0.6 §7.1`): the store has no + #: provider, and giving `ctrlrun approve` one would make an unreachable resource a failure + #: of the command a human answers with. `Control.execute` rechecks it on the presenting + #: pass, strictly before the store call that consumes the approval, and refuses where the + #: two differ or where only one side has one. That recheck **narrows** the window between + #: the human's decision and the effect; a change landing after the comparison and before + #: the reservation is not refused (§6.7). + precondition_fingerprint: str | None = None def __post_init__(self) -> None: if not self.request_id: @@ -347,6 +361,45 @@ def policy_in_force(policy_hash: str | None) -> Iterator[None]: _POLICY_AT_REQUEST.reset(token) +#: SPEC-v0.7 §6.2: the precondition fingerprint captured on the request pass, travelling to +#: `build_request` exactly as `_POLICY_AT_REQUEST` does and for the same reason: the provider +#: protocol takes an action and a ttl and nothing else. +#: +#: **The residual is `_POLICY_AT_REQUEST`'s, and here it is refused rather than read as "not +#: recorded".** A third-party provider that builds its `ApprovalRequest` itself records no +#: fingerprint, and the presenting pass, which names the provider, then meets an approval +#: without one: `precondition_missing` (§6.4), never a skip. +_PRECONDITION_AT_REQUEST: ContextVar[str | None] = ContextVar( + "ctrlrun_precondition_at_request", default=None +) + +#: SPEC-v0.7 §6.2: the domain tag inside the fingerprint's canonical input, so a fingerprint +#: can never equal another hash of the same mapping. Never a document on its own (§9.3). +_PRECONDITION_SCHEMA: Final = "ctrlrun.precondition/v1" + + +@contextmanager +def _precondition_at_request(fingerprint: str | None) -> Iterator[None]: + """Record `fingerprint` on any request built inside this block (SPEC-v0.7 §6.2).""" + token = _PRECONDITION_AT_REQUEST.set(fingerprint) + try: + yield + finally: + _PRECONDITION_AT_REQUEST.reset(token) + + +def _precondition_fingerprint(state: Mapping[str, Any]) -> str: + """`"sha256:" + hex(SHA-256(canonical_bytes({"schema": ..., "state": state})))` (§6.2). + + Through `canonical_bytes` and nothing else, so the float rejection, the non-string-key + refusal and the lone-surrogate refusal are inherited rather than re-argued. Whatever it + raises is the caller's to turn into `precondition_unavailable`; this function decides + nothing about the action. + """ + document = {"schema": _PRECONDITION_SCHEMA, "state": dict(state)} + return "sha256:" + hashlib.sha256(canonical_bytes(document)).hexdigest() + + def build_request(action: Action, ttl: timedelta, now: datetime) -> ApprovalRequest: """Build a request for `action`, validating the ttl. Package-internal, not public API. @@ -361,6 +414,7 @@ def build_request(action: Action, ttl: timedelta, now: datetime) -> ApprovalRequ action_hash=action.action_hash, action=action, policy_hash=_POLICY_AT_REQUEST.get(), + precondition_fingerprint=_PRECONDITION_AT_REQUEST.get(), created_at=now, expires_at=now + ttl, ) diff --git a/src/ctrlrun/conformance/store/fixtures.py b/src/ctrlrun/conformance/store/fixtures.py index 5ba9d10..8274b5c 100644 --- a/src/ctrlrun/conformance/store/fixtures.py +++ b/src/ctrlrun/conformance/store/fixtures.py @@ -319,6 +319,25 @@ def get_approval(self, approval_id: str) -> Any: return replace(record, request=replace(record.request, action=action)) +class _DropsThePreconditionFingerprint(_Wrapped): + """Reads every approval back without its precondition fingerprint (SPEC-v0.7 §6.4, T266). + + What a backend that never added `0005`'s column, or a restore from before it, looks like + from above: the request went in with a fingerprint and comes out with none. + """ + + def get_approval(self, approval_id: str) -> Any: + record = self._inner.get_approval(approval_id) + return None if record is None else _without_fingerprint(record) + + def approvals_for(self, action_hash: str) -> Any: + return tuple(_without_fingerprint(r) for r in self._inner.approvals_for(action_hash)) + + +def _without_fingerprint(record: Any) -> Any: + return replace(record, request=replace(record.request, precondition_fingerprint=None)) + + class _RenumbersEvents(_Wrapped): """`append_event` returns an event carrying an id other than the one it stored.""" @@ -595,6 +614,12 @@ def raises_not_executed(root: Path) -> StoreBackend: _wrapping("coerces-an-argument", _CoercesAnArgument), because="the action hash changed across the store", ), + Fixture( + "drops-the-precondition-fingerprint", + {"approval": "precondition-fingerprint"}, + _wrapping("drops-the-precondition-fingerprint", _DropsThePreconditionFingerprint), + because="precondition_fingerprint came back None", + ), Fixture( "renumbers-events", {"evidence": "event-ids"}, diff --git a/src/ctrlrun/conformance/store/suites.py b/src/ctrlrun/conformance/store/suites.py index 3df52b0..d24bbe3 100644 --- a/src/ctrlrun/conformance/store/suites.py +++ b/src/ctrlrun/conformance/store/suites.py @@ -23,7 +23,7 @@ import tempfile import threading from collections.abc import Callable, Mapping, Sequence -from dataclasses import dataclass, fields +from dataclasses import dataclass, fields, replace from datetime import UTC, datetime, timedelta from functools import partial from typing import Any @@ -152,6 +152,11 @@ def _differing_field(wrote: Any, read: Any) -> tuple[str, Any, Any] | None: read-only mappings `Action` freezes its arguments into (`v0.1 §2.2`). """ for spec in fields(wrote): + if not spec.compare: + # A field the record excludes from its own equality is not part of what it says: + # `Receipt`'s stored document is how a read-back receipt is hashed (SPEC-v0.7 + # §6.11), present on the one read and absent on the one written, by design. + continue mine, theirs = getattr(wrote, spec.name), getattr(read, spec.name, None) if mine != theirs: return spec.name, mine, theirs @@ -724,6 +729,66 @@ def approval_binding(backend: StoreBackend, processes: int = CONTENDERS) -> Case return passed("binding", title) +@case("precondition-fingerprint", "an approval's precondition fingerprint round-trips") +def approval_precondition_fingerprint( + backend: StoreBackend, processes: int = CONTENDERS +) -> CaseResult: + """SPEC-v0.7 §6.4, T266. `ApprovalRecord` is rebuilt from columns, so the fingerprint the + recheck reads back must be one, and a store that drops it makes every approval requested + with a provider come back without one. + + A dropping store is safe and useless, and both halves are the kernel's doing rather than this + store's: the request pass reads its own request back and refuses where the fingerprint is not + there, withdrawing the request it can reach so a later presentation of it has nothing to spend + (SPEC-v0.7 §6.4 states the bound and its residual), and a presentation of an approval carrying + one on one side only is `precondition_missing`. So an operator whose store drops this column can + request no approval at all for an action that names a provider. This case is what tells an + implementer why, by name, before an operator does. + """ + title = approval_precondition_fingerprint.title + store = _clocked(backend, lambda: T0) + fingerprint = "sha256:" + "ab" * 32 + carried = replace( + build_request(an_action(payment_id="txn_pf"), timedelta(minutes=15), T0), + precondition_fingerprint=fingerprint, + ) + bare = build_request(an_action(payment_id="txn_pf0"), timedelta(minutes=15), T0) + store.put_approval_request(carried) + store.put_approval_request(bare) + store.grant_approval(carried.request_id, "cli:conformance") + + readers: list[tuple[str, StateStore]] = [("the store that wrote it", store)] + reopened = backend.reopen() + if reopened is not None: + readers.append(("a second handle on the same backend", reopened)) + for where, reader in readers: + record = reader.get_approval(carried.request_id) + listed = [r for r in reader.approvals_for(carried.action_hash)] + for how, found in ( + ("get_approval", record), + ("approvals_for", listed[0] if listed else None), + ): + got = None if found is None else found.request.precondition_fingerprint + if got != fingerprint: + return failed( + "precondition-fingerprint", + title, + f"{how} through {where}: precondition_fingerprint came back {got!r}, " + f"expected {fingerprint!r}. Every approval requested with a provider would " + "then be refused at every presentation (SPEC-v0.7 §6.4)", + ) + plain = reader.get_approval(bare.request_id) + if plain is None or plain.request.precondition_fingerprint is not None: + return failed( + "precondition-fingerprint", + title, + f"an approval requested with no provider came back through {where} carrying " + f"{None if plain is None else plain.request.precondition_fingerprint!r}; absent " + "means absent", + ) + return passed("precondition-fingerprint", title) + + @case("single-use", "an approval is consumed exactly once") def approval_single_use(backend: StoreBackend, processes: int = CONTENDERS) -> CaseResult: title = approval_single_use.title @@ -1945,6 +2010,7 @@ def _clocked(backend: StoreBackend, clock: Callable[[], datetime]) -> StateStore approval_consume_cross_process, approval_answered_once, approval_binding, + approval_precondition_fingerprint, approval_single_use, approval_expiry, approval_atomic, diff --git a/src/ctrlrun/control.py b/src/ctrlrun/control.py index 9c93f37..a0774c5 100644 --- a/src/ctrlrun/control.py +++ b/src/ctrlrun/control.py @@ -26,8 +26,12 @@ DEFAULT_APPROVAL_TTL, Approval, ApprovalProvider, + ApprovalRecord, + ApprovalRequest, ApprovalStatus, LocalApprovalProvider, + _precondition_at_request, + _precondition_fingerprint, check_consumable, policy_in_force, ) @@ -130,6 +134,27 @@ RECONCILE_RAISED: Final = "raised" RECONCILE_INVALID_RETURN: Final = "invalid_return" +#: SPEC-v0.7 §6.2: the three reasons a precondition refuses, each a value of an existing field +#: (`ApprovalMismatch.reason`, `APPROVAL_INVALIDATED.data.reason`, and on the request pass +#: `ActionDenied.reason`). Distinct because a precondition refusal and an ordinary +#: `ApprovalMismatch` share a type, and a test asserting only the type could not tell which +#: guard fired. Private: §9.2 adds no public name for them, and every test asserts the string. +_PRECONDITION_CHANGED: Final = "precondition_changed" +_PRECONDITION_MISSING: Final = "precondition_missing" +_PRECONDITION_UNAVAILABLE: Final = "precondition_unavailable" +_PRECONDITION_REASONS: Final = frozenset( + {_PRECONDITION_CHANGED, _PRECONDITION_MISSING, _PRECONDITION_UNAVAILABLE} +) + +#: SPEC-v0.7 §6.4 — who a request withdrawn by the kernel was answered by. Not a human and not +#: a policy: the fingerprint the request pass computed was not recorded, so the request is made +#: unanswerable through `deny_approval`, and the approver says which of the two it was. +_WITHDRAWN_BY: Final = "ctrlrun:precondition-not-recorded" + +#: The two outcomes of `_withdraw` that are this call's own writes. Anything else happened to +#: the request rather than to it, and the refusal says so rather than claiming a withdrawal. +_WITHDRAWALS: Final = frozenset({"denied", "spent"}) + #: Where `Control.from_file` keeps its store, and the env var that overrides it (SPEC §8). STATE_ENV_VAR: Final = "CTRLRUN_STATE" DEFAULT_STATE_DIR: Final = ".ctrlrun" @@ -324,6 +349,101 @@ def ask(self, effect_key: str) -> tuple[str, str | None, str | None]: return RECONCILED_UNKNOWN, RECONCILE_INVALID_RETURN, repr(answer) +class _Compared: + """What one presenting pass compared, for the event and the receipt (SPEC-v0.7 §6.2). + + Mutable and short-lived, as `_Observation` is: `execute` makes one per call, each recheck + resets it and fills it, and the receipt reads it. Hashes only. `error` is the provider's + failure by its type name, never its message: a provider that put the balance it read into + its exception would otherwise carry raw state into the evidence through the one field + nobody thought to check (§6.5). + """ + + __slots__ = ("at_recheck", "at_request", "error") + + def __init__(self, at_request: str | None = None) -> None: + self.at_request = at_request + self.at_recheck: str | None = None + self.error: str | None = None + + def reset(self) -> None: + self.at_request = None + self.at_recheck = None + self.error = None + + def data(self) -> dict[str, Any]: + data: dict[str, Any] = { + "precondition_at_request": self.at_request, + "precondition_at_recheck": self.at_recheck, + } + if self.error is not None: + data["error"] = self.error + return data + + def spent(self) -> dict[str, Any]: + """The two fields for `APPROVAL_CONSUMED`, and nothing at all where nothing was + compared: absent means absent on an event as much as on a receipt (§6.11).""" + if self.at_request is None and self.at_recheck is None: + return {} + return { + "precondition_at_request": self.at_request, + "precondition_at_recheck": self.at_recheck, + } + + +_Preconditions = Callable[[Action], Mapping[str, Any]] + + +def _fetched(provider: _Preconditions, action: Action) -> tuple[str | None, str | None]: + """Ask the operator's provider and fingerprint the answer: `(fingerprint, None)` or + `(None, what went wrong)`, never raising an `Exception` (SPEC-v0.7 §6.5). + + A provider that raises, returns something that is not a `Mapping`, or returns something + `canonical_bytes` refuses has produced no fingerprint, and a comparison that was never made + is not a comparison that passed (`v0.4 §3.8`). What went wrong is named by **type only**. The + return value exists here for as long as it takes to hash it and goes nowhere else. + + A `BaseException` that is not an `Exception` propagates untouched, as it does from every + other hook in this file: nothing has been reserved when this runs, so an interrupt leaves + nothing to tidy. + """ + try: + # `object`, not the annotation's `Mapping`: the annotation is what the operator + # promised, and the check below is what happens when the promise is not kept. + # + # **Every line that touches what the provider handed back is inside this `try`**, the + # `isinstance` included: `isinstance` reads `__class__`, and an object whose `__class__` + # raises used to carry its own message out of `Control` as a raw exception, with no + # refusal reason and no receipt. A provider's return value is the operator's data, and + # nothing about it may escape as anything but `precondition_unavailable`. + state: object = provider(action) + if not isinstance(state, Mapping): + return None, f"returned {type(state).__name__}, not a mapping" + return _precondition_fingerprint(state), None + except Exception as exc: + return None, type(exc).__name__ + + +def _hash_or_none(value: object) -> str | None: + """A fingerprint read back out of an event's data, or `None` for anything else. + + Events are JSON, and a row-writer can put anything in one. A fingerprint is a string or it + is nothing, and a resumed leg's receipt says `null` rather than whatever was found. + """ + return value if isinstance(value, str) else None + + +def _checked_preconditions(preconditions: object, where: str) -> _Preconditions | None: + """`None`, or a callable (SPEC-v0.7 §6.2). Anything else is a wiring bug, refused at the + door: at decoration time for `@protect`, before any evidence for `execute`.""" + if preconditions is not None and not callable(preconditions): + raise InvalidArgument( + f"{where}: preconditions must be a callable taking the Action and returning a " + f"mapping, not {type(preconditions).__name__}" + ) + return cast("_Preconditions | None", preconditions) + + # --- Control --------------------------------------------------------------------------- @@ -626,6 +746,7 @@ def execute( lease: timedelta | None = None, reconcile: Callable[[str], ReconcileOutcome] | None = None, reconcile_eagerly: bool = False, + preconditions: Callable[[Action], Mapping[str, Any]] | None = None, ) -> Receipt: """Decide, run and record one action. Returns the receipt for its terminal state. @@ -641,10 +762,26 @@ def execute( `reconcile` asks the remote what happened to an effect whose outcome is unknown, and is the only authority besides a human that may move a record out of `AMBIGUOUS` (SPEC-v0.2 §2.2). It runs at most once per call. + + `preconditions` reads the state an approval depends on (SPEC-v0.7 §6). It is called + with the `Action` and returns a mapping, which is hashed through `canonical_bytes` and + kept only as a fingerprint. Under `APPROVE` it is called when the approval is requested, + and the fingerprint is stored with the request; and on the presenting pass it is called + again **strictly before** the store call that consumes the approval, before each such + call, and the action is refused `ApprovalMismatch(reason="precondition_changed")` where + the two differ, `"precondition_missing"` where only one side has one, and + `"precondition_unavailable"` where the provider raises, returns something that is not a + mapping, or returns one `canonical_bytes` refuses. A refusal reserves nothing and leaves + the approval granted. **It + narrows the window between a human's decision and the effect; it does not close it.** + A change landing after the comparison and before the reservation is not refused, and + the world can move again before the executor's request lands (§6.7). `ALLOW`, `DENY` + and `Control.resume` never call it. """ self._report_clock_skew() if effect_key is not None and not effect_key: raise InvalidArgument("effect_key must be a non-empty string or None") + provider = _checked_preconditions(preconditions, "execute(preconditions=...)") self._check_environment(action) held = self._lease if lease is None else _checked_lease(lease, "execute(lease=...)") reconciler = _reconciler(reconcile, reconcile_eagerly, "execute") @@ -670,7 +807,15 @@ def execute( observation.decided(expired) observation.block(PRINCIPAL_EXPIRED) return self._observed( - action, expired, executor, effect_key, started_at, observation, reconciler, held + action, + expired, + executor, + effect_key, + started_at, + observation, + reconciler, + held, + provider, ) self._append(EventType.ACTION_DENIED, action, {"reason": PRINCIPAL_EXPIRED}, effect_key) self._record( @@ -711,6 +856,7 @@ def execute( observation, reconciler, held, + provider, ) self._refuse_authority(action, result, started_at, effect_key) self._append( @@ -746,6 +892,7 @@ def execute( observation, reconciler, held, + provider, ) # SPEC-v0.6 §7.2.1's third bullet: *"the refusal is recorded against the approval # so the history shows a grant that met a denial."* It was not. An independent @@ -781,10 +928,19 @@ def execute( ) if observation is not None: return self._observed( - action, evaluation, executor, effect_key, started_at, observation, reconciler, held + action, + evaluation, + executor, + effect_key, + started_at, + observation, + reconciler, + held, + provider, ) + compared = _Compared() approval, reservation = self._secure( - action, evaluation, started_at, effect_key, held, reconciler + action, evaluation, started_at, effect_key, held, reconciler, provider, compared ) attempt = 1 if reservation is None else reservation.attempt @@ -796,7 +952,13 @@ def execute( # its lease expired and another attempt declared the effect AMBIGUOUS. The # refusal is terminal for this proposal, so it gets a receipt like any other. self._refused( - action, evaluation, started_at, effect_key, refused, approval=approval + action, + evaluation, + started_at, + effect_key, + refused, + approval=approval, + compared=compared, ) raise self._append(EventType.EXECUTION_STARTED, action, {}, effect_key, approval=approval) @@ -810,6 +972,7 @@ def execute( started_at, reconciler, held_key=effect_key, + compared=compared, ) # --- observe mode (SPEC-v0.3 §6) ---------------------------------------------------- @@ -824,6 +987,7 @@ def _observed( observation: _Observation, reconciler: _Reconciler, lease: timedelta, + preconditions: _Preconditions | None = None, ) -> Receipt: """Run an action observe mode has finished deciding about (SPEC-v0.3 §6.2). @@ -833,8 +997,9 @@ def _observed( attempted from every one of them: observe mode *executes*, and an attempt that runs an effect must own its key where it can. """ + compared = _Compared() approval, reservation = self._observe_secure( - action, evaluation, effect_key, lease, observation + action, evaluation, effect_key, lease, observation, preconditions, compared ) held_key = None if reservation is None else effect_key attempt = 1 if reservation is None else reservation.attempt @@ -865,6 +1030,7 @@ def _observed( reconciler, held_key=held_key, observation=observation, + compared=compared, ) def _observe_secure( @@ -874,6 +1040,8 @@ def _observe_secure( effect_key: str | None, lease: timedelta, observation: _Observation, + preconditions: _Preconditions | None, + compared: _Compared, ) -> tuple[Approval | None, Reservation | None]: """Attempt what `_secure` takes, record every refusal, and hold nothing it lost. @@ -900,7 +1068,9 @@ def _observe_secure( if approval_id is None and effect_key is None: return None, None try: - approval, reservation = self._observe_take(action, approval_id, effect_key, lease) + approval, reservation = self._observe_take( + action, approval_id, effect_key, lease, preconditions, compared + ) except (DuplicateEffect, AmbiguousEffect) as refused: if isinstance(refused, AmbiguousEffect): # SPEC-v0.7 §3.6, as in `_secure`: observe mode reserves, so it meets E3 too. @@ -922,7 +1092,7 @@ def _observe_secure( self._append( EventType.APPROVAL_INVALIDATED, action, - {"reason": mismatch.reason, "action_hash": action.action_hash}, + self._invalidated(action, mismatch, compared), effect_key, approval_id=approval_id, ) @@ -954,7 +1124,13 @@ def _observe_secure( return approval, reservation def _observe_take( - self, action: Action, approval_id: str | None, effect_key: str | None, lease: timedelta + self, + action: Action, + approval_id: str | None, + effect_key: str | None, + lease: timedelta, + preconditions: _Preconditions | None, + compared: _Compared, ) -> tuple[Approval | None, Reservation | None]: """Observe mode's `_take`: **check the grant, never spend it** (SPEC-v0.6 §7.2.3). @@ -972,6 +1148,11 @@ def _observe_take( The verdict is computed with the same pure `check_consumable` every store applies, so the four refusals observe mode records are the four `_secure` would have raised, from one implementation rather than two. + + SPEC-v0.7 §6.8: **observe mode rechecks and records.** Where the grant is one enforce + mode would consume, the precondition is compared exactly as `_recheck` compares it, and + a refusal is raised here for `_observe_secure` to record; the action still runs and no + grant is spent. """ if approval_id is not None: verdict = check_consumable( @@ -985,6 +1166,8 @@ def _observe_take( # `as_approval()` and not a hand-built `Approval`: one construction, so observe # mode cannot drift from what a store returns. record = verdict.record + if record is not None: + self._compare(action, record, preconditions, compared) approval = None if record is None else record.as_approval() # **And no event, which is the change worth naming.** `_observe_secure` used to # append `APPROVAL_CONSUMED` here. Nothing is consumed now, and there is no @@ -1018,7 +1201,7 @@ def resume(self, continuation: str, executor: Callable[[], Any]) -> Receipt: self._report_clock_skew() held = self._store.take_continuation(continuation) action = held.action - started_at, approval = self._resumed_context(action, held.record.created_at) + started_at, approval, compared = self._resumed_context(action, held.record.created_at) # SPEC-v0.3 §2.5 — a continuation is a store-wide token, so a Control in another # environment can reach one. Evaluating a staging action inside a production # deployment is the fail-open §2.5 exists to close. @@ -1062,6 +1245,10 @@ def resume(self, continuation: str, executor: Callable[[], Any]) -> Receipt: observation.decided(evaluation) if evaluation.decision is Decision.DENY: observation.block(evaluation.reason) + # SPEC-v0.7 §6.8: **no recheck on a resumed leg**, for `v0.6 §7.2.3`'s reason. The + # approval was consumed on the first leg, after that leg's recheck, and refusing here + # would strand a reservation the remote may already be acting on. The receipt says so: + # the fingerprint the approval was requested with, and no recheck. return self._outcome( action, evaluation, @@ -1073,32 +1260,46 @@ def resume(self, continuation: str, executor: Callable[[], Any]) -> Receipt: _Reconciler(None, False), held_key=held.effect_key, observation=observation, + compared=compared, ) def _resumed_context( self, action: Action, fallback: datetime - ) -> tuple[datetime, Approval | None]: + ) -> tuple[datetime, Approval | None, _Compared]: """Recover the original attempt's evidence, including after a process restart. EXECUTION_STARTED durably binds the consumed approval to this action ID. Looking up a grant by action hash instead could attribute a later, unrelated approval. These events already exist in every supported store, including older databases; no continuation schema change or in-process cache is needed. + + SPEC-v0.7 §6.11 — and the first leg's comparison, off its `APPROVAL_CONSUMED`. This leg + rechecks nothing (§6.8), and its receipt is the only one the action gets: recording what + the leg that consumed the approval compared is what makes *on a committed action they + are equal* true of a resumed one too. """ proposed = fallback started = fallback approval_id = None + compared = _Compared() for event in self._store.events(): if event.action_id != action.action_id: continue if event.type is EventType.ACTION_PROPOSED: proposed = event.ts + elif event.type is EventType.APPROVAL_CONSUMED: + compared.at_request = _hash_or_none(event.data.get("precondition_at_request")) + compared.at_recheck = _hash_or_none(event.data.get("precondition_at_recheck")) elif event.type is EventType.EXECUTION_STARTED: started = proposed approval_id = event.approval_id record = None if approval_id is None else self._store.get_approval(approval_id) approval = None if record is None else record.as_approval() - return started, approval + if compared.at_request is None and record is not None: + # An approval consumed before this event carried the comparison, or by a path that + # compared nothing: the record still says what it was requested with. + compared.at_request = record.request.precondition_fingerprint + return started, approval, compared def _outcome( self, @@ -1113,6 +1314,7 @@ def _outcome( *, held_key: str | None, observation: _Observation | None = None, + compared: _Compared | None = None, ) -> Receipt: """Run the executor and record what happened (SPEC-v0.1 §5.5). @@ -1172,6 +1374,7 @@ def _outcome( attempt, refused, approval, + compared, did=f"the executor raised NotExecuted: {exc}", ) raise @@ -1192,6 +1395,7 @@ def _outcome( effect_key=effect_key, attempt=attempt, observation=observation, + compared=compared, ) raise except BaseException as exc: @@ -1257,6 +1461,7 @@ def _outcome( effect_key=effect_key, attempt=attempt, observation=observation, + compared=compared, ) raise if held_key is not None: @@ -1275,6 +1480,7 @@ def _outcome( attempt, refused, approval, + compared, did="the executor returned, so the remote may well have acted", ) raise @@ -1288,6 +1494,7 @@ def _outcome( effect_key=effect_key, attempt=attempt, observation=observation, + compared=compared, ) def _suspend( @@ -1418,6 +1625,8 @@ def _secure( effect_key: str | None, lease: timedelta, reconciler: _Reconciler, + preconditions: _Preconditions | None, + compared: _Compared, ) -> tuple[Approval | None, Reservation | None]: """Take everything this action needs before it may run: the grant, and the key. @@ -1425,9 +1634,16 @@ def _secure( first, so a replayed approval is what gets raised when a duplicate effect would also apply (T4), and a refused reservation leaves the approval granted for the action the human actually saw (T12). + + SPEC-v0.7 §6.2: under `APPROVE`, the precondition is rechecked **strictly before each + `_take`**, so the provider can never run after a reservation exists. "Each" because this + may take twice, once more after a `reconcile` hook moves an `AMBIGUOUS` record, and the + hook is a network call whose duration would otherwise sit inside the window. """ approval_id = ( - self._presented(action, effect_key) if evaluation.decision is Decision.APPROVE else None + self._presented(action, effect_key, evaluation, started_at, preconditions) + if evaluation.decision is Decision.APPROVE + else None ) if approval_id is None and effect_key is None: # SPEC-v0.6 §7.2's `ALLOW` row, which §7.2.2 step 1 quietly assumed a reservation @@ -1447,6 +1663,12 @@ def _secure( # and whatever the second attempt meets is final. for reconciled in (False, True): try: + if approval_id is not None: + # **Immediately before `_take`, and nothing between them.** The window this + # narrows is the time from the provider's fetch to the store call; anything + # inserted here widens it, and a later item adding a check on this path + # (the attempt ceiling, §5.5) belongs before this line or after `_take`. + self._recheck(action, approval_id, preconditions, compared) approval, reservation = self._take(action, approval_id, effect_key, lease) break except AmbiguousEffect as refused: @@ -1468,6 +1690,7 @@ def _secure( effect_key, refused, approval_id=approval_id, + compared=compared, ) raise except ActionDenied as denied: @@ -1507,7 +1730,7 @@ def _secure( self._append( EventType.APPROVAL_INVALIDATED, action, - {"reason": mismatch.reason, "action_hash": action.action_hash}, + self._invalidated(action, mismatch, compared), effect_key, approval_id=approval_id, ) @@ -1520,21 +1743,32 @@ def _secure( approval_id=approval_id, approver=self._approver_of(approval_id), effect_key=effect_key, + compared=compared, ) raise except DuplicateEffect as refused: # SPEC §5.4 — this effect already happened or is happening now. The approval, # if one was presented, was not consumed: it is still worth something. self._refused( - action, evaluation, started_at, effect_key, refused, approval_id=approval_id + action, + evaluation, + started_at, + effect_key, + refused, + approval_id=approval_id, + compared=compared, ) raise if approval is not None: + # SPEC-v0.7 §6.11 — what was compared, on the event that says the grant was spent. + # A suspended action writes no receipt on this leg, and the resumed leg's is the + # only receipt it ever gets, so without this the one comparison that happened would + # leave no trace at all. Hashes only, and absent where nothing was compared. self._append( EventType.APPROVAL_CONSUMED, action, - {"approver": approval.approver}, + {"approver": approval.approver, **compared.spent()}, effect_key, approval_id=approval.approval_id, ) @@ -1686,6 +1920,7 @@ def _refused( *, approval: Approval | None = None, approval_id: str | None = None, + compared: _Compared | None = None, ) -> None: """Record a refusal by the effect key: the event, and a `blocked` receipt (§6.1).""" presented = approval.approval_id if approval is not None else approval_id @@ -1705,6 +1940,7 @@ def _refused( approval_id=presented, approver=self._approver_of(presented), effect_key=effect_key, + compared=compared, ) def _unrecorded( @@ -1716,6 +1952,7 @@ def _unrecorded( attempt: int, refused: CTRLRunError, approval: Approval | None, + compared: _Compared | None = None, *, did: str, ) -> None: @@ -1747,9 +1984,17 @@ def _unrecorded( approval=approval, effect_key=effect_key, attempt=attempt, + compared=compared, ) - def _presented(self, action: Action, effect_key: str | None) -> str: + def _presented( + self, + action: Action, + effect_key: str | None, + evaluation: Evaluation, + started_at: datetime, + preconditions: _Preconditions | None, + ) -> str: """The approval this call presents, or record a request and suspend the action. With nothing presented, `ApprovalRequired` is raised so the caller can come back with @@ -1760,11 +2005,37 @@ def _presented(self, action: Action, effect_key: str | None) -> str: presented = _PRESENTED_APPROVAL.get(None) if presented is not None: return presented + # SPEC-v0.7 §6.2: the fingerprint is captured here, before the request exists, and a + # provider that produces none refuses the action before any human is asked (§6.5). + fingerprint = None + if preconditions is not None: + fingerprint, error = _fetched(preconditions, action) + if fingerprint is None: + self._refuse_unfetched_request(action, evaluation, started_at, effect_key, error) # SPEC-v0.6 §7.1 — the request records which policy was in force while it was built. # `Control` is the only object holding both a policy and a provider, and the provider - # protocol takes neither, so it travels the way a presented approval does. - with policy_in_force(self._policy_hash): - request = self._approvals.request(action, self._approval_ttl) + # protocol takes neither, so it travels the way a presented approval does. The + # fingerprint travels beside it, by the same route and for the same reason. + try: + with policy_in_force(self._policy_hash), _precondition_at_request(fingerprint): + request = self._approvals.request(action, self._approval_ttl) + except Exception: + if fingerprint is not None: + # SPEC-v0.7 §6.4's residual, the half with no race in it: a provider that + # recorded a request and *then* raised leaves a row `Control` never learns the + # id of, so there is nothing to withdraw. The exception is the provider's and + # propagates; what the kernel owes is a line saying a fingerprint was computed, + # so an operator knows an unfingerprinted request may be sitting in the store. + _LOG.warning( + "%s: the approval provider raised after a precondition fingerprint was " + "computed; if it recorded a request before raising, that request carries no " + "fingerprint and no presentation of it can compare anything (SPEC-v0.7 §6.4)", + action.name, + ) + raise + # The request exists in the store from here, whatever happens next, so it is recorded + # before anything is decided about it: a row with no `APPROVAL_REQUESTED` behind it is + # evidence nobody can read. self._append( EventType.APPROVAL_REQUESTED, action, @@ -1772,6 +2043,16 @@ def _presented(self, action: Action, effect_key: str | None) -> str: effect_key, approval_id=request.request_id, ) + if fingerprint is not None and not self._recorded(request, fingerprint): + # SPEC-v0.7 §6.4: **never a skip**, and without this it was one. A provider that + # builds its own `ApprovalRequest` (`build_request` is package-internal) and a store + # that does not persist the column both leave an approval that was requested with a + # fingerprint carrying none -- and an approval with none, presented by a call that + # names no provider, is 0.6.1's path: consumed with nothing compared. The request + # pass is where that is visible, so it is where it is refused. + self._refuse_unrecorded_request( + action, evaluation, started_at, effect_key, request, fingerprint + ) raise ApprovalRequired( f"{action.name} requires approval: run 'ctrlrun approve {request.request_id}', " f"then retry inside ctrlrun.with_approval({request.request_id!r})", @@ -1779,6 +2060,312 @@ def _presented(self, action: Action, effect_key: str | None) -> str: action_id=action.action_id, ) + def _refuse_unfetched_request( + self, + action: Action, + evaluation: Evaluation, + started_at: datetime, + effect_key: str | None, + error: str | None, + ) -> NoReturn: + """SPEC-v0.7 §6.2's request-pass row: no fingerprint, so no request and no human. + + `ActionDenied(reason="precondition_unavailable")`, `ACTION_DENIED` with the reason, and a + `denied` receipt keeping `decision: approve`, because the policy did decide `approve` and + what refused the action was a provider that could not be read. The error is by type + name only (§6.5). + """ + _LOG.warning( + "%s: the precondition provider produced no fingerprint (%s), so no approval is " + "requested (SPEC-v0.7 §6.5)", + action.name, + error, + ) + self._append( + EventType.ACTION_DENIED, + action, + {"reason": _PRECONDITION_UNAVAILABLE, "error": error}, + effect_key, + ) + message = ( + f"{action.name}: the precondition provider produced no fingerprint ({error}), so no " + "approval was requested" + ) + self._record( + action, + evaluation, + ReceiptResult.DENIED, + started_at, + error=message, + effect_key=effect_key, + ) + raise ActionDenied(message, reason=_PRECONDITION_UNAVAILABLE, action_id=action.action_id) + + def _recorded(self, request: ApprovalRequest, fingerprint: str) -> bool: + """Did the fingerprint reach the record a later presentation will read? (§6.4) + + **The read-back, and only the read-back.** An earlier build also compared the returned + `ApprovalRequest`, and the review found that guard subsumed: a presentation reads the + store, so a returned object that differs from the row changes nothing a later pass sees, + and every way of losing the fingerprint that a presentation could meet -- a store + without the column, a provider that builds its own request -- is visible here. A guard + that can only fire where a later one would, with the same result, is documentation + rather than defence (`CONTRIBUTING.md`, the first of the four shapes of a false green). + + One `get_approval`, on the request pass only. + """ + record = self._store.get_approval(request.request_id) + return record is not None and record.request.precondition_fingerprint == fingerprint + + def _refuse_unrecorded_request( + self, + action: Action, + evaluation: Evaluation, + started_at: datetime, + effect_key: str | None, + request: ApprovalRequest, + fingerprint: str, + ) -> NoReturn: + """Refuse, and leave nothing behind that another path could spend (SPEC-v0.7 §6.4). + + Refusing this call alone would not be enough: the request the provider already recorded + is answerable, and a human granting it would leave a grant any call naming no provider + could spend with nothing compared. It is withdrawn through `deny_approval`, an existing + store method, so `check_consumable` refuses it for ever with a denial's own reason; a + grant that landed inside the window is withdrawn by being spent instead, on nothing. + + The residual is stated rather than hidden: `Control` learns the request exists only when + the provider returns, so an approval granted **and presented** before that is spent + before there is anything to withdraw. Closing that needs a store call that records the + request and its fingerprint together, and `StateStore` is frozen (`v0.6 §9.2`). + """ + withdrawn = self._withdraw(request) + compared = _Compared() + compared.at_recheck = fingerprint + outcome = ( + f"the request is withdrawn ({withdrawn})" + if withdrawn in _WITHDRAWALS + else f"the request could not be withdrawn ({withdrawn})" + ) + _LOG.warning( + "%s: the precondition fingerprint was not recorded with approval request %s, so %s " + "and the action is refused (SPEC-v0.7 §6.4)", + action.name, + request.request_id, + outcome, + ) + self._append( + EventType.APPROVAL_INVALIDATED, + action, + { + "reason": _PRECONDITION_MISSING, + "action_hash": action.action_hash, + "withdrawn": withdrawn, + **compared.data(), + }, + effect_key, + approval_id=request.request_id, + ) + self._append( + EventType.ACTION_DENIED, + action, + {"reason": _PRECONDITION_MISSING}, + effect_key, + approval_id=request.request_id, + ) + message = ( + f"{action.name}: the precondition fingerprint was not recorded with approval " + f"request {request.request_id}, so no presentation of it could compare anything; " + f"{outcome}" + ) + self._record( + action, + evaluation, + ReceiptResult.DENIED, + started_at, + error=message, + approval_id=request.request_id, + effect_key=effect_key, + compared=compared, + ) + raise ActionDenied(message, reason=_PRECONDITION_MISSING, action_id=action.action_id) + + def _withdraw(self, request: ApprovalRequest) -> str: + """Make a request nobody may answer, with the methods a store already has (§6.4). + + `deny_approval` for a request still pending, which is the ordinary case and leaves a + record `check_consumable` refuses by `approval_denied`. A record that is no longer + pending refuses that, so a grant that landed inside the window is spent instead: a + consumed approval authorizes nothing either, and nothing was reserved or run for it. + + **What it returns is what happened, and it reads the row back to find out.** An earlier + build reported the status it had read *before* its own failed `consume_approval`, and + said `consumed` whether this call had spent the grant or another caller had: a + presentation that won the race ran the action while the evidence said the request had + been withdrawn `granted`. The answers are distinct now -- `denied` and `spent` for this + call's own writes, `already_consumed` where somebody else got there first, and + `not_withdrawn:` where nothing was withdrawn -- and only the first + two let the refusal call itself a withdrawal. + + **Every exception is caught, and the width is the point**, as in + `_spend_unneeded_approval` for the opposite reason. There the action proceeds because + there is nothing to protect; here it is refused whatever the store does, so catching a + driver error can only add a refusal and its evidence. Letting one out left no + `ACTION_DENIED`, no receipt, and an answerable request carrying no fingerprint, which is + the hole this method exists to close. + """ + try: + self._store.deny_approval(request.request_id, _WITHDRAWN_BY) + return "denied" + except Exception as refused: + _LOG.info("%s could not be denied (%s); it is not pending", request.request_id, refused) + record = self._read_back(request) + if record is not None and record.status is ApprovalStatus.GRANTED: + try: + self._store.consume_approval(request.request_id, record.action_hash) + return "spent" + except Exception as refused: + _LOG.warning( + "%s was granted inside the window and could not be spent (%s)", + request.request_id, + refused, + ) + found = self._read_back(request) + if found is None: + return "not_withdrawn:absent" + if found.status is ApprovalStatus.DENIED: + # Denied while this call was looking, by a human or by another withdrawal: the + # request is unanswerable, which is what this method is for. + return "denied" + if found.status is ApprovalStatus.CONSUMED: + return "already_consumed" + return f"not_withdrawn:{found.status}" + + def _read_back(self, request: ApprovalRequest) -> ApprovalRecord | None: + """The record as it stands now, or `None` where there is none or it cannot be read. + + A store that raises here leaves the caller saying `not_withdrawn:absent`, which is the + honest answer when nothing can be read: it claims no write. + """ + try: + return self._store.get_approval(request.request_id) + except Exception as refused: + _LOG.warning( + "%s: the approval record could not be read back (%s)", request.request_id, refused + ) + return None + + def _recheck( + self, + action: Action, + approval_id: str, + preconditions: _Preconditions | None, + compared: _Compared, + ) -> None: + """SPEC-v0.7 §6.2: the precondition, compared **strictly before** the store call that + consumes the approval. Raises the refusal; returns where the store call may proceed. + + `Control` reads the record it is about to present (`get_approval`, an existing read) + and decides with `check_consumable`, the pure function every store applies. Where no + provider is named and the record carries no fingerprint, the precondition question does + not arise and the store call is 0.6.1's exactly. + + **Where the verdict is a refusal, the provider is not called** (§6.6), and the refusal + is raised from this read, with the reason 0.6.1 gives, and **nothing is written to the + store**. Not from the store call: a `pending` record a human grants between this read + and that call would then be consumed with no recheck, which is a skip. And nothing + written, because the only write this read could ask for is the lapse of an expired + grant, and whose clock decides that is `v0.1 §4.2 A3`'s question: the answer stays the + store's. A `Control` whose clock ran ahead of its store's used to send the grant to + `consume_approval`, the store consumed it by its own clock, and the row then said + `consumed` while the events said expired and the receipt said blocked. Safe, and untrue. + The row keeps what the store gave it, `APPROVAL_EXPIRED` records the lapse this clock + saw, and `check_consumable` refuses the grant at every later presentation anyway. + + What the record carried is recorded either way (§6.11): a refusal that would have + happened whatever the world did still says which fingerprint the approval was + requested with. + + This narrows the window between the human's decision and the reservation to the time + between this fetch and that store call, and does not close it (§6.7). + """ + compared.reset() + record = self._store.get_approval(approval_id) + stored = None if record is None else record.request.precondition_fingerprint + if preconditions is None and stored is None: + return + compared.at_request = stored + verdict = check_consumable(record, approval_id, action.action_hash, self._clock()) + if verdict.refusal is not None: + raise verdict.refusal + assert record is not None # a verdict with no refusal carries its record + self._compare(action, record, preconditions, compared) + + def _compare( + self, + action: Action, + record: ApprovalRecord, + preconditions: _Preconditions | None, + compared: _Compared, + ) -> None: + """The comparison itself, for `_recheck` and for observe mode (SPEC-v0.7 §6.2, §6.8). + + Three refusals, each its own reason, and `compared` says what was compared: both + fingerprints where both exist, the one that exists where only one does, and the + provider's failure by type. **Never a skip**: a fingerprint on only one side is + `precondition_missing`, because "skip" would mean a store that drops the column, or a + path that names no provider, turns the check off (§6.4). + """ + stored = record.request.precondition_fingerprint + compared.at_request = stored + if preconditions is None and stored is None: + return + approval_id = record.approval_id + if preconditions is not None: + fresh, error = _fetched(preconditions, action) + if fresh is None: + compared.error = error + _LOG.warning( + "%s: the precondition provider produced no fingerprint (%s); approval %s is " + "refused and left granted (SPEC-v0.7 §6.5)", + action.name, + error, + approval_id, + ) + raise ApprovalMismatch( + f"approval {approval_id}: the precondition provider produced no " + f"fingerprint ({error}); nothing was reserved", + reason=_PRECONDITION_UNAVAILABLE, + approval_id=approval_id, + ) + compared.at_recheck = fresh + if stored is None or compared.at_recheck is None: + raise ApprovalMismatch( + f"approval {approval_id} has a precondition fingerprint on one side only " + f"(requested with {stored}, presented with {compared.at_recheck}); a fingerprint " + "on one side is a refusal and never a skip", + reason=_PRECONDITION_MISSING, + approval_id=approval_id, + ) + if stored != compared.at_recheck: + raise ApprovalMismatch( + f"approval {approval_id} was granted against precondition {stored} and the " + f"provider now reports {compared.at_recheck}; the approval is left granted", + reason=_PRECONDITION_CHANGED, + approval_id=approval_id, + ) + + @staticmethod + def _invalidated( + action: Action, mismatch: ApprovalMismatch, compared: _Compared + ) -> dict[str, Any]: + """`APPROVAL_INVALIDATED`'s data: the reason, and for a precondition refusal the two + fingerprints it compared, hashes only, and the provider's failure by type (§6.2).""" + data: dict[str, Any] = {"reason": mismatch.reason, "action_hash": action.action_hash} + if mismatch.reason in _PRECONDITION_REASONS: + data.update(compared.data()) + return data + def _take( self, action: Action, approval_id: str | None, effect_key: str | None, lease: timedelta ) -> tuple[Approval | None, Reservation | None]: @@ -2037,6 +2624,7 @@ def _record( effect_key: str | None = None, attempt: int = 1, observation: _Observation | None = None, + compared: _Compared | None = None, ) -> Receipt: # SPEC-v0.3 §6.3 — one place turns a terminal outcome into an observed receipt, so # `result`, `execution` and `would_have` cannot disagree about the same action. The @@ -2070,6 +2658,10 @@ def _record( policy_hash=self._policy_hash, policy_version=self._policy.version, controls=evaluation.controls, + # SPEC-v0.7 §6.11: what the presenting pass compared, hashes only, `None` where + # there was none. + precondition_at_request=None if compared is None else compared.at_request, + precondition_at_recheck=None if compared is None else compared.at_recheck, ) # The store assigns `seq`, `prev_hash` and `hash` (SPEC-v0.6 §6.2, §6.3), so what goes # to the sinks and back to the caller is the **chained** receipt. Handing the unchained @@ -2237,6 +2829,7 @@ def protect( reconcile: Callable[[str], ReconcileOutcome] | None = None, reconcile_eagerly: bool = False, control: Control | None = None, + preconditions: Callable[[Action], Mapping[str, Any]] | None = None, ) -> Callable[[Callable[P, R]], Callable[P, R]]: """Bind a function to an action name: every call becomes a decided, recorded Action. @@ -2253,9 +2846,16 @@ def protect( `reconcile` asks the remote what happened to an effect whose outcome is unknown (SPEC-v0.2 §2). With `reconcile_eagerly`, it also runs immediately after this call produces an `AMBIGUOUS` outcome, rather than only when one blocks a later attempt. + + `preconditions` reads the state an approval depends on, and is `Control.execute`'s keyword + (SPEC-v0.7 §6.2): called with the `Action` when the approval is requested and again on the + presenting pass, before the store call that consumes it, with a refusal where the two + fingerprints differ or only one exists. The recheck narrows the window a human's approval + leaves open; it does not close it (§6.7). Not callable is refused here, at decoration time. """ if not name: raise InvalidArgument("protect(name=...) must be a non-empty action name") + provider = _checked_preconditions(preconditions, f"protect({name!r}, preconditions=...)") _check_template(name, "effect", effect) _check_template(name, "resource", resource) held = None if lease is None else _checked_lease(lease, f"protect({name!r}, lease=...)") @@ -2336,6 +2936,7 @@ def executor() -> R: lease=held, reconcile=reconcile, reconcile_eagerly=reconcile_eagerly, + preconditions=provider, ) except ApprovalRequired as pending: if not wait: @@ -2353,6 +2954,7 @@ def executor() -> R: lease=held, reconcile=reconcile, reconcile_eagerly=reconcile_eagerly, + preconditions=provider, ) return returned[0] diff --git a/src/ctrlrun/migrations.py b/src/ctrlrun/migrations.py index 65971ed..1fad644 100644 --- a/src/ctrlrun/migrations.py +++ b/src/ctrlrun/migrations.py @@ -294,6 +294,20 @@ def sql(self, dialect: str) -> tuple[str, ...]: 'ALTER TABLE approvals ADD COLUMN IF NOT EXISTS policy_hash_at_approval TEXT COLLATE "C"', ) +#: SPEC-v0.7 §6.11: the precondition fingerprint captured when an approval was requested. A +#: column for `0004`'s reason: `ApprovalRecord` is rebuilt from columns, so a value the recheck +#: reads back must be one. Nullable, and **not backfilled**: every approval requested before +#: this migration was requested without a provider, which is exactly what `NULL` means. +#: +#: A hash and never the state it was computed from (§6.10), so this column holds nothing a +#: reader of the approvals table could learn the resource's state from. +_PRECONDITION_FINGERPRINT: Final = ( + "ALTER TABLE approvals ADD COLUMN precondition_fingerprint TEXT", +) +_PRECONDITION_FINGERPRINT_PG: Final = ( + 'ALTER TABLE approvals ADD COLUMN IF NOT EXISTS precondition_fingerprint TEXT COLLATE "C"', +) + #: The ordered set this binary knows. `NNNN_snake_name`: four digits, zero-padded, so #: lexicographic order is application order. MIGRATIONS: Final[tuple[Migration, ...]] = ( @@ -301,6 +315,11 @@ def sql(self, dialect: str) -> tuple[str, ...]: Migration("0002_receipt_chain", _RECEIPT_CHAIN, postgres=_RECEIPT_CHAIN_PG), Migration("0003_resolved_by", _RESOLVED_BY, postgres=_RESOLVED_BY_PG), Migration("0004_policy_provenance", _POLICY_PROVENANCE, postgres=_POLICY_PROVENANCE_PG), + Migration( + "0005_precondition_fingerprint", + _PRECONDITION_FINGERPRINT, + postgres=_PRECONDITION_FINGERPRINT_PG, + ), ) HEAD: Final = MIGRATIONS[-1].id diff --git a/src/ctrlrun/postgres.py b/src/ctrlrun/postgres.py index d97fe0b..adbb755 100644 --- a/src/ctrlrun/postgres.py +++ b/src/ctrlrun/postgres.py @@ -70,7 +70,7 @@ MissingDependency, ) from .migrations import migrate -from .receipt import Event, EventType, Receipt +from .receipt import RECEIPT_SCHEMA, Event, EventType, Receipt, _document_hash, _stored_receipt from .state import ( ClockSkew, DelegationRecord, @@ -692,8 +692,8 @@ def _read_approval(self, connection: Any, approval_id: str) -> ApprovalRecord | with connection.cursor() as cursor: cursor.execute( "SELECT approval_id, action_hash, status, action_json, approver, created_at, " - "granted_at, expires_at, consumed_at, policy_hash_at_approval " - f"FROM {self._q}.approvals WHERE approval_id = %s", + "granted_at, expires_at, consumed_at, policy_hash_at_approval, " + f"precondition_fingerprint FROM {self._q}.approvals WHERE approval_id = %s", (approval_id,), ) row = cursor.fetchone() @@ -707,6 +707,7 @@ def _read_approval(self, connection: Any, approval_id: str) -> ApprovalRecord | created_at=datetime.fromisoformat(str(row[5])), expires_at=datetime.fromisoformat(str(row[7])), policy_hash=None if row[9] is None else str(row[9]), + precondition_fingerprint=None if row[10] is None else str(row[10]), ), status=ApprovalStatus(row[2]), approver=row[4], @@ -1461,8 +1462,8 @@ def put_approval_request(self, request: ApprovalRequest) -> None: f"INSERT INTO {self._q}.approvals(" "approval_id, action_hash, status, action_json, " "approver, created_at, granted_at, expires_at, consumed_at, " - "policy_hash_at_approval) " - "VALUES(%s,%s,%s,%s,NULL,%s,NULL,%s,NULL,%s)", + "policy_hash_at_approval, precondition_fingerprint) " + "VALUES(%s,%s,%s,%s,NULL,%s,NULL,%s,NULL,%s,%s)", ( request.request_id, request.action_hash, @@ -1471,6 +1472,7 @@ def put_approval_request(self, request: ApprovalRequest) -> None: _iso(request.created_at), _iso(request.expires_at), request.policy_hash, + request.precondition_fingerprint, ), ) except Exception as duplicate: @@ -1860,8 +1862,15 @@ def put_receipt(self, receipt: Receipt) -> Receipt: "the receipt chain has no head row; this database predates " "0002_receipt_chain and was not migrated" ) - chained = replace(receipt, seq=int(head[0]), prev_hash=str(head[1])) - digest = chained.chain_hash() + # SPEC-v0.7 §6.11 rule (b), as SQLite's: one dictionary, hashed and serialized. + chained = replace( + receipt, + schema=RECEIPT_SCHEMA, + seq=int(head[0]), + prev_hash=str(head[1]), + ) + document = chained.to_dict() + digest = _document_hash(document) cursor.execute( f"INSERT INTO {self._q}.receipts(" "receipt_id, action_id, effect_key, result, json, ts, seq, prev_hash, hash) " @@ -1871,7 +1880,7 @@ def put_receipt(self, receipt: Receipt) -> Receipt: chained.action_id, chained.effect_key, str(chained.result), - json.dumps(chained.to_dict(), sort_keys=True), + json.dumps(document, sort_keys=True), _iso(chained.finished_at), chained.seq, chained.prev_hash, @@ -1907,9 +1916,7 @@ def receipts(self) -> tuple[Receipt, ...]: # `json` here is `json.dumps(..., sort_keys=True)` and SQLite's is `to_json()`, which are # different byte strings -- and the chain does not care, because `chain_hash` recomputes # the canonical form from the parsed document rather than hashing whatever was stored. - return tuple( - replace(Receipt.from_dict(json.loads(str(row[0]))), hash=row[1]) for row in rows - ) + return tuple(_stored_receipt(json.loads(str(row[0])), row[1]) for row in rows) def chain_head(self) -> tuple[int, str] | None: with self._connection().cursor() as cursor: diff --git a/src/ctrlrun/receipt.py b/src/ctrlrun/receipt.py index e1e8380..0393b03 100644 --- a/src/ctrlrun/receipt.py +++ b/src/ctrlrun/receipt.py @@ -15,14 +15,14 @@ import os import secrets from collections.abc import Mapping -from dataclasses import dataclass, field +from dataclasses import dataclass, field, replace from datetime import UTC, datetime from enum import StrEnum from pathlib import Path from typing import Any, Final, Protocol from .action import Principal, canonical_bytes -from .errors import InvalidArgument +from .errors import CTRLRunError, InvalidArgument from .policy import Decision #: SPEC-v0.3 §12.2. The bump landed with build-list item 1, because that is when the first v2 @@ -32,7 +32,44 @@ #: `policy_version` and `controls`. Every `v2` field keeps its meaning, and `Receipt.from_dict` #: reads all five with `.get`, so a `v2` receipt on disk still parses -- which is the rule every #: reader upgrades before any writer switches. -RECEIPT_SCHEMA: Final = "ctrlrun.receipt/v3" +#: SPEC-v0.7 §6.11. `v4` adds `precondition_at_request` and `precondition_at_recheck`, and it is +#: the first bump that does not rehash every older receipt: a receipt read from a store is +#: hashed as the document it was read from, and renders under its own schema's label and keys. +RECEIPT_SCHEMA: Final = "ctrlrun.receipt/v4" + +_V1: Final = "ctrlrun.receipt/v1" +_V2: Final = "ctrlrun.receipt/v2" +_V3: Final = "ctrlrun.receipt/v3" +_V4: Final = "ctrlrun.receipt/v4" + +#: SPEC-v0.7 §6.11: each schema's top-level key set, exactly its released writers': `v1`, 19 +#: keys, by 0.1.0 and 0.2.0; `v2`, 21, by 0.3.0rc1 to 0.5.0; `v3`, 26, by 0.6.0 and 0.6.1; +#: `v4`, 28. Counted from those releases' own `to_dict`, not from memory. +_V1_KEYS: Final = ( + "schema", + "receipt_id", + "action_id", + "action", + "action_hash", + "principal", + "resource", + "arguments", + "environment", + "decision", + "decision_reason", + "approval_id", + "approver", + "effect_key", + "attempt", + "result", + "error", + "started_at", + "finished_at", +) +_V2_KEYS: Final = (*_V1_KEYS[:16], "execution", "would_have", *_V1_KEYS[16:]) +_V3_KEYS: Final = (*_V2_KEYS, "seq", "prev_hash", "policy_hash", "policy_version", "controls") +_V4_KEYS: Final = (*_V3_KEYS, "precondition_at_request", "precondition_at_recheck") +_KEYS: Final = {_V1: _V1_KEYS, _V2: _V2_KEYS, _V3: _V3_KEYS, _V4: _V4_KEYS} #: The two files of SPEC-v0.1 §6, written beside the state database. #: SPEC-v0.6 §6.2. The `prev_hash` of receipt 1, and the hash the head row starts at (§3.7), so @@ -252,7 +289,21 @@ def _controls_of(value: object) -> tuple[str, ...]: @dataclass(frozen=True) class Receipt: - """Portable evidence of one action that reached a terminal state (SPEC-v0.1 §6.1).""" + """Portable evidence of one action that reached a terminal state (SPEC-v0.1 §6.1). + + `ctrlrun.receipt/v4` (SPEC-v0.7 §6.11) adds `precondition_at_request` and + `precondition_at_recheck`: the fingerprint the approval was requested with, and the one + computed on the presenting pass, each `None` where there was none. Hashes only, never the + state they were computed from. On a refusal they say which side moved or was missing; on a + committed action they are equal, and the receipt records that the world was compared + before the reservation. That comparison narrows the window between a human's decision and + the effect and does not close it (§6.7). + + `schema` is the schema the receipt is written under. A receipt read from a store keeps the + one it was written with, renders under that schema's label and keys, and is hashed as the + document it was read from, so a `v3` receipt a released 0.6 wrote still rehashes to its + stored hash under a `v4` binary. + """ receipt_id: str action_id: str @@ -309,6 +360,28 @@ class Receipt: #: `verify_chain` compare stored against recomputed without the protocol growing a second #: reader (§9.1). hash: str | None = None + #: SPEC-v0.7 §6.11: the fingerprint the presented approval was requested with, and the one + #: the presenting pass computed. `None` where there was none. Read only from a `v4` + #: document, so no reader surfaces the value of a key a document's schema does not declare. + precondition_at_request: str | None = None + precondition_at_recheck: str | None = None + #: The schema this receipt is written under (§6.11). A receipt this binary builds is + #: `RECEIPT_SCHEMA`; one read from a store keeps the label its document declared, or `""` + #: where it declared none, which renders with no `schema` key at all. + schema: str = RECEIPT_SCHEMA + #: SPEC-v0.7 §6.11: **hash what was stored.** The document this receipt was read from, set + #: by the store read path *after* its own `replace(..., hash=...)`, and `None` on a receipt + #: this binary built. `chain_hash()` hashes it when present. + #: + #: Not an `__init__` parameter and not carried through `dataclasses.replace()` (rule (a)): + #: a modified copy has no stored document and is hashed from what it now says, so G11's own + #: tamper, `replace(target, decision_reason=...)`, is still `content_altered`, and a read-back + #: receipt written again is hashed from the dictionary `put_receipt` serializes. Excluded from + #: equality, because two receipts saying the same thing are the same evidence however each + #: was obtained. + _stored_document: Mapping[str, Any] | None = field( + default=None, init=False, repr=False, compare=False + ) def chain_hash(self) -> str: """This receipt's own hash: `sha256:` + SHA-256 of its canonical form (§6.2). @@ -319,13 +392,37 @@ def chain_hash(self) -> str: The canonical form is `v0.1 §2.3`'s, through `canonical_bytes`. A second canonicalizer is the drift this codebase must not have (§6.2). + + SPEC-v0.7 §6.11: a receipt read from a store is hashed as **the document it was read + from**, not as this binary would render it. Every schema then rehashes to its stored + hash, and every tamper the schema bump could hide (a key added, a label changed or + removed, a label nobody knows) changes that document and is `content_altered` by + construction, with no rule about key sets for a reader to get wrong. """ - return "sha256:" + hashlib.sha256(canonical_bytes(self.to_dict())).hexdigest() + document = self._stored_document + return _document_hash(self.to_dict() if document is None else document) def to_dict(self) -> dict[str, Any]: - """The receipt as plain JSON-serializable data, in the field order of SPEC §6.1.""" + """The receipt as plain JSON-serializable data, in the field order of SPEC §6.1. + + Rendered under its own schema's label and key set (SPEC-v0.7 §6.11): a `v4` receipt as + `v4`, a `v3` one as the `v3` document, and a `v1` or `v2` one under that version's label + and keys, where 0.6.1 rendered all three under the `v3` label. A label this binary does + not know renders under `v3`'s keys, the widest set it reads whatever the label says, + and never shows the two `v4` fields it did not read. The hash no longer depends on + this rendering for a receipt read from a store. + """ + full = self._full_document() + keys = _KEYS.get(self.schema, _V3_KEYS) + if self.schema == _V1: + full["principal"] = {"agent": self.principal.agent, "user": self.principal.user} + if not self.schema: + keys = keys[1:] + return {key: full[key] for key in keys} + + def _full_document(self) -> dict[str, Any]: return { - "schema": RECEIPT_SCHEMA, + "schema": self.schema, "receipt_id": self.receipt_id, "action_id": self.action_id, "action": self.action, @@ -351,6 +448,8 @@ def to_dict(self) -> dict[str, Any]: "policy_hash": self.policy_hash, "policy_version": self.policy_version, "controls": list(self.controls), + "precondition_at_request": self.precondition_at_request, + "precondition_at_recheck": self.precondition_at_recheck, } def to_json(self) -> str: @@ -359,7 +458,17 @@ def to_json(self) -> str: @classmethod def from_dict(cls, document: Mapping[str, Any]) -> Receipt: - """The inverse of `to_dict`: a receipt read back out of a store or a JSONL file.""" + """The inverse of `to_dict`: a receipt read back out of a store or a JSONL file. + + SPEC-v0.7 §6.11: **never raises over the schema or over a key.** `receipts()` builds + every row through here, so a raise on one tampered row would blind every reader at + once; an absent or unknown `schema`, or an extra key, is left to the hash to report. The + two precondition fields are read only from a `v4` document, so no reader surfaces the + value of a key the document's schema does not declare. A row that cannot be parsed at + all -- not an object, or missing a field every schema has -- raises as it did at 0.6.1. + """ + declared = document.get("schema") + schema = declared if isinstance(declared, str) else "" principal = document["principal"] expires_at = principal.get("expires_at") return cls( @@ -407,6 +516,13 @@ def from_dict(cls, document: Mapping[str, Any]) -> Receipt: policy_hash=document.get("policy_hash"), policy_version=document.get("policy_version"), controls=_controls_of(document.get("controls")), + precondition_at_request=( + document.get("precondition_at_request") if schema == _V4 else None + ), + precondition_at_recheck=( + document.get("precondition_at_recheck") if schema == _V4 else None + ), + schema=schema, ) @classmethod @@ -416,6 +532,28 @@ def from_json(cls, line: str) -> Receipt: return cls.from_dict(document) +def _document_hash(document: Mapping[str, Any]) -> str: + """`"sha256:" + hex(SHA-256(canonical_bytes(document)))`, the chain's one hash (§6.2). + + One function, so `chain_hash()` and a store's `put_receipt` cannot come to hash two + different things. `put_receipt` hashes the exact dictionary it serializes (SPEC-v0.7 §6.11 + rule (b)), and this is what it hashes it with. + """ + return "sha256:" + hashlib.sha256(canonical_bytes(document)).hexdigest() + + +def _stored_receipt(document: Mapping[str, Any], stored_hash: str | None) -> Receipt: + """A receipt as a store read it: its stored hash, and the document it was read from. + + SPEC-v0.7 §6.11: the stored document is set **after** `replace(..., hash=...)`, because + rule (a) makes `replace()` drop it; setting it first would build a receipt and then throw + away the one thing the read was for. The only writer of the private field. + """ + receipt = replace(Receipt.from_dict(document), hash=stored_hash) + object.__setattr__(receipt, "_stored_document", document) + return receipt + + class EventSink(Protocol): """Somewhere a copy of every `Event` and `Receipt` goes (SPEC-v0.2 §4.1). @@ -485,6 +623,11 @@ def _append(self, path: Path, line: str) -> None: #: §6.5's closed set of break names. A chain that only catches the easy case is worse than none, #: because it gets quoted as though it caught all of them -- so a break is reported *by name* and #: at a `seq`, never as a bare "invalid". +#: What `link_broken` and `head_mismatch` say a row hashes to when nothing can: §6.5's names are +#: a closed set, so a document the canonicalizer refuses is `content_altered` like any other +#: altered document, and the rows that link to it are told why the comparison has no left side. +_NO_HASH: Final = "" + CHAIN_BREAKS: Final = ( "content_altered", "hash_missing", @@ -607,7 +750,30 @@ def verify_chain(store: ChainSource) -> ChainReport: # Resync on what is actually there, so one hole reports one gap rather than # renumbering every receipt after it. expected_seq = seq - recomputed = receipt.chain_hash() + try: + recomputed = receipt.chain_hash() + except CTRLRunError as refused: + # SPEC-v0.7 §6.11: a stored document this reader cannot canonicalize is a document + # nothing in this library wrote -- `put_receipt` hashes what it serializes, so every + # row it wrote canonicalizes by construction. It is therefore **altered**, and named + # here rather than raised out of the walk: one such row used to stop the whole read, + # so `ctrlrun receipts --verify-chain` exited with no report at all and a forgery at + # another `seq` went unnamed. + # + # By its type, never its message: the canonicalizer quotes what it refused, and a + # lone surrogate in a report is a report that cannot be printed. + breaks.append( + ChainBreak( + "content_altered", + seq, + f"the stored document has no canonical form ({type(refused).__name__}), so " + "its hash cannot be recomputed; nothing that writes receipts could have " + "stored it", + ) + ) + expected_prev = _NO_HASH + expected_seq = seq + 1 + continue stored = receipt.hash if stored is None: # A chained row whose stored hash is gone. **Not a skip.** This was the only check diff --git a/src/ctrlrun/state.py b/src/ctrlrun/state.py index 350f43b..120707d 100644 --- a/src/ctrlrun/state.py +++ b/src/ctrlrun/state.py @@ -54,7 +54,15 @@ ) from .errors import AmbiguousEffect, DuplicateEffect, InvalidArgument from .migrations import migrate -from .receipt import GENESIS_HASH, Event, EventType, Receipt +from .receipt import ( + GENESIS_HASH, + RECEIPT_SCHEMA, + Event, + EventType, + Receipt, + _document_hash, + _stored_receipt, +) _LOG = logging.getLogger(__name__) @@ -768,8 +776,10 @@ def put_receipt(self, receipt: Receipt) -> Receipt: # to one process can offer -- and `reopen()` returning `None` is how this backend # declares that (§2.4). seq = self._chain_seq + 1 - chained = replace(receipt, seq=seq, prev_hash=self._chain_hash) - digest = chained.chain_hash() + # SPEC-v0.7 §6.11: written under the schema this binary writes, and hashed as the + # dictionary that says so. The same rule as SQLite's and Postgres's, below. + chained = replace(receipt, schema=RECEIPT_SCHEMA, seq=seq, prev_hash=self._chain_hash) + digest = _document_hash(chained.to_dict()) stored = replace(chained, hash=digest) self._receipts.append(stored) self._chain_seq = seq @@ -1247,8 +1257,20 @@ def put_receipt(self, receipt: Receipt) -> Receipt: "the receipt chain has no head row; this database predates " "0002_receipt_chain and was not migrated" ) - chained = replace(receipt, seq=int(row["seq"]), prev_hash=str(row["hash"])) - digest = chained.chain_hash() + # SPEC-v0.7 §6.11 rule (b): **hash the exact dictionary that is serialized**, and + # never a stored document. The column and the JSON beside it come from one + # dictionary, so the read-time hash of that JSON is this write-time hash for every + # receipt nobody touched. Written under `RECEIPT_SCHEMA` whatever schema the receipt + # was read under: the chain fields exist only from `v3`, and a `v1` document written + # into the chain would carry no `seq` of its own. + chained = replace( + receipt, + schema=RECEIPT_SCHEMA, + seq=int(row["seq"]), + prev_hash=str(row["hash"]), + ) + document = chained.to_dict() + digest = _document_hash(document) connection.execute( "INSERT INTO receipts(receipt_id, action_id, effect_key, result, json, ts, " "seq, prev_hash, hash) VALUES(?,?,?,?,?,?,?,?,?)", @@ -1257,7 +1279,7 @@ def put_receipt(self, receipt: Receipt) -> Receipt: chained.action_id, chained.effect_key, str(chained.result), - chained.to_json(), + json.dumps(document, ensure_ascii=False, separators=(",", ":")), _iso(chained.finished_at), chained.seq, chained.prev_hash, @@ -1305,7 +1327,9 @@ def receipts(self) -> tuple[Receipt, ...]: .fetchall() ) # `hash` comes off the column, because a document cannot contain its own hash (§6.2). - return tuple(replace(Receipt.from_json(row["json"]), hash=row["hash"]) for row in rows) + # And the parsed document stays with the receipt (SPEC-v0.7 §6.11), so `chain_hash()` + # hashes what was stored rather than what this binary would render. + return tuple(_stored_receipt(json.loads(row["json"]), row["hash"]) for row in rows) # --- delegations (SPEC-v0.3 §5.2) ------------------------------------------------- @@ -1387,7 +1411,8 @@ def put_approval_request(self, request: ApprovalRequest) -> None: try: self._connection().execute( "INSERT INTO approvals(approval_id, action_hash, status, action_json, " - "created_at, expires_at, policy_hash_at_approval) VALUES(?,?,?,?,?,?,?)", + "created_at, expires_at, policy_hash_at_approval, precondition_fingerprint) " + "VALUES(?,?,?,?,?,?,?,?)", ( request.request_id, request.action_hash, @@ -1396,6 +1421,7 @@ def put_approval_request(self, request: ApprovalRequest) -> None: _iso(request.created_at), _iso(request.expires_at), request.policy_hash, + request.precondition_fingerprint, ), ) except sqlite3.IntegrityError as exc: @@ -1578,6 +1604,7 @@ def _read_approval( created_at=datetime.fromisoformat(row["created_at"]), expires_at=datetime.fromisoformat(row["expires_at"]), policy_hash=row["policy_hash_at_approval"], + precondition_fingerprint=row["precondition_fingerprint"], ), status=ApprovalStatus(row["status"]), approver=row["approver"], diff --git a/src/ctrlrun/verify/guarantees.py b/src/ctrlrun/verify/guarantees.py index 2f2599c..7528229 100644 --- a/src/ctrlrun/verify/guarantees.py +++ b/src/ctrlrun/verify/guarantees.py @@ -72,6 +72,15 @@ class Guarantee: "v0.7 §8 T238", ), ), + Guarantee( + "G16", + # Not "a moved precondition is refused": a precondition that moves after the comparison + # is not refused (§6.7), and a title is the shortest sentence this project writes about + # a guarantee. What is compared is the fingerprint, and what G16 grades is one that had + # already moved when the recheck read it. + "a moved fingerprint is refused", + ("v0.1 §4.2", "v0.7 §8 T253", "v0.7 §8 T254"), + ), ) #: By id, for `--only` and for the report. Insertion order is catalogue order. @@ -129,6 +138,15 @@ class Guarantee: ) NO_DELEGABLE_GRANT: Final = "no grant is delegable" +#: SPEC-v0.7 §8.9, G16's note, printed once beneath the table as `EFFECT_TEMPLATE_NOTE` is. G16 +#: is graded against verify's own stand-in for the operator's provider, because a provider is +#: named in code and not in any document verify reads; this says so where a reader looks. +PRECONDITION_NOTE: Final = ( + "verify supplies its own precondition provider; whether your @protect declares one is in " + "your code, which verify does not read. The gateway and the ACS hook cannot name a " + "provider at all, and refuse an approval that carries a fingerprint" +) + #: G4's second N/A (§2.2). No backend in v0.4 reaches it — `SQLiteStateStore` refuses #: `:memory:` precisely so that it cannot — and the row exists so a v0.6 backend that cannot #: make the guarantee reports N/A rather than a green it did not earn. diff --git a/src/ctrlrun/verify/report.py b/src/ctrlrun/verify/report.py index 6605140..423e477 100644 --- a/src/ctrlrun/verify/report.py +++ b/src/ctrlrun/verify/report.py @@ -244,16 +244,20 @@ def to_text(self) -> str: f"store {self.store['backend']}, scratch (created and destroyed for this run)" ) lines.append("") - noted = False + noted: set[str] = set() for result in self.guarantees: lines.append(_result_line(result)) note = result.detail.get("note") - if note and not noted: + if note and str(note) not in noted: # §4.1's example prints the `@protect` sentence once, under the first # guarantee the missing template takes out. Every one of them carries it in # `detail.note` (T102); repeating it on three consecutive lines would push # the rows that differ off the reader's screen. - noted = True + # + # **Once per note, not once per report** (SPEC-v0.7 §8.9): G16's note is a + # different sentence, and a report that printed only the first note it met + # would drop G16's on every document that also lacks an `effect:` template. + noted.add(str(note)) lines += _wrapped_note(str(note)) if result.counterexample is not None: lines += [f" {line}" for line in result.counterexample.to_text().split("\n")] diff --git a/src/ctrlrun/verify/scenarios.py b/src/ctrlrun/verify/scenarios.py index 01db124..f7c85eb 100644 --- a/src/ctrlrun/verify/scenarios.py +++ b/src/ctrlrun/verify/scenarios.py @@ -63,6 +63,7 @@ ActionDenied, AmbiguousEffect, ApprovalMismatch, + ApprovalRequired, AuthorityDenied, AuthorityEscalation, CTRLRunError, @@ -850,13 +851,14 @@ def execute( executor: _Executor, effect_key: str | None, approval_id: str | None, + preconditions: Callable[[Action], Mapping[str, Any]] | None = None, ) -> Receipt: if approval_id is None: - return control.execute(action, executor, effect_key) + return control.execute(action, executor, effect_key, preconditions=preconditions) from ..control import with_approval with with_approval(approval_id): - return control.execute(action, executor, effect_key) + return control.execute(action, executor, effect_key, preconditions=preconditions) def refused( self, @@ -2429,6 +2431,112 @@ def read_then_commit() -> Any: finally: store.close() + # --- G16: a moved precondition is refused before the reservation --------------------- + + def g16(self) -> GuaranteeResult: + """SPEC-v0.7 §8.9. The precondition recheck, against the kernel in this configuration, + with verify's own provider standing in for the operator's code. + + **Graded as G5 and G10 are, and never `N/A` for want of a fingerprint in the document**: + a provider is named in code (`@protect(preconditions=...)`), not in any document verify + reads, so "the configuration names no fingerprint" is a sentence verify has no way to + make true. The note printed beneath the table says so. `N/A` only where no action reaches + `approve`, which is G1's reason and G1's one weakness, inherited and stated. + + The approval still being `granted` after the refusal is what proves the refusal came + before the store call that consumes it; the missing `EFFECT_RESERVED` proves it came + before the reservation, where there is a key to reserve. The positive control is the + same approval presented once the provider reports the world the human saw again, which + must commit: without it, a kernel that refused every presentation would pass. + + What this grades is the refusal of a change landing **before** the comparison. The + residual window after it, which the recheck narrows and does not close, is not a + property verify could grade: a correct kernel does not refuse it (§6.7). + """ + selection = self.select(decisions=(Decision.APPROVE,)) + if selection is None: + return self.na("G16", self.unselected(reg.NO_APPROVE_RULE), **self.unselected_detail()) + control, store, recorder, _ = self._control_for("G16", selection) + seen = f"{reg.SYNTHETIC_PREFIX}-the-state-a-human-approved" + world = {"state": seen} + + def provider(action: Action) -> Mapping[str, Any]: + return {"resource": world["state"]} + + def body(detail: dict[str, Any]) -> None: + detail["approved_by_verify"] = True + detail["note"] = reg.PRECONDITION_NOTE + action = selection.build() + key = selection.effect_key + asked = self.refused( + lambda: self.execute(control, action, _Executor(), key, None, provider), + (ApprovalRequired,), + "ApprovalRequired on the request pass", + "an action that requires approval ran without one", + ) + request_id = str(getattr(asked, "request_id", "")) + store.grant_approval(request_id, APPROVER) + + world["state"] = f"{reg.SYNTHETIC_PREFIX}-the-state-it-moved-to" + executor = _Executor() + refusal = self.refused( + lambda: self.execute(control, action, executor, key, request_id, provider), + (ApprovalMismatch,), + "ApprovalMismatch(reason='precondition_changed') before the reservation", + "the approval opened the action in a world that moved after it was granted", + ) + reason = getattr(refusal, "reason", "") + _expect( + reason == "precondition_changed", + "ApprovalMismatch(reason='precondition_changed')", + f"ApprovalMismatch(reason={reason!r})", + ) + _expect( + executor.calls == 0, + "the executor is not reached", + f"the executor was called {executor.calls} times", + ) + record = store.get_approval(request_id) + _expect( + record is not None and record.status is ApprovalStatus.GRANTED, + "the approval is still granted, so the refusal came before the store call " + "that consumes it", + f"the approval is {None if record is None else record.status}", + ) + if key is not None: + reserved = [ + event + for event in recorder.events + if event.type is EventType.EFFECT_RESERVED and event.effect_key == key + ] + _expect( + not reserved and store.get_effect(key) is None, + f"nothing reserved {key!r}", + f"{len(reserved)} EFFECT_RESERVED and a record in " + f"{getattr(store.get_effect(key), 'state', None)}", + ) + _expect( + _named_event( + recorder, EventType.APPROVAL_INVALIDATED, reason="precondition_changed" + ), + "APPROVAL_INVALIDATED with reason 'precondition_changed'", + f"events were {recorder.types()}", + ) + + world["state"] = seen + committed = _Executor() + receipt = self.execute(control, action, committed, key, request_id, provider) + _expect_control( + receipt.result is ReceiptResult.COMMITTED and committed.calls == 1, + "the same approval, presented once the world is the one the human saw, commits", + f"it ended {receipt.result} after {committed.calls} executor calls", + ) + + try: + return self.graded("G16", selection, store, recorder, body) + finally: + store.close() + @dataclass(frozen=True) class _AlteredChain: diff --git a/tests/test_demo.py b/tests/test_demo.py index 6a66259..6ea3f5a 100644 --- a/tests/test_demo.py +++ b/tests/test_demo.py @@ -97,6 +97,9 @@ "policy_hash", "policy_version", "controls", + # SPEC-v0.7 §6.11: `ctrlrun.receipt/v4`. Fingerprints, never the state they hash. + "precondition_at_request", + "precondition_at_recheck", ) diff --git a/tests/test_observe.py b/tests/test_observe.py index cb4ff94..7fb96bb 100644 --- a/tests/test_observe.py +++ b/tests/test_observe.py @@ -206,7 +206,7 @@ def test_T82_the_observed_receipt_round_trips_through_json(store, clock): ) document = json.loads(receipt.to_json()) - assert document["schema"] == "ctrlrun.receipt/v3" + assert document["schema"] == "ctrlrun.receipt/v4" assert document["result"] == "observed" assert document["execution"] == "committed" assert document["would_have"]["decision"] == "deny" diff --git a/tests/test_preconditions.py b/tests/test_preconditions.py new file mode 100644 index 0000000..0fe696a --- /dev/null +++ b/tests/test_preconditions.py @@ -0,0 +1,3402 @@ +"""Precondition fingerprints. Build-list item 5; SPEC-v0.7 §6, §7, §8.5 T253-T269. + +An approval binds to an `action_hash` and an expiry. It does not bind to the state of the world +it was granted against. A human approves *delete customer C123* when the balance is zero and the +account inactive; thirty minutes later the balance is $50,000 and the account is active. The +action has not changed. The world has. + +**The recheck narrows the window between a human's decision and the action's execution; it +does not close it.** It is a network call, so it cannot run inside the atomic reservation write, +and a change that lands after the comparison and before the reservation is not refused. T261b +opens exactly that window and asserts the action is *not* refused, because that is what the +kernel does, and a test that said otherwise would make the documentation false. + +Every refusal below asserts its `reason`. A precondition mismatch and an ordinary +`ApprovalMismatch` share a type, and a test that asserted only the type could not tell which +guard fired: the first of the four shapes of a false green in the milestone's plan. +""" + +from __future__ import annotations + +import hashlib +import inspect +import json +import logging +import os +import re +import sqlite3 +import subprocess +import textwrap +import uuid +import venv +from datetime import UTC, datetime, timedelta +from pathlib import Path +from typing import Any + +import pytest + +import test_migrations as releases +from ctrlrun import ( + Action, + ActionDenied, + ApprovalMismatch, + ApprovalRequired, + Control, + InMemoryStateStore, + InvalidArgument, + JSONLEventSink, + NotExecuted, + Policy, + Principal, + SQLiteStateStore, + Suspended, + protect, + with_approval, +) +from ctrlrun.action import canonical_bytes +from ctrlrun.approval import ApprovalStatus +from ctrlrun.receipt import RECEIPT_SCHEMA, EventType, ReceiptResult + +POLICY = """ +schema: ctrlrun.policy/v1 +actions: + customer.delete: + decision: approve + customer.read: + decision: allow + customer.purge: + decision: deny +""" + +OBSERVE_POLICY = POLICY.replace("ctrlrun.policy/v1", "ctrlrun.policy/v3") + "mode: observe\n" + +KEY = "delete:C123" + +#: What the human looked at: a zero balance on an inactive account. +AT_REQUEST = {"balance": 0, "active": False} +#: What the world became while the human deliberated. +MOVED = {"balance": 5_000_000, "active": True} + +CHANGED = "precondition_changed" +MISSING = "precondition_missing" +UNAVAILABLE = "precondition_unavailable" + + +def fingerprint(state: Any) -> str: + """SPEC-v0.7 §6.2's fingerprint, computed here independently of the library.""" + document = {"schema": "ctrlrun.precondition/v1", "state": state} + return "sha256:" + hashlib.sha256(canonical_bytes(document)).hexdigest() + + +class World: + """A precondition provider standing in for the resource an operator reads. + + It counts its calls, because several rows of §6 and §7 are statements that it is *not* + called, and an assertion that nothing happened needs a counter that would have moved. + """ + + def __init__(self, state: Any = None) -> None: + self.state: Any = dict(AT_REQUEST) if state is None else state + self.calls = 0 + self.fail: BaseException | None = None + self.seen: list[Action] = [] + + def __call__(self, action: Action) -> Any: + self.calls += 1 + self.seen.append(action) + if self.fail is not None: + raise self.fail + return dict(self.state) if isinstance(self.state, dict) else self.state + + +def _counting(make): + """A provider that hands back exactly what `make()` returns, and counts its calls.""" + + def provider(action: Action) -> Any: + provider.calls += 1 # type: ignore[attr-defined] + return make() + + provider.calls = 0 # type: ignore[attr-defined] + return provider + + +class Executor: + def __init__(self, behaviour: Any = None) -> None: + self.calls = 0 + self._behaviour = behaviour + + def __call__(self) -> Any: + self.calls += 1 + if self._behaviour is not None: + return self._behaviour() + return "deleted" + + +def an_action(control: Control, customer_id: str = "C123", name: str = "customer.delete"): + return Action( + name=name, + arguments={"customer_id": customer_id}, + principal=Principal(agent="ops-agent", user="ada"), + environment=control.environment, + ) + + +@pytest.fixture +def control(state_store, fake_clock): + return Control(Policy.from_yaml(POLICY), state_store, clock=fake_clock) + + +def requested(control: Control, action: Action, world: World | None, key: str | None = KEY) -> str: + """The request pass: `ApprovalRequired`, and the id of the request it created.""" + with pytest.raises(ApprovalRequired) as pending: + control.execute(action, Executor(), key, preconditions=world) + return pending.value.request_id + + +def granted(control: Control, action: Action, world: World | None, key: str | None = KEY) -> str: + request_id = requested(control, action, world, key) + control.store.grant_approval(request_id, "human:alice") + return request_id + + +def present( + control: Control, + action: Action, + request_id: str, + world: World | None, + executor: Executor | None = None, + key: str | None = KEY, + **kwargs: Any, +): + with with_approval(request_id): + return control.execute(action, executor or Executor(), key, preconditions=world, **kwargs) + + +def refused( + control: Control, + action: Action, + request_id: str, + world: World | None, + executor: Executor | None = None, + key: str | None = KEY, + **kwargs: Any, +) -> ApprovalMismatch: + with pytest.raises(ApprovalMismatch) as raised: + present(control, action, request_id, world, executor, key, **kwargs) + return raised.value + + +def invalidated(store, request_id: str): + return [ + event + for event in store.events() + if event.type is EventType.APPROVAL_INVALIDATED and event.approval_id == request_id + ] + + +def last_receipt(store, action: Action): + found = [receipt for receipt in store.receipts() if receipt.action_id == action.action_id] + assert found, f"no receipt for {action.action_id}" + return found[-1] + + +# --- §6.2: the keyword, and what it refuses at the door -------------------------------------- + + +def test_a_preconditions_keyword_that_is_not_callable_is_invalid_on_execute(control): + """§6.2: *"A `preconditions=` that is not callable is `InvalidArgument`."*""" + action = an_action(control) + with pytest.raises(InvalidArgument) as refused_: + control.execute(action, Executor(), KEY, preconditions={"balance": 0}) # type: ignore[arg-type] + assert "preconditions" in str(refused_.value) + assert control.store.events() == (), "a wiring bug wrote evidence before it was refused" + + +def test_a_preconditions_keyword_that_is_not_callable_is_refused_at_decoration_time(): + """§6.2: at decoration time for `@protect`, so the mistake fails on import.""" + with pytest.raises(InvalidArgument) as refused_: + protect("customer.delete", preconditions="balance") # type: ignore[arg-type] + assert "preconditions" in str(refused_.value) + + +def test_there_is_no_flag_that_skips_the_recheck(): + """SPEC-v0.7 §1.1: no `skip_preconditions`. A flag that relaxes a check is the thing the + milestone's plan names by name, so its absence is asserted on every signature it could + have landed on, not assumed.""" + for callable_ in (Control.__init__, Control.execute, protect): + names = set(inspect.signature(callable_).parameters) + assert not {name for name in names if "skip" in name or "optimistic" in name}, names + assert "preconditions" in inspect.signature(Control.execute).parameters + assert "preconditions" in inspect.signature(protect).parameters + assert inspect.signature(Control.execute).parameters["preconditions"].default is None + + +# --- T253 / T254: a moved fingerprint refuses by its own reason, and leaves the grant --------- + + +def test_T253_a_moved_fingerprint_refuses_by_its_own_reason(control, state_store): + world = World() + action = an_action(control) + request_id = granted(control, action, world) + world.state = dict(MOVED) + executor = Executor() + + mismatch = refused(control, action, request_id, world, executor) + + assert mismatch.reason == CHANGED, ( + f"the refusal carried reason {mismatch.reason!r}; a precondition mismatch and an " + "ordinary one share a type, so only the reason says which guard fired" + ) + assert executor.calls == 0 + assert state_store.get_effect(KEY) is None, "a refused recheck left an effect record" + assert not any(event.type is EventType.EFFECT_RESERVED for event in state_store.events()) + events = invalidated(state_store, request_id) + assert len(events) == 1 + data = events[0].data + assert data["reason"] == CHANGED + assert data["precondition_at_request"] == fingerprint(AT_REQUEST) + assert data["precondition_at_recheck"] == fingerprint(MOVED) + receipt = last_receipt(state_store, action) + assert receipt.result is ReceiptResult.BLOCKED + assert receipt.precondition_at_request == fingerprint(AT_REQUEST) + assert receipt.precondition_at_recheck == fingerprint(MOVED) + + +def test_T253_the_negative_precondition_without_a_provider_the_moved_world_would_run(control): + """The `else` behind T253: the same presentation with nobody asking the world commits, so + T253's refusal is the recheck's and not something the kernel refuses anyway.""" + action = an_action(control) + request_id = granted(control, action, None) + executor = Executor() + + receipt = present(control, action, request_id, None, executor) + + assert receipt.result is ReceiptResult.COMMITTED and executor.calls == 1 + + +def test_T254_the_approval_is_left_granted_and_opens_the_world_the_human_saw(control, state_store): + world = World() + action = an_action(control) + request_id = granted(control, action, world) + world.state = dict(MOVED) + refused(control, action, request_id, world) + + record = state_store.get_approval(request_id) + assert record is not None and record.status is ApprovalStatus.GRANTED, ( + "a refusal by a reported fact spent the human's answer" + ) + + world.state = dict(AT_REQUEST) + executor = Executor() + receipt = present(control, action, request_id, world, executor) + + assert receipt.result is ReceiptResult.COMMITTED and executor.calls == 1 + assert state_store.get_approval(request_id).status is ApprovalStatus.CONSUMED + assert receipt.precondition_at_request == receipt.precondition_at_recheck + assert receipt.precondition_at_recheck == fingerprint(AT_REQUEST) + + +def test_a_committed_receipt_records_that_the_world_was_checked(control, state_store): + """§6.11: on a committed action the two fields are equal, and the receipt is `v4`.""" + world = World() + action = an_action(control) + request_id = granted(control, action, world) + + receipt = present(control, action, request_id, world) + + assert receipt.schema == RECEIPT_SCHEMA == "ctrlrun.receipt/v4" + assert receipt.precondition_at_request == fingerprint(AT_REQUEST) + assert receipt.precondition_at_recheck == fingerprint(AT_REQUEST) + document = receipt.to_dict() + assert document["schema"] == "ctrlrun.receipt/v4" + assert document["precondition_at_request"] == fingerprint(AT_REQUEST) + assert document["precondition_at_recheck"] == fingerprint(AT_REQUEST) + stored = state_store.get_approval(request_id) + assert stored.request.precondition_fingerprint == fingerprint(AT_REQUEST) + + +def test_a_receipt_with_no_precondition_carries_two_nulls(control, state_store): + """Absent means absent: an action nobody asked the world about says so, with `null`.""" + action = an_action(control) + request_id = granted(control, action, None) + + receipt = present(control, action, request_id, None) + + assert receipt.precondition_at_request is None + assert receipt.precondition_at_recheck is None + assert state_store.get_approval(request_id).request.precondition_fingerprint is None + + +# --- T255 / T256: a provider that fails refuses the action ------------------------------------ + + +def test_T255_a_provider_that_raises_on_the_presenting_pass_fails_closed(control, state_store): + world = World() + action = an_action(control) + request_id = granted(control, action, world) + world.fail = ConnectionError("the CRM is down") + executor = Executor() + + mismatch = refused(control, action, request_id, world, executor) + + assert mismatch.reason == UNAVAILABLE + assert mismatch.reason != CHANGED, "an outage and a moved world need different remedies" + assert executor.calls == 0 + assert state_store.get_effect(KEY) is None, "nothing may be reserved" + assert state_store.get_approval(request_id).status is ApprovalStatus.GRANTED + data = invalidated(state_store, request_id)[0].data + assert data["reason"] == UNAVAILABLE + assert data["error"] == "ConnectionError", "the exception is recorded by its type name only" + assert data["precondition_at_request"] == fingerprint(AT_REQUEST) + assert data["precondition_at_recheck"] is None + + +def test_T255_a_provider_that_raises_on_the_request_pass_creates_no_request(control, state_store): + world = World() + world.fail = TimeoutError("the CRM did not answer") + action = an_action(control) + + with pytest.raises(ActionDenied) as denied: + control.execute(action, Executor(), KEY, preconditions=world) + + assert denied.value.reason == UNAVAILABLE + assert state_store.approvals_for(action.action_hash) == (), "a request exists; a human is asked" + types = [event.type for event in state_store.events()] + assert EventType.APPROVAL_REQUESTED not in types + denial = [event for event in state_store.events() if event.type is EventType.ACTION_DENIED] + assert denial and denial[-1].data["reason"] == UNAVAILABLE + receipt = last_receipt(state_store, action) + assert receipt.result is ReceiptResult.DENIED + assert str(receipt.decision) == "approve", "the denied receipt must keep `decision: approve`" + + +class _Unencodable: + """Nothing `canonical_bytes` could ever encode.""" + + +CANONICALIZER_REFUSES = { + "a float at depth three": {"account": {"ledger": {"balance": 0.5}}}, + "a mapping with an integer key": {"account": {7: "active"}}, + "a string holding a lone surrogate": {"name": "caf\ud800"}, + "an object json cannot encode": {"account": _Unencodable()}, +} + + +@pytest.mark.parametrize("label", sorted(CANONICALIZER_REFUSES)) +def test_T256_what_the_canonicalizer_refuses_is_the_canonicalizers_refusal(label): + """The negative precondition for T256: each of these is refused by `canonical_bytes` + itself, so the fail-closed below is the canonicalizer's refusal inherited and not a check + this item added and could get wrong.""" + with pytest.raises(Exception): # noqa: B017 - which exception is the canonicalizer's business + canonical_bytes( + {"schema": "ctrlrun.precondition/v1", "state": CANONICALIZER_REFUSES[label]} + ) + + +#: A list of pairs: the one non-mapping that `dict()` would happily turn into one, and that the +#: canonicalizer accepts inside the envelope. Only the `Mapping` check refuses it. +PAIRS = [["balance", 0], ["active", False]] + + +def test_T256_a_list_is_refused_by_the_mapping_check_and_by_nothing_else(): + """The negative precondition for the list case: the canonicalizer accepts it inside the + envelope and `dict()` would convert it, so a refusal proves the `Mapping` check is live + rather than subsumed by either.""" + canonical_bytes({"schema": "ctrlrun.precondition/v1", "state": PAIRS}) + assert dict(PAIRS) == {"balance": 0, "active": False} + + +RETURNS = {**CANONICALIZER_REFUSES, "a list of pairs": PAIRS, "None": None} + + +@pytest.mark.parametrize("label", sorted(RETURNS)) +def test_T256_on_the_presenting_pass_it_is_unavailable(control, state_store, label): + world = World() + action = an_action(control) + request_id = granted(control, action, world) + world.state = RETURNS[label] + executor = Executor() + + mismatch = refused(control, action, request_id, world, executor) + + assert mismatch.reason == UNAVAILABLE, label + assert executor.calls == 0 + assert state_store.get_effect(KEY) is None + assert state_store.get_approval(request_id).status is ApprovalStatus.GRANTED + + +@pytest.mark.parametrize("label", sorted(RETURNS)) +def test_T256_on_the_request_pass_it_is_denied_with_no_request(control, state_store, label): + world = World() + world.state = RETURNS[label] + action = an_action(control) + + with pytest.raises(ActionDenied) as denied: + control.execute(action, Executor(), KEY, preconditions=world) + + assert denied.value.reason == UNAVAILABLE, label + assert state_store.approvals_for(action.action_hash) == () + + +# --- T257: a fingerprint on one side only is a refusal, never a skip --------------------------- + + +def test_T257_an_approval_with_a_fingerprint_presented_without_a_provider(control, state_store): + world = World() + action = an_action(control) + request_id = granted(control, action, world) + executor = Executor() + + mismatch = refused(control, action, request_id, None, executor) + + assert mismatch.reason == MISSING + assert executor.calls == 0 + assert state_store.get_effect(KEY) is None + assert state_store.get_approval(request_id).status is ApprovalStatus.GRANTED + data = invalidated(state_store, request_id)[0].data + assert data["precondition_at_request"] == fingerprint(AT_REQUEST) + assert data["precondition_at_recheck"] is None + + +def test_T257_a_provider_presenting_an_approval_with_no_fingerprint(control, state_store): + """The request was created by a call that named no provider; this one names one.""" + action = an_action(control) + request_id = granted(control, action, None) + world = World() + executor = Executor() + + mismatch = refused(control, action, request_id, world, executor) + + assert mismatch.reason == MISSING + assert executor.calls == 0 + assert state_store.get_approval(request_id).status is ApprovalStatus.GRANTED + data = invalidated(state_store, request_id)[0].data + assert data["precondition_at_request"] is None + assert data["precondition_at_recheck"] == fingerprint(AT_REQUEST), ( + "the two precondition_missing cases are told apart by which field is null" + ) + + +def _null_the_column_sqlite(store: SQLiteStateStore, request_id: str) -> None: + connection = sqlite3.connect(store.path) + try: + changed = connection.execute( + "UPDATE approvals SET precondition_fingerprint = NULL WHERE approval_id = ?", + (request_id,), + ).rowcount + connection.commit() + finally: + connection.close() + assert changed == 1 + + +def test_T257_a_store_that_lost_the_column_is_a_refusal_not_a_skip(tmp_path, fake_clock): + """§6.4: *"a store that drops the column turns the check off"* is what "skip" would mean. + The request is created with a fingerprint, the column is then set to `NULL` underneath the + store, and the approval is presented with the provider.""" + store = SQLiteStateStore(tmp_path / "state.db", clock=fake_clock) + control = Control(Policy.from_yaml(POLICY), store, clock=fake_clock) + world = World() + action = an_action(control) + request_id = granted(control, action, world) + assert store.get_approval(request_id).request.precondition_fingerprint is not None + _null_the_column_sqlite(store, request_id) + assert store.get_approval(request_id).request.precondition_fingerprint is None + executor = Executor() + + mismatch = refused(control, action, request_id, world, executor) + + assert mismatch.reason == MISSING + assert executor.calls == 0 + assert store.get_effect(KEY) is None + store.close() + + +# --- T258 / T259: ALLOW, DENY and resume never call the provider ------------------------------ + + +def test_T258_allow_never_calls_the_provider(control, state_store): + world = World() + action = an_action(control, name="customer.read") + + receipt = control.execute(action, Executor(), "read:C123", preconditions=world) + + assert receipt.result is ReceiptResult.COMMITTED + assert world.calls == 0 + assert receipt.precondition_at_request is None and receipt.precondition_at_recheck is None + + +def test_T258_allow_with_a_presented_approval_never_calls_the_provider(control, state_store): + """A presented approval on an `ALLOW` action is spent by `v0.6 §7.2.2`'s path, without a + recheck: nothing the world could say would change what runs.""" + world = World() + approved = an_action(control) + request_id = granted(control, approved, world) + before = world.calls + allowed = an_action(control, name="customer.read") + + receipt = present(control, allowed, request_id, world, key="read:C123") + + assert receipt.result is ReceiptResult.COMMITTED + assert world.calls == before + + +def test_T258_deny_never_calls_the_provider(control, state_store): + world = World() + action = an_action(control, name="customer.purge") + + with pytest.raises(ActionDenied): + control.execute(action, Executor(), "purge:C123", preconditions=world) + + assert world.calls == 0 + + +def test_T259_resume_never_calls_the_provider(control, state_store): + world = World() + action = an_action(control) + request_id = granted(control, action, world) + + def suspend() -> Any: + raise Suspended("continuation-C123") + + with pytest.raises(Suspended): + present(control, action, request_id, world, Executor(suspend)) + before = world.calls + assert before == 2, "the request pass and the presenting pass each fetch once" + + receipt = control.resume("continuation-C123", lambda: "deleted") + + assert receipt.result is ReceiptResult.COMMITTED + assert world.calls == before, "Control.resume called the provider" + # It did not recheck, and its receipt still says what the leg that consumed the approval + # compared: that receipt is the only one this action gets (review finding 6b). + assert receipt.precondition_at_recheck == fingerprint(AT_REQUEST) + + +# --- T260: raw provider output reaches no evidence -------------------------------------------- + +SENTINEL = "SENTINEL-balance-50000-PHI-7f3a" + + +def _every_row(database) -> str: + connection = sqlite3.connect(database) + try: + tables = [ + row[0] + for row in connection.execute("SELECT name FROM sqlite_master WHERE type='table'") + ] + text = [] + for table in tables: + for row in connection.execute(f"SELECT * FROM {table}"): + text.append(repr(tuple(row))) + finally: + connection.close() + return "\n".join(text) + + +def test_T260_raw_provider_output_reaches_no_evidence(tmp_path, fake_clock, caplog): + """§6.10. The provider returns a sentinel; after a committed action, a refused one, an + unavailable one (whose exception message carries the sentinel, the one field nobody + thought to check) and a request-pass denial, the sentinel is in no row of any table, no + JSONL line, no event, no receipt and no captured log record.""" + caplog.set_level(logging.DEBUG, logger="ctrlrun") + store = SQLiteStateStore(tmp_path / "state.db", clock=fake_clock) + sink = JSONLEventSink(tmp_path / "jsonl") + control = Control(Policy.from_yaml(POLICY), store, clock=fake_clock, sinks=[sink]) + world = World({"balance": SENTINEL, "active": False}) + + committed = an_action(control, "C1") + request_id = granted(control, committed, world, "delete:C1") + present(control, committed, request_id, world, key="delete:C1") + + moved = an_action(control, "C2") + request_id = granted(control, moved, world, "delete:C2") + world.state = {"balance": SENTINEL + "-moved", "active": True} + refused(control, moved, request_id, world, key="delete:C2") + + unavailable = an_action(control, "C3") + world.state = {"balance": SENTINEL, "active": False} + request_id = granted(control, unavailable, world, "delete:C3") + world.fail = RuntimeError(f"could not read balance {SENTINEL}") + refused(control, unavailable, request_id, world, key="delete:C3") + + denied = an_action(control, "C4") + with pytest.raises(ActionDenied): + control.execute(denied, Executor(), "delete:C4", preconditions=world) + + observe = Control(Policy.from_yaml(OBSERVE_POLICY), store, clock=fake_clock, sinks=[sink]) + observed = an_action(observe, "C5") + world.fail = None + world.state = {"balance": SENTINEL, "active": False} + request_id = granted(control, observed, world, "delete:C5") + world.state = {"balance": SENTINEL + "-observed", "active": True} + present(observe, observed, request_id, world, key="delete:C5") + + store.close() + assert world.calls >= 8, "the provider was not exercised on every path" + assert SENTINEL not in _every_row(tmp_path / "state.db"), "raw state reached a table" + for path in (sink.receipts_path, sink.events_path): + assert SENTINEL not in path.read_text(encoding="utf-8"), f"raw state reached {path.name}" + for record in caplog.records: + assert SENTINEL not in record.getMessage(), f"raw state reached a log line: {record}" + + +# --- T261 / T261b: the window, before the compare and after it --------------------------------- + + +def test_T261_a_change_before_the_compare_is_refused(control, state_store): + """The world moves after the request and before the presenting pass calls the provider: + the provider reads the moved state, the comparison sees it, and the action is refused.""" + world = World() + action = an_action(control) + request_id = granted(control, action, world) + world.state = dict(MOVED) # before the presenting pass has fetched anything + + mismatch = refused(control, action, request_id, world) + + assert mismatch.reason == CHANGED + + +def test_T261b_the_residual_window_a_change_after_the_compare_and_before_the_reservation_is_not_refused( # noqa: E501 + control, state_store +): + """SPEC-v0.7 §6.7, segment 2: **the residual window this item narrows and does not close.** + + The comparison has passed and `consume_approval_and_reserve` has not yet run. The test + changes the resource inside that interval by wrapping the store call so the change lands + ahead of the real call; nothing is added to the library for the test's sake. **The action + is not refused, and it commits against a world the human never saw.** That is the kernel's + behaviour, the reason every sentence about the recheck says *narrows*, and the reason a + resource that can refuse a stale write itself (a conditional request, a compare-and-swap) + is the executor's business and not something this recheck substitutes for. + """ + world = World() + action = an_action(control) + request_id = granted(control, action, world) + order: list[str] = [] + real = state_store.consume_approval_and_reserve + + def the_world_moves_first(*args: Any, **kwargs: Any): + order.append(f"reserve after {world.calls} fetches") + world.state = dict(MOVED) + return real(*args, **kwargs) + + state_store.consume_approval_and_reserve = the_world_moves_first # type: ignore[method-assign] + executor = Executor() + fetched_before = world.calls + + receipt = present(control, action, request_id, world, executor) + + assert order == [f"reserve after {fetched_before + 1} fetches"], ( + "the change did not land between the compare and the reservation, so this test did " + "not open the window it is named for" + ) + assert world.state == MOVED, "the world did not move inside the window" + assert receipt.result is ReceiptResult.COMMITTED, ( + "the residual window was refused; the recheck cannot run inside the reservation, so " + "either this test is not opening the window or the documentation is now wrong" + ) + assert executor.calls == 1 + assert receipt.precondition_at_recheck == fingerprint(AT_REQUEST), ( + "the receipt records what the recheck compared, which is the world before it moved" + ) + assert receipt.precondition_at_recheck != fingerprint(MOVED) + + +# --- T262: an approval that would be refused anyway never reaches the provider ------------------ + + +def test_T262_a_consumed_approval_never_reaches_the_provider(control, state_store): + world = World() + action = an_action(control) + request_id = granted(control, action, world) + present(control, action, request_id, world) + world.state = dict(MOVED) + before = world.calls + + mismatch = refused(control, action, request_id, world) + + assert mismatch.reason == "consumed" + assert world.calls == before + + +def test_T262_an_expired_approval_never_reaches_the_provider(control, state_store, fake_clock): + world = World() + action = an_action(control) + request_id = granted(control, action, world) + fake_clock.advance(timedelta(hours=1)) + world.state = dict(MOVED) + before = world.calls + + mismatch = refused(control, action, request_id, world) + + assert mismatch.reason == "expired" + assert world.calls == before + # **Nothing is written for it** (review finding 2): whose clock decides expiry is the + # store's, and this refusal is `Control`'s own read. The lapse is in `APPROVAL_EXPIRED`, + # and `check_consumable` refuses the grant at every later presentation. + record = state_store.get_approval(request_id) + assert record.status is ApprovalStatus.GRANTED and record.consumed_at is None + assert any( + event.type is EventType.APPROVAL_EXPIRED and event.approval_id == request_id + for event in state_store.events() + ) + + +def test_T262_a_hash_mismatched_approval_never_reaches_the_provider(control, state_store): + world = World() + action = an_action(control) + request_id = granted(control, action, world) + mutated = an_action(control, "C999") + world.state = dict(MOVED) + before = world.calls + + mismatch = refused(control, mutated, request_id, world, key="delete:C999") + + assert mismatch.reason == "mismatch" + assert world.calls == before + + +def test_T262_a_denied_approval_never_reaches_the_provider(control, state_store): + world = World() + action = an_action(control) + request_id = requested(control, action, world) + state_store.deny_approval(request_id, "human:alice") + world.state = dict(MOVED) + before = world.calls + + with pytest.raises(ActionDenied) as denied: + present(control, action, request_id, world) + + assert denied.value.reason == "approval_denied" + assert world.calls == before + + +def test_T262_a_pending_approval_never_reaches_the_provider(control, state_store): + world = World() + action = an_action(control) + request_id = requested(control, action, world) + before = world.calls + + mismatch = refused(control, action, request_id, world) + + assert mismatch.reason == "pending" + assert world.calls == before + + +def test_T262_a_grant_that_lands_after_the_read_is_not_consumed_without_a_recheck( + control, state_store +): + """The window §6.6's read opens, closed in the fail-closed direction. + + `Control` reads the record, finds it `pending`, and does not call the provider. If a human + grants it between that read and the store call, a store call made anyway would consume a + fingerprinted approval that no recheck ever looked at, which is a skip. So a refusal the + read found is raised from the read, and the store is not asked to consume at all. + """ + world = World() + action = an_action(control) + request_id = requested(control, action, world) + real = state_store.get_approval + answered: list[str] = [] + + def a_human_answers_right_after_the_read(approval_id: str): + record = real(approval_id) + if not answered and record is not None and record.status is ApprovalStatus.PENDING: + answered.append(approval_id) + state_store.grant_approval(approval_id, "human:alice") + return record + + state_store.get_approval = a_human_answers_right_after_the_read # type: ignore[method-assign] + executor = Executor() + + mismatch = refused(control, action, request_id, world, executor) + + assert answered == [request_id], "the grant did not land inside the window" + assert mismatch.reason == "pending" + assert executor.calls == 0 + assert real(request_id).status is ApprovalStatus.GRANTED, "the grant was spent unchecked" + assert state_store.get_effect(KEY) is None + + +# --- T263: the recheck runs before each take ---------------------------------------------------- + + +def _ambiguous_record(store, key: str) -> None: + store.reserve_effect(key, "act_earlier", timedelta(minutes=5)) + store.begin_execution(key, "act_earlier") + store.mark_ambiguous(key, "act_earlier", "the response was lost") + + +def test_T263_a_reconciled_record_is_rechecked_before_the_second_take(control, state_store): + """`_secure` may take twice: once more after a `reconcile` hook moves an `AMBIGUOUS` + record. The hook is a network call, and a world that moves during it is refused on the + second take.""" + world = World() + action = an_action(control) + request_id = granted(control, action, world) + _ambiguous_record(state_store, KEY) + before = world.calls + + def reconcile(effect_key: str) -> str: + world.state = dict(MOVED) + return "not_executed" + + executor = Executor() + mismatch = refused(control, action, request_id, world, executor, reconcile=reconcile) + + assert world.calls - before == 2, "the provider was not called before each take" + assert mismatch.reason == CHANGED + assert executor.calls == 0 + assert state_store.get_approval(request_id).status is ApprovalStatus.GRANTED + + +def test_T263_the_positive_control_a_world_that_holds_still_commits_after_two_fetches( + control, state_store +): + world = World() + action = an_action(control) + request_id = granted(control, action, world) + _ambiguous_record(state_store, KEY) + before = world.calls + executor = Executor() + + receipt = present( + control, action, request_id, world, executor, reconcile=lambda _: "not_executed" + ) + + assert receipt.result is ReceiptResult.COMMITTED and executor.calls == 1 + assert world.calls - before == 2 + + +# --- §6.5: what propagates, and what the provider is handed ------------------------------------- + + +def test_a_base_exception_from_the_provider_propagates_with_nothing_reserved(control, state_store): + world = World() + action = an_action(control) + request_id = granted(control, action, world) + world.fail = KeyboardInterrupt() + receipts = len(state_store.receipts()) + + with pytest.raises(KeyboardInterrupt): + present(control, action, request_id, world) + + assert state_store.get_effect(KEY) is None + assert len(state_store.receipts()) == receipts, "a receipt was written for an interrupt" + assert state_store.get_approval(request_id).status is ApprovalStatus.GRANTED + + +def test_the_provider_is_handed_the_whole_action(control): + """§6.9: the hook is general, and it is given the principal and the resource as well.""" + world = World() + action = an_action(control) + requested(control, action, world) + + assert world.seen and world.seen[0].action_hash == action.action_hash + assert world.seen[0].principal == action.principal + + +def test_protect_wait_true_captures_and_rechecks(tmp_path, fake_clock): + """The `@protect` row of §7: the decorator names the provider, the request pass captures + it, `wait=True` blocks on a scripted human, and the presenting pass rechecks.""" + from ctrlrun import ScriptedApprovalProvider, context + + store = SQLiteStateStore(tmp_path / "state.db", clock=fake_clock) + human = ScriptedApprovalProvider(store, ["grant"], clock=fake_clock) + control = Control(Policy.from_yaml(POLICY), store, human, clock=fake_clock) + world = World() + ran: list[str] = [] + + @protect( + "customer.delete", + effect="delete:{customer_id}", + wait=True, + control=control, + preconditions=world, + ) + def delete(customer_id: str) -> str: + ran.append(customer_id) + return "deleted" + + with context("ops-agent", "ada"): + assert delete("C123") == "deleted" + + assert ran == ["C123"] + assert world.calls == 2 + receipt = store.receipts()[-1] + assert receipt.precondition_at_request == receipt.precondition_at_recheck + assert receipt.precondition_at_recheck == fingerprint(AT_REQUEST) + store.close() + + +def test_protect_wait_true_refuses_a_world_that_moved_while_the_human_answered( + tmp_path, fake_clock +): + from ctrlrun import ScriptedApprovalProvider, context + + store = SQLiteStateStore(tmp_path / "state.db", clock=fake_clock) + world = World() + + class MovesTheWorld(ScriptedApprovalProvider): + def wait(self, request_id, timeout=None): + answer = super().wait(request_id, timeout) + world.state = dict(MOVED) + return answer + + human = MovesTheWorld(store, ["grant"], clock=fake_clock) + control = Control(Policy.from_yaml(POLICY), store, human, clock=fake_clock) + ran: list[str] = [] + + @protect( + "customer.delete", + effect="delete:{customer_id}", + wait=True, + control=control, + preconditions=world, + ) + def delete(customer_id: str) -> str: + ran.append(customer_id) + return "deleted" + + with context("ops-agent", "ada"), pytest.raises(ApprovalMismatch) as raised: + delete("C123") + + assert raised.value.reason == CHANGED + assert ran == [] + store.close() + + +# --- §6.8: observe mode rechecks and records, and spends nothing -------------------------------- + + +def test_observe_mode_rechecks_records_and_runs_spending_no_grant(tmp_path, fake_clock): + store = SQLiteStateStore(tmp_path / "state.db", clock=fake_clock) + enforce = Control(Policy.from_yaml(POLICY), store, clock=fake_clock) + observe = Control(Policy.from_yaml(OBSERVE_POLICY), store, clock=fake_clock) + world = World() + action = an_action(enforce) + request_id = granted(enforce, action, world) + world.state = dict(MOVED) + before = world.calls + executor = Executor() + + receipt = present(observe, action, request_id, world, executor) + + assert world.calls == before + 1, "observe mode did not call the provider where enforce would" + assert executor.calls == 1, "observe mode refused something" + assert receipt.result is ReceiptResult.OBSERVED + assert receipt.would_have is not None + assert receipt.would_have.blocked_reason == "approval_mismatch" + data = invalidated(store, request_id)[0].data + assert data["reason"] == CHANGED + assert data["precondition_at_recheck"] == fingerprint(MOVED) + assert store.get_approval(request_id).status is ApprovalStatus.GRANTED + store.close() + + +def test_observe_mode_request_pass_never_fetches(tmp_path, fake_clock): + """`v0.3 §6.2`: observe mode creates no request, so its request pass never fetches.""" + store = SQLiteStateStore(tmp_path / "state.db", clock=fake_clock) + observe = Control(Policy.from_yaml(OBSERVE_POLICY), store, clock=fake_clock) + world = World() + action = an_action(observe) + + receipt = observe.execute(action, Executor(), KEY, preconditions=world) + + assert receipt.result is ReceiptResult.OBSERVED + assert world.calls == 0 + assert store.approvals_for(action.action_hash) == () + store.close() + + +def test_in_memory_and_sqlite_stores_both_carry_the_fingerprint(fake_clock, tmp_path): + for store in ( + InMemoryStateStore(clock=fake_clock), + SQLiteStateStore(tmp_path / "state.db", clock=fake_clock), + ): + control = Control(Policy.from_yaml(POLICY), store, clock=fake_clock) + world = World() + request_id = requested(control, an_action(control), world) + record = store.get_approval(request_id) + assert record.request.precondition_fingerprint == fingerprint(AT_REQUEST), store + approvals = store.approvals_for(record.action_hash) + assert approvals[0].request.precondition_fingerprint == fingerprint(AT_REQUEST) + store.close() + + +def test_the_unavailable_refusal_is_not_an_executor_outcome(control, state_store): + """`v0.7 §1.1`: a precondition check never writes `FAILED`. The refusal is before the + reservation, so there is no record to write anything to, and `NotExecuted` is never + raised by it.""" + world = World() + action = an_action(control) + request_id = granted(control, action, world) + world.fail = NotExecuted("a provider borrowing the executor's vocabulary") + + mismatch = refused(control, action, request_id, world) + + assert mismatch.reason == UNAVAILABLE + assert state_store.get_effect(KEY) is None + assert not any(event.type is EventType.EXECUTION_FAILED for event in state_store.events()) + + +def test_the_approval_invalidated_data_carries_hashes_only(control, state_store): + world = World() + action = an_action(control) + request_id = granted(control, action, world) + world.state = dict(MOVED) + refused(control, action, request_id, world) + + data = invalidated(state_store, request_id)[0].data + for name in ("precondition_at_request", "precondition_at_recheck"): + assert str(data[name]).startswith("sha256:") and len(data[name]) == 71 + assert "balance" not in json.dumps(dict(data)) + + +# --- T257 through the gateway and the ACS hook: they name no provider, and refuse --------------- + +GATEWAY_POLICY = """ +schema: ctrlrun.policy/v2 +actions: + mcp.acme.delete_customer: + effect: "delete:{customer_id}" + decision: approve + acs.crm.delete_customer: + effect: "delete:{customer_id}" + decision: approve +""" + + +def _gateway(store, fake_clock): + pytest.importorskip("httpx", reason="the gateway extra is not installed") + from ctrlrun.gateway.server import Gateway, GatewayConfig + + forwarded: list[bytes] = [] + + def forwarder(body, headers, *, fresh): + forwarded.append(body) + raise AssertionError("a refused call reached the upstream") + + control = Control(Policy.from_yaml(GATEWAY_POLICY), store, clock=fake_clock) + config = GatewayConfig( + upstream="http://127.0.0.1:9/mcp", alias="acme", principal_header="X-Agent", port=0 + ) + return Gateway(config, control, forwarder), control, forwarded + + +def _tools_call() -> tuple[bytes, dict[str, str]]: + body = { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": {"name": "delete_customer", "arguments": {"customer_id": "C123"}}, + } + headers = { + "MCP-Protocol-Version": "2026-07-28", + "Mcp-Method": "tools/call", + "Mcp-Name": "delete_customer", + "X-Agent": "ops-agent", + } + return json.dumps(body).encode(), headers + + +def _fingerprinted_approval_for(control: Control, action: Action, world: World) -> str: + """Request and grant an approval for exactly `action`'s hash, through the path that names + a provider, which is the only one that can create a fingerprint.""" + request_id = requested(control, action, world, "delete:C123") + control.store.grant_approval(request_id, "human:alice") + return request_id + + +def test_T257_the_gateway_refuses_a_fingerprinted_approval_and_stays_refused_until_it_expires( + tmp_path, fake_clock +): + """§6.4 with its bound. The gateway presents the newest granted approval for the action's + hash and names no provider; an approval requested with a fingerprint was granted against a + world this path cannot recheck. It is refused `-41006` with the reason, the second and + third identical calls are refused the same way with no fresh request, and once the + approval expires the next call creates one.""" + store = SQLiteStateStore(tmp_path / "state.db", clock=fake_clock) + gateway, control, forwarded = _gateway(store, fake_clock) + body, headers = _tools_call() + + first = json.loads(gateway.handle(body, headers).body) + assert first["error"]["code"] == -41002, first + unfingerprinted = first["error"]["data"]["request_id"] + action = store.get_approval(unfingerprinted).request.action + fingerprinted = _fingerprinted_approval_for(control, action, World()) + assert store.find_granted_approval(action.action_hash).approval_id == fingerprinted + + for attempt in range(3): + response = gateway.handle(body, headers) + document = json.loads(response.body) + assert response.status == 409, (attempt, document) + assert document["error"]["code"] == -41006, (attempt, document) + assert document["error"]["data"]["reason"] == MISSING, (attempt, document) + assert len(store.approvals_for(action.action_hash)) == 2, "a fresh request was created" + assert store.get_approval(fingerprinted).status is ApprovalStatus.GRANTED + assert forwarded == [] and store.get_effect("delete:C123") is None + + fake_clock.advance(timedelta(minutes=16)) + after = json.loads(gateway.handle(body, headers).body) + + assert after["error"]["code"] == -41002, after + assert len(store.approvals_for(action.action_hash)) == 3, "the bound did not release" + assert forwarded == [] + store.close() + + +def test_T257_the_acs_hook_refuses_a_fingerprinted_approval(tmp_path, fake_clock): + pytest.importorskip("httpx", reason="the gateway extra is not installed") + from ctrlrun.acs import ACS_VERSION, TOOL_CALL_REQUEST, AcsControlHook + + store = SQLiteStateStore(tmp_path / "state.db", clock=fake_clock) + control = Control(Policy.from_yaml(GATEWAY_POLICY), store, clock=fake_clock) + hook = AcsControlHook(control, prefix="acs") + + def envelope() -> dict[str, Any]: + return { + "jsonrpc": "2.0", + "method": TOOL_CALL_REQUEST, + "id": 1, + "params": { + "acs_version": ACS_VERSION, + "request_id": "11111111-1111-4111-8111-111111111111", + "timestamp": "2026-09-04T10:00:00Z", + "metadata": { + "agent_id": "ops-agent", + "session_id": "22222222-2222-4222-8222-222222222222", + "environment": "production", + "user_context": {"user_id": "ada", "roles": ["support"]}, + }, + "payload": { + "tool": {"name": "delete_customer", "provider": "crm"}, + "arguments": {"customer_id": {"value": "C123"}}, + }, + }, + } + + asked = hook.handle(envelope())["result"] + assert asked["decision"] == "ask", asked + requested_ids = [ + event.approval_id for event in store.events() if event.type is EventType.APPROVAL_REQUESTED + ] + action = store.get_approval(requested_ids[-1]).request.action + fingerprinted = _fingerprinted_approval_for(control, action, World()) + + answer = hook.handle(envelope())["result"] + + assert answer["decision"] == "deny", answer + assert answer["reason_codes"] == ["ctrlrun.blocked", MISSING], answer + assert store.get_approval(fingerprinted).status is ApprovalStatus.GRANTED + assert store.get_effect("delete:C123") is None + store.close() + + +# --- T264 / T265: a database built by 0.6.1's own code ------------------------------------------ + + +POSTGRES_URL = os.environ.get("CTRLRUN_TEST_POSTGRES") +postgres = pytest.mark.skipif( + not POSTGRES_URL, reason="CTRLRUN_TEST_POSTGRES is not set; no server to run against" +) +BACKENDS = ["sqlite", pytest.param("postgres", marks=postgres)] + +RELEASE = "0.6.1" + +#: The instant 0.6.1's build script writes at, and a clock a few minutes after it, so the +#: approvals it granted are still unexpired when 0.7 presents them. +BUILT_AT = datetime(2026, 9, 1, 12, 0, tzinfo=UTC) + + +def after_the_build() -> datetime: + return BUILT_AT + timedelta(minutes=5) + + +@pytest.fixture(scope="session") +def release_061(tmp_path_factory): + """`ctrlrun[postgres]==0.6.1` in an interpreter of its own (SPEC-v0.7 §8.5 T264). + + **The previous release's own code, never a hand-written fixture** (`v0.6 §3.5` item 2). + With the `postgres` extra, because T264 is on both backends and 0.6.1's Postgres store is + the thing whose schema the forward migration has to accept. + """ + root = tmp_path_factory.mktemp("rel-0.6.1-postgres") + env = root / "venv" + venv.create(env, with_pip=True, symlinks=os.name != "nt") + python = env / "bin" / "python" + done = subprocess.run( + [str(python), "-m", "pip", "install", "-q", f"ctrlrun[postgres]=={RELEASE}"], + capture_output=True, + text=True, + env=releases._clean_env(), + ) + if done.returncode != 0: + if releases.REQUIRE_RELEASES: + raise AssertionError( + f"ctrlrun[postgres]=={RELEASE} could not be installed, and " + "CTRLRUN_REQUIRE_RELEASE_FIXTURES=1. SPEC-v0.7 T264 asks for 0.6.1's own code; " + f"skipping is not that.\n{done.stderr}" + ) + pytest.skip(f"ctrlrun=={RELEASE} could not be installed; no network") + probe = subprocess.run( + [str(python), "-c", "import ctrlrun, importlib.metadata as m; print(m.version('ctrlrun'))"], + capture_output=True, + text=True, + env=releases._clean_env(), + cwd=root, + ) + assert probe.stdout.strip() == RELEASE, ( + f"the release interpreter imports {probe.stdout.strip()!r}, not {RELEASE}: the fixture " + f"would be this tree's database and prove the opposite of what it says.\n{probe.stderr}" + ) + return python + + +BUILD_061 = textwrap.dedent(""" + import json, sys + from datetime import UTC, datetime, timedelta + from ctrlrun import (Action, ActionDenied, ApprovalRequired, Control, Policy, Principal, + with_approval) + from ctrlrun.state import DelegationRecord, SQLiteStateStore + + backend, target = sys.argv[1], sys.argv[2] + T0 = datetime(2026, 9, 1, 12, 0, tzinfo=UTC) + clock = lambda: T0 + if backend == "sqlite": + store = SQLiteStateStore(target, clock=clock) + else: + from ctrlrun.postgres import PostgresStateStore + url, schema = target.rsplit("#", 1) + store = PostgresStateStore(url, schema=schema, clock=clock) + control = Control(Policy.from_yaml(sys.stdin.read()), store, clock=clock) + + def act(customer, name="customer.delete"): + return Action(name=name, arguments={"customer_id": customer}, + principal=Principal(agent="ops-agent", user="ada")) + + def request(action, key): + try: + control.execute(action, lambda: "x", key) + except ApprovalRequired as pending: + return pending.request_id + raise SystemExit("0.6.1 did not ask for an approval") + + control.execute(act("C1", "customer.read"), lambda: "read", "read:C1") + consumed = request(act("C2"), "delete:C2") + store.grant_approval(consumed, "human:alice") + with with_approval(consumed): + control.execute(act("C2"), lambda: "deleted", "delete:C2") + granted = request(act("C3"), "delete:C3") + store.grant_approval(granted, "human:alice") + pending = request(act("C4"), "delete:C4") + try: + control.execute(act("C5", "customer.purge"), lambda: "x", None) + except ActionDenied: + pass + else: + raise SystemExit("0.6.1 did not deny a purge") + def lost(): + raise TimeoutError("the response was lost") + try: + control.execute(act("C6", "customer.read"), lost, "read:C6") + except TimeoutError: + pass + store.put_delegation(DelegationRecord( + delegation_id="dlg_" + "b" * 32, parent_id="ops", depth=1, + grant_json='{"actions":["customer.*"],"delegable":false,"expires_at":null,"resources":null}', + created_by_agent="ops-agent", created_by_user="ada", created_via="api", created_at=T0)) + receipts = store.receipts() + print(json.dumps({ + "consumed": consumed, "granted": granted, "pending": pending, + "policy_hash": store.get_approval(granted).request.policy_hash, + "receipts": [[r.receipt_id, r.seq, r.hash] for r in receipts], + "events": [e.event_id for e in store.events()], + "delegation": "dlg_" + "b" * 32, + })) + store.close() +""") + +OPEN_061 = textwrap.dedent(""" + import sys + from ctrlrun.errors import SchemaMismatch + backend, target = sys.argv[1], sys.argv[2] + try: + if backend == "sqlite": + from ctrlrun.state import SQLiteStateStore + SQLiteStateStore(target) + else: + from ctrlrun.postgres import PostgresStateStore + url, schema = target.rsplit("#", 1) + PostgresStateStore(url, schema=schema) + except SchemaMismatch as refused: + print("REFUSED", refused) + else: + raise SystemExit("0.6.1 opened a database 0.7 migrated") +""") + + +class _Built: + """A database 0.6.1 built, on one backend, and what 0.6.1 said it put there.""" + + def __init__(self, backend: str, target: str, facts: dict[str, Any], schema: str | None): + self.backend = backend + self.target = target + self.facts = facts + self.schema = schema + + def open(self, clock): + if self.backend == "sqlite": + return SQLiteStateStore(self.target, clock=clock) + from ctrlrun.postgres import PostgresStateStore + + return PostgresStateStore(POSTGRES_URL, schema=self.schema, clock=clock) + + def sql(self, statement: str, parameters: tuple[Any, ...] = ()) -> list[tuple[Any, ...]]: + if self.backend == "sqlite": + connection = sqlite3.connect(self.target) + try: + rows = connection.execute(statement, parameters).fetchall() + connection.commit() + finally: + connection.close() + return [tuple(row) for row in rows] + import psycopg + + with psycopg.connect(POSTGRES_URL, autocommit=True) as connection: + connection.execute(f'SET search_path TO "{self.schema}"') + cursor = connection.execute(statement.replace("?", "%s"), parameters) + return [tuple(row) for row in cursor.fetchall()] if cursor.description else [] + + +@pytest.fixture +def built_by_061(request, release_061, tmp_path): + backend = request.param + schema = None + if backend == "sqlite": + target = str(tmp_path / "state.db") + else: + from ctrlrun.postgres import PostgresStateStore + + schema = f"pre_{uuid.uuid4().hex[:12]}" + PostgresStateStore.create_schema(POSTGRES_URL, schema) + target = f"{POSTGRES_URL}#{schema}" + done = subprocess.run( + [str(release_061), "-c", BUILD_061, backend, target], + input=POLICY, + capture_output=True, + text=True, + env=releases._clean_env(), + cwd=tmp_path, + ) + assert done.returncode == 0, done.stderr + built = _Built(backend, target, json.loads(done.stdout), schema) + try: + yield built + finally: + if schema is not None: + from ctrlrun.postgres import PostgresStateStore + + PostgresStateStore.drop_schema(POSTGRES_URL, schema) + + +def _applied(built: _Built) -> list[str]: + return [row[0] for row in built.sql("SELECT migration_id FROM schema_version ORDER BY 1")] + + +@pytest.mark.parametrize("built_by_061", BACKENDS, indirect=True) +def test_T264_a_database_built_by_061_migrates_and_keeps_every_row(built_by_061): + built = built_by_061 + fake_clock = after_the_build + facts = built.facts + assert "0005_precondition_fingerprint" not in _applied(built), "0.6.1 knows 0005?" + columns_before = built.sql("SELECT * FROM approvals WHERE approval_id = ?", (facts["granted"],)) + assert columns_before, "0.6.1 did not write the approval" + + store = built.open(fake_clock) + + assert _applied(built)[-1] == "0005_precondition_fingerprint" + for approval_id, status in ( + (facts["consumed"], ApprovalStatus.CONSUMED), + (facts["granted"], ApprovalStatus.GRANTED), + (facts["pending"], ApprovalStatus.PENDING), + ): + record = store.get_approval(approval_id) + assert record is not None and record.status is status, approval_id + assert record.request.precondition_fingerprint is None, approval_id + assert store.get_approval(facts["granted"]).approver == "human:alice" + assert store.get_approval(facts["granted"]).policy_hash_at_approval == facts["policy_hash"] + nulls = built.sql("SELECT COUNT(*) FROM approvals WHERE precondition_fingerprint IS NULL") + total = built.sql("SELECT COUNT(*) FROM approvals") + assert nulls == total == [(3,)], (nulls, total) + + assert str(store.get_effect("read:C1").state) == "committed" + assert str(store.get_effect("delete:C2").state) == "committed" + assert str(store.get_effect("read:C6").state) == "ambiguous" + kept = {receipt.receipt_id: receipt for receipt in store.receipts()} + for receipt_id, seq, stored_hash in facts["receipts"]: + assert receipt_id in kept, f"receipt {receipt_id} was lost" + assert kept[receipt_id].seq == seq and kept[receipt_id].hash == stored_hash + assert kept[receipt_id].schema == "ctrlrun.receipt/v3" + assert kept[receipt_id].chain_hash() == stored_hash, ( + f"a receipt 0.6.1 wrote no longer rehashes to its stored hash at seq {seq}" + ) + assert set(facts["events"]) <= {event.event_id for event in store.events()} + assert store.get_delegation(facts["delegation"]) is not None + + # The migrated store still works, and a 0.6.1 approval meets the recheck the way §6.4 + # says: with no provider it is 0.6.1's path, with one it is a refusal and never a skip. + control = Control(Policy.from_yaml(POLICY), store, clock=fake_clock) + c3 = store.get_approval(facts["granted"]).request.action + mismatch = refused(control, c3, facts["granted"], World(), key="delete:C3") + assert mismatch.reason == MISSING + receipt = present(control, c3, facts["granted"], None, key="delete:C3") + assert receipt.result is ReceiptResult.COMMITTED + store.close() + + +@pytest.mark.parametrize("built_by_061", BACKENDS, indirect=True) +def test_T264_061_refuses_the_migrated_database_naming_0005_and_both_versions( + built_by_061, release_061, fake_clock, tmp_path +): + """The backward direction, **before any other table is read**: proved by taking the other + tables away first, as `v0.6` T148 does. A 0.6.1 that read `approvals` on its way to the + version check would raise a driver error here instead of `SchemaMismatch`.""" + from ctrlrun.migrations import ctrlrun_version + + built = built_by_061 + built.open(fake_clock).close() + for table in ( + "effects", + "approvals", + "receipts", + "events", + "delegations", + "continuations", + "receipt_chain", + ): + built.sql(f"DROP TABLE IF EXISTS {table}") + + done = subprocess.run( + [str(release_061), "-c", OPEN_061, built.backend, built.target], + capture_output=True, + text=True, + env=releases._clean_env(), + cwd=tmp_path, + ) + + assert done.returncode == 0, done.stderr + message = done.stdout + assert message.startswith("REFUSED"), message + assert "0005_precondition_fingerprint" in message, message + # Both versions, each by the phrase 0.6.1 puts it in: the build that is refusing, and the + # build that wrote the migration it does not know. Until item 6 bumps this tree's version + # the two strings are the same, so each is asserted in its own slot. + assert f"This build is ctrlrun {RELEASE}" in message, message + assert f"(written by ctrlrun {ctrlrun_version()})" in message, message + + +def _chain_continued_by_07(built: _Built, fake_clock): + store = built.open(fake_clock) + control = Control(Policy.from_yaml(POLICY), store, clock=fake_clock) + world = World() + for customer in ("C7", "C8"): + action = an_action(control, customer) + request_id = granted(control, action, world, f"delete:{customer}") + present(control, action, request_id, world, key=f"delete:{customer}") + return store + + +def _stored_json(built: _Built, seq: int) -> str: + return built.sql("SELECT json FROM receipts WHERE seq = ?", (seq,))[0][0] + + +def _rewrite(built: _Built, seq: int, text: str) -> None: + built.sql("UPDATE receipts SET json = ? WHERE seq = ?", (text, seq)) + + +FABRICATED = "sha256:" + "f" * 64 + + +def _add_a_recheck(document: dict[str, Any]) -> dict[str, Any]: + return {**document, "precondition_at_recheck": FABRICATED} + + +def _relabel(schema: str): + def tamper(document: dict[str, Any]) -> dict[str, Any]: + return {**document, "schema": schema} + + return tamper + + +def _unlabel(document: dict[str, Any]) -> dict[str, Any]: + return {key: value for key, value in document.items() if key != "schema"} + + +TAMPERS = { + "a v3 row given a precondition_at_recheck key": _add_a_recheck, + "a chained v3 row relabelled v1": _relabel("ctrlrun.receipt/v1"), + "a chained v3 row relabelled v2": _relabel("ctrlrun.receipt/v2"), + "a v3 row with its schema key removed": _unlabel, + "a v3 row saying ctrlrun.receipt/v9": _relabel("ctrlrun.receipt/v9"), +} + + +@pytest.mark.parametrize("built_by_061", BACKENDS, indirect=True) +def test_T265_a_chain_written_by_061_and_continued_by_07_verifies_end_to_end( + built_by_061, fake_clock, tmp_path, monkeypatch +): + from ctrlrun.receipt import verify_chain + from ctrlrun.verify.scenarios import _AlteredChain + + built = built_by_061 + store = _chain_continued_by_07(built, fake_clock) + receipts = store.receipts() + old = [receipt for receipt in receipts if receipt.schema == "ctrlrun.receipt/v3"] + new = [receipt for receipt in receipts if receipt.schema == "ctrlrun.receipt/v4"] + assert len(old) == len(built.facts["receipts"]) >= 4 + assert len(new) == 2 and len(receipts) == len(old) + len(new) + for receipt in old: + assert receipt.chain_hash() == receipt.hash, f"v3 seq {receipt.seq} rehashes differently" + for receipt in new: + document = json.loads(_stored_json(built, receipt.seq)) + assert document["schema"] == "ctrlrun.receipt/v4" + assert document["precondition_at_recheck"] == fingerprint(AT_REQUEST) + + report = verify_chain(store) + assert report.ok, report.breaks + assert report.verified == len(receipts) + g11 = verify_chain(_AlteredChain(tuple(store.receipts()), store.chain_head())) + assert g11.ok, g11.breaks + store.close() + + if built.backend == "sqlite": + from click.testing import CliRunner + + from ctrlrun.cli import main as cli + + result = CliRunner().invoke( + cli.main, ["receipts", "--verify-chain", "--store-url", f"sqlite://{built.target}"] + ) + assert result.exit_code == 0, result.output + assert f"{len(receipts)} of {len(receipts)}" in result.output, result.output + + +@pytest.mark.parametrize("label", sorted(TAMPERS)) +@pytest.mark.parametrize("built_by_061", BACKENDS, indirect=True) +def test_T265_every_tamper_the_schema_bump_could_hide_is_content_altered( + built_by_061, fake_clock, tmp_path, monkeypatch, label +): + """§6.11: *hash what was stored.* Each tamper changes the stored document, so each is + `content_altered` at its `seq`, with no rule about key sets for the reader to get wrong. + + **The mutation this catches**: `chain_hash()` hashing `to_dict()` for a stored receipt + instead of its stored document. That leaves the untouched `v3` rows verifying (a `v3` + receipt renders its document byte for byte) and the relabelled rows failing (each renders + under its own label), and it is caught by exactly one of these five: the added + `precondition_at_recheck` key, which then verifies cleanly. + """ + from ctrlrun.receipt import verify_chain + + built = built_by_061 + _chain_continued_by_07(built, fake_clock).close() + seq = 2 + original = _stored_json(built, seq) + document = json.loads(original) + assert document["schema"] == "ctrlrun.receipt/v3", "the target is not a row 0.6.1 wrote" + _rewrite(built, seq, json.dumps(TAMPERS[label](document))) + + store = built.open(fake_clock) + receipts = store.receipts() # MUST NOT raise: a raise here blinds every reader at once + report = verify_chain(store) + assert not report.ok, f"{label}: the chain verified" + assert ("content_altered", seq) in [(b.name, b.seq) for b in report.breaks], ( + label, + report.breaks, + ) + tampered = next(receipt for receipt in receipts if receipt.seq == seq) + assert tampered.precondition_at_recheck is None, "an undeclared key's value was surfaced" + assert FABRICATED not in tampered.to_json(), "an undeclared key's value was rendered" + assert len(receipts) == len(built.facts["receipts"]) + 2 + store.close() + + if built.backend == "sqlite": + from click.testing import CliRunner + + from ctrlrun.cli import main as cli + + url = f"sqlite://{built.target}" + listed = CliRunner().invoke(cli.main, ["receipts", "--store-url", url]) + assert listed.exit_code == 0, listed.output + for receipt in receipts: + assert receipt.receipt_id in listed.output, f"{receipt.receipt_id} was not listed" + as_json = CliRunner().invoke(cli.main, ["receipts", "--json", "--store-url", url]) + assert FABRICATED not in as_json.output + policy = tmp_path / "ctrlrun.yaml" + policy.write_text(POLICY, encoding="utf-8") + monkeypatch.setenv("CTRLRUN_CONFIG", str(policy)) + stats = CliRunner().invoke(cli.main, ["stats", "--json", "--store-url", url]) + assert stats.exit_code == 0, stats.output + assert json.loads(stats.output)["actions"] == len(receipts) + + _rewrite(built, seq, original) + restored = built.open(fake_clock) + assert verify_chain(restored).ok, "restoring the row did not restore the chain" + restored.close() + + +def test_T265_a_read_back_receipt_altered_with_replace_is_content_altered(tmp_path, fake_clock): + """The sixth case, in memory rather than on a row: rule (a) of §6.11. G11's own tamper is + `replace(target, decision_reason=...)` on a read-back receipt handed to `verify_chain`; if + the stored document survived `replace()`, the altered receipt would hash the untouched + document and verify. And the same receipt written again reads back clean.""" + from dataclasses import replace + + from ctrlrun.receipt import new_receipt_id, verify_chain + from ctrlrun.verify.scenarios import _AlteredChain + + store = SQLiteStateStore(tmp_path / "state.db", clock=fake_clock) + control = Control(Policy.from_yaml(POLICY), store, clock=fake_clock) + for customer in ("C1", "C2", "C3"): + control.execute(an_action(control, customer, "customer.read"), Executor(), None) + receipts = store.receipts() + target = receipts[1] + altered = replace(target, decision_reason=f"{target.decision_reason}-altered") + + report = verify_chain( + _AlteredChain( + tuple(altered if item.seq == target.seq else item for item in receipts), + store.chain_head(), + ) + ) + assert ("content_altered", target.seq) in [(b.name, b.seq) for b in report.breaks], ( + "a receipt altered with replace() still hashed its stored document" + ) + + written = store.put_receipt(replace(altered, receipt_id=new_receipt_id())) + back = next(item for item in store.receipts() if item.receipt_id == written.receipt_id) + assert back.chain_hash() == back.hash == written.hash, "the written-again receipt is altered" + assert verify_chain(store).ok + store.close() + + +# --- T266: the store conformance suite covers the column ----------------------------------------- + + +def test_T266_the_store_conformance_suite_covers_the_column(): + from ctrlrun.conformance.report import SuiteStatus + from ctrlrun.conformance.store import SUITES, run + from ctrlrun.conformance.store.backends import InMemoryBackend, SQLiteBackend + from ctrlrun.conformance.store.fixtures import FIXTURES + + assert "precondition-fingerprint" in {case.id for case in SUITES["approval"]} + fixture = next(item for item in FIXTURES if item.name == "drops-the-precondition-fingerprint") + assert fixture.cases == {"approval": "precondition-fingerprint"} + + broken = run(fixture.backend(), only=("precondition-fingerprint",)) + case = next(c for s in broken.suites for c in s.cases if c.id == "precondition-fingerprint") + assert case.status is SuiteStatus.FAIL and fixture.because in (case.reason or "") + + import tempfile + + for backend in ( + InMemoryBackend(), + SQLiteBackend(__import__("pathlib").Path(tempfile.mkdtemp())), + ): + passed = run(backend, only=("precondition-fingerprint",)) + case = next(c for s in passed.suites for c in s.cases if c.id == "precondition-fingerprint") + assert case.status is SuiteStatus.PASS, case.reason + + +@postgres +def test_T266_postgres_persists_the_column(): + from ctrlrun.conformance.report import SuiteStatus + from ctrlrun.conformance.store import run + from ctrlrun.conformance.store.backends import PostgresBackend + + backend = PostgresBackend(POSTGRES_URL) + try: + report = run(backend, only=("precondition-fingerprint",)) + case = next(c for s in report.suites for c in s.cases if c.id == "precondition-fingerprint") + assert case.status is SuiteStatus.PASS, case.reason + finally: + backend.reset() + + +@postgres +def test_T257_a_postgres_store_that_lost_the_column_is_a_refusal(fake_clock): + from ctrlrun.postgres import PostgresStateStore + + schema = f"pre_{uuid.uuid4().hex[:12]}" + PostgresStateStore.create_schema(POSTGRES_URL, schema) + try: + store = PostgresStateStore(POSTGRES_URL, schema=schema, clock=fake_clock) + control = Control(Policy.from_yaml(POLICY), store, clock=fake_clock) + world = World() + action = an_action(control) + request_id = granted(control, action, world) + built = _Built("postgres", "", {}, schema) + built.sql( + "UPDATE approvals SET precondition_fingerprint = NULL WHERE approval_id = ?", + (request_id,), + ) + assert store.get_approval(request_id).request.precondition_fingerprint is None + + mismatch = refused(control, action, request_id, world) + + assert mismatch.reason == MISSING + assert store.get_effect(KEY) is None + store.close() + finally: + PostgresStateStore.drop_schema(POSTGRES_URL, schema) + + +# --- T267: §7's column, row by row ------------------------------------------------------------- + + +def test_T267_control_evaluate_never_calls_the_provider(control): + """`Control.evaluate` takes no provider at all: the row's "no" is structural, and the + count proves nothing else reached one.""" + world = World() + action = an_action(control) + request_id = granted(control, action, world) + before = world.calls + + with with_approval(request_id): + assert str(control.evaluate(action).decision) == "approve" + + assert "preconditions" not in inspect.signature(Control.evaluate).parameters + assert world.calls == before + + +def test_T267_needs_approval_never_calls_the_provider(control): + from ctrlrun import context, needs_approval + + world = World() + granted(control, an_action(control), world) + before = world.calls + + with context("ops-agent", "ada"): + assert needs_approval(control, "customer.delete", {"customer_id": "C123"}) is True + + assert world.calls == before + + +def test_T267_an_interrupt_provider_records_the_fingerprint_and_its_wait_never_fetches( + tmp_path, fake_clock +): + """`InterruptApprovalProvider` builds its requests through `build_request`, so the + fingerprint is recorded; its `wait` records an answer and never calls the provider.""" + from ctrlrun import ApprovalAnswer, InterruptApprovalProvider + + class Human: + framework = "test-framework" + carries_approved_arguments = False + + def interrupt(self, pending): + return ApprovalAnswer(granted=True, approver="human:alice") + + store = SQLiteStateStore(tmp_path / "state.db", clock=fake_clock) + provider = InterruptApprovalProvider(store, Human(), clock=fake_clock) + control = Control(Policy.from_yaml(POLICY), store, provider, clock=fake_clock) + world = World() + action = an_action(control) + request_id = requested(control, action, world) + assert store.get_approval(request_id).request.precondition_fingerprint == fingerprint( + AT_REQUEST + ) + before = world.calls + + provider.wait(request_id, None) + + assert world.calls == before + assert store.get_approval(request_id).status is ApprovalStatus.GRANTED + receipt = present(control, action, request_id, world) + assert receipt.result is ReceiptResult.COMMITTED + assert world.calls == before + 1, "Control.execute rechecks in full before consuming" + store.close() + + +def test_T267_the_operator_servers_write_tools_never_call_the_provider( + tmp_path, fake_clock, monkeypatch +): + pytest.importorskip("httpx", reason="the gateway extra is not installed") + from ctrlrun.gateway.operator import OperatorConfig, OperatorServer + + store = SQLiteStateStore(tmp_path / "state.db", clock=fake_clock) + control = Control(Policy.from_yaml(POLICY), store, clock=fake_clock) + + class Approver: + def resolve(self, context): + if not context.headers.get("x-approver"): + return None + return Principal(agent="approver-app", user="alice") + + server = OperatorServer( + OperatorConfig(principal_header="x-approver", user_header="x-approver-user"), + control, + Approver(), + ) + world = World() + to_grant = requested(control, an_action(control, "C1"), world, "delete:C1") + to_deny = requested(control, an_action(control, "C2"), world, "delete:C2") + before = world.calls + + def call(tool: str, arguments: dict[str, Any]) -> dict[str, Any]: + body = { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": {"name": tool, "arguments": arguments}, + } + headers = { + "MCP-Protocol-Version": "2026-07-28", + "Mcp-Method": "tools/call", + "Mcp-Name": tool, + "X-Approver": "alice", + } + return json.loads(server.handle(json.dumps(body).encode(), headers).body) + + assert "error" not in call("approve", {"request_id": to_grant}) + assert "error" not in call("deny", {"request_id": to_deny}) + assert store.get_approval(to_grant).status is ApprovalStatus.GRANTED + assert store.get_approval(to_deny).status is ApprovalStatus.DENIED + assert world.calls == before + store.close() + + +# --- T268: the documentation says narrows -------------------------------------------------------- + + +REPO_ROOT = Path(__file__).resolve().parents[1] + +#: T268's words, on word boundaries. Stems for the three that inflect into the same claim +#: (prevents, prevention; guarantees, guaranteed; ensures, ensured), exact forms for the rest. +CLAIM = re.compile( + r"\b(prevent\w*|close|closes|closed|closing|guarantee\w*|ensur\w*|opens nothing|cannot|" + # `nothing can …` and `no can …`: three of these arrived in one round, each true of + # the ordinary path and false of a residual the same document declares, and none of them + # used a word the pattern had. An absolute is a claim whether or not it is phrased as one. + r"blocks|nothing can|no \w+ can)\b", + re.IGNORECASE, +) +#: "Mentions a precondition or a fingerprint", and the recheck that compares them: §8's own +#: positive control, *"the recheck prevents a stale approval"*, names neither of the first two. +SUBJECT = re.compile(r"precondition|fingerprint|recheck", re.IGNORECASE) + +#: Where a Markdown block ends: a blank line, a list item, a table row or a heading. A sentence +#: never runs across one, so a bullet's claim is not joined to the sentence before it. +_BLOCK = re.compile(r"\n\s*\n|\n(?=\s*(?:[-*] |\d+\. |\||#))") + + +def _sentences(text: str) -> list[str]: + """Sentences, with Markdown line wraps undone: a claim wrapped across two lines is one.""" + found: list[str] = [] + for block in _BLOCK.split(text): + flowing = re.sub(r"\s+", " ", block).strip() + found += [ + part.strip() + for part in re.split(r"(?<=[.!?])\s+(?=[A-Z*`(\[_\"|])", flowing) + if part.strip() + ] + return found + + +def _flagged(text: str) -> list[str]: + return [ + sentence + for sentence in _sentences(text) + if CLAIM.search(sentence) and SUBJECT.search(sentence) + ] + + +def _spec_section_six() -> str: + spec = (REPO_ROOT / "docs" / "SPEC-v0.7.md").read_text(encoding="utf-8") + start = spec.index("\n## 6. Precondition fingerprints") + end = spec.index("\n## 7. ") + return spec[start:end] + + +def _spec_section_twelve_five() -> str: + spec = (REPO_ROOT / "docs" / "SPEC-v0.7.md").read_text(encoding="utf-8") + start = spec.index("\n### 12.5 Item 5") + end = spec.index("\n### 12.6 ") + return spec[start:end] + + +def _docstrings() -> str: + """The docstrings of every public name §6 adds or amends (§9.2).""" + from ctrlrun.approval import ApprovalRequest + from ctrlrun.receipt import Receipt + + return "\n\n".join( + inspect.getdoc(item) or "" for item in (protect, Control.execute, ApprovalRequest, Receipt) + ) + + +def _guarantee_titles() -> str: + """Every `verify` guarantee title, as a sentence each (review finding 9). + + A title is the shortest sentence this project writes about a guarantee and the one an + operator reads first, so it is scanned with the rest: G16's said "a moved precondition is + refused", and a precondition that moves after the comparison is not. + """ + from ctrlrun.verify.guarantees import GUARANTEES + + return "\n\n".join(f"{item.id}: {item.title}." for item in GUARANTEES) + + +SCANNED = { + "README.md": lambda: (REPO_ROOT / "README.md").read_text(encoding="utf-8"), + "guarantee titles": _guarantee_titles, + "CHANGELOG.md": lambda: (REPO_ROOT / "CHANGELOG.md").read_text(encoding="utf-8"), + "SPEC-v0.7 §6": _spec_section_six, + "SPEC-v0.7 §12.5": _spec_section_twelve_five, + "docstrings": _docstrings, +} + +#: **Every sentence below is allow-listed because somebody wrote it here on purpose**, on +#: `v0.6` T180's design: a new occurrence fails, and whoever adds one says which kind it is. +#: Matched by a fragment unique to the sentence, so a rewrap does not break the list and a +#: reworded claim does. +#: +#: Sentences that **disclaim**: they say the recheck narrows, and name what it does not do. +DISCLAIMS: dict[str, tuple[str, ...]] = { + "CHANGELOG.md": ( + "A precondition fingerprint **narrows** the window between a human's approval and the " + "action's execution; it does not close it.", + "and precondition fingerprints, which **narrow** the window between a human's approval " + "and the action's execution and do not close it", + ), + "SPEC-v0.7 §6": ( + "**A precondition fingerprint narrows the window between a human's decision and the " + "action's execution; it does not close one.**", + ), + "docstrings": ( + "The recheck narrows the window a human's approval leaves open; it does not close it", + ), +} + +#: Sentences where the word is about something else: a catalogue's name, a path that has no +#: provider to recheck with, a provider that produced nothing to compare, and a 0.6 process the +#: new one has no way to see. +ANOTHER_SUBJECT: dict[str, tuple[str, ...]] = { + "CHANGELOG.md": ( + '"a moved fingerprint is refused" before the reservation, under `ctrlrun.guarantees/v3`.', + # "fail-closed" beside the approver string `ctrlrun:precondition-not-recorded`. + "as though a human had said no: fail-closed, bounded by the TTL", + ), + "SPEC-v0.7 §6": ( + "the alternative is a path that cannot recheck spending an approval that was granted " + "conditional on a recheck.", + "so the comparison cannot be made, and **a check that cannot be made is not a check that " + "passed**", + # What closing §6.4's residual would take, which is a store method rather than a claim + # about the recheck (review finding 1). + "Closing the rest needs a store call that records the request and its fingerprint", + # The bound the withdrawal claims, which is the opposite of an absolute. + "So the claim this section makes is bounded", + "That is fail-closed, bounded by the TTL and traceable through the approver", + ), + "SPEC-v0.7 §12.5": ( + # The same residual, and a module path that happens to contain the word "guarantees". + "closing that needs a store call that records the request and its fingerprint together", + "It went into `verify.guarantees.__all__` and not into §9.2", + # A third-party provider's capability, and a closed set of chain-break names: neither is + # a claim about what the recheck does. + "cannot record a fingerprint however careful it is", + "Fixing it needs a new name in `CHAIN_BREAKS`", + ), +} + + +def test_T268_the_pattern_fires_on_a_prevention_claim(): + """The positive control: a scan that never fires is a scan nothing exercises.""" + assert _flagged("Some prose. The recheck prevents a stale approval. More prose.") == [ + "The recheck prevents a stale approval." + ] + assert _flagged("The precondition check blocks a stale world.") + assert _flagged("The request is withdrawn, so nothing can spend that fingerprint later.") + assert _flagged("No presentation can reach a precondition that moved.") + assert not _flagged("The fingerprint narrows a window.") + + +@pytest.mark.parametrize("name", sorted(SCANNED)) +def test_T268_the_documentation_says_narrows(name): + allowed = DISCLAIMS.get(name, ()) + ANOTHER_SUBJECT.get(name, ()) + unexplained = [ + sentence + for sentence in _flagged(SCANNED[name]()) + if not any(fragment in sentence for fragment in allowed) + ] + assert not unexplained, ( + f"{name} has a sentence about a precondition or a fingerprint that uses a word of " + "prevention, and it is not on the allow-list. The recheck narrows a window and does not " + "close one; rewrite it, or add it to DISCLAIMS or ANOTHER_SUBJECT saying which it is:\n" + + "\n".join(f" - {sentence}" for sentence in unexplained) + ) + + +def test_T268_every_allow_listed_fragment_still_matches_a_flagged_sentence(): + """An allow-list entry that matches no flagged sentence is a stale exemption waiting to + cover a new claim that happens to share its words.""" + for name, fragments in {**DISCLAIMS, **ANOTHER_SUBJECT}.items(): + flagged = _flagged(SCANNED[name]()) + for fragment in fragments: + assert any(fragment in sentence for sentence in flagged), ( + f"{name}: allow-listed fragment matches no flagged sentence: {fragment!r}" + ) + + +# --- T269: G16 in verify ------------------------------------------------------------------------- + +AUTHORITY_PAYMENTS = REPO_ROOT / "examples" / "authority" / "payments.yaml" +V1_PAYMENTS = REPO_ROOT / "examples" / "policies" / "payments.yaml" + + +def _g16(path, **kwargs): + from ctrlrun.verify import run + + report = run(path, only=("G16",), **kwargs) + return report, next(result for result in report.guarantees if result.id == "G16") + + +def test_T269_G16_is_in_the_v3_catalogue(): + from ctrlrun.verify import guarantees as reg + + assert reg.CATALOGUE == "ctrlrun.guarantees/v3" + assert "G16" in reg.BY_ID + assert reg.BY_ID["G16"].descends_from + + +@pytest.mark.authority +@pytest.mark.parametrize("path", [AUTHORITY_PAYMENTS, V1_PAYMENTS], ids=["authority", "v1"]) +def test_T269_G16_passes_on_the_shipped_examples(path): + from ctrlrun.verify import Status + from ctrlrun.verify import guarantees as reg + + report, result = _g16(path) + + assert result.status is Status.PASS, (result.reason, result.counterexample) + assert result.detail["note"] == reg.PRECONDITION_NOTE + assert "verify supplies its own precondition provider" in report.to_text() + + +def test_T269_G16_is_not_applicable_where_nothing_requires_approval(tmp_path): + from ctrlrun.verify import Status + from ctrlrun.verify import guarantees as reg + + path = tmp_path / "ctrlrun.yaml" + path.write_text("schema: ctrlrun.policy/v1\nactions:\n a.read:\n decision: allow\n") + + _, result = _g16(path) + + assert result.status is Status.NOT_APPLICABLE + assert result.reason == reg.NO_APPROVE_RULE + + +def test_T269_G16_fails_where_the_recheck_is_gone(monkeypatch): + """The guarantee is the test and not the mechanism: delete the recheck and G16 is `fail` + on the refusal half, not `control failed` and not `pass`.""" + from ctrlrun.verify import Status + + monkeypatch.setattr(Control, "_recheck", lambda self, *args, **kwargs: None) + + report, result = _g16(V1_PAYMENTS) + + if result.status is not Status.FAIL: + raise AssertionError(f"G16 reported {result.status} with the recheck deleted") + assert result.reason != "control failed" + assert report.exit_code == 1 + + +def test_T269_G16_fails_where_the_fingerprint_is_lost_after_the_request(monkeypatch): + """A kernel whose stored fingerprint goes missing between the request and the presentation, + which is §6.4's database restored from before the migration: the presenting pass meets a + provider and an approval without one, and that is `precondition_missing` and not + `precondition_changed`. **G16's assertion on the reason, not on the type, is what tells the + two apart**, and this is the test that pins it. + + The loss is after the request pass on purpose: dropping it earlier is caught by the + request-pass read-back (review finding 1), which is a different guard and a different test. + """ + from dataclasses import replace + + from ctrlrun.state import SQLiteStateStore + from ctrlrun.verify import Status + + original = SQLiteStateStore.get_approval + reads: list[str] = [] + + def losing(self, approval_id): + record = original(self, approval_id) + reads.append(approval_id) + if record is None or len(reads) <= 1: + return record + return replace(record, request=replace(record.request, precondition_fingerprint=None)) + + monkeypatch.setattr(SQLiteStateStore, "get_approval", losing) + + _, result = _g16(V1_PAYMENTS) + + assert result.status is Status.FAIL, result.status + assert result.reason != "control failed" + assert "precondition_missing" in (result.reason or ""), result.reason + + +def test_T269_G16_with_a_broken_control_is_control_failed(monkeypatch): + from ctrlrun.verify import Status, scenarios + + monkeypatch.setattr(scenarios._Executor, "__call__", lambda self: None) + + _, result = _g16(V1_PAYMENTS) + + assert result.status is Status.FAIL + assert result.reason == "control failed" + + +def test_T269_G16_is_deterministic(): + """`v0.4 §3.7`: two runs against one configuration produce the same guarantee JSON.""" + first = _g16(V1_PAYMENTS)[1].to_dict() + second = _g16(V1_PAYMENTS)[1].to_dict() + assert first == second + + +def test_the_fingerprint_is_not_shown_to_anybody_deciding(tmp_path, fake_clock): + """§6.10: a hash tells a human nothing about the world they are approving, so the + fingerprint is not added to the webhook document, to `ctrlrun inspect`'s approval entries, + or to the operator server's pending listing. The receipt and `APPROVAL_INVALIDATED` carry + it, and those are evidence, not the question put to a human.""" + from ctrlrun.reporting import approval_document, inspection_for + from ctrlrun.webhook import _payload + + store = SQLiteStateStore(tmp_path / "state.db", clock=fake_clock) + control = Control(Policy.from_yaml(POLICY), store, clock=fake_clock) + action = an_action(control) + request_id = requested(control, action, World()) + record = store.get_approval(request_id) + assert record.request.precondition_fingerprint == fingerprint(AT_REQUEST) + + webhook = json.loads(_payload(record.request, None)) + assert "precondition" not in json.dumps(webhook) + assert "precondition" not in json.dumps(approval_document(record)) + document = inspection_for(store, action.action_id) + assert document is not None + assert all("precondition" not in json.dumps(entry) for entry in document["approvals"]) + assert fingerprint(AT_REQUEST) not in json.dumps(webhook) + store.close() + + +def test_the_operator_servers_pending_listing_carries_no_fingerprint(tmp_path, fake_clock): + pytest.importorskip("httpx", reason="the gateway extra is not installed") + from ctrlrun.gateway.operator import OperatorConfig, OperatorServer + + store = SQLiteStateStore(tmp_path / "state.db", clock=fake_clock) + control = Control(Policy.from_yaml(POLICY), store, clock=fake_clock) + server = OperatorServer( + OperatorConfig(principal_header="x-approver", user_header="x-approver-user"), + control, + None, + ) + requested(control, an_action(control), World()) + record = store.approvals_for(an_action(control).action_hash)[0] + + entry = server._pending_entry(record, fake_clock()) + + assert record.request.precondition_fingerprint is not None + assert "precondition" not in json.dumps(entry) + store.close() + + +def test_T267_an_adapters_protected_tool_rechecks_through_the_interrupt_seam(tmp_path, fake_clock): + """§7's adapter row: an adapter's protected tool is `@protect` reached through a framework + (`v0.5 §4.1`), so the decorator's provider is captured on the request pass, the framework's + interrupt answers, and the re-presentation compares before it consumes. Driven through + `InterruptApprovalProvider`, the seam both reference adapters use; neither adapter builds + `@protect` itself, so there is nothing adapter-side to forward.""" + from ctrlrun import ApprovalAnswer, InterruptApprovalProvider, context + + world = World() + + class Framework: + framework = "test-framework" + carries_approved_arguments = False + moves_the_world = False + + def interrupt(self, pending): + if self.moves_the_world: + world.state = dict(MOVED) + return ApprovalAnswer(granted=True, approver="human:alice") + + store = SQLiteStateStore(tmp_path / "state.db", clock=fake_clock) + framework = Framework() + provider = InterruptApprovalProvider(store, framework, clock=fake_clock) + control = Control(Policy.from_yaml(POLICY), store, provider, clock=fake_clock) + ran: list[str] = [] + + @protect( + "customer.delete", + effect="delete:{customer_id}", + wait=True, + control=control, + preconditions=world, + ) + def delete(customer_id: str) -> str: + ran.append(customer_id) + return "deleted" + + with context("ops-agent", "ada"): + assert delete("C1") == "deleted" + assert world.calls == 2 and ran == ["C1"] + + framework.moves_the_world = True + with context("ops-agent", "ada"), pytest.raises(ApprovalMismatch) as raised: + delete("C2") + assert raised.value.reason == CHANGED + assert ran == ["C1"] + store.close() + + +def test_T267_delegate_and_revoke_take_no_provider(): + """§7: they create and remove authority and consume no approval, so the "no" is the + signature's, and it is written down rather than assumed.""" + for method in (Control.delegate, Control.revoke, Control.resume, Control.evaluate): + assert "preconditions" not in inspect.signature(method).parameters, method.__name__ + + +def test_put_receipt_writes_v4_whatever_schema_the_receipt_was_read_under(tmp_path, fake_clock): + """§12.5: a `v1` or `v2` key set has no `seq`, so a receipt written into the chain under + its old label would carry no position in its own document and read back `unchained`. A + store writes the schema this binary writes.""" + from dataclasses import replace + + from ctrlrun.receipt import new_receipt_id, verify_chain + + store = SQLiteStateStore(tmp_path / "state.db", clock=fake_clock) + control = Control(Policy.from_yaml(POLICY), store, clock=fake_clock) + control.execute(an_action(control, "C1", "customer.read"), Executor(), None) + template = store.receipts()[0] + for label in ("ctrlrun.receipt/v1", "ctrlrun.receipt/v2", "ctrlrun.receipt/v3"): + written = store.put_receipt(replace(template, receipt_id=new_receipt_id(), schema=label)) + assert written.schema == RECEIPT_SCHEMA, label + back = store.receipts() + assert all(receipt.seq is not None for receipt in back), "a re-put receipt lost its seq" + assert {receipt.schema for receipt in back} == {RECEIPT_SCHEMA} + assert verify_chain(store).ok + store.close() + + +def test_every_schema_renders_under_its_own_label_and_key_set(): + """§6.11's key sets, counted: `v1` 19, `v2` 21, `v3` 26, `v4` 28; an unknown label renders + `v3`'s keys under its own label, and an absent one renders no `schema` key.""" + from dataclasses import replace + + from ctrlrun.receipt import Receipt + + base = Receipt( + receipt_id="ctr_" + "1" * 32, + action_id="act_1", + action="customer.read", + action_hash="sha256:" + "0" * 64, + principal=Principal(agent="ops-agent", user="ada"), + resource=None, + arguments={}, + environment="production", + decision="allow", # type: ignore[arg-type] + decision_reason="decision", + result=ReceiptResult.COMMITTED, + started_at=datetime(2026, 9, 1, tzinfo=UTC), + finished_at=datetime(2026, 9, 1, tzinfo=UTC), + precondition_at_request=FABRICATED, + ) + counts = { + "ctrlrun.receipt/v1": 19, + "ctrlrun.receipt/v2": 21, + "ctrlrun.receipt/v3": 26, + "ctrlrun.receipt/v4": 28, + "ctrlrun.receipt/v9": 26, + "": 25, + } + for label, count in counts.items(): + document = replace(base, schema=label).to_dict() + assert len(document) == count, (label, sorted(document)) + assert document.get("schema") == (label or None) + if label != "ctrlrun.receipt/v4": + assert FABRICATED not in json.dumps(document), label + assert replace(base, schema="ctrlrun.receipt/v1").to_dict()["principal"] == { + "agent": "ops-agent", + "user": "ada", + } + + +def test_T269_G16s_note_is_printed_beneath_G3s_on_the_full_catalogue(): + """The window the note rule is about: a full run on a document with no `effect:` template + puts G3's note first, and G16's is a different sentence. A report that printed only the + first note it met would drop G16's here, and a run of G16 alone never opens that window.""" + from ctrlrun.verify import guarantees as reg + from ctrlrun.verify import run + + text = re.sub(r"\s+", " ", run(V1_PAYMENTS).to_text()) + + assert reg.EFFECT_TEMPLATE_NOTE in text, "G3's note is not in this report" + assert reg.PRECONDITION_NOTE in text, "G16's note was dropped beneath G3's" + assert text.index(reg.EFFECT_TEMPLATE_NOTE) < text.index(reg.PRECONDITION_NOTE) + + +class _Ahead: + """`Control`'s clock, running ahead of the store's by a fixed amount.""" + + def __init__(self, behind, by: timedelta) -> None: + self._behind = behind + self.by = by + + def __call__(self) -> datetime: + return self._behind() + self.by + + +def _divergent(state_store, fake_clock) -> tuple[Control, _Ahead]: + ahead = _Ahead(fake_clock, timedelta(0)) + return Control(Policy.from_yaml(POLICY), state_store, clock=ahead), ahead + + +def test_where_neither_side_has_a_fingerprint_the_store_call_is_061s_exactly( + state_store, fake_clock +): + """§6.2's first row: *unchanged from 0.6.1*. The read §6.6 adds must not decide anything + where the precondition question does not arise, and the one place that is visible is a + `Control` whose clock disagrees with its store's: 0.6.1 let the store's clock decide expiry + at consumption, and so does this, rather than `Control` refusing from its own read.""" + control, ahead = _divergent(state_store, fake_clock) + action = an_action(control) + request_id = granted(control, action, None) + ahead.by = timedelta(minutes=20) # past expiry by Control's clock, not by the store's + executor = Executor() + + receipt = present(control, action, request_id, None, executor) + + assert receipt.result is ReceiptResult.COMMITTED and executor.calls == 1 + + +def test_where_a_precondition_is_in_play_a_divergent_clock_spends_nothing_that_runs( + state_store, fake_clock +): + """§12.5's stated caveat, pinned: with a precondition in play `Control` raises the refusal + its own read found, and an expired grant goes to the store through `consume_approval` only. + A store whose clock disagrees then spends a grant `Control` calls expired, and that is all: + nothing reserved, nothing run, and the provider never called.""" + control, ahead = _divergent(state_store, fake_clock) + world = World() + action = an_action(control) + request_id = granted(control, action, world) + ahead.by = timedelta(minutes=20) + before = world.calls + executor = Executor() + + mismatch = refused(control, action, request_id, world, executor) + + assert mismatch.reason == "expired" + assert executor.calls == 0 and world.calls == before + assert state_store.get_effect(KEY) is None + + +# ================================================================================================= +# The independent review of efb3b42: ten findings, each a test before it was a fix. +# ================================================================================================= + + +class _DroppingStore: + """A store that accepts a fingerprint and reads it back as `None` (review finding 1). + + The shape of a backend that never applied `0005`, a restore from before it, or a wrapper + that rebuilds records: the request goes in carrying a fingerprint and comes out without one. + """ + + def __init__(self, inner) -> None: + self._inner = inner + + def __getattr__(self, name: str) -> Any: + return getattr(self._inner, name) + + def get_approval(self, approval_id: str): + from dataclasses import replace as _replace + + record = self._inner.get_approval(approval_id) + if record is None: + return None + return _replace(record, request=_replace(record.request, precondition_fingerprint=None)) + + def approvals_for(self, action_hash: str): + return tuple( + self.get_approval(r.approval_id) for r in self._inner.approvals_for(action_hash) + ) + + +class ThirdPartyProvider: + """An `ApprovalProvider` from outside the package (review finding 1). + + `build_request` is package-internal, so a third-party provider builds its own + `ApprovalRequest` and records no fingerprint, however the store behaves. `on_request` is + what the race variants use to act inside the window `Control` has not seen yet. + """ + + def __init__(self, store, clock, on_request=None) -> None: + self._store = store + self._clock = clock + self._on_request = on_request + #: The request it last built, which is the only handle a test has on a row `Control` + #: may never learn the id of. + self.last: Any = None + + def request(self, action: Action, ttl: timedelta = timedelta(minutes=15)): + from ctrlrun.approval import ApprovalRequest, new_request_id + + now = self._clock() + request = ApprovalRequest( + request_id=new_request_id(), + action_hash=action.action_hash, + action=action, + created_at=now, + expires_at=now + ttl, + ) + self._store.put_approval_request(request) + self.last = request + if self._on_request is not None: + self._on_request(request) + return request + + def wait(self, request_id: str, timeout: timedelta | None = None): + return None + + +def _requested_id(store) -> str: + ids = [ + event.approval_id + for event in store.events() + if event.type is EventType.APPROVAL_REQUESTED and event.approval_id + ] + assert ids, "no request was created" + return ids[-1] + + +def test_R1_a_fingerprint_the_store_did_not_record_refuses_the_request_pass(tmp_path, fake_clock): + """**Blocking finding 1.** A request pass that names a provider computes a fingerprint; if + the store hands it back without one, every later presentation that names no provider (the + gateway's and the ACS hook's shape) would consume it with no comparison at all, because + neither side has a fingerprint and that is 0.6.1's path. §6.4's *never a skip* was false for + exactly the store §6.4 names. + + So the request pass reads its own request back and refuses where the fingerprint is not + there, before any human is asked.""" + inner = SQLiteStateStore(tmp_path / "state.db", clock=fake_clock) + store = _DroppingStore(inner) + control = Control(Policy.from_yaml(POLICY), store, clock=fake_clock) + world = World() + action = an_action(control) + + with pytest.raises(ActionDenied) as denied: + control.execute(action, Executor(), KEY, preconditions=world) + + assert denied.value.reason == MISSING + receipt = last_receipt(store, action) + assert receipt.result is ReceiptResult.DENIED and str(receipt.decision) == "approve" + assert receipt.precondition_at_request is None + assert receipt.precondition_at_recheck == fingerprint(AT_REQUEST) + request_id = _requested_id(store) + data = invalidated(store, request_id)[0].data + assert data["reason"] == MISSING + assert data["precondition_at_request"] is None + assert data["precondition_at_recheck"] == fingerprint(AT_REQUEST) + inner.close() + + +@pytest.mark.parametrize("shape", ["a store that drops it", "a third-party provider"]) +def test_R1_the_leftover_request_cannot_be_granted_and_spent_by_a_no_provider_call( + tmp_path, fake_clock, shape +): + """**Blocking finding 1's requirement.** Refusing the request pass is not enough on its own: + the request the provider already recorded is still there, and a human could grant it and any + call naming no provider could then spend it unchecked. It is withdrawn through the store's + own `deny_approval`, so `check_consumable` refuses it for ever, by the reason a denial + gives.""" + inner = SQLiteStateStore(tmp_path / "state.db", clock=fake_clock) + if shape == "a store that drops it": + store: Any = _DroppingStore(inner) + approvals = None + else: + store = inner + approvals = ThirdPartyProvider(inner, fake_clock) + control = Control(Policy.from_yaml(POLICY), store, approvals, clock=fake_clock) + world = World() + action = an_action(control) + with pytest.raises(ActionDenied): + control.execute(action, Executor(), KEY, preconditions=world) + request_id = _requested_id(store) + + assert inner.get_approval(request_id).status is ApprovalStatus.DENIED, ( + "the request the provider recorded is still answerable, so a human can grant it and a " + "call naming no provider can spend it with no comparison" + ) + with pytest.raises(ApprovalMismatch): + inner.grant_approval(request_id, "human:alice") + + executor = Executor() + with pytest.raises(ActionDenied) as refused_: + present(control, action, request_id, None, executor) + + assert refused_.value.reason == "approval_denied" + assert executor.calls == 0 + assert store.get_effect(KEY) is None + inner.close() + + +def test_R1_a_grant_that_lands_inside_the_request_is_withdrawn_by_spending_it(tmp_path, fake_clock): + """The same requirement where the window is not empty: a provider that answers its own + request before returning (a scripted approver, an automation on the webhook notification) + leaves a *granted* approval carrying no fingerprint. `deny_approval` refuses a record that + is no longer pending, so it is withdrawn by being spent: consumed, on nothing.""" + store = SQLiteStateStore(tmp_path / "state.db", clock=fake_clock) + granted_in_the_window: list[str] = [] + + def answers_at_once(request): + store.grant_approval(request.request_id, "human:alice") + granted_in_the_window.append(request.request_id) + + provider = ThirdPartyProvider(store, fake_clock, on_request=answers_at_once) + control = Control(Policy.from_yaml(POLICY), store, provider, clock=fake_clock) + action = an_action(control) + + with pytest.raises(ActionDenied) as denied: + control.execute(action, Executor(), KEY, preconditions=World()) + + assert denied.value.reason == MISSING + request_id = granted_in_the_window[0] + assert store.get_approval(request_id).status is ApprovalStatus.CONSUMED, ( + "a grant that landed inside the window is still spendable by a call with no provider" + ) + executor = Executor() + with pytest.raises(ApprovalMismatch) as refused_: + present(control, action, request_id, None, executor) + assert refused_.value.reason == "consumed" + assert executor.calls == 0 and store.get_effect(KEY) is None + store.close() + + +def test_R1_the_residual_a_presentation_inside_the_request_is_not_refused(tmp_path, fake_clock): + """**The residual this fix leaves, stated as T261b states its own.** + + `Control` learns a request exists only when the provider returns, so an approval granted + *and presented* before that is spent before there is anything to withdraw. This test drives + exactly that: a third-party provider that grants its own request and presents it through a + call naming no provider, inside `request()`. **The nested action is not refused**, and + closing that window needs a store call that records the fingerprint and the request + together, which `StateStore` has no method for: a finding for the maintainer, not a method + this item adds. + + What the fix does leave true: the request is unusable **afterwards**, and the evidence says + a fingerprint was computed and never recorded.""" + store = SQLiteStateStore(tmp_path / "state.db", clock=fake_clock) + ran: list[str] = [] + control: list[Control] = [] + + def grants_and_spends_it(request): + store.grant_approval(request.request_id, "human:alice") + with with_approval(request.request_id): + control[0].execute(an_action(control[0]), Executor(lambda: ran.append("nested")), KEY) + + provider = ThirdPartyProvider(store, fake_clock, on_request=grants_and_spends_it) + control.append(Control(Policy.from_yaml(POLICY), store, provider, clock=fake_clock)) + action = an_action(control[0]) + + with pytest.raises(ActionDenied) as denied: + control[0].execute(action, Executor(), KEY, preconditions=World()) + + assert denied.value.reason == MISSING + assert ran == ["nested"], ( + "this test no longer opens the window it is named for: nothing ran inside the request" + ) + assert store.get_effect(KEY) is not None, "the nested action reserved nothing" + store.close() + + +def test_R2_an_expiry_only_this_clock_sees_writes_nothing_to_the_store(state_store, fake_clock): + """**Finding 2.** The pre-read sent an expired grant to `consume_approval`, and a store + whose clock still called it live consumed it: the row said `consumed` while the events said + `APPROVAL_EXPIRED` and `APPROVAL_INVALIDATED` with no `APPROVAL_CONSUMED`, and the receipt + said expired. Safe, and untrue. + + Whose clock decides is `v0.1 §4.2 A3`'s question, and the answer stays the store's: where a + precondition is in play `Control` raises the refusal its own read found and **writes + nothing**, so the row keeps the status the store gave it and the evidence agrees with it.""" + control, ahead = _divergent(state_store, fake_clock) + world = World() + action = an_action(control) + request_id = granted(control, action, world) + ahead.by = timedelta(minutes=20) # expired for Control, one minute of life for the store + executor = Executor() + + mismatch = refused(control, action, request_id, world, executor) + + assert mismatch.reason == "expired" + record = state_store.get_approval(request_id) + assert record.status is ApprovalStatus.GRANTED, ( + f"the store wrote {record.status} for an expiry only Control's clock sees" + ) + assert record.consumed_at is None + types = [str(event.type) for event in state_store.events() if event.approval_id == request_id] + assert "APPROVAL_CONSUMED" not in types, types + assert types[-2:] == ["APPROVAL_EXPIRED", "APPROVAL_INVALIDATED"], types + assert executor.calls == 0 and state_store.get_effect(KEY) is None + + +def _tamper_one(database, seq: int, change) -> str: + connection = sqlite3.connect(database) + try: + (text,) = connection.execute("SELECT json FROM receipts WHERE seq = ?", (seq,)).fetchone() + connection.execute( + "UPDATE receipts SET json = ? WHERE seq = ?", + (json.dumps(change(json.loads(text))), seq), + ) + connection.commit() + finally: + connection.close() + return text + + +UNHASHABLE = { + "an added key holding a float": lambda document: {**document, "x_extra": 1.5}, + "an added key holding a lone surrogate": lambda document: {**document, "x_extra": "\ud800"}, +} + + +@pytest.mark.parametrize("label", sorted(UNHASHABLE)) +def test_R3_a_row_the_canonicalizer_refuses_is_content_altered_and_not_a_raised_walk( + tmp_path, fake_clock, label +): + """**Finding 3.** A key holding a float or a lone surrogate made `chain_hash()` raise out of + `verify_chain`, so one tampered row stopped the walk: `ctrlrun receipts --verify-chain` + exited 1 with no report, and a forged `decision_reason` at another `seq` went unnamed. A + reader that cannot hash a stored document has not found it intact; the row is reported. + + Sound because `put_receipt` writes only what canonicalized: a document this refuses is one + nothing in this library wrote.""" + from ctrlrun.receipt import verify_chain + + database = tmp_path / "state.db" + store = SQLiteStateStore(database, clock=fake_clock) + control = Control(Policy.from_yaml(POLICY), store, clock=fake_clock) + for customer in ("C1", "C2", "C3"): + control.execute(an_action(control, customer, "customer.read"), Executor(), None) + store.close() + _tamper_one(database, 2, UNHASHABLE[label]) + + reopened = SQLiteStateStore(database, clock=fake_clock) + receipts = reopened.receipts() # MUST NOT raise + report = verify_chain(reopened) + + assert len(receipts) == 3 + assert not report.ok + named = [(item.name, item.seq) for item in report.breaks] + assert ("content_altered", 2) in named, named + assert ("link_broken", 3) in named, "the next receipt's link was left unquestioned" + # And it says *why* it has no left side to compare against. A reader told that receipt 3's + # link is broken against a hash carried over from receipt 1 would go looking at the wrong + # row; there is no hash for receipt 2, and the break says so. + link = next(item for item in report.breaks if (item.name, item.seq) == ("link_broken", 3)) + assert "" in link.detail, link.detail + altered = next(item for item in report.breaks if item.seq == 2) + assert "InvalidArgument" in altered.detail and "1.5" not in altered.detail + assert report.verified == 1 + reopened.close() + + +def test_R3_the_cli_reports_every_break_even_where_one_row_cannot_be_hashed(tmp_path, fake_clock): + """The consequence an operator sees: a forged `decision_reason` at one `seq` and an + unhashable row at another. Both are named, and the command still exits non-zero.""" + from click.testing import CliRunner + + from ctrlrun.cli import main as cli + + database = tmp_path / "state.db" + store = SQLiteStateStore(database, clock=fake_clock) + control = Control(Policy.from_yaml(POLICY), store, clock=fake_clock) + for customer in ("C1", "C2", "C3"): + control.execute(an_action(control, customer, "customer.read"), Executor(), None) + store.close() + _tamper_one(database, 2, lambda document: {**document, "decision_reason": "forged"}) + _tamper_one(database, 3, lambda document: {**document, "x_extra": 1.5}) + + result = CliRunner().invoke( + cli.main, ["receipts", "--verify-chain", "--store-url", f"sqlite://{database}"] + ) + + assert result.exit_code != 0, result.output + assert result.exception is None or isinstance(result.exception, SystemExit), result.exception + assert "seq 2" in result.output and "seq 3" in result.output, result.output + assert result.output.count("content_altered") >= 2, result.output + + +def test_R6_a_refusal_that_would_have_happened_anyway_still_says_what_the_approval_carried( + control, state_store +): + """**Finding 6a.** `compared.reset()` left `precondition_at_request` null on a receipt for + an approval that does carry a fingerprint, so the evidence for a consumed or expired grant + said "no precondition" about an approval requested with one. The pre-read knows it.""" + world = World() + action = an_action(control) + request_id = granted(control, action, world) + present(control, action, request_id, world) + world.state = dict(MOVED) + + refused(control, action, request_id, world) + + receipt = last_receipt(state_store, action) + assert receipt.result is ReceiptResult.BLOCKED + assert receipt.precondition_at_request == fingerprint(AT_REQUEST), ( + "the receipt says the approval carried no fingerprint, and it carried one" + ) + assert receipt.precondition_at_recheck is None, "nothing was compared on this pass" + + +def test_R6_a_resumed_legs_receipt_records_the_first_legs_comparison(control, state_store): + """**Finding 6b.** A suspended action's only receipt is the resumed leg's, and it said + `precondition_at_recheck: null`, so the comparison that did happen left no trace and + §6.11's *on a committed action they are equal* was false there. The first leg records what + it compared on `APPROVAL_CONSUMED`, and the resumed leg reads it back.""" + world = World() + action = an_action(control) + request_id = granted(control, action, world) + + def suspend() -> Any: + raise Suspended("continuation-C123") + + with pytest.raises(Suspended): + present(control, action, request_id, world, Executor(suspend)) + + consumed = [ + event + for event in state_store.events() + if event.type is EventType.APPROVAL_CONSUMED and event.approval_id == request_id + ] + assert len(consumed) == 1 + assert consumed[0].data["precondition_at_request"] == fingerprint(AT_REQUEST) + assert consumed[0].data["precondition_at_recheck"] == fingerprint(AT_REQUEST) + + receipt = control.resume("continuation-C123", lambda: "deleted") + + assert receipt.result is ReceiptResult.COMMITTED + assert receipt.precondition_at_request == fingerprint(AT_REQUEST) + assert receipt.precondition_at_recheck == fingerprint(AT_REQUEST), ( + "the resumed leg's receipt is the only one this action gets, and it does not say the " + "world was compared before the reservation" + ) + + +def test_R6_an_approval_consumed_with_no_precondition_carries_no_fields(control, state_store): + """The other direction: absent means absent on the event too.""" + action = an_action(control) + request_id = granted(control, action, None) + present(control, action, request_id, None) + + consumed = [ + event for event in state_store.events() if event.type is EventType.APPROVAL_CONSUMED + ] + assert consumed and "precondition_at_request" not in consumed[-1].data + + +def test_R7_a_canonicalizer_message_that_quotes_the_state_reaches_no_evidence( + tmp_path, fake_clock, caplog +): + """**Finding 7.** `canonical_bytes` names what it refused: *"payload.state.balance has a int + key 7310042"*. A mutant recording `str(exc)` instead of the type name passed every test, + because no provider in the suite returned state the canonicalizer quoted.""" + caplog.set_level(logging.DEBUG, logger="ctrlrun") + store = SQLiteStateStore(tmp_path / "state.db", clock=fake_clock) + sink = JSONLEventSink(tmp_path / "jsonl") + control = Control(Policy.from_yaml(POLICY), store, clock=fake_clock, sinks=[sink]) + quoted = 7310042 + world = World() + action = an_action(control, "C1") + request_id = granted(control, action, world, "delete:C1") + world.state = {"balance": {quoted: "the sentinel is the key"}} + + mismatch = refused(control, action, request_id, world, key="delete:C1") + + assert mismatch.reason == UNAVAILABLE + # The negative precondition: this test proves nothing unless the canonicalizer really does + # put what it refused into its message. + with pytest.raises(InvalidArgument) as raised: + canonical_bytes({"schema": "ctrlrun.precondition/v1", "state": world.state}) + assert str(quoted) in str(raised.value), ( + "the canonicalizer no longer quotes what it refused, so this test proves nothing" + ) + store.close() + assert str(quoted) not in _every_row(tmp_path / "state.db"), "the state reached a table" + for path in (sink.receipts_path, sink.events_path): + assert str(quoted) not in path.read_text(encoding="utf-8"), path.name + for record in caplog.records: + assert str(quoted) not in record.getMessage(), record + + +def test_R8_a_return_value_whose_type_raises_is_unavailable_and_not_an_escape(control, state_store): + """**Finding 8.** `isinstance(state, Mapping)` sat outside the `try`, and `isinstance` reads + `__class__`: an object whose `__class__` raises carried its message out of `Control` as a + raw `ValueError`, with no receipt and no refusal reason. Everything a provider hands back is + inside the `try` now.""" + + class Hostile: + @property + def __class__(self): + raise ValueError("balance=" + SENTINEL) + + world = World() + action = an_action(control) + request_id = granted(control, action, world) + # Handed back by the provider itself: `World` asks `isinstance` of its own state, which + # would raise inside the provider call and be caught there, masking what this is about. + hostile = _counting(Hostile) + executor = Executor() + + mismatch = refused(control, action, request_id, hostile, executor) + + assert mismatch.reason == UNAVAILABLE + assert SENTINEL not in str(mismatch) + assert executor.calls == 0 and state_store.get_effect(KEY) is None + data = invalidated(state_store, request_id)[0].data + assert data["error"] == "ValueError" and SENTINEL not in json.dumps(dict(data)) + + +def test_R8_the_request_pass_refuses_the_same_return_value(control, state_store): + class Hostile: + @property + def __class__(self): + raise ValueError(SENTINEL) + + action = an_action(control) + + with pytest.raises(ActionDenied) as denied: + control.execute(action, Executor(), KEY, preconditions=_counting(Hostile)) + + assert denied.value.reason == UNAVAILABLE + assert SENTINEL not in str(denied.value) + + +READER_061 = textwrap.dedent(""" + import sys + from ctrlrun.receipt import verify_chain + from ctrlrun.state import SQLiteStateStore + + store = SQLiteStateStore(sys.argv[1]) + print("OPEN", flush=True) + sys.stdin.readline() + report = verify_chain(store) + print("BREAKS", [(b.name, b.seq) for b in report.breaks], flush=True) +""") + +WRITER_061 = textwrap.dedent(""" + import sys + from ctrlrun import Action, Control, Policy, Principal, with_approval + from ctrlrun.state import SQLiteStateStore + + store = SQLiteStateStore(sys.argv[1]) + control = Control(Policy.from_yaml(sys.argv[2]), store) + print("OPEN", flush=True) + request_id = sys.stdin.readline().strip() + action = Action(name="customer.delete", arguments={"customer_id": "C123"}, + principal=Principal(agent="ops-agent", user="ada")) + with with_approval(request_id): + receipt = control.execute(action, lambda: "deleted by 0.6.1", "delete:C123") + print("RESULT", receipt.result, flush=True) +""") + + +def test_R4_a_061_reader_open_across_the_migration_misreports_the_chain( + release_061, tmp_path, fake_clock +): + """**Finding 4.** The upgrade rule was written as *stop every 0.6 process before the first + caller passes `preconditions=`*, and the trigger is earlier than that: the first receipt any + 0.7 process writes is `v4`, and a 0.6.1 reader still holding the store open rehashes it + under `v3`'s keys and calls a correct chain altered. Nothing here uses `preconditions=`. + + The rule is *stop every 0.6 process before any 0.7 process opens the store*, and this is + what it is for.""" + database = tmp_path / "state.db" + reader = subprocess.Popen( + [str(release_061), "-c", READER_061, str(database)], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + env=releases._clean_env(), + cwd=tmp_path, + ) + try: + assert reader.stdout is not None + opened = reader.stdout.readline().strip() + assert opened == "OPEN", (opened, reader.stderr.read() if reader.stderr else "") + # 0.7 opens the database 0.6.1 created, migrates it to `0005`, and writes one `v4` + # receipt. Nothing here passes `preconditions=`. + store = SQLiteStateStore(database, clock=fake_clock) + control = Control(Policy.from_yaml(POLICY), store, clock=fake_clock) + control.execute(an_action(control, "C1", "customer.read"), Executor(), None) + store.close() + out, err = reader.communicate("go\n", timeout=120) + finally: + reader.kill() + + assert "BREAKS" in out, (out, err) + breaks = out.split("BREAKS", 1)[1].strip() + assert breaks != "[]", ( + "0.6.1 read a v4 receipt without complaint, so the upgrade rule this documents is " + f"no longer what it is for: {out}" + ) + assert "content_altered" in breaks or "head_mismatch" in breaks, breaks + + +def test_R4_a_061_writer_open_across_the_migration_spends_a_fingerprinted_approval( + release_061, tmp_path +): + """The other half of the same rule: a 0.6.1 process holding the store open consumes an + approval 0.7 fingerprinted, with no comparison, because it knows neither the column nor the + check. The kernel has no way to see that process, which is why the rule is operational.""" + database = tmp_path / "state.db" + writer = subprocess.Popen( + [str(release_061), "-c", WRITER_061, str(database), POLICY], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + env=releases._clean_env(), + cwd=tmp_path, + ) + try: + assert writer.stdout is not None + opened = writer.stdout.readline().strip() + assert opened == "OPEN", (opened, writer.stderr.read() if writer.stderr else "") + # Real clocks on both sides: the 0.6.1 process has its own, and an approval this test + # dated in the past would be expired for it before it could spend anything. + store = SQLiteStateStore(database) + control = Control(Policy.from_yaml(POLICY), store) + world = World() + action = an_action(control) + request_id = granted(control, action, world) + assert store.get_approval(request_id).request.precondition_fingerprint is not None + world.state = dict(MOVED) + out, err = writer.communicate(request_id + "\n", timeout=120) + finally: + writer.kill() + + assert "RESULT committed" in out, (out, err) + assert store.get_approval(request_id).status is ApprovalStatus.CONSUMED + assert world.calls == 1, "0.6.1 called the provider, which it has no way to do" + store.close() + + +def test_R5_a_third_party_store_hashes_from_to_dict_and_the_spec_says_so(): + """**Finding 5.** `_stored_receipt` is private and v0.7 adds no public name, so a store + outside this package hashes its read-back receipts from `to_dict()`, as 0.6.1 did: an + untouched `v3` or `v4` row still verifies, and a key added to a stored document does not + show. §6.11 has to say that rather than leave an implementer to find it.""" + from dataclasses import replace as _replace + + from ctrlrun.receipt import Receipt, verify_chain + + store = InMemoryStateStore() + written = [ + store.put_receipt( + _replace( + Receipt( + receipt_id=f"ctr_{index:032d}", + action_id=f"act_{index}", + action="customer.read", + action_hash="sha256:" + "0" * 64, + principal=Principal(agent="ops-agent"), + resource=None, + arguments={}, + environment="production", + decision="allow", # type: ignore[arg-type] + decision_reason="decision", + result=ReceiptResult.COMMITTED, + started_at=datetime(2026, 9, 1, tzinfo=UTC), + finished_at=datetime(2026, 9, 1, tzinfo=UTC), + ) + ) + ) + for index in range(3) + ] + + class ThirdPartyChain: + """What a store that never learned the private setter hands a reader.""" + + def receipts(self): + return tuple( + _replace( + Receipt.from_dict({**item.to_dict(), "x_added": "a key nobody wrote"}), + hash=item.hash, + ) + for item in written + ) + + def chain_head(self): + return store.chain_head() + + assert verify_chain(store).ok + assert verify_chain(ThirdPartyChain()).ok, ( + "a third-party store's read-back receipts no longer verify, which is a stronger claim " + "than §6.11 makes for them" + ) + spec = (REPO_ROOT / "docs" / "SPEC-v0.7.md").read_text(encoding="utf-8") + section = spec[spec.index("### 6.11") : spec.index("## 7. ")] + assert "A store outside this package" in section, ( + "§6.11 does not say what a third-party store gets, and an implementer would have to " + "find it by reading a private name" + ) + + +def test_R9_the_guarantee_titles_are_scanned_and_G16s_is_qualified(): + """**Finding 9.** "a moved precondition is refused" is unqualified, and a precondition that + moves after the comparison is not refused. The title says what is compared, and the titles + are scanned by T268 like every other sentence this item writes.""" + from ctrlrun.verify import guarantees as reg + + assert "guarantee titles" in SCANNED + assert reg.BY_ID["G16"].title == "a moved fingerprint is refused" + assert len(reg.BY_ID["G16"].title) <= max(len(g.title) for g in reg.GUARANTEES) + + +def test_R10_the_note_is_not_a_public_name(): + """**Finding 10.** `PRECONDITION_NOTE` went into `__all__` and not into §9.2, and §9.2 is + the list of what v0.7 adds. Nothing public needs it: `scenarios.py` reads it as an + attribute, as it reads every other reason in that module.""" + from ctrlrun.verify import guarantees as reg + + assert "PRECONDITION_NOTE" not in reg.__all__ + assert reg.PRECONDITION_NOTE.startswith("verify supplies its own") + + +def test_R6_a_resumed_leg_takes_no_fingerprint_from_a_tampered_event(tmp_path, fake_clock): + """An event's data is JSON, and a row-writer can put anything in one. A resumed leg reads + the first leg's comparison out of `APPROVAL_CONSUMED`, so what it reads is a string or it is + nothing: the receipt says `null` rather than whatever was found there.""" + from dataclasses import replace as _replace + + store = SQLiteStateStore(tmp_path / "state.db", clock=fake_clock) + + class Rewritten: + """The store with one event's data rewritten underneath the reader.""" + + def __init__(self, inner) -> None: + self._inner = inner + + def __getattr__(self, name: str) -> Any: + return getattr(self._inner, name) + + def events(self): + return tuple( + _replace(event, data={**event.data, "precondition_at_recheck": 50000}) + if event.type is EventType.APPROVAL_CONSUMED + else event + for event in self._inner.events() + ) + + control = Control(Policy.from_yaml(POLICY), store, clock=fake_clock) + world = World() + action = an_action(control) + request_id = granted(control, action, world) + + def suspend() -> Any: + raise Suspended("continuation-C123") + + with pytest.raises(Suspended): + present(control, action, request_id, world, Executor(suspend)) + + resuming = Control(Policy.from_yaml(POLICY), Rewritten(store), clock=fake_clock) + receipt = resuming.resume("continuation-C123", lambda: "deleted") + + assert receipt.precondition_at_recheck is None + assert receipt.precondition_at_request == fingerprint(AT_REQUEST) + store.close() + + +# ================================================================================================= +# Round two of the review: seven follow-ups, each a test before it was a fix. +# ================================================================================================= + + +def _withdrawn_of(store, request_id: str) -> str | None: + events = invalidated(store, request_id) + assert events, f"no APPROVAL_INVALIDATED for {request_id}" + return events[0].data.get("withdrawn") + + +def test_R2_1_a_presentation_that_wins_the_race_is_reported_as_what_happened(tmp_path, fake_clock): + """**Round-2 finding 1.** The withdrawal reported the status it read *before* its own + failed `consume_approval`, and said `consumed` whether it had spent the grant or somebody + else had. A presentation that won the race therefore ran the action while the evidence said + the request had been withdrawn `granted`, with the row saying `consumed`. + + What is recorded is what happened: `already_consumed` where another caller spent it, and + `withdrawn` in the message only where one of the two writes was this one's.""" + store = SQLiteStateStore(tmp_path / "state.db", clock=fake_clock) + ran: list[str] = [] + holder: list[Control] = [] + provider = ThirdPartyProvider( + store, + fake_clock, + on_request=lambda request: store.grant_approval(request.request_id, "human:alice"), + ) + control = Control(Policy.from_yaml(POLICY), store, provider, clock=fake_clock) + holder.append(control) + real_consume = store.consume_approval + raced: list[str] = [] + + def a_presentation_wins_the_race(approval_id: str, action_hash: str): + if not raced: + raced.append(approval_id) + with with_approval(approval_id): + holder[0].execute( + an_action(holder[0]), Executor(lambda: ran.append("raced") or "done"), KEY + ) + return real_consume(approval_id, action_hash) + + store.consume_approval = a_presentation_wins_the_race # type: ignore[method-assign] + action = an_action(control) + + with pytest.raises(ActionDenied) as denied: + control.execute(action, Executor(), KEY, preconditions=World()) + + assert denied.value.reason == MISSING + assert ran == ["raced"], "the race did not happen, so this test proves nothing" + request_id = raced[0] + assert store.get_approval(request_id).status is ApprovalStatus.CONSUMED + assert _withdrawn_of(store, request_id) == "already_consumed", ( + "the evidence claims a withdrawal this call did not make" + ) + assert "is withdrawn" not in str(denied.value), str(denied.value) + assert "could not be withdrawn (already_consumed)" in str(denied.value) + store.close() + + +def test_R2_1_a_request_that_was_never_persisted_is_not_reported_as_withdrawn(tmp_path, fake_clock): + """The same rule for the provider that records nothing: there is no row, so there is + nothing that was withdrawn, and the message says so rather than asserting a write.""" + store = SQLiteStateStore(tmp_path / "state.db", clock=fake_clock) + + class RecordsNothing(ThirdPartyProvider): + def request(self, action: Action, ttl: timedelta = timedelta(minutes=15)): + from ctrlrun.approval import ApprovalRequest, new_request_id + + now = fake_clock() + self.last = ApprovalRequest( + request_id=new_request_id(), + action_hash=action.action_hash, + action=action, + created_at=now, + expires_at=now + ttl, + ) + return self.last + + provider = RecordsNothing(store, fake_clock) + control = Control(Policy.from_yaml(POLICY), store, provider, clock=fake_clock) + + with pytest.raises(ActionDenied) as denied: + control.execute(an_action(control), Executor(), KEY, preconditions=World()) + + assert denied.value.reason == MISSING + assert _withdrawn_of(store, provider.last.request_id) == "not_withdrawn:absent" + assert "is withdrawn" not in str(denied.value), str(denied.value) + assert "could not be withdrawn (not_withdrawn:absent)" in str(denied.value) + store.close() + + +def test_R2_1_the_two_withdrawals_this_call_makes_are_named_apart(tmp_path, fake_clock): + """`denied` for a request still pending, `spent` for a grant that landed inside the window: + two different writes, and an operator reading the evidence can tell which happened.""" + outcomes = {} + + def granting(target): + return lambda request: target.grant_approval(request.request_id, "human:alice") + + for label, grant in (("denied", False), ("spent", True)): + store = SQLiteStateStore(tmp_path / f"{label}.db", clock=fake_clock) + provider = ThirdPartyProvider( + store, fake_clock, on_request=granting(store) if grant else None + ) + control = Control(Policy.from_yaml(POLICY), store, provider, clock=fake_clock) + with pytest.raises(ActionDenied) as denied: + control.execute(an_action(control), Executor(), KEY, preconditions=World()) + outcomes[label] = _withdrawn_of(store, provider.last.request_id) + assert f"withdrawn ({label})" in str(denied.value), str(denied.value) + store.close() + + assert outcomes == {"denied": "denied", "spent": "spent"} + + +def test_R2_2_a_store_error_during_the_withdrawal_still_refuses_and_records( + tmp_path, fake_clock, caplog +): + """**Round-2 finding 2.** `deny_approval` raising a driver error carried it out of + `Control`: no `ACTION_DENIED`, no receipt, and the unfingerprinted request left answerable, + which a human could then grant and any no-provider call could spend. + + The width is `_spend_unneeded_approval`'s argument in the other direction: there the action + proceeds because there is nothing to protect, here it is refused whatever the store did, so + catching everything can only add refusals and evidence.""" + caplog.set_level(logging.DEBUG, logger="ctrlrun") + store = SQLiteStateStore(tmp_path / "state.db", clock=fake_clock) + provider = ThirdPartyProvider(store, fake_clock) + control = Control(Policy.from_yaml(POLICY), store, provider, clock=fake_clock) + + def the_store_is_locked(*args: Any, **kwargs: Any): + raise sqlite3.OperationalError("database is locked") + + store.deny_approval = the_store_is_locked # type: ignore[method-assign] + action = an_action(control) + + with pytest.raises(ActionDenied) as denied: + control.execute(action, Executor(), KEY, preconditions=World()) + + assert denied.value.reason == MISSING + assert _withdrawn_of(store, provider.last.request_id) == "not_withdrawn:pending" + assert "is withdrawn" not in str(denied.value), str(denied.value) + receipt = last_receipt(store, action) + assert receipt.result is ReceiptResult.DENIED + assert any(event.type is EventType.ACTION_DENIED for event in store.events()) + store.close() + + +def test_R2_3_a_provider_that_raises_after_recording_is_named_in_the_log( + tmp_path, fake_clock, caplog +): + """**Round-2 finding 3.** A provider that records a request and *then* raises leaves the + same orphan with no race at all, and `Control` never learns the id, so there is nothing to + withdraw. The exception is the provider's and propagates; what the kernel owes is a line + naming the action and saying a fingerprint was computed, so an operator reading the log + knows an unfingerprinted request may be sitting in the store.""" + caplog.set_level(logging.DEBUG, logger="ctrlrun") + store = SQLiteStateStore(tmp_path / "state.db", clock=fake_clock) + + def records_then_raises(request): + raise RuntimeError("the webhook POST failed after the row was written") + + provider = ThirdPartyProvider(store, fake_clock, on_request=records_then_raises) + control = Control(Policy.from_yaml(POLICY), store, provider, clock=fake_clock) + + with pytest.raises(RuntimeError): + control.execute(an_action(control), Executor(), KEY, preconditions=World()) + + warnings = [ + record.getMessage() + for record in caplog.records + if record.levelno >= logging.WARNING and "customer.delete" in record.getMessage() + ] + assert any("fingerprint" in message for message in warnings), warnings + assert store.get_approval(provider.last.request_id).status is ApprovalStatus.PENDING + store.close() + + +def test_R2_4_the_gateway_reads_a_withdrawal_as_an_answer_until_it_expires(tmp_path, fake_clock): + """**Round-2 finding 4.** A withdrawal is a `deny_approval`, so `find_denied_request` + returns it and the gateway's *"no is an answer"* pre-check refuses every call for that + action hash until the request expires, as though a human had said no. Fail-closed, bounded + by the TTL, and traceable through the approver, and it is documented rather than left for + an operator to discover.""" + store = SQLiteStateStore(tmp_path / "state.db", clock=fake_clock) + gateway, _unused, forwarded = _gateway(store, fake_clock) + body, headers = _tools_call() + first = json.loads(gateway.handle(body, headers).body) + action = store.get_approval(first["error"]["data"]["request_id"]).request.action + + class Bare(ThirdPartyProvider): + pass + + withdrawing = Control( + Policy.from_yaml(GATEWAY_POLICY), + store, + Bare(store, fake_clock), + clock=fake_clock, + ) + with pytest.raises(ActionDenied): + withdrawing.execute(action, Executor(), "delete:C123", preconditions=World()) + + denied_request = store.find_denied_request(action.action_hash) + assert denied_request is not None + assert store.get_approval(denied_request.request_id).approver.startswith("ctrlrun:") + + response = json.loads(gateway.handle(body, headers).body) + + assert response["error"]["code"] == -41003, response + assert forwarded == [] + fake_clock.advance(timedelta(minutes=16)) + after = json.loads(gateway.handle(body, headers).body) + assert after["error"]["code"] == -41002, "the bound did not lapse with the request" + store.close() + + +def test_R2_deferred_a_malformed_value_of_a_declared_key_still_blinds_every_reader( + tmp_path, fake_clock +): + """**Deferred, and pinned so it cannot drift quietly** (SPEC-v0.7 §6.11, §12.5). + + A float among a receipt's `controls` is a malformed *value* of a key the schema declares, so + `_controls_of` raises out of `from_dict` and every reader of that store stops: `receipts`, + `--verify-chain`, `stats` and G11 together, from one `UPDATE`. The schema-level and + added-key cases are each named at their `seq` and leave every other row readable; this one + is not, and 0.6.1 behaves the same way. v0.7 neither introduces nor widens it, and a fix + needs a name in `CHAIN_BREAKS` (a closed set on a `v0.6 §6.5` surface) or a raw-row reader. + + This test asserts today's behaviour, so whoever fixes it has to come here and say so. + """ + from click.testing import CliRunner + + from ctrlrun.cli import main as cli + + database = tmp_path / "state.db" + store = SQLiteStateStore(database, clock=fake_clock) + control = Control(Policy.from_yaml(POLICY), store, clock=fake_clock) + for customer in ("C1", "C2", "C3"): + control.execute(an_action(control, customer, "customer.read"), Executor(), None) + store.close() + _tamper_one(database, 2, lambda document: {**document, "controls": [1.5]}) + + reopened = SQLiteStateStore(database, clock=fake_clock) + with pytest.raises(InvalidArgument): + reopened.receipts() + reopened.close() + + url = f"sqlite://{database}" + for arguments in (["receipts"], ["receipts", "--verify-chain"]): + result = CliRunner().invoke(cli.main, [*arguments, "--store-url", url]) + assert result.exit_code != 0, (arguments, result.output) + assert "ctr_" not in result.output, "a row of the two that are intact was listed" + + +def test_R2_2_a_store_error_during_the_withdrawal_still_refuses_and_records_when_the_grant_cannot_be_spent( # noqa: E501 + tmp_path, fake_clock +): + """The other half of finding 2: the grant landed inside the window, so the withdrawal is a + `consume_approval`, and that is the call the store refuses with a driver error. The action + is still refused and recorded, and the evidence says the grant is still standing.""" + store = SQLiteStateStore(tmp_path / "state.db", clock=fake_clock) + provider = ThirdPartyProvider( + store, + fake_clock, + on_request=lambda request: store.grant_approval(request.request_id, "human:alice"), + ) + control = Control(Policy.from_yaml(POLICY), store, provider, clock=fake_clock) + + def the_store_is_locked(*args: Any, **kwargs: Any): + raise sqlite3.OperationalError("database is locked") + + store.consume_approval = the_store_is_locked # type: ignore[method-assign] + action = an_action(control) + + with pytest.raises(ActionDenied) as denied: + control.execute(action, Executor(), KEY, preconditions=World()) + + assert denied.value.reason == MISSING + assert _withdrawn_of(store, provider.last.request_id) == "not_withdrawn:granted" + assert "is withdrawn" not in str(denied.value), str(denied.value) + assert last_receipt(store, action).result is ReceiptResult.DENIED + assert store.get_approval(provider.last.request_id).status is ApprovalStatus.GRANTED + store.close() diff --git a/tests/test_protect.py b/tests/test_protect.py index 29b1569..b7802e5 100644 --- a/tests/test_protect.py +++ b/tests/test_protect.py @@ -685,6 +685,10 @@ def read(customer_id: str) -> None: ... "policy_hash", "policy_version", "controls", + # SPEC-v0.7 §6.11: `ctrlrun.receipt/v4`. What the presenting pass compared, hashes + # only, and `null` here because nothing asked for a precondition. + "precondition_at_request", + "precondition_at_recheck", } assert document["schema"] == RECEIPT_SCHEMA assert document["receipt_id"].startswith("ctr_") diff --git a/tests/test_verify.py b/tests/test_verify.py index befaa2b..0793b4f 100644 --- a/tests/test_verify.py +++ b/tests/test_verify.py @@ -161,23 +161,25 @@ def test_T101_a_policy_with_no_approve_rule_makes_G1_and_G2_not_applicable(tmp_p report = run(path) results = _by_id(report) - for gid in ("G1", "G2"): + # G16 is N/A for G1's reason: a precondition binds only where an approval is consumed + # (SPEC-v0.7 §8.9), and nothing here requires one. + for gid in ("G1", "G2", "G16"): assert results[gid].status is Status.NOT_APPLICABLE, gid assert results[gid].reason == reg.NO_APPROVE_RULE # The `else` branch: either one reported `pass` is the defect this test exists for. assert results[gid].status is not Status.PASS assert report.applicable == report.passed + report.failed - # G1 and G2 for the missing approve band, G8 and G9 for the missing authority section, - # and G13, which is N/A on every SQLite run: SQLite has no clock of its own (SPEC-v0.7 - # §8.9). The rest are applicable, G14 among them, and the count is over those. + # G1, G2 and G16 for the missing approve band, G8 and G9 for the missing authority + # section, and G13, which is N/A on every SQLite run: SQLite has no clock of its own + # (SPEC-v0.7 §8.9). The rest are applicable, G14 among them, and the count is over those. assert report.applicable == 8 - assert report.not_applicable == 5 + assert report.not_applicable == 6 text = report.to_text() # The fraction is passes over applicable and never the catalogue size: with five N/As a # thirteen-guarantee catalogue must not report thirteen over thirteen. assert f"{len(reg.GUARANTEES)}/{len(reg.GUARANTEES)}" not in text assert f"{report.passed}/{report.applicable} declared guarantees pass." in text - assert "5 not applicable: G1, G2, G8, G9, G13." in text + assert "6 not applicable: G1, G2, G8, G9, G13, G16." in text def test_T101b_zero_applicable_guarantees_is_not_a_pass(tmp_path): @@ -789,13 +791,15 @@ def test_G11_is_applicable_even_where_every_action_is_denied(tmp_path): def test_the_catalogue_is_closed_and_ordered(): """SPEC-v0.7 §9.4: `v3` is G1 to G16, and each id lands with its item. Ordered by number, - so an id that arrives before a lower one still sits where a reader looks for it.""" + so an id that arrives before a lower one still sits where a reader looks for it, and + unreleased `main` carries a partial `v3` until item 6 asserts all sixteen.""" assert reg.CATALOGUE == "ctrlrun.guarantees/v3" ids = [guarantee.id for guarantee in reg.GUARANTEES] assert ids[:11] == [f"G{n}" for n in range(1, 12)] - assert "G13" in ids + assert "G13" in ids and "G16" in ids assert ids == sorted(ids, key=lambda gid: int(gid[1:])), ids assert len(ids) == len(set(ids)) + assert set(ids) <= {f"G{n}" for n in range(1, 17)}, ids for guarantee in reg.GUARANTEES: assert guarantee.descends_from, f"{guarantee.id} names no acceptance test" @@ -863,16 +867,17 @@ def test_observe_mode_is_refused_before_any_scenario_runs(tmp_path): assert "observe" in str(refused.value) -def test_the_v1_payments_template_reports_six_over_six(): +def test_the_v1_payments_template_reports_seven_over_seven(): """The definition of done, dogfooded rather than described (SPEC-v0.4 §4.1).""" report = run(V1_PAYMENTS) assert report.exit_code == 0 - assert (report.passed, report.applicable, report.not_applicable) == (6, 6, 7) + assert (report.passed, report.applicable, report.not_applicable) == (7, 7, 7) text = report.to_text() - assert "6/6 declared guarantees pass." in text + assert "7/7 declared guarantees pass." in text # G13 is N/A on SQLite, which has no clock of its own, and G14 joins G3, G4 and G5 where # the effect template lives in the @protect decorator verify does not read (SPEC-v0.7 §8.9). + # G16 is graded: verify brings its own precondition provider (§8.9). assert "7 not applicable: G3, G4, G5, G8, G9, G13, G14." in text assert "10/10" not in text diff --git a/tests/test_verify_action.py b/tests/test_verify_action.py index 34e4619..5e057ed 100644 --- a/tests/test_verify_action.py +++ b/tests/test_verify_action.py @@ -137,10 +137,10 @@ def test_T118_ci_asserts_the_two_shapes_the_specification_names(): steps = _workflow()["jobs"]["verify"]["steps"] script = "\n".join(step.get("run", "") for step in steps) - assert 'test "$AUTHORITY" = "verified 11/11"' in script - assert 'test "$TEMPLATES" = "verified 6/6"' in script + assert 'test "$AUTHORITY" = "verified 13/13"' in script + assert 'test "$TEMPLATES" = "verified 7/7"' in script assert 'test "$AUTHORITY_NA" = "1"' in script - assert 'test "$TEMPLATES_NA" = "6"' in script + assert 'test "$TEMPLATES_NA" = "7"' in script @pytest.mark.authority @@ -152,11 +152,11 @@ def test_T118_the_two_configurations_really_do_report_those_shapes(): templates = run(V1_PAYMENTS) assert authority.badge is not None - assert authority.badge["message"] == "verified 12/12" + assert authority.badge["message"] == "verified 13/13" # G13 only: SQLite has no clock of its own to diverge from (SPEC-v0.7 §8.9). assert authority.not_applicable == 1 assert templates.badge is not None - assert templates.badge["message"] == "verified 6/6" + assert templates.badge["message"] == "verified 7/7" assert templates.not_applicable == 7 @@ -199,9 +199,9 @@ def test_T119_the_denominator_is_applicable_and_never_the_catalogue_size(): assert badge is not None assert badge["message"] == f"verified {report.passed}/{report.applicable}" - assert report.applicable == 6 + assert report.applicable == 7 assert report.applicable < len(reg.GUARANTEES) - assert "/10" not in badge["message"] + assert f"/{len(reg.GUARANTEES)}" not in badge["message"] def test_T119_the_colour_is_about_failures_and_has_no_amber_for_not_applicable( @@ -285,7 +285,7 @@ def test_T120_a_configuration_with_not_applicable_guarantees_still_writes_a_badg assert report.exit_code == 0 assert report.badge is not None - assert report.badge["message"] == "verified 6/6" + assert report.badge["message"] == "verified 7/7" def test_T120_a_failing_run_writes_a_red_badge_and_a_non_zero_exit(tmp_path, monkeypatch): diff --git a/tests/test_verify_authority.py b/tests/test_verify_authority.py index bb95f96..d3c165e 100644 --- a/tests/test_verify_authority.py +++ b/tests/test_verify_authority.py @@ -92,8 +92,8 @@ def test_T108_the_authority_example_exercises_G7_G8_and_G9(): assert results["G9"].detail["dimensions_exercised"] == list(DIMENSIONS) assert results["G9"].detail["dimensions_unconstrained"] == [] assert report.exit_code == 0 - assert report.passed == 12 - assert report.applicable == 12 + assert report.passed == 13 + assert report.applicable == 13 def test_T108_G8_asserts_the_denial_by_reason_and_not_by_type(tmp_path, monkeypatch): diff --git a/tests/test_verify_report.py b/tests/test_verify_report.py index 1c5269b..4948eb8 100644 --- a/tests/test_verify_report.py +++ b/tests/test_verify_report.py @@ -133,9 +133,9 @@ def test_T113_a_failing_report_names_the_subject_and_prints_the_counterexample( @pytest.mark.parametrize( ("document", "expected"), [ - (ALL_APPLICABLE, "10/10 declared guarantees pass. 3 not applicable"), - (WITH_NOT_APPLICABLE, "6/6 declared guarantees pass. 7 not applicable"), - (EMPTY, "0/0 declared guarantees pass. 13 not applicable"), + (ALL_APPLICABLE, "11/11 declared guarantees pass. 3 not applicable"), + (WITH_NOT_APPLICABLE, "7/7 declared guarantees pass. 7 not applicable"), + (EMPTY, "0/0 declared guarantees pass. 14 not applicable"), ], ids=["passing", "some-na", "all-na"], )