diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f00a619f..a985b196 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -133,7 +133,7 @@ jobs: set -eu echo "authority: $AUTHORITY ($AUTHORITY_NA not applicable)" echo "templates: $TEMPLATES ($TEMPLATES_NA not applicable)" - test "$AUTHORITY" = "verified 25/25" + test "$AUTHORITY" = "verified 26/26" # G13 is N/A on SQLite, the action's default store: SQLite has no clock of its own # to diverge from; G15 is N/A because neither document declares `max_attempts`. # G16 is graded on both: verify brings its own precondition provider (SPEC-v0.7 §8.9). @@ -152,7 +152,7 @@ jobs: # SPEC-v0.11 §8 moved the templates count again: G31 needs only an action to # build a chain from, so it is graded wherever any guarantee is, and both # examples gained one passing row when item 4 landed. - test "$TEMPLATES" = "verified 12/12" + test "$TEMPLATES" = "verified 13/13" test "$TEMPLATES_NA" = "16" test -s verify-badge.json test -s verify-report.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b77a8c7..929621ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,46 @@ any change to one appears here. trusted. Found by review; the tests that missed it all tampered with a row's *content*, and `{}` and a float among the controls are both valid JSON. +- **An anchor: the chain's head, recorded where the store's writer cannot reach it** + (`SPEC-v0.11.md` §2, §3). The receipt chain detects alteration. It does not detect + **truncation**, because the head that would catch it is a row in the same database. Measured on + a six-receipt chain, in two statements: + + ``` + DELETE FROM receipts WHERE seq > 3 + UPDATE receipt_chain SET seq = ?, hash = ? + -> ok=True verified=3 breaks=[] + ``` + + Three receipts erased, and the chain reports itself intact. An anchor records the pair the head + holds outside the database, at an interval the operator chooses, and the same two statements are + then named `anchor_broken` at the anchored `seq`. + + **What an anchor proves, and what it does not.** It freezes a **prefix**: anything at or below + an anchored `seq` can no longer be removed or altered without the anchored pair failing to + reproduce. **An append is not detected**, because it lands above every anchored `seq`; nor are + receipts created and destroyed between two anchors; nor who wrote any of it. The window you are + exposed to is `(last anchored seq, current head]`, and its size is your choice of interval. + That is the number to quote rather than any sentence about tamper-evidence, and there is a test + that runs a forged append and requires both reports to stay clean. + + **No keys.** The anchor consumes a timestamp and issues nothing: no key generation, no rotation, + no revocation, no signing. Signing stays off the roadmap for the reason `SPEC-v0.6.md` §11 + gives, and a test greps this module's own source to keep that true. + +- `ctrlrun.anchor`: `AnchorProvider` (a four-call protocol you implement, because CTRLRun ships no + timestamp client and a network client does not belong in this wheel), `verify_anchors`, + `AnchorReport`, `ANCHOR_BREAKS`, and `anchor=` on `Control`. +- **`ANCHOR_BREAKS` is its own closed set and `CHAIN_BREAKS` does not change.** `anchor_broken`, + `anchor_missing`, `anchor_repudiated`. Putting them in `CHAIN_BREAKS` would fail `G11`'s control + with `control failed` on every anchoring deployment, because that control reads the whole + `ChainReport`. `anchor_unavailable` is in neither set: an unreachable provider is a transport + failure, and grading it as tampering would make a network blip indistinguishable from a + truncation. +- **`ctrlrun anchor`**, with `--verify`, and migration **`0008_anchor_checkpoint_hold`**. +- **`G28`, a truncation past an anchor fails**, whose positive control is the attack itself run + against a real store. + - **One chain, five receipt schema versions, walked end to end** (`SPEC-v0.11.md` §6). A store kept since v0.6 holds five: `v3` (0.6), `v4` (0.7), `v5` (0.8), `v6` (0.9), `v7` (0.10). **No new field**: `schema` has existed since `SPEC-v0.3.md` §12.2. What is new is the proof diff --git a/examples/anchored-chain/ctrlrun.yaml b/examples/anchored-chain/ctrlrun.yaml new file mode 100644 index 00000000..0f920a5b --- /dev/null +++ b/examples/anchored-chain/ctrlrun.yaml @@ -0,0 +1,10 @@ +# The policy this example runs under. Unknown actions are denied; there is no default-allow. +schema: ctrlrun.policy/v2 + +actions: + stripe.refund: + effect: "refund:{payment_id}" + rules: + - when: { amount_gte: 0, amount_lte: 50000 } + decision: allow + - decision: deny diff --git a/examples/anchored-chain/main.py b/examples/anchored-chain/main.py new file mode 100644 index 00000000..6cca27f9 --- /dev/null +++ b/examples/anchored-chain/main.py @@ -0,0 +1,168 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2026 The CTRLRun contributors +# SPDX-License-Identifier: Apache-2.0 +"""Erasing the end of the receipt log costs two SQL statements. An anchor makes it show. + +The receipt chain detects **alteration**: edit a receipt and the hash no longer matches. It does +not detect **truncation**, and that has been written down since `SPEC-v0.6.md` §6.4 rather than +discovered here. The reason is structural: the head that would catch it is a row in the same +database, so an administrator with write access deletes the tail and updates one more row. + + DELETE FROM receipts WHERE seq > 3; + UPDATE receipt_chain SET seq = 3, hash = ''; + +Two statements, and `ctrlrun receipts --verify-chain` reports the log intact. + +An **anchor** records the pair the head holds -- a `seq` and the hash at it -- somewhere the +database's writer does not control. This example runs both halves so you can see the difference, +and prints what an anchor does **not** prove as plainly as what it does. + + python examples/anchored-chain/main.py + +No network, and a state directory of its own under `.ctrlrun/examples/`. +""" + +from __future__ import annotations + +import shutil +import sqlite3 +from datetime import UTC, datetime, timedelta +from pathlib import Path + +from ctrlrun import Action, Control, Policy, Principal, SQLiteStateStore +from ctrlrun.anchor import Anchor, make_anchor, verify_anchors +from ctrlrun.receipt import verify_chain + +HERE = Path(__file__).resolve().parent +STATE = Path(".ctrlrun/examples/anchored-chain") + + +class FileAnchorProvider: + """An anchor provider, in the shape `SPEC-v0.11.md` §3.2 defines and nothing more. + + **CTRLRun ships none**, deliberately: `ROADMAP.md` names RFC 3161, and an RFC 3161 client is + a network client, which does not belong in a wheel whose rule is stdlib plus `pyyaml` and + `click`. So the provider is yours. A real one would be a timestamp authority, a transparency + log, an append-only bucket in another account, or a file on a host your database's writer + cannot reach. + + This one is a JSON file in a **different directory** from the store, which is the smallest + thing that illustrates the property. It is not a good anchor and does not pretend to be: an + anchor is worth exactly what its record is worth, and a file beside the database is worth + nothing. The same sentence `THREAT_MODEL.md` uses about a revocation feed applies here. + """ + + def __init__(self, path: Path) -> None: + self._path = path + self._path.parent.mkdir(parents=True, exist_ok=True) + self._held: dict[str, Anchor] = {} + self._clock = datetime(2026, 1, 1, tzinfo=UTC) + + def make(self, seq: int, hash: str, kind: str) -> tuple[str, datetime]: + self._clock += timedelta(minutes=1) + token = f"anchor-{kind}-{seq}" + self._held[token] = Anchor(seq=seq, hash=hash, token=token, kind=kind, at=self._clock) + return token, self._clock + + def check(self, seq: int, hash: str, token: str) -> bool: + held = self._held.get(token) + return held is not None and held.seq == seq and held.hash == hash + + def latest(self) -> tuple[int, str] | None: + if not self._held: + return None + newest = max(self._held.values(), key=lambda item: item.seq) + return (newest.seq, newest.token) + + def since(self, seq: int) -> tuple[Anchor, ...]: + return tuple(item for item in self._held.values() if item.seq >= seq) + + +def refund(payment_id: str, amount: int) -> Action: + return Action( + name="stripe.refund", + arguments={"payment_id": payment_id, "amount": amount}, + principal=Principal(agent="payments-agent"), + ) + + +def four_refunds(database: Path) -> None: + store = SQLiteStateStore(database) + control = Control(Policy.from_file(HERE / "ctrlrun.yaml"), store) + for index in range(4): + control.execute(refund(f"pi_{index}", 1200), lambda: {"ok": True}, f"refund:pi_{index}") + store.close() + + +def erase_the_tail(database: Path, keep_through: int) -> None: + """The attack, in the two statements it really takes. Nothing here goes through CTRLRun.""" + connection = sqlite3.connect(database) + connection.execute("DELETE FROM receipts WHERE seq > ?", (keep_through,)) + row = connection.execute("SELECT seq, hash FROM receipts ORDER BY seq DESC LIMIT 1").fetchone() + connection.execute("UPDATE receipt_chain SET seq = ?, hash = ? WHERE id = 1", row) + connection.commit() + connection.close() + + +def main() -> None: + if STATE.exists(): + shutil.rmtree(STATE) + STATE.mkdir(parents=True) + + print("1. Four refunds, then somebody erases the last two.\n") + plain = STATE / "not-anchored" / "state.db" + plain.parent.mkdir(parents=True) + four_refunds(plain) + erase_the_tail(plain, keep_through=2) + + store = SQLiteStateStore(plain) + report = verify_chain(store) + store.close() + print(f" ctrlrun receipts --verify-chain: {report.verified} of {report.chained} verified") + print(f" breaks: {[break_.name for break_ in report.breaks] or 'none'}") + print(f" the chain says it is intact: {report.ok}") + print(" Two receipts are gone and nothing says so. This is SPEC-v0.6 §6.4, by design.\n") + + print("2. The same four refunds, anchored first, then the same two statements.\n") + anchored = STATE / "anchored" / "state.db" + anchored.parent.mkdir(parents=True) + # The provider's record lives OUTSIDE the store's directory, which is the whole idea. + provider = FileAnchorProvider(STATE / "outside" / "anchors.json") + + four_refunds(anchored) + store = SQLiteStateStore(anchored) + anchor = make_anchor(store, provider) + print(f" anchored seq {anchor.seq} at {anchor.at.isoformat()}") + clean = verify_anchors(store, provider) + print(f" before any tamper: ok={clean.ok}, {clean.checked} anchor(s) reproduce\n") + store.close() + + erase_the_tail(anchored, keep_through=2) + store = SQLiteStateStore(anchored) + chain = verify_chain(store) + anchors = verify_anchors(store, provider) + store.close() + + print(f" the chain still says intact: {chain.ok}") + print(f" the anchor says: ok={anchors.ok}") + for problem in anchors.breaks: + print(f" {problem.name} at seq {problem.seq}: {problem.detail}") + + print() + print("What an anchor proves: everything at or below an anchored seq is frozen. Removing or") + print("altering any of it stops the anchored pair reproducing, and the operator's own record") + print("is what decides, not a row in the database under suspicion.") + print() + print("What it does NOT prove, which matters as much:") + print(" - an APPEND is not detected. A forged receipt lands above every anchored seq, so no") + print(" anchored pair stops reproducing, and the next anchor freezes it like any other.") + print(" - receipts written and erased BETWEEN two anchors are not detected either.") + print(" - it does not say who wrote any of it. An anchor is not a signature.") + print(" - an administrator who rewrites everything before the next anchor is out of scope.") + print() + print("The window you are exposed to is (last anchored seq, current head]. Its size is your") + print("choice of interval, and that is the number to tune and to quote.") + + +if __name__ == "__main__": + main() diff --git a/src/ctrlrun/anchor.py b/src/ctrlrun/anchor.py new file mode 100644 index 00000000..cd6f1f15 --- /dev/null +++ b/src/ctrlrun/anchor.py @@ -0,0 +1,602 @@ +# SPDX-FileCopyrightText: 2026 The CTRLRun contributors +# SPDX-License-Identifier: Apache-2.0 +"""The anchor: the chain's head, recorded where the store's writer cannot reach it. + +`SPEC-v0.11.md` §2 and §3. The receipt chain detects **alteration**. It does not detect +**truncation** or **append**, because the head that would catch them is a row in the same +database: two `UPDATE`s and the record is consistent and wrong. That has been true and written +down since `SPEC-v0.6.md` §6.4. + +An anchor puts the pair the head already holds, a `seq` and the hash at it, somewhere the store's +writer does not control, at an interval the operator chooses. + +**What an anchor proves, and what it does not** (§2.4, stated here because this is the first thing +in this project a reader could mistake for tamper-proofing): + +An anchor **freezes a prefix**. It records that at time T the chain's head was `(seq, hash)`, so +anything at or below that `seq` can no longer be removed or altered without the anchored pair +failing to reproduce, unless an anchored checkpoint accounts for its removal (§4.6). + +| Attack | Detected? | +|---|---| +| a truncation, when the anchored `seq` is above the new head | **yes** | +| any rewrite at or below an anchored `seq` | **yes**: the hash there differs | +| a forged **append** | **no.** It lands at head + 1, above every anchored `seq` | +| receipts written and erased between two anchors | **no.** Never at or below an anchored `seq` | +| an administrator who rewrites everything before the next anchor | **no** | +| who wrote any of it | **no.** Authorship is out of scope; signing stays off the roadmap | + +The exposed window is `(last anchored seq, current head]`, and its size is the operator's choice +of interval. That is the number an operator tunes, and it is the number to quote rather than any +sentence about tamper-evidence. + +**Rule 1 (§1.1): the anchor consumes a timestamp and issues nothing.** No key generation, no +rotation, no revocation, no signing. `SPEC-v0.3.md` §1.1's rule that CTRLRun consumes identity and +issues none, applied to time. Nothing in this module mints anything. +""" + +from __future__ import annotations + +from collections.abc import Iterable, Sequence +from dataclasses import dataclass +from datetime import datetime +from typing import Any, Final, Protocol + +from .action import canonical_bytes +from .errors import CTRLRunError, InvalidArgument + +#: §3.2. The two kinds of anchor, and they are **ordered separately**. +#: +#: An `interval` anchor is the scheduled one: each must be above the last, because the chain only +#: grows. A `checkpoint` anchor is the one a prune takes over its checkpoint (§4.6), and it is +#: ordered only against other checkpoints. +#: +#: A first draft ordered all anchors jointly by `seq` and a review showed what that costs: a +#: deployment anchoring hourly and pruning at ninety days makes its checkpoint anchor far *below* +#: its newest interval anchor, so the joint rule refused it, so the prune was refused, **forever**. +INTERVAL: Final = "interval" +CHECKPOINT: Final = "checkpoint" +ANCHOR_KINDS: Final = (INTERVAL, CHECKPOINT) + +#: §3.4's closed set, and it is **its own** set rather than two more names in `CHAIN_BREAKS`. +#: +#: `CHAIN_BREAKS` is closed on a `SPEC-v0.6.md` §6.5 surface and stays closed at six. A first +#: draft amended it to eight and a review ran what that costs: `G11`'s positive control is +#: `intact.ok and intact.verified >= 3` over the whole `ChainReport`, so **any** eighth kind +#: appearing there fails `G11` with `control failed`, the status that means the kernel is broken. +#: `anchor_missing` fires on every anchoring deployment while verify's own scratch store never +#: anchors, so the failure would have been universal rather than rare. +#: +#: Keeping them apart is better on three counts: `G11` is genuinely untouched rather than argued +#: to be, this milestone amends one frozen surface instead of two, and §9's frozen table can name +#: this as a **symbol** a test imports, where "`CHAIN_BREAKS` gains two members" is a membership +#: claim the frozen-name test's shape cannot express. +ANCHOR_BREAKS: Final = ( + "anchor_broken", + "anchor_missing", + "anchor_repudiated", +) + +#: **`anchor_unavailable` is deliberately NOT in `ANCHOR_BREAKS`**, and an earlier draft put it +#: there. It is a **transport failure**, not a finding about the evidence, and a set that +#: conflates the two teaches an operator to ignore the ones that matter: a timestamp authority +#: briefly unreachable would grade `G28` `fail`, indistinguishable in the report from a +#: truncation. `SPEC-v0.10.md`'s `upstream_unverified` is the precedent and it cuts the other way, +#: because it is a *decision-time* refusal rather than a break in a verification report. +#: +#: **Refusing to act when you cannot ask is fail-closed; reporting tampering when you cannot ask +#: is a false positive.** So an unreachable provider makes the report `unavailable`, which is +#: neither `ok` nor broken, and `G28` grades `N/A` with that reason. +ANCHOR_UNAVAILABLE: Final = "anchor_unavailable" + +#: The domain tag an anchor's provider answer is canonicalized under, so an anchor token can never +#: collide with a scope hash or a precondition fingerprint computed over the same mapping. +#: `SPEC-v0.9.md` §5.5 makes the same argument for a scope hash. +ANCHOR_DOMAIN: Final = "ctrlrun.anchor/v1" + + +@dataclass(frozen=True) +class Anchor: + """One anchored pair, as the provider recorded it and as the local table caches it (§3.1). + + Not a copy of the chain, not a backup, and not a second head: one pair and a time. The whole + of its power is that reproducing it later requires the chain between genesis and that `seq` + to be exactly what it was. + + `at` is the **provider's** time and never this process's clock. Rule 1 is that the anchor + consumes a timestamp and issues none, so a time CTRLRun generated would be CTRLRun vouching + for itself, which is the thing an external anchor exists to stop. + """ + + #: The chain position this anchor froze. + seq: int + #: The chain hash at that position. + hash: str + #: Whatever the provider returned to identify its own record. Opaque here, always. + token: str + #: `interval` or `checkpoint` (§3.2). + kind: str + #: When the provider says it anchored this pair. + at: datetime + + def to_dict(self) -> dict[str, Any]: + return { + "seq": self.seq, + "hash": self.hash, + "token": self.token, + "kind": self.kind, + "at": self.at.isoformat(), + } + + +class AnchorProvider(Protocol): + """The operator's own code, answering from the operator's own system (§3.2). + + `ROADMAP.md` names RFC 3161 and §9 puts **no** RFC 3161 client in the wheel: the kernel's rule + since `SPEC-v0.1.md` is that the core stays stdlib plus `pyyaml` and `click`, and a timestamp + protocol client is a network client. So this is the shape `SPEC-v0.9.md` §5.4 settled for a + scope provider, with the kernel matching rather than fetching. + + **Four calls, not two, and the last two are why.** An earlier draft had `make` and `check` + alone, and a review broke it in one extra statement: with only those, the record of *which* + anchors exist lives in CTRLRun's own table, so deleting the newest row there leaves the older + anchor reproducing and the truncation invisible. Three SQL statements instead of two, which is + the number this design claimed to avoid. `latest()` and `since()` move that history to the + side that cannot be rewritten. + """ + + def make(self, seq: int, hash: str, kind: str) -> tuple[str, datetime]: + """Anchor this pair. Returns the provider's token and the time it recorded, or raises. + + **The return is a pair rather than §3.2's bare token, and this is a deviation the PR + records rather than one made quietly.** §3.2's table says *"returns an opaque token"* + while §3.3 says CTRLRun caches *"the pair, the token, and the time"*, and §10 refuses + *"an anchor whose time runs backwards against the one before it"*. A time the provider + does not supply is one CTRLRun would have to read from its own clock, which rule 1 + forbids: the anchor consumes a timestamp and issues none. + """ + ... + + def check(self, seq: int, hash: str, token: str) -> bool: + """Does the provider still vouch for this pair under this token? + + **It is allowed to say no**, and that is its one substantive answer and the entire reason + for holding the record outside the store. `anchor_repudiated` exists for it. + """ + ... + + def latest(self) -> tuple[int, str] | None: + """The highest `seq` the provider holds an anchor for, and its token. `None` if it holds + none. + + Answered **outside**, which is what makes §3.3's sentence true rather than hopeful: a + local table that was emptied verifies exactly as a store that never anchored does. + """ + ... + + def since(self, seq: int) -> Sequence[Anchor]: + """Every anchor the provider holds at or above `seq`. Enumeration, not a single answer. + + **This is why §4.6's argument is not circular.** With `latest()` alone the kernel can read + only its own local table for the history of which anchors exist, and that table is a cache + the writer under suspicion can trim: delete every local row at or below a laundering + checkpoint and nothing above it is missing, so `latest()` reveals nothing. + """ + ... + + +@dataclass(frozen=True) +class AnchorBreak: + """One thing wrong with the anchor record, named and placed (§3.4).""" + + name: str + seq: int | None + detail: str + + def to_dict(self) -> dict[str, Any]: + return {"name": self.name, "seq": self.seq, "detail": self.detail} + + +@dataclass(frozen=True) +class AnchorReport: + """What `verify_anchors` found. Separate from `ChainReport`, structurally (§3.4, §8.1). + + **`G11`'s contract does not change**, and the separation is what makes that true rather than + argued. `G11`'s control reads `intact.ok` over the whole `ChainReport`, so it cannot carry an + anchor break and pass: one extra break of any kind fails the control. A first draft asserted + that a report could carry `anchor_broken` while `G11` passed, and running it showed it cannot. + + **Three states, not two.** `ok` and `unavailable` are different answers, and collapsing them + is how a briefly unreachable timestamp authority becomes indistinguishable from a truncation. + """ + + #: Every anchor reproduced and nothing was missing. False if anything at all was wrong. + ok: bool + #: The provider could not be reached or would not answer. **Not a break, and not a pass.** + unavailable: bool + #: Why, where `unavailable`; `None` otherwise. + reason: str | None + #: How many anchored pairs were actually checked against the chain. + checked: int + #: How many were superseded by an anchored checkpoint (§4.6). Reported, never hidden. + superseded: int + breaks: list[AnchorBreak] + + def to_dict(self) -> dict[str, Any]: + return { + "ok": self.ok, + "unavailable": self.unavailable, + "reason": self.reason, + "checked": self.checked, + "superseded": self.superseded, + "breaks": [item.to_dict() for item in self.breaks], + } + + +class AnchorSource(Protocol): + """What `verify_anchors` needs from a store: the local anchor cache, the chain, the checkpoint. + + A `Protocol` rather than an import of `StateStore`, because `state.py` imports *this* module + and `ARCHITECTURE.md` §6 says dependencies point downward. The same reason `ChainSource` + exists in `receipt.py`. + """ + + def anchors(self) -> tuple[Anchor, ...]: ... + + def checkpoint(self) -> tuple[int, str] | None: ... + + def receipts(self) -> tuple[Any, ...]: ... + + def chain_head(self) -> tuple[int, str] | None: ... + + +def canonical_anchor(seq: int, hash: str, kind: str) -> bytes: + """The bytes a provider is asked to vouch for, under this module's own domain tag (§3.2). + + Through `canonical_bytes`, so there is one canonicalizer in this library and not two + (`SPEC-v0.6.md` §6.2). The domain tag is what stops an anchor token colliding with a scope + hash or a precondition fingerprint computed over the same mapping, which is `SPEC-v0.9.md` + §5.5's argument for a scope hash. + """ + return canonical_bytes({"domain": ANCHOR_DOMAIN, "seq": seq, "hash": hash, "kind": kind}) + + +def _checked_kind(kind: str) -> str: + if kind not in ANCHOR_KINDS: + raise InvalidArgument( + f"an anchor kind must be one of {', '.join(ANCHOR_KINDS)}, got {kind!r}" + ) + return kind + + +class _AnchorStore(Protocol): + """What `make_anchor` needs from a store: the head, the local cache, and a way to add to it. + + Narrower than `AnchorSource` on purpose. Making an anchor never reads a receipt, so a + protocol that demanded `receipts()` here would say that it does. + """ + + def chain_head(self) -> tuple[int, str] | None: ... + + def anchors(self) -> tuple[Anchor, ...]: ... + + def put_anchor(self, anchor: Anchor) -> None: ... + + +def make_anchor( + store: _AnchorStore, + provider: AnchorProvider, + *, + kind: str = INTERVAL, + at: tuple[int, str] | None = None, +) -> Anchor: + """Anchor the chain's current head, and cache what came back (§3.1, §3.2). + + **Fail-closed, in the direction §10's table states**: a provider that raises, times out or + answers a shape the canonicalizer refuses means the anchor is **not made** and nothing is + recorded as anchored. An anchor half-made is worse than none, because the local table would + then claim a pair the provider never saw and every later verification would report + `anchor_repudiated` about an honest store. + + Two orderings are refused here rather than at verification, because both are the operator's + configuration going wrong and the cheapest place to say so is the moment it happens: + + * an `interval` anchor at or below the previous `interval` anchor, since the chain only grows; + * an anchor whose time runs backwards against the one before it of the same kind. A monotonic + sequence is the only property the kernel can check about a timestamp it did not issue, and a + sequence that goes backwards is either a misconfiguration or the attack. + """ + kind = _checked_kind(kind) + if at is not None: + # §4.6: a prune anchors its **checkpoint**, and a checkpoint names the `seq` pruned + # through, which is below the head by construction. Without this a checkpoint anchor + # could only ever be taken over the head, and §4.6's supersession rule -- an anchored + # `seq` at or below an anchored checkpoint is superseded rather than broken -- would + # have nothing to match against. Found by a mutation: ordering the two kinds jointly + # survived every test, because nothing could produce a checkpoint anchor below an + # interval one for the per-kind rule to have to allow. + seq, digest = at + else: + head = store.chain_head() + if head is None: + raise InvalidArgument( + "this store has no chain head to anchor; nothing has written a chained receipt yet" + ) + seq, digest = head + _require_ordering(store.anchors(), seq, kind) + + try: + answer = provider.make(seq, digest, kind) + except CTRLRunError: + raise + except Exception as refused: + raise InvalidArgument( + f"{ANCHOR_UNAVAILABLE}: the anchor provider did not answer " + f"({type(refused).__name__}), so nothing was anchored" + ) from refused + + token, anchored_at = _checked_answer(answer) + anchor = Anchor(seq=seq, hash=digest, token=token, kind=kind, at=anchored_at) + _require_time_moves_forward(store.anchors(), anchor) + store.put_anchor(anchor) + return anchor + + +def _checked_answer(answer: object) -> tuple[str, datetime]: + """What a provider returned, or a refusal naming what was wrong with it. + + By shape and not by trust: an operator's provider is their own code, and a provider that + returns `None` on failure rather than raising is the shape that would otherwise cache an + anchor whose token is the string `"None"`. + """ + if not isinstance(answer, tuple) or len(answer) != 2: + raise InvalidArgument( + "an anchor provider's make() must return (token, time); it returned " + f"{type(answer).__name__}" + ) + token, at = answer + if not isinstance(token, str) or not token: + raise InvalidArgument( + f"an anchor token must be a non-empty string, got {type(token).__name__}" + ) + if not isinstance(at, datetime) or at.tzinfo is None: + raise InvalidArgument( + "an anchor's time must be a timezone-aware datetime, because an anchor is a claim " + f"about when; got {type(at).__name__}" + ) + return token, at + + +def _require_ordering(existing: Iterable[Anchor], seq: int, kind: str) -> None: + """§3.2's ordering rule, **per kind** and never jointly. + + A `checkpoint` anchor is ordered only against other checkpoints. Ordering the two kinds + together refused every checkpoint a pruning deployment would ever take, because a prune's + checkpoint sits far below the newest interval anchor. + """ + same = [anchor for anchor in existing if anchor.kind == kind] + if not same: + return + highest = max(anchor.seq for anchor in same) + if seq <= highest: + raise InvalidArgument( + f"an {kind} anchor must be above the last {kind} anchor: this one names seq {seq} " + f"and the last names seq {highest}" + ) + + +def _require_time_moves_forward(existing: Iterable[Anchor], anchor: Anchor) -> None: + same = [item for item in existing if item.kind == anchor.kind] + if not same: + return + latest = max(item.at for item in same) + if anchor.at < latest: + raise InvalidArgument( + f"this {anchor.kind} anchor's time {anchor.at.isoformat()} is before the last one's " + f"{latest.isoformat()}; a timestamp sequence that runs backwards is a " + "misconfiguration or the attack, and CTRLRun cannot tell which" + ) + + +def verify_anchors(store: AnchorSource, provider: AnchorProvider) -> AnchorReport: + """Check every anchor the provider holds against the chain in this store (§3.4). + + **The provider is asked what it holds before the local table is consulted**, and that ordering + is the section's load-bearing decision (§3.3). CTRLRun's table is a *cache*, not a record: a + row deleted from it is checked anyway, because the question came from outside; a table that + was emptied verifies exactly as a store with no anchors does, which is `anchor_missing`. + + An earlier draft had this read the local table and ask the provider about what it found. That + is the same defect one level up: the set of questions came from the rewritable side, so + deleting the newest local row removed the only question that would have failed. + + **Precedence, because two rows can both hold.** In the canonical attack, truncate the chain + *and* delete the newest local anchor, and both `anchor_broken` and `anchor_missing` apply. + `anchor_broken` wins: `anchor_missing` reads to an operator as a misconfiguration and + `anchor_broken` reads as tamper, and naming the milder one first is how a real finding gets + filed as a config ticket. + + **`anchor_missing` does not fire on the exposed window.** An earlier definition read *"the + store's chain reaches a `seq` none of them covers"*, which is the state §2.4 blesses as normal: + `(last anchored seq, current head]` is where every honest deployment lives between anchors. + One honest action after an anchor would have failed `G28` on every anchoring deployment. A + fail-closed check that fires on the honest case is not fail-closed, it is broken. + """ + try: + held = list(provider.since(0)) + newest = provider.latest() + except Exception as refused: + return AnchorReport( + ok=False, + unavailable=True, + reason=( + f"the anchor provider could not be reached ({type(refused).__name__}); " + "nothing about the anchors is known either way" + ), + checked=0, + superseded=0, + breaks=[], + ) + + cached = {anchor.token: anchor for anchor in store.anchors()} + breaks: list[AnchorBreak] = [] + + if not held and not cached: + # §3.4: a configuration that anchors and holds no anchor at all. This is + # `SPEC-v0.10.md` §4.3's `upstream_unverified` in a new place: an anchor that is never + # made would otherwise switch the check off by being absent, which is `SPEC-v0.4.md` + # §3.8's false green. + return AnchorReport( + ok=False, + unavailable=False, + reason=None, + checked=0, + superseded=0, + breaks=[ + AnchorBreak( + "anchor_missing", + None, + "this configuration anchors and the provider holds no anchor at all", + ) + ], + ) + + by_seq = _chain_hashes(store) + checkpoint = store.checkpoint() + checkpoint_seq = None if checkpoint is None else checkpoint[0] + anchored_checkpoints = {anchor.seq for anchor in held if anchor.kind == CHECKPOINT} | { + anchor.seq for anchor in cached.values() if anchor.kind == CHECKPOINT + } + + checked = 0 + superseded = 0 + for anchor in sorted(held, key=lambda item: (item.seq, item.kind)): + present = by_seq.get(anchor.seq) + if anchor.token not in cached: + # §3.2: the provider names an anchor the local table lacks. This is the deletion + # attack seen from the side that cannot be rewritten. + # + # **It does not `continue`, and an earlier version did.** In the canonical attack the + # chain is truncated *and* the newest local row deleted, so both this and + # `anchor_broken` apply, and a probe of exactly that showed the earlier version + # reporting only this one. §3.4's rule is that `anchor_broken` wins, for the reason + # it gives: `anchor_missing` reads to an operator as a misconfiguration and + # `anchor_broken` reads as tamper, so naming the milder one alone is how a real + # finding gets filed as a config ticket. Both are reported, and the sort below puts + # the one that means tamper first. + breaks.append( + AnchorBreak( + "anchor_missing", + anchor.seq, + f"the provider holds an anchor at seq {anchor.seq} that this store's local " + "table does not; the cache was trimmed, or this is not the store that was " + "anchored", + ) + ) + if present is not None and present == anchor.hash: + # The chain still reproduces it, so the cache is the only thing wrong. The + # provider's answer is what decides, and it decided the pair is intact; there is + # nothing further to check, because `check()` needs a token this store lost. + continue + + if present is None: + # §4.6: absent is not automatically broken. An anchored `seq` at or below a + # checkpoint that is ITSELF anchored is **superseded**: the checkpoint's anchor + # carries the claim forward, and the provider's own record still shows that a prune + # happened, at what seq and when. + if ( + checkpoint_seq is not None + and anchor.seq <= checkpoint_seq + and checkpoint_seq in anchored_checkpoints + ): + superseded += 1 + continue + breaks.append( + AnchorBreak( + "anchor_broken", + anchor.seq, + f"the anchored seq {anchor.seq} is absent from this chain, and no anchored " + "checkpoint accounts for its removal", + ) + ) + continue + + if present != anchor.hash: + breaks.append( + AnchorBreak( + "anchor_broken", + anchor.seq, + f"seq {anchor.seq} hashes to {present} and the anchor froze {anchor.hash}", + ) + ) + continue + + if anchor.token not in cached: + # Already reported `anchor_missing` above, and `anchor_broken` just now if the chain + # disagreed too. There is no cached token to ask `check()` about. + continue + + try: + vouched = provider.check(anchor.seq, anchor.hash, anchor.token) + except Exception as refused: + return AnchorReport( + ok=False, + unavailable=True, + reason=( + f"the anchor provider could not be reached ({type(refused).__name__}) while " + f"checking seq {anchor.seq}; nothing about the anchors is known either way" + ), + checked=checked, + superseded=superseded, + breaks=[], + ) + if not vouched: + breaks.append( + AnchorBreak( + "anchor_repudiated", + anchor.seq, + f"the provider does not recognise the pair at seq {anchor.seq} under the " + "token this store holds for it", + ) + ) + continue + checked += 1 + + if newest is not None and not any(anchor.seq == newest[0] for anchor in held): + breaks.append( + AnchorBreak( + "anchor_missing", + newest[0], + f"the provider's latest() names seq {newest[0]} and its since() did not return " + "it; the provider is answering two different questions inconsistently", + ) + ) + + # `anchor_broken` first, whatever order they were found in: an operator reading the top of a + # report must see the one that means tamper rather than the one that means misconfiguration. + breaks.sort(key=lambda item: ANCHOR_BREAKS.index(item.name)) + return AnchorReport( + ok=not breaks, + unavailable=False, + reason=None, + checked=checked, + superseded=superseded, + breaks=breaks, + ) + + +def _chain_hashes(store: AnchorSource) -> dict[int, str]: + """Every chained receipt's `seq` and the hash stored for it. + + Read through the same `receipts()` every other reader uses, so a row this binary cannot + construct (`SPEC-v0.11.md` §5.2) is simply not in the map: it has no hash to compare, and + `verify_chain` is already reporting `content_altered` about it. The anchor does not report + the same fact a second time under a different name. + """ + found: dict[int, str] = {} + for receipt in store.receipts(): + seq = getattr(receipt, "seq", None) + digest = getattr(receipt, "hash", None) + if isinstance(seq, int) and isinstance(digest, str): + found[seq] = digest + return found diff --git a/src/ctrlrun/cli/main.py b/src/ctrlrun/cli/main.py index 0da75e99..2da5336a 100644 --- a/src/ctrlrun/cli/main.py +++ b/src/ctrlrun/cli/main.py @@ -13,16 +13,24 @@ from __future__ import annotations +import importlib import json from collections.abc import Mapping from dataclasses import dataclass from datetime import UTC, datetime from pathlib import Path -from typing import Any, Final +from typing import Any, Final, cast import click from ..action import Principal +from ..anchor import ( + ANCHOR_KINDS, + INTERVAL, + AnchorProvider, + make_anchor, + verify_anchors, +) from ..approval import ApprovalRecord, LocalApprovalProvider from ..authority import Budget, Delegation, grant_from_json, grant_from_yaml from ..control import DEFAULT_STATE_DIR, Control, state_path @@ -540,6 +548,132 @@ def _report_chain(store: StateStore, *, as_json: bool) -> None: raise SystemExit(1) +def _loaded_anchor_provider(dotted: str) -> AnchorProvider: + """The operator's own anchor provider, named as `module:attribute` (SPEC-v0.11 §3.2). + + **An import path, because there is nothing else it could be.** §9 puts no RFC 3161 client in + the wheel: the kernel stays stdlib plus `pyyaml` and `click`, and a timestamp protocol client + is a network client. So the provider is the operator's code, and a command run from `cron` + needs a way to name it. `module:attribute` is the shape every Python tool uses for this. + + The attribute may be the provider or a zero-argument callable returning one, because an + operator whose provider needs a connection has nowhere else to build it. + + **The spec does not specify this**, and it is recorded as a decision rather than presented as + one: §9 freezes `ctrlrun anchor` as a command and says nothing about how it reaches the + provider. + """ + module_name, _, attribute = dotted.partition(":") + if not module_name or not attribute: + raise click.UsageError( + f"--provider must be 'module:attribute', got {dotted!r}. It names the anchor provider " + "in your own code: CTRLRun ships none, because a timestamp client is a network client" + ) + try: + module = importlib.import_module(module_name) + except ImportError as exc: + raise click.UsageError( + f"--provider {dotted}: {module_name} could not be imported: {exc}" + ) from exc + try: + found = getattr(module, attribute) + except AttributeError: + raise click.UsageError( + f"--provider {dotted}: {module_name} has no attribute {attribute!r}" + ) from None + provider = found() if callable(found) and not hasattr(found, "make") else found + for call in ("make", "check", "latest", "since"): + if not callable(getattr(provider, call, None)): + raise click.UsageError( + f"--provider {dotted}: an AnchorProvider needs make, check, latest and since " + f"(SPEC-v0.11 §3.2); this one has no {call}()" + ) + return cast(AnchorProvider, provider) + + +@main.command(name="anchor") +@click.option( + "--provider", + "dotted", + required=True, + metavar="MODULE:ATTR", + help="Your anchor provider (SPEC-v0.11 §3.2). CTRLRun ships none.", +) +@click.option( + "--verify", + "verify_only", + is_flag=True, + help="Check every anchor the provider holds against this chain, and make none.", +) +@click.option( + "--kind", + type=click.Choice(list(ANCHOR_KINDS)), + default=INTERVAL, + help="interval (the scheduled anchor) or checkpoint (a prune's).", +) +@click.option("--json", "as_json", is_flag=True, help="Print the report as JSON.") +@STORE_URL_OPTION +def anchor_command( + dotted: str, verify_only: bool, kind: str, as_json: bool, store_url: str | None +) -> None: + """Anchor this store's chain head outside the store, or check the anchors already made. + + The chain detects alteration. It does not detect truncation or append, because the head that + would catch them is a row in the same database (SPEC-v0.6 §6.4). An anchor records the pair + the head holds somewhere your database's writer does not control. + + **What it proves:** anything at or below an anchored seq can no longer be removed or altered + without the anchored pair failing to reproduce. **What it does not:** an append is not + detected, because it lands above every anchored seq; nor are receipts created and destroyed + between two anchors; nor who wrote any of it. The window you are exposed to is + (last anchored seq, current head], and its size is your choice of interval. + """ + provider = _loaded_anchor_provider(dotted) + store = _store(store_url) + if verify_only: + _report_anchors(store, provider, as_json=as_json) + return + try: + made = make_anchor(store, provider, kind=kind) + except CTRLRunError as exc: + raise _fail(exc) from exc + if as_json: + click.echo(json.dumps(made.to_dict(), ensure_ascii=False, separators=(",", ":"))) + return + click.echo(f"anchored seq {made.seq} ({made.kind}) at {iso_timestamp(made.at)}") + click.echo(f" hash {made.hash}") + click.echo(f" token {made.token}") + + +def _report_anchors(store: StateStore, provider: AnchorProvider, *, as_json: bool) -> None: + """`--verify` against the operator's own store, in `_report_chain`'s shape. + + **Three outcomes, not two.** An unreachable provider is `unavailable`, which is neither ok + nor broken: refusing to act when you cannot ask is fail-closed, and reporting tampering when + you cannot ask is a false positive (SPEC-v0.11 §3.4). It still exits non-zero, because an + operator scripting this needs to know the check did not happen. + """ + report = verify_anchors(store, provider) + if as_json: + click.echo(json.dumps(report.to_dict(), ensure_ascii=False, separators=(",", ":"))) + elif report.unavailable: + click.echo(f"anchors: not checked. {report.reason}") + else: + click.echo(f"anchors: {report.checked} checked against this chain") + if report.superseded: + click.echo( + f" {report.superseded} superseded by an anchored checkpoint " + "(a prune accounts for them)" + ) + for problem in report.breaks: + where = "" if problem.seq is None else f" at seq {problem.seq}" + click.echo(f" {problem.name}{where}: {problem.detail}") + if report.ok: + click.echo("every anchor reproduces") + if not report.ok: + raise SystemExit(1) + + @main.command() @click.option( "--state", diff --git a/src/ctrlrun/control.py b/src/ctrlrun/control.py index 6e503b88..773d4d69 100644 --- a/src/ctrlrun/control.py +++ b/src/ctrlrun/control.py @@ -26,6 +26,7 @@ from typing import Any, Final, NoReturn, ParamSpec, TypeVar, cast from .action import Action, Principal, canonical_bytes +from .anchor import AnchorProvider from .approval import ( APPROVAL_UNRECORDED, APPROVALS_UNVERIFIABLE, @@ -844,6 +845,7 @@ def __init__( approver_identity: ApproverIdentity | None = None, require_approved_policy: bool = False, upstream: str | None = None, + anchor: AnchorProvider | None = None, ) -> None: self._policy = policy self._store = store @@ -865,6 +867,17 @@ def __init__( # SPEC-v0.8 §8.4. **In code and not in the file it governs**, or the file would switch # off its own governance. Default false: opt in, then fail closed. self._require_approved_policy = require_approved_policy + # SPEC-v0.11 §9 — **a property of the deployment, not of an action**, which is why it is + # here and not on `execute`. A deployment anchors its receipt chain or it does not; no + # single action decides that, and a per-action parameter would invite one caller to + # anchor and another not to, in the same store. + # + # `Control` never *makes* an anchor on an action's path. Anchoring is an operator's act + # on a schedule (`ctrlrun anchor`), and putting it on the write path would mean an + # unreachable timestamp authority could block an agent from acting, which is a + # availability cost this milestone has no reason to impose: the anchor is about reading + # the record later, not about deciding now. + self._anchor = anchor # SPEC-v0.10 §4.3 — the upstream this deployment fronts, which only a surface holding the # connection can name. The gateway passes `GatewayConfig.upstream`; in-process it is # `None`, and §4.4 makes a pinned action refuse `upstream_unverified` there. diff --git a/src/ctrlrun/migrations.py b/src/ctrlrun/migrations.py index 78a33ff1..b9f212b8 100644 --- a/src/ctrlrun/migrations.py +++ b/src/ctrlrun/migrations.py @@ -393,6 +393,89 @@ def sql(self, dialect: str) -> tuple[str, ...]: """, ) +#: SPEC-v0.11 §9 — the three tables items 2 and 3 need, in **one** migration, because §9 freezes +#: the id `0008_anchor_checkpoint_hold` and a migration id is a name that cannot be amended once +#: a store has applied it. Item 2 creates all three; item 3 fills `checkpoints` and `holds`. +#: +#: `anchors` is the **cache** and never the record (§3.3). The record is the operator's provider, +#: outside the store, and that distinction is the whole of why an anchor is worth anything: a row +#: here that somebody deleted is checked anyway, because `verify_anchors` asks the provider what +#: it holds before it reads this table. +#: +#: `token` is the primary key rather than `seq`: a `seq` can legitimately carry both an `interval` +#: and a `checkpoint` anchor (§3.2 orders the kinds separately), and a provider's token is the one +#: value it promises to recognise again. +_ANCHOR_CHECKPOINT_HOLD: Final = ( + """ + CREATE TABLE anchors ( + token TEXT PRIMARY KEY, + seq INTEGER NOT NULL, + hash TEXT NOT NULL, + kind TEXT NOT NULL, + at TEXT NOT NULL + ) + """, + "CREATE INDEX ix_anchors_seq ON anchors (kind, seq)", + # One row, `id = 1`, exactly as `receipt_chain` is: a store has one pruned-through point, and + # a table that could hold two is a table a reader has to choose from (SPEC-v0.11 §4.2). + """ + CREATE TABLE prune_checkpoint ( + id INTEGER PRIMARY KEY CHECK (id = 1), + seq INTEGER NOT NULL, + hash TEXT NOT NULL, + schema TEXT NOT NULL, + at TEXT NOT NULL + ) + """, + """ + CREATE TABLE holds ( + hold_id TEXT PRIMARY KEY, + from_seq INTEGER NOT NULL, + to_seq INTEGER, + reason TEXT NOT NULL, + placed_by TEXT NOT NULL, + placed_at TEXT NOT NULL, + released_at TEXT, + released_by TEXT + ) + """, + "CREATE INDEX ix_holds_range ON holds (from_seq, to_seq)", +) +_ANCHOR_CHECKPOINT_HOLD_PG: Final = ( + """ + CREATE TABLE IF NOT EXISTS anchors ( + token TEXT PRIMARY KEY COLLATE "C", + seq BIGINT NOT NULL, + hash TEXT NOT NULL COLLATE "C", + kind TEXT NOT NULL COLLATE "C", + at TIMESTAMPTZ NOT NULL + ) + """, + "CREATE INDEX IF NOT EXISTS ix_anchors_seq ON anchors (kind, seq)", + """ + CREATE TABLE IF NOT EXISTS prune_checkpoint ( + id INTEGER PRIMARY KEY CHECK (id = 1), + seq BIGINT NOT NULL, + hash TEXT NOT NULL COLLATE "C", + schema TEXT NOT NULL COLLATE "C", + at TIMESTAMPTZ NOT NULL + ) + """, + """ + CREATE TABLE IF NOT EXISTS holds ( + hold_id TEXT PRIMARY KEY COLLATE "C", + from_seq BIGINT NOT NULL, + to_seq BIGINT, + reason TEXT NOT NULL COLLATE "C", + placed_by TEXT NOT NULL COLLATE "C", + placed_at TIMESTAMPTZ NOT NULL, + released_at TIMESTAMPTZ, + released_by TEXT COLLATE "C" + ) + """, + "CREATE INDEX IF NOT EXISTS ix_holds_range ON holds (from_seq, to_seq)", +) + #: The ordered set this binary knows. `NNNN_snake_name`: four digits, zero-padded, so #: lexicographic order is application order. MIGRATIONS: Final[tuple[Migration, ...]] = ( @@ -411,6 +494,11 @@ def sql(self, dialect: str) -> tuple[str, ...]: postgres=_VERIFIED_APPROVER_PG, ), Migration("0007_budget_ledger", _BUDGET_LEDGER, postgres=_BUDGET_LEDGER_PG), + Migration( + "0008_anchor_checkpoint_hold", + _ANCHOR_CHECKPOINT_HOLD, + postgres=_ANCHOR_CHECKPOINT_HOLD_PG, + ), ) HEAD: Final = MIGRATIONS[-1].id diff --git a/src/ctrlrun/postgres.py b/src/ctrlrun/postgres.py index aea5e467..ad7017ce 100644 --- a/src/ctrlrun/postgres.py +++ b/src/ctrlrun/postgres.py @@ -43,6 +43,7 @@ from typing import Any, Final from .action import Action +from .anchor import Anchor from .approval import ( Approval, ApprovalRecord, @@ -2202,3 +2203,45 @@ def chain_head(self) -> tuple[int, str] | None: cursor.execute(f"SELECT seq, hash FROM {self._q}.receipt_chain WHERE id = 1") row = cursor.fetchone() return None if row is None else (int(row[0]), str(row[1])) + + # --- anchors (SPEC-v0.11 §3.3) ---------------------------------------------------- + + def put_anchor(self, anchor: Anchor) -> None: + """Cache one anchor. A cache and never the record (§3.3), as SQLite's is.""" + connection = self._connection() + try: + with connection.cursor() as cursor: + cursor.execute( + f"INSERT INTO {self._q}.anchors (token, seq, hash, kind, at) " + "VALUES (%s, %s, %s, %s, %s) ON CONFLICT (token) DO NOTHING", + (anchor.token, anchor.seq, anchor.hash, anchor.kind, anchor.at), + ) + except BaseException: + self._rollback(connection) + raise + self._commit(connection) + + def anchors(self) -> tuple[Anchor, ...]: + with self._connection().cursor() as cursor: + cursor.execute( + f"SELECT token, seq, hash, kind, at FROM {self._q}.anchors " + "ORDER BY seq, kind, token" + ) + rows = cursor.fetchall() + return tuple( + Anchor( + seq=int(row[1]), + hash=str(row[2]), + token=str(row[0]), + kind=str(row[3]), + at=row[4], + ) + for row in rows + ) + + def checkpoint(self) -> tuple[int, str] | None: + """The `seq` a prune pruned through and the hash at it (SPEC-v0.11 §4.2, §4.6).""" + with self._connection().cursor() as cursor: + cursor.execute(f"SELECT seq, hash FROM {self._q}.prune_checkpoint WHERE id = 1") + row = cursor.fetchone() + return None if row is None else (int(row[0]), str(row[1])) diff --git a/src/ctrlrun/state.py b/src/ctrlrun/state.py index c051633b..4994bad5 100644 --- a/src/ctrlrun/state.py +++ b/src/ctrlrun/state.py @@ -33,6 +33,7 @@ from typing import Any, Final, Protocol, TypeVar from .action import Action, Principal +from .anchor import Anchor from .approval import ( Approval, ApprovalRecord, @@ -883,6 +884,33 @@ def chain_head(self) -> tuple[int, str] | None: """ ... + def put_anchor(self, anchor: Anchor) -> None: + """Cache one anchor (SPEC-v0.11 §3.3). **Amends SPEC-v0.6 §9.2's frozen protocol.** + + §9.2's bar for a new method is *a second backend could not be written without it*, and it + is cleared: an anchor's local cache cannot be reconstructed from the tables that exist. + No receipt carries a token, and the point of the cache is to hold what the provider + answered, which nothing else in this store has ever seen. + """ + ... + + def anchors(self) -> tuple[Anchor, ...]: + """Every cached anchor, oldest `seq` first (SPEC-v0.11 §3.3). + + **A cache and not a record.** `verify_anchors` asks the provider what it holds before it + reads this, so a store whose anchors table was emptied verifies exactly as one that never + anchored: `anchor_missing`, which is a break. + """ + ... + + def checkpoint(self) -> tuple[int, str] | None: + """The `seq` a prune pruned through and the hash at it, or `None` (SPEC-v0.11 §4.2). + + The **read** ships with item 2 because §4.6's supersession rule is part of what + `anchor_broken` means; `put_checkpoint` ships with item 3, which is what writes one. + """ + ... + def events(self) -> tuple[Event, ...]: """Every event, oldest first (SPEC-v0.6 §9.2). @@ -996,6 +1024,11 @@ def __init__(self, *, clock: Callable[[], datetime] = _utc_now) -> None: self._ledger: list[Consumption] = [] self._events: list[Event] = [] self._receipts: list[Receipt] = [] + #: SPEC-v0.11 §3.3's cache, in memory. This backend's `reopen()` is `None`: it declares + #: that its storage does not outlive the object, so an anchor cached here is gone with + #: the process, exactly as every other row in it is. + self._anchors: list[Anchor] = [] + self._checkpoint: tuple[int, str] | None = None #: The chain head (§6.3), starting where `0002_receipt_chain` starts it: seq 0 #: carrying the genesis hash, so an empty store is a chain of length zero rather #: than a truncated one. @@ -1038,6 +1071,19 @@ def chain_head(self) -> tuple[int, str] | None: with self._lock: return (self._chain_seq, self._chain_hash) + def put_anchor(self, anchor: Anchor) -> None: + with self._lock: + if all(held.token != anchor.token for held in self._anchors): + self._anchors.append(anchor) + + def anchors(self) -> tuple[Anchor, ...]: + with self._lock: + return tuple(sorted(self._anchors, key=lambda item: (item.seq, item.kind, item.token))) + + def checkpoint(self) -> tuple[int, str] | None: + with self._lock: + return self._checkpoint + def events(self) -> tuple[Event, ...]: """An immutable snapshot of the event log, in append order.""" with self._lock: @@ -1677,6 +1723,60 @@ def chain_head(self) -> tuple[int, str] | None: ) return None if row is None else (int(row["seq"]), str(row["hash"])) + # --- anchors (SPEC-v0.11 §3.3) ---------------------------------------------------- + + def put_anchor(self, anchor: Anchor) -> None: + """Cache one anchor the provider made. **A cache, never the record** (§3.3). + + The record is the operator's provider, outside this store, and that is the whole of what + makes an anchor worth anything: `verify_anchors` asks the provider what it holds *before* + it reads this table, so a row deleted from here is checked anyway. + + Keyed on `token`: a `seq` can carry both an `interval` and a `checkpoint` anchor, because + §3.2 orders the two kinds separately, and the token is the one value a provider promises + to recognise again. + """ + connection = self._connection() + with connection: + connection.execute( + "INSERT INTO anchors (token, seq, hash, kind, at) VALUES (?, ?, ?, ?, ?) " + "ON CONFLICT(token) DO NOTHING", + (anchor.token, anchor.seq, anchor.hash, anchor.kind, anchor.at.isoformat()), + ) + + def anchors(self) -> tuple[Anchor, ...]: + rows = ( + self._connection() + .execute("SELECT token, seq, hash, kind, at FROM anchors ORDER BY seq, kind, token") + .fetchall() + ) + return tuple( + Anchor( + seq=int(row["seq"]), + hash=str(row["hash"]), + token=str(row["token"]), + kind=str(row["kind"]), + at=datetime.fromisoformat(row["at"]), + ) + for row in rows + ) + + def checkpoint(self) -> tuple[int, str] | None: + """The `seq` a prune pruned through and the chain hash at it (SPEC-v0.11 §4.2). + + **Read here in item 2 and written by item 3.** §4.6's rule is part of what + `anchor_broken` *means*, not an addition to it: an anchored `seq` below a checkpoint that + is itself anchored is **superseded**, not broken. An anchor shipped without that clause + would report every anchor older than the retention window as tampering, forever, on any + deployment that ever prunes, and §3.4's definition would be wider than its code. + """ + row = ( + self._connection() + .execute("SELECT seq, hash FROM prune_checkpoint WHERE id = 1") + .fetchone() + ) + return None if row is None else (int(row["seq"]), str(row["hash"])) + def events(self) -> tuple[Event, ...]: rows = self._connection().execute("SELECT * FROM events ORDER BY event_id").fetchall() return tuple( diff --git a/src/ctrlrun/verify/guarantees.py b/src/ctrlrun/verify/guarantees.py index eff1cae0..f4e3f154 100644 --- a/src/ctrlrun/verify/guarantees.py +++ b/src/ctrlrun/verify/guarantees.py @@ -213,6 +213,18 @@ class Guarantee: "a swapped upstream is denied", ("v0.10 §4.3", "v0.10 §4.7 T490", "v0.10 §4.7 T493"), ), + Guarantee( + "G28", + # 30 characters against `report._TITLE_WIDTH`'s 32. **It says truncation and does not say + # append**, and an earlier draft said both. §2.4's table is what this title has to agree + # with: an append lands above every anchored `seq`, so no anchored pair stops reproducing + # and a later anchor freezes the forged chain as readily as an honest one. Correcting the + # prose that argues a claim and leaving the claim in the registry would be worse than not + # correcting it: the argument is read once and the registry is read by every operator who + # runs `verify`. + "truncation past an anchor fails", + ("v0.11 §3", "v0.11 §3.6 T530", "v0.11 §3.6 T533"), + ), Guarantee( "G31", # 27 characters against `report._TITLE_WIDTH`'s 32. It grades **the walk**, not the diff --git a/src/ctrlrun/verify/scenarios.py b/src/ctrlrun/verify/scenarios.py index 92341e2b..a7417810 100644 --- a/src/ctrlrun/verify/scenarios.py +++ b/src/ctrlrun/verify/scenarios.py @@ -44,6 +44,7 @@ from uuid import uuid4 from ..action import Action, Principal +from ..anchor import Anchor, make_anchor, verify_anchors from ..approval import ( DEFAULT_APPROVAL_TTL, ApprovalStatus, @@ -4583,6 +4584,120 @@ def body(detail: dict[str, Any]) -> None: _upstream.forget(f"{reg.SYNTHETIC_PREFIX}-upstream") store.close() + # --- G28: a truncation past an anchor fails ------------------------------------------ + + def g28(self) -> GuaranteeResult: + """SPEC-v0.11 §3, §8. A chain truncated at or below an anchored `seq` is refused. + + **The positive control is the attack itself**, run against a real store: truncate the + chain, fix the head the way an administrator with write access would, and require the + break to be named. Without it this guarantee ships a mechanism that has never seen the + thing it exists for, which is `SPEC-v0.4.md` §2.2's guarantee that could not have failed. + §2.1 measured the attack at two SQL statements, undetected: + + two SQL statements: ok=True verified=3 breaks=[] + + **Verify supplies the provider**, as it supplies a scope provider for `G23` and a + revocation feed for `G20`, and for the same reason §8.1 gives: a guarantee about a code + surface is graded against a scenario verify constructs rather than reported `N/A` about + something it never saw. Whether *this* deployment configures a provider is a fact about + its own code, which verify does not read, and the report says so. + + **What this does NOT grade, and the title does not claim:** an append. §2.4's table is + the whole bounded claim, and an appended row lands above every anchored `seq`, so no + anchored pair stops reproducing. `T533` asserts that directly, so the limit is a tested + property rather than a sentence in a document. + """ + selection = self.select(decisions=(Decision.ALLOW, Decision.APPROVE, Decision.DENY)) + if selection is None: + return self.na("G28", self.unselected(reg.NO_ACTIONS)) + control, store, recorder, _ = self._control_for("G28", selection) + + def body(detail: dict[str, Any]) -> None: + for index in range(4): + action = selection.build() + key = ( + None + if selection.effect_key is None + else f"{selection.effect_key!s}-{reg.SYNTHETIC_PREFIX}-anchor-{index}" + ) + with suppress(CTRLRunError): + self.execute( + control, + action, + _Executor(lambda: f"{APPROVER}-result"), + key, + self.approve(control, store, action, selection), + ) + written = _written(store) + _expect_control( + len(written) >= 3, + "the scenario wrote a chain to anchor", + f"only {len(written)} receipts reached the store", + ) + + provider = _VerifyAnchorProvider() + made = make_anchor(store, provider) + detail["anchored_seq"] = made.seq + + # The control: an untouched chain reproduces its anchor. Without this, every + # assertion below passes against a checker that always says broken. + intact = verify_anchors(store, provider) + _expect_control( + intact.ok and intact.checked == 1 and not intact.unavailable, + "an untouched chain reproduces its anchor", + f"it reported ok={intact.ok} checked={intact.checked} " + f"{[(b.name, b.seq) for b in intact.breaks]}", + ) + + # §2.1's attack: erase a suffix and fix the head, which is what makes it invisible to + # the chain. Verify does not know which backend it is on, so it presents the + # truncated chain as a view rather than issuing a DELETE. + kept = tuple(item for item in written if item.seq is not None and item.seq <= 2) + _expect_control( + bool(kept) and len(kept) < len(written), + "the truncation removes some receipts and keeps some", + f"it kept {len(kept)} of {len(written)}", + ) + last_seq, last_hash = kept[-1].seq, kept[-1].hash + _expect_control( + last_seq is not None and last_hash is not None, + "the receipts kept by the truncation carry a seq and a hash", + f"the last kept receipt has seq={last_seq!r} hash={last_hash!r}", + ) + assert last_seq is not None and last_hash is not None # narrowed by the control + # The head is fixed to name the new last row, which is exactly what makes §2.1's + # attack invisible to the chain: without this the chain would report head_mismatch + # and the anchor would be grading something the chain already caught. + truncated = _AnchoredChain(kept, (last_seq, last_hash), store) + + # The chain alone does not notice, which is the defect this item exists to answer. + chain = verify_chain(truncated) + detail["chain_after_truncation"] = { + "ok": chain.ok, + "breaks": [{"name": b.name, "seq": b.seq} for b in chain.breaks], + } + + report = verify_anchors(truncated, provider) + detail["anchor_breaks"] = [{"name": b.name, "seq": b.seq} for b in report.breaks] + _expect( + not report.ok and not report.unavailable, + "a chain truncated past an anchored seq is refused against its anchor", + f"the anchor report was ok={report.ok} unavailable={report.unavailable}", + ) + _expect( + any( + item.name == "anchor_broken" and item.seq == made.seq for item in report.breaks + ), + f"the truncation is named `anchor_broken` at seq {made.seq}", + f"it was reported as {[(b.name, b.seq) for b in report.breaks]}", + ) + + try: + return self.graded("G28", selection, store, recorder, body) + finally: + store.close() + # --- G31: one chain, five receipt schema versions, walked end to end ------------------ def g31(self) -> GuaranteeResult: @@ -4944,6 +5059,73 @@ def _post(connection: Any, *, pause: Callable[[], None] | None = None) -> str: return f"{APPROVER}-result" +class _VerifyAnchorProvider: + """The anchor provider verify supplies for `G28` (SPEC-v0.11 §3.2, §8.1). + + In memory, and deliberately the simplest thing that satisfies the protocol: it records what + it was asked to vouch for and answers about it. It is **not** a timestamp authority and does + not pretend to be one. What `G28` grades is that CTRLRun asks the right questions of whatever + the operator supplies and refuses on the right answers, exactly as `G23` grades a scope + provider verify supplies rather than one it found. + + Its clock moves forward on every `make`, because §3.2 refuses an anchor whose time runs + backwards and a provider returning a constant would make that rule ungradeable. + """ + + def __init__(self) -> None: + self._held: dict[str, Anchor] = {} + self._at = datetime(2026, 1, 1, tzinfo=UTC) + + def make(self, seq: int, hash: str, kind: str) -> tuple[str, datetime]: + self._at += timedelta(seconds=1) + token = f"{reg.SYNTHETIC_PREFIX}-anchor-{kind}-{seq}" + self._held[token] = Anchor(seq=seq, hash=hash, token=token, kind=kind, at=self._at) + return token, self._at + + def check(self, seq: int, hash: str, token: str) -> bool: + held = self._held.get(token) + return held is not None and held.seq == seq and held.hash == hash + + def latest(self) -> tuple[int, str] | None: + if not self._held: + return None + best = max(self._held.values(), key=lambda item: item.seq) + return (best.seq, best.token) + + def since(self, seq: int) -> tuple[Anchor, ...]: + return tuple(item for item in self._held.values() if item.seq >= seq) + + +@dataclass(frozen=True) +class _AnchoredChain: + """A store's chain with a suffix erased and the head fixed, as §2.1's attack leaves it. + + Verify does not know which backend it is on, so it cannot truncate with a `DELETE`. This + presents what the store would return afterwards, to the same readers an operator runs. + + The anchors and the checkpoint come from the **real** store, because the attack §2.1 + describes erases receipts and rewrites the head; it does not touch the anchor cache. The + case where it touches that too is `T532`, and it is a different break. + """ + + _receipts: tuple[Receipt, ...] + _head: tuple[int, str] | None + _store: Any + + def receipts(self) -> tuple[Receipt, ...]: + return self._receipts + + def chain_head(self) -> tuple[int, str] | None: + return self._head + + def anchors(self) -> tuple[Anchor, ...]: + return tuple(self._store.anchors()) + + def checkpoint(self) -> tuple[int, str] | None: + result: tuple[int, str] | None = self._store.checkpoint() + return result + + @dataclass(frozen=True) class _AlteredChain: """A read-only view of a store's chain with one receipt changed (G11). diff --git a/tests/test_anchor.py b/tests/test_anchor.py new file mode 100644 index 00000000..5bbacfa8 --- /dev/null +++ b/tests/test_anchor.py @@ -0,0 +1,841 @@ +# SPDX-FileCopyrightText: 2026 The CTRLRun contributors +# SPDX-License-Identifier: Apache-2.0 +"""The anchor, and the two things it makes detectable. SPEC-v0.11 §2, §3; T530-T541. + +**The attack is the deliverable and it is written first.** The receipt chain detects alteration. +It does not detect truncation, because the head that would catch it is a row in the same +database. Measured at `main` before this item, on a six-receipt chain, in two statements:: + + DELETE FROM receipts WHERE seq > 3 + UPDATE receipt_chain SET seq = ?, hash = ? + + two SQL statements: ok=True verified=3 breaks=[] + +Three receipts erased, and the chain reports itself intact. + +**The bounded claim is tested, not merely written** (`T531`). An anchor freezes a *prefix*: an +append lands at head + 1, above every anchored `seq`, so no anchored pair stops reproducing and a +later anchor freezes the forged chain as readily as an honest one. That is the first thing in +this project a reader could mistake for tamper-proofing, so `T531` runs a forged append and +requires **both** reports to stay clean. + +`ANCHOR_BREAKS` is its own closed set and `CHAIN_BREAKS` does not change (`T541`, §3.4). +""" + +from __future__ import annotations + +import json +import os +import sqlite3 +import uuid +from datetime import UTC, datetime, timedelta +from pathlib import Path + +import pytest + +from ctrlrun import Control, Policy, SQLiteStateStore +from ctrlrun.action import Action, Principal +from ctrlrun.anchor import ( + ANCHOR_BREAKS, + CHECKPOINT, + INTERVAL, + Anchor, + make_anchor, + verify_anchors, +) +from ctrlrun.errors import InvalidArgument +from ctrlrun.receipt import _document_hash, verify_chain + +T0 = datetime(2026, 1, 1, 12, 0, tzinfo=UTC) +LEASE = timedelta(minutes=5) +ALLOW = "schema: ctrlrun.policy/v1\nactions:\n stripe.refund:\n decision: allow\n" + +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" +) + + +def an_action(payment_id: str) -> Action: + return Action( + name="stripe.refund", + arguments={"payment_id": payment_id, "amount": 2000}, + principal=Principal(agent="chain-agent"), + ) + + +class Provider: + """An operator's anchor provider, as small as the protocol allows (§3.2). + + **Outside the store**, which is the entire point: nothing a `DELETE` against the database + reaches can change what this holds. `raise_on` makes it unreachable, which is a transport + failure and not a finding about the evidence (§3.4). + """ + + def __init__(self, *, raise_on: tuple[str, ...] = (), disown: bool = False) -> None: + self.held: dict[str, Anchor] = {} + self.at = T0 + self.raise_on = raise_on + self.disown = disown + self.checks: list[tuple[int, str]] = [] + + def _maybe_raise(self, call: str) -> None: + if call in self.raise_on: + raise ConnectionError(f"the timestamp authority is unreachable ({call})") + + def make(self, seq: int, hash: str, kind: str) -> tuple[str, datetime]: + self._maybe_raise("make") + self.at += timedelta(minutes=1) + token = f"tok-{kind}-{seq}" + self.held[token] = Anchor(seq=seq, hash=hash, token=token, kind=kind, at=self.at) + return token, self.at + + def check(self, seq: int, hash: str, token: str) -> bool: + self._maybe_raise("check") + self.checks.append((seq, token)) + if self.disown: + return False + held = self.held.get(token) + return held is not None and held.seq == seq and held.hash == hash + + def latest(self) -> tuple[int, str] | None: + self._maybe_raise("latest") + if not self.held: + return None + best = max(self.held.values(), key=lambda item: item.seq) + return (best.seq, best.token) + + def since(self, seq: int) -> tuple[Anchor, ...]: + self._maybe_raise("since") + return tuple(item for item in self.held.values() if item.seq >= seq) + + +def _rewrite_chain_from(database: Path, *, at: int, find: str, replace: str) -> None: + """Alter one receipt and recompute every hash after it, plus the head. + + An administrator with write access who edits one row and leaves the hashes is caught by + `verify_chain`; one who recomputes is not, and `THREAT_MODEL.md` has listed them as out of + scope for the chain since v0.6. The anchor is what narrows that, for everything at or below + an anchored `seq`. + """ + connection = sqlite3.connect(database) + connection.row_factory = sqlite3.Row + rows = connection.execute( + "SELECT seq, json, prev_hash FROM receipts WHERE seq IS NOT NULL ORDER BY seq" + ).fetchall() + previous: str | None = None + for row in rows: + document = json.loads(row["json"]) + if row["seq"] == at: + document = json.loads(row["json"].replace(find, replace)) + assert document != json.loads(row["json"]) or find not in row["json"], ( + f"the tamper {find!r} changed nothing at seq {at}" + ) + if previous is not None: + document["prev_hash"] = previous + digest = _document_hash(document) + connection.execute( + "UPDATE receipts SET json = ?, prev_hash = ?, hash = ? WHERE seq = ?", + ( + json.dumps(document, separators=(",", ":"), ensure_ascii=False), + document.get("prev_hash"), + digest, + row["seq"], + ), + ) + previous = digest + connection.execute( + "UPDATE receipt_chain SET seq = ?, hash = ? WHERE id = 1", (rows[-1]["seq"], previous) + ) + connection.commit() + connection.close() + + +def a_chain(database: Path, count: int = 6) -> SQLiteStateStore: + store = SQLiteStateStore(database, clock=lambda: T0) + control = Control(Policy.from_yaml(ALLOW), store, clock=lambda: T0) + for index in range(count): + control.execute( + an_action(f"p{index}"), lambda: {"ok": True}, f"refund:p{index}", lease=LEASE + ) + return store + + +def truncate(database: Path, through: int) -> None: + """§2.1's attack, in SQL, underneath the store: erase a suffix and fix the head. + + Two statements, which is the number that makes this invisible to the chain. Anything more + would be testing a clumsier attacker than the one the threat model describes. + """ + connection = sqlite3.connect(database) + connection.execute("DELETE FROM receipts WHERE seq > ?", (through,)) + row = connection.execute("SELECT seq, hash FROM receipts ORDER BY seq DESC LIMIT 1").fetchone() + connection.execute("UPDATE receipt_chain SET seq = ?, hash = ? WHERE id = 1", row) + connection.commit() + connection.close() + + +# --- T530: the deliverable --------------------------------------------------------------------- + + +def test_T530_a_truncation_past_an_anchored_seq_is_named(tmp_path) -> None: + """SPEC-v0.11 §2.1, §3.4. **The attack this milestone exists for.** + + The negative control comes first: the same two statements against the same store with **no** + anchor, so this test shows what is being fixed rather than asserting it. Without that half, + every row below would pass against an anchor that reports `anchor_broken` unconditionally. + """ + database = tmp_path / "state.db" + store = a_chain(database) + store.close() + + # (a) Without an anchor: the chain reports itself intact after three receipts are erased. + truncate(database, through=3) + unanchored = SQLiteStateStore(database, clock=lambda: T0) + report = verify_chain(unanchored) + unanchored.close() + assert report.ok, ( + "§2.1 says a truncation with the head fixed is undetected, and this store detected it; " + "the attack this item answers is not the attack being run" + ) + assert report.verified == 3 + + # (b) With one. The same two statements, and now they are named. + second = tmp_path / "anchored.db" + store = a_chain(second) + provider = Provider() + anchor = make_anchor(store, provider) + assert anchor.seq == 6 and anchor.kind == INTERVAL + before = verify_anchors(store, provider) + assert before.ok and before.checked == 1, before + store.close() + + truncate(second, through=3) + reopened = SQLiteStateStore(second, clock=lambda: T0) + chain = verify_chain(reopened) + anchors = verify_anchors(reopened, provider) + reopened.close() + + assert chain.ok, "the chain still does not notice, which is why the anchor exists" + assert not anchors.ok + assert not anchors.unavailable, "a truncation is a finding, never a transport failure" + named = [(item.name, item.seq) for item in anchors.breaks] + assert ("anchor_broken", 6) in named, named + + +# --- T531: the bounded claim, run rather than written ------------------------------------------ + + +def test_T531_a_forged_append_is_NOT_detected_and_that_is_the_claim(tmp_path) -> None: + """SPEC-v0.11 §2.4. **An anchor freezes a prefix.** + + An appended row lands at head + 1, above every anchored `seq`, so no anchored pair stops + reproducing. A later anchor freezes the forged chain as readily as an honest one. + + An earlier draft of §2.4 said the anchor closes "a suffix erased **or appended**", and a + review ran it. This test is that review, kept: if somebody later makes the anchor claim more + than it can do, this goes red and the documentation has to change with it. + """ + database = tmp_path / "state.db" + store = a_chain(database, 4) + provider = Provider() + make_anchor(store, provider) + store.close() + + connection = sqlite3.connect(database) + last = connection.execute( + "SELECT json, hash, seq FROM receipts ORDER BY seq DESC LIMIT 1" + ).fetchone() + document = json.loads(last[0]) + document["seq"] = last[2] + 1 + document["prev_hash"] = last[1] + document["receipt_id"] = "ctr_" + "f" * 28 + document["arguments"] = {"payment_id": "FORGED", "amount": 999999} + digest = _document_hash(document) + connection.execute( + "INSERT INTO receipts (receipt_id, action_id, ts, json, seq, prev_hash, hash) " + "VALUES (?, ?, ?, ?, ?, ?, ?)", + ( + document["receipt_id"], + document["action_id"], + document["finished_at"], + json.dumps(document, separators=(",", ":"), ensure_ascii=False), + document["seq"], + last[1], + digest, + ), + ) + connection.execute( + "UPDATE receipt_chain SET seq = ?, hash = ? WHERE id = 1", (document["seq"], digest) + ) + connection.commit() + connection.close() + + reopened = SQLiteStateStore(database, clock=lambda: T0) + chain = verify_chain(reopened) + anchors = verify_anchors(reopened, provider) + forged = [item for item in reopened.receipts() if item.receipt_id == document["receipt_id"]] + reopened.close() + + assert forged, "the forged row was not inserted, so this test asserts nothing" + assert chain.ok, "the chain detects an append after all; §2.4's table needs correcting" + assert anchors.ok, ( + "the anchor detected an append. That is a stronger claim than SPEC-v0.11 §2.4 makes, and " + "if it is now true the table, ROADMAP.md's paragraph and G28's title all have to say so" + ) + + +# --- T532: the third statement, and which break wins ------------------------------------------- + + +def test_T532_deleting_the_local_anchor_row_too_still_reports_the_tamper(tmp_path) -> None: + """SPEC-v0.3 §3.3 and §3.4's precedence rule. + + The local table is a **cache**, not a record: `verify_anchors` asks the provider what it holds + before reading it, so deleting the row does not remove the question. An earlier design had + `make` and `check` alone, and a review broke it in one extra statement, because the set of + questions then came from the rewritable side. + + **`anchor_broken` wins.** Both apply here, and `anchor_missing` reads to an operator as a + misconfiguration where `anchor_broken` reads as tamper. A first implementation of this + reported only the milder one, found by running exactly this case. + """ + database = tmp_path / "state.db" + store = a_chain(database) + provider = Provider() + make_anchor(store, provider) + store.close() + + truncate(database, through=3) + connection = sqlite3.connect(database) + connection.execute("DELETE FROM anchors") + connection.commit() + connection.close() + + reopened = SQLiteStateStore(database, clock=lambda: T0) + assert reopened.anchors() == (), "the local cache was not emptied, so this proves nothing" + report = verify_anchors(reopened, provider) + reopened.close() + + named = [(item.name, item.seq) for item in report.breaks] + assert ("anchor_broken", 6) in named, named + assert ("anchor_missing", 6) in named, named + assert report.breaks[0].name == "anchor_broken", ( + f"the milder break is named first, so a tamper reads as a config ticket: {named}" + ) + + +def test_T532b_an_emptied_cache_alone_is_missing_and_not_broken(tmp_path) -> None: + """The other half: the chain is intact and only the cache was trimmed. + + That is not tampering with the evidence, and calling it `anchor_broken` would be the false + positive §3.4 refuses in the other direction. + """ + database = tmp_path / "state.db" + store = a_chain(database) + provider = Provider() + make_anchor(store, provider) + store.close() + + connection = sqlite3.connect(database) + connection.execute("DELETE FROM anchors") + connection.commit() + connection.close() + + reopened = SQLiteStateStore(database, clock=lambda: T0) + report = verify_anchors(reopened, provider) + reopened.close() + + named = [(item.name, item.seq) for item in report.breaks] + assert named == [("anchor_missing", 6)], named + + +# --- T533: G11's contract does not change ------------------------------------------------------ + + +def test_T533_G11_passes_on_a_store_whose_anchor_report_carries_every_break(tmp_path) -> None: + """SPEC-v0.11 §8.1, asserted directly rather than argued. + + **The first draft asserted this and was wrong.** It claimed a `ChainReport` could carry + `anchor_broken` while `G11` passed; `G11`'s control reads `intact.ok` over the whole report, + so one extra break of any kind fails it with `control failed`, the status that means the + kernel is broken. Worse, `anchor_missing` fires on every anchoring deployment while verify's + own scratch store never anchors, so the failure would have been universal. + + The separation is therefore **structural**: the anchor has its own report and its own closed + set. This test is what makes that a property of the code rather than a paragraph. + """ + database = tmp_path / "state.db" + store = a_chain(database) + provider = Provider() + make_anchor(store, provider) + store.close() + + truncate(database, through=3) + connection = sqlite3.connect(database) + connection.execute("DELETE FROM anchors") + connection.commit() + connection.close() + + reopened = SQLiteStateStore(database, clock=lambda: T0) + chain = verify_chain(reopened) + anchors = verify_anchors(reopened, provider) + reopened.close() + + assert not anchors.ok and len(anchors.breaks) >= 2 + # Every name the anchor reported is in ITS set and in no chain report. + for item in anchors.breaks: + assert item.name in ANCHOR_BREAKS, item.name + assert chain.ok, ( + "an anchor break reached the ChainReport, which fails G11's control with `control " + f"failed` on every anchoring deployment: {[(b.name, b.seq) for b in chain.breaks]}" + ) + assert all(item.name not in ANCHOR_BREAKS for item in chain.breaks) + + +def test_T533b_the_two_closed_sets_do_not_overlap() -> None: + """`CHAIN_BREAKS` stays closed at six and gains nothing (§3.4).""" + from ctrlrun.receipt import CHAIN_BREAKS + + assert CHAIN_BREAKS == ( + "content_altered", + "hash_missing", + "link_broken", + "missing", + "head_mismatch", + "unchained", + ), "item 2 amended a frozen closed set; §3.4 says it does not" + assert ANCHOR_BREAKS == ("anchor_broken", "anchor_missing", "anchor_repudiated") + assert not set(CHAIN_BREAKS) & set(ANCHOR_BREAKS) + # `anchor_unavailable` is deliberately in NEITHER: it is a transport failure, not a finding + # about the evidence, and a set that conflated them would grade a briefly unreachable + # timestamp authority indistinguishably from a truncation. + assert "anchor_unavailable" not in ANCHOR_BREAKS + assert "anchor_unavailable" not in CHAIN_BREAKS + + +# --- T534: fail-closed, in both directions ----------------------------------------------------- + + +def test_T534_a_provider_that_raises_anchors_nothing(tmp_path) -> None: + """§10's first row. An anchor half-made is worse than none: the local table would claim a + pair the provider never saw, and every later verification would report `anchor_repudiated` + about an honest store.""" + database = tmp_path / "state.db" + store = a_chain(database) + provider = Provider(raise_on=("make",)) + + with pytest.raises(InvalidArgument) as refused: + make_anchor(store, provider) + + assert "anchor_unavailable" in str(refused.value) + assert store.anchors() == (), "an anchor was cached for a pair the provider never saw" + store.close() + + +def test_T534b_an_unreachable_provider_at_verification_is_unavailable_not_broken(tmp_path): + """§10's second row, and the distinction the whole of §3.4 turns on. + + **Refusing to act when you cannot ask is fail-closed; reporting tampering when you cannot ask + is a false positive.** An earlier draft made this a break, so a briefly unreachable timestamp + authority graded `G28` `fail`, indistinguishable in the report from a truncation. + """ + database = tmp_path / "state.db" + store = a_chain(database) + provider = Provider() + make_anchor(store, provider) + + provider.raise_on = ("since",) + report = verify_anchors(store, provider) + store.close() + + assert report.unavailable is True + assert report.ok is False, "unavailable is not a pass either" + assert report.breaks == [], ( + "an unreachable provider produced a break, so a network blip is indistinguishable from " + f"a truncation: {[(b.name, b.seq) for b in report.breaks]}" + ) + assert report.reason and "could not be reached" in report.reason + + +def test_T534c_a_configuration_that_anchors_and_holds_none_is_missing(tmp_path) -> None: + """§3.4's row that the section turns on. An anchor that is never made would otherwise switch + the check off by being absent, which is `SPEC-v0.4 §3.8`'s false green.""" + database = tmp_path / "state.db" + store = a_chain(database) + report = verify_anchors(store, Provider()) + store.close() + + assert not report.ok + assert not report.unavailable + assert [item.name for item in report.breaks] == ["anchor_missing"] + + +def test_T534d_the_ordinary_window_between_anchors_is_not_a_break(tmp_path) -> None: + """§2.4 and §3.4. **`anchor_missing` does not fire on the exposed window.** + + An earlier draft read *"the store's chain reaches a `seq` none of them covers"*, which is the + state every honest deployment lives in between anchors. A review measured it, one honest + action after an anchor:: + + A. honest deployment, anchor just taken at head 4 -> [] + B. same deployment, ONE honest action later + -> [('anchor_missing', 5, 'the chain reaches seq 5; ...')] + + `G28` would have failed on every anchoring deployment except in the instant after an anchor. + **A fail-closed check that fires on the honest case is not fail-closed, it is broken.** + """ + database = tmp_path / "state.db" + store = a_chain(database, 4) + provider = Provider() + make_anchor(store, provider) + assert verify_anchors(store, provider).ok, "an anchor just taken does not reproduce" + + # One honest action later, which is where every deployment spends its time. + control = Control(Policy.from_yaml(ALLOW), store, clock=lambda: T0) + control.execute(an_action("later"), lambda: {"ok": True}, "refund:later", lease=LEASE) + report = verify_anchors(store, provider) + store.close() + + assert report.ok, ( + "the ordinary window between anchors was reported as a break, so this check fires on " + f"every honest deployment: {[(b.name, b.seq) for b in report.breaks]}" + ) + assert report.breaks == [] + + +def test_T534e_a_provider_that_disowns_a_pair_is_repudiated(tmp_path) -> None: + """§3.4. `anchor_repudiated` exists because the outside record is **allowed to say no**, and + that is its one substantive answer and the entire reason for holding it outside the store. + The first draft's set had no name for `check()` returning false.""" + database = tmp_path / "state.db" + store = a_chain(database) + provider = Provider() + make_anchor(store, provider) + + provider.disown = True + report = verify_anchors(store, provider) + store.close() + + assert not report.ok and not report.unavailable + assert [(item.name, item.seq) for item in report.breaks] == [("anchor_repudiated", 6)] + assert provider.checks, "check() was never called, so the provider was never asked" + + +# --- T535: the orderings, which are per kind --------------------------------------------------- + + +def test_T535_an_interval_anchor_must_be_above_the_last_interval_anchor(tmp_path) -> None: + database = tmp_path / "state.db" + store = a_chain(database, 3) + provider = Provider() + make_anchor(store, provider) + + with pytest.raises(InvalidArgument) as refused: + make_anchor(store, provider) + + assert "must be above the last interval anchor" in str(refused.value) + store.close() + + +def test_T535b_a_checkpoint_anchor_is_ordered_only_against_other_checkpoints(tmp_path) -> None: + """§3.2. **The two kinds are ordered separately**, and a joint ordering refused the one anchor + §4.6 requires. + + A deployment anchoring hourly and pruning at ninety days makes its checkpoint anchor far + *below* its newest interval anchor. A draft that ordered all anchors by `seq` refused it, so + the prune was refused, **forever**, and §4.6 exists precisely so an anchoring deployment does + not have to choose between pruning and a permanent tamper signal. + """ + database = tmp_path / "state.db" + store = a_chain(database, 6) + provider = Provider() + interval = make_anchor(store, provider) + assert interval.seq == 6 + + # A checkpoint far below the newest interval anchor, which is the shape a prune produces. + low = Anchor( + seq=2, + hash=store.receipts()[1].hash or "", + token="tok-checkpoint-2", + kind=CHECKPOINT, + at=T0 + timedelta(hours=1), + ) + provider.held[low.token] = low + store.put_anchor(low) + + # It is accepted, and a SECOND checkpoint at or below it is not. + assert {item.kind for item in store.anchors()} == {INTERVAL, CHECKPOINT} + with pytest.raises(InvalidArgument) as refused: + make_anchor(store, provider, kind=CHECKPOINT) + store.close() + assert "checkpoint" in str(refused.value) + + +def test_T535c_an_anchor_whose_time_runs_backwards_is_refused(tmp_path) -> None: + """§3.2. A monotonic sequence is the only property the kernel can check about a timestamp it + did not issue, and a sequence that goes backwards is either a misconfiguration or the attack; + CTRLRun cannot tell which, so it refuses.""" + database = tmp_path / "state.db" + store = a_chain(database, 3) + provider = Provider() + make_anchor(store, provider) + + control = Control(Policy.from_yaml(ALLOW), store, clock=lambda: T0) + control.execute(an_action("more"), lambda: {"ok": True}, "refund:more", lease=LEASE) + provider.at = T0 - timedelta(days=1) + + with pytest.raises(InvalidArgument) as refused: + make_anchor(store, provider) + store.close() + assert "runs backwards" in str(refused.value) + + +def test_T535d_a_provider_answering_a_shape_the_kernel_refuses_anchors_nothing(tmp_path) -> None: + """An operator's provider is their own code. One that returns `None` on failure rather than + raising is the shape that would otherwise cache an anchor whose token is the string + `"None"`.""" + database = tmp_path / "state.db" + store = a_chain(database, 3) + + class Wrong: + def make(self, seq, hash, kind): + return None + + def check(self, seq, hash, token): + return True + + def latest(self): + return None + + def since(self, seq): + return () + + with pytest.raises(InvalidArgument) as refused: + make_anchor(store, Wrong()) + assert "must return (token, time)" in str(refused.value) + assert store.anchors() == () + store.close() + + +# --- T536: rule 1 ------------------------------------------------------------------------------ + + +def test_T536_the_anchor_module_issues_nothing() -> None: + """Rule 1 (§1.1): **the anchor consumes a timestamp and issues nothing.** + + No key generation, no rotation, no revocation, no signing. That is the line between this + milestone and the one `ROADMAP.md` keeps off the roadmap, and an anchor that minted anything + would have crossed it. Asserted against the module's source, because "it does not mint + anything" is a claim about the environment until something checks it, in the shape + `CLAIMS.md` uses. + """ + import ctrlrun.anchor as module + + source = Path(module.__file__).read_text(encoding="utf-8") + for minted in ( + "secrets.", + "os.urandom", + "generate_private_key", + "generate_key", + "sign(", + "PrivateKey", + "uuid4(", + ): + assert minted not in source, ( + f"ctrlrun/anchor.py contains {minted!r}. Rule 1 is that the anchor consumes a " + "timestamp and issues nothing; if this is now signing, ROADMAP.md and SPEC-v0.6 §11 " + "both have to say so first" + ) + # And the time an anchor carries is the provider's, never this process's clock. + assert "datetime.now" not in source, ( + "the anchor read a clock of its own. An anchor's time must be the provider's: a time " + "CTRLRun generated would be CTRLRun vouching for itself" + ) + + +# --- T537: both backends ----------------------------------------------------------------------- + + +@postgres +def test_T537_postgres_anchors_and_names_a_truncation_the_same_way() -> None: + """The amendment is to `StateStore`, so a backend without it could not implement §3 at all.""" + import psycopg + + from ctrlrun.postgres import PostgresStateStore + + schema = f"anchor_{uuid.uuid4().hex[:12]}" + PostgresStateStore.create_schema(POSTGRES_URL, schema) + try: + store = PostgresStateStore(POSTGRES_URL, schema=schema, clock=lambda: T0) + control = Control(Policy.from_yaml(ALLOW), store, clock=lambda: T0) + for index in range(6): + control.execute( + an_action(f"p{index}"), lambda: {"ok": True}, f"refund:p{index}", lease=LEASE + ) + provider = Provider() + anchor = make_anchor(store, provider) + assert anchor.seq == 6 + assert verify_anchors(store, provider).ok + cached = store.anchors() + assert len(cached) == 1 and cached[0].token == anchor.token + assert cached[0].at == anchor.at, "the anchor's time did not survive the round trip" + store.close() + + with psycopg.connect(POSTGRES_URL) as connection: + with connection.cursor() as cursor: + cursor.execute(f'DELETE FROM "{schema}".receipts WHERE seq > 3') + cursor.execute( + f'SELECT seq, hash FROM "{schema}".receipts ORDER BY seq DESC LIMIT 1' + ) + row = cursor.fetchone() + cursor.execute( + f'UPDATE "{schema}".receipt_chain SET seq = %s, hash = %s WHERE id = 1', row + ) + connection.commit() + + reopened = PostgresStateStore(POSTGRES_URL, schema=schema, clock=lambda: T0) + chain = verify_chain(reopened) + report = verify_anchors(reopened, provider) + reopened.close() + + assert chain.ok, "the Postgres chain detected the truncation, so §2.1 does not hold here" + assert ("anchor_broken", 6) in [(item.name, item.seq) for item in report.breaks] + finally: + PostgresStateStore.drop_schema(POSTGRES_URL, schema) + + +# --- T538: the migration ----------------------------------------------------------------------- + + +def test_T538_migration_0008_creates_the_three_tables(tmp_path) -> None: + """SPEC-v0.11 §9. One migration, because a migration id is a name that cannot be amended + once a store has applied it: item 2 creates all three tables and item 3 fills two of them.""" + from ctrlrun.migrations import HEAD, MIGRATIONS + + assert HEAD == "0008_anchor_checkpoint_hold" + assert MIGRATIONS[-1].id == HEAD + + database = tmp_path / "state.db" + store = SQLiteStateStore(database, clock=lambda: T0) + store.close() + connection = sqlite3.connect(database) + tables = { + row[0] for row in connection.execute("SELECT name FROM sqlite_master WHERE type='table'") + } + connection.close() + assert {"anchors", "prune_checkpoint", "holds"} <= tables, sorted(tables) + + +def test_T538b_an_older_store_migrates_forward_and_keeps_its_chain(tmp_path) -> None: + """The upgrade path: a store written before 0008 gains the tables and its chain still + verifies. Nothing about an existing receipt moves.""" + database = tmp_path / "state.db" + store = a_chain(database, 4) + before = [(item.seq, item.hash) for item in store.receipts()] + store.close() + + connection = sqlite3.connect(database) + connection.execute("DROP TABLE anchors") + connection.execute("DROP TABLE prune_checkpoint") + connection.execute("DROP TABLE holds") + connection.execute( + "DELETE FROM schema_version WHERE migration_id = '0008_anchor_checkpoint_hold'" + ) + connection.commit() + connection.close() + + reopened = SQLiteStateStore(database, clock=lambda: T0) + assert [(item.seq, item.hash) for item in reopened.receipts()] == before + assert verify_chain(reopened).ok + assert reopened.anchors() == () + assert reopened.checkpoint() is None + reopened.close() + + +# --- T530b: the other half of §2.4's "yes" column ---------------------------------------------- + + +def test_T530b_a_rewrite_at_or_below_an_anchored_seq_fails_the_anchor(tmp_path) -> None: + """§2.4's second row: **any rewrite at or below an anchored `seq`** is detected, because the + hash there differs. + + `T530` covers a row that is *absent*. This covers one that is *present and different*, which + is a different branch and was reached by no test: a mutation deleting the hash comparison + entirely left the whole file green. + + The chain catches this one too, and that is the point rather than a redundancy: the anchor + must not go quiet about a tamper just because another reader would have caught it, or an + operator who runs only the anchor check learns nothing. + """ + database = tmp_path / "state.db" + store = a_chain(database, 5) + provider = Provider() + anchor = make_anchor(store, provider) + store.close() + + # The administrator who rewrites **every row including the head**, which is the case + # `THREAT_MODEL.md` has always said the chain alone cannot catch: alter receipt 2, then + # recompute every hash after it and the head, so the chain is internally consistent again. + # That is the tamper the anchor exists for, and a half-done one -- editing the document and + # leaving the hash column -- is caught by `verify_chain` instead and proves nothing here. + _rewrite_chain_from(database, at=2, find='"amount":2000', replace='"amount":1') + + reopened = SQLiteStateStore(database, clock=lambda: T0) + chain = verify_chain(reopened) + report = verify_anchors(reopened, provider) + rewritten = [item for item in reopened.receipts() if item.seq == 2] + reopened.close() + + # The tamper landed, and the chain cannot see it. Without both of these the test would pass + # against a rewrite that never happened, or against one the chain already caught, and in + # neither case would it be showing what the anchor adds. + assert rewritten and rewritten[0].arguments.get("amount") == 1, "the rewrite did not land" + assert chain.ok, ( + "the chain caught a full rewrite, so this test is not exercising the case the anchor " + f"exists for: {[(b.name, b.seq) for b in chain.breaks]}" + ) + + assert not report.ok and not report.unavailable + named = [(item.name, item.seq) for item in report.breaks] + assert ("anchor_broken", anchor.seq) in named, ( + f"a rewrite below the anchored seq did not fail the anchor: {named}" + ) + + +# --- T535e: the ordering that a joint rule would have refused forever -------------------------- + + +def test_T535e_a_checkpoint_anchor_below_the_newest_interval_anchor_is_accepted(tmp_path) -> None: + """§3.2 and §4.6. **The case a joint ordering refuses, and refuses permanently.** + + A deployment anchoring hourly and pruning at ninety days takes its checkpoint anchor over the + `seq` it pruned through, which is far *below* its newest interval anchor. A draft that + ordered all anchors by `seq` refused it, so the prune was refused, and §4.6 exists precisely + so that an anchoring deployment does not have to choose between pruning and a permanent + tamper signal. + + **A mutation is why this test exists.** Restoring the joint ordering survived every other + test in this file, because nothing could produce a checkpoint anchor below an interval one + for the per-kind rule to have to allow: `make_anchor` anchored the head and nothing else. + That was a gap in the implementation as much as in the tests, and `at=` closes it. + """ + database = tmp_path / "state.db" + store = a_chain(database, 6) + provider = Provider() + interval = make_anchor(store, provider) + assert interval.seq == 6 and interval.kind == INTERVAL + + # What a prune does: anchor the checkpoint over the seq it is about to prune through. + rows = store.receipts() + below = rows[1] + assert below.seq == 2 and below.hash is not None + checkpoint = make_anchor(store, provider, kind=CHECKPOINT, at=(below.seq, below.hash)) + store.close() + + assert checkpoint.kind == CHECKPOINT + assert checkpoint.seq == 2, ( + "a checkpoint anchor below the newest interval anchor was refused, which makes a " + "pruning deployment choose between retention and a permanent tamper signal (§4.6)" + ) + assert checkpoint.seq < interval.seq diff --git a/tests/test_demo.py b/tests/test_demo.py index 76f19390..06768cca 100644 --- a/tests/test_demo.py +++ b/tests/test_demo.py @@ -1117,6 +1117,11 @@ def test_the_cli_offers_exactly_the_commands_the_spec_freezes(): # SPEC-scan.md §9.4. The same shape and for the same reason: a subcommand that adds no # table, column, event, error or policy key, on its own no version line. "scan", + # SPEC-v0.11 §9. An anchor is made on a schedule **by an operator**, where every other + # surface in this kernel is a library call made by an agent, so it is a command rather + # than a parameter. §11 keeps the management plane off the roadmap and this is not one: + # it writes rows and prints lines, and it decides nothing. + "anchor", } diff --git a/tests/test_examples.py b/tests/test_examples.py index 8471ce91..869683e5 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -49,6 +49,11 @@ "authority", "cookbook", "without-an-agent", + # SPEC-v0.11 §3 — not a refusal at all. Every §1.1 scenario ends in CTRLRun declining to + # act; this one is about reading the record **afterwards**, and nothing in it is refused. + # It has its own test below, because what it must print is the bounded claim rather than a + # refusal string. + "anchored-chain", "__pycache__", ) @@ -568,3 +573,48 @@ def test_every_action_the_readme_cites_carries_the_decision_it_is_cited_for(row) f"README puts {action!r} in the {column!r} column of the {stem} row, " f"but {stem}.yaml can only decide {sorted(reachable)}" ) + + +# --- the anchored chain (SPEC-v0.11 §3) -------------------------------------------------------- + + +def test_T539_the_anchored_chain_example_shows_both_halves_and_overclaims_nothing( + tmp_path, no_network +): + """`examples/anchored-chain`. SPEC-v0.11 §2.4, and rule 1. + + **Both halves or neither.** An example that only showed the anchor catching a truncation + would be an advertisement: the reader has to see that the chain alone reports the same store + intact, or the anchor is solving a problem they have no reason to believe in. + + **And it must not overclaim.** This is the first thing in this project a reader could mistake + for tamper-proofing, so the script prints what an anchor does *not* prove as plainly as what + it does, and this asserts those lines. `CLAIMS.md` uses the same shape: "there is no such + claim" is a statement about the environment until something checks it. + """ + done = _run_script(EXAMPLES / "anchored-chain" / "main.py", tmp_path, no_network) + assert done.returncode == 0, done.stdout + done.stderr + output = done.stdout + + # Half one: the chain alone does not notice, which is SPEC-v0.6 §6.4 by design. + assert "the chain says it is intact: True" in output, output + # Half two: the anchor does. + assert "anchor_broken at seq 4" in output, output + assert "the anchor says: ok=False" in output, output + + # The bounded claim, in the script's own words. + for limit in ( + "an APPEND is not detected", + "erased BETWEEN two anchors are not detected", + "does not say who wrote any of it", + "An anchor is not a signature", + "out of scope", + "(last anchored seq, current head]", + ): + assert limit in output, f"the example no longer states its limit {limit!r}:\n{output}" + + # And it claims nothing this project forbids anywhere. + for forbidden in ("tamper-proof", "tamperproof", "immutable", "cannot be altered"): + assert forbidden not in output.lower(), ( + f"the example printed {forbidden!r}, which is a claim an anchor does not support" + ) diff --git a/tests/test_five_schema_versions.py b/tests/test_five_schema_versions.py index 549faa32..34cb6862 100644 --- a/tests/test_five_schema_versions.py +++ b/tests/test_five_schema_versions.py @@ -311,9 +311,11 @@ def test_T524_G31_is_in_the_catalogue_and_the_catalogue_moved_once() -> None: assert reg.CATALOGUE == "ctrlrun.guarantees/v7" assert reg.BY_ID["G31"].title == "five receipt schemas verify" assert reg.BY_ID["G31"].descends_from, "G31 names no acceptance test" - # §8 assigns ids in item order, so G31 lands before G28 to G30 exist. A stub row for them - # would report something before its check existed, which is the false green §8 forbids. - assert {"G28", "G29", "G30", "G32"}.isdisjoint({g.id for g in reg.GUARANTEES}) + # §8 assigns ids in **item** order rather than landing order, so the catalogue legitimately + # has holes between items. `G28` landed with item 2; `G29`, `G30` and `G32` are item 3's and + # are still unbuilt. A stub row for one of those would report something before its check + # existed, which is the false green §8 forbids. + assert {"G29", "G30", "G32"}.isdisjoint({g.id for g in reg.GUARANTEES}) # --- T525: the script that proves the premise -------------------------------------------------- diff --git a/tests/test_ledger.py b/tests/test_ledger.py index 75180bd2..aec6bccd 100644 --- a/tests/test_ledger.py +++ b/tests/test_ledger.py @@ -338,7 +338,11 @@ def test_T414_the_migration_runs_and_a_0_8_0_store_upgrades(tmp_path) -> None: """`0007_budget_ledger` is additive and forward-only (`v0.6 §3`).""" from ctrlrun.migrations import HEAD, MIGRATIONS - assert HEAD == "0007_budget_ledger" + # `HEAD` moves with every milestone that adds a migration; what T414 is about is that + # `0007_budget_ledger` is additive and forward-only, which is asserted below against the + # migration itself rather than against whichever id happens to be last. + assert HEAD == "0008_anchor_checkpoint_hold" + assert any(migration.id == "0007_budget_ledger" for migration in MIGRATIONS) assert [migration.id for migration in MIGRATIONS][-1] == HEAD store = SQLiteStateStore(tmp_path / "state.db") try: diff --git a/tests/test_policy_versioning.py b/tests/test_policy_versioning.py index e2ea635e..3410964f 100644 --- a/tests/test_policy_versioning.py +++ b/tests/test_policy_versioning.py @@ -1598,6 +1598,9 @@ def test_T177c_the_command_list_is_exactly_the_one_the_spec_froze(): # request, so `ctrlrun approve` answers it, and a second command would be a second # approval path. "policy", + # SPEC-v0.11 §9. An anchor is made on a schedule by an operator, where every other + # surface in this kernel is a library call made by an agent. + "anchor", ] assert sorted(cli.main.commands) == sorted(frozen_by_v0_6 + after_v0_6) diff --git a/tests/test_repository_signals.py b/tests/test_repository_signals.py index 4e0738f6..2c648ef6 100644 --- a/tests/test_repository_signals.py +++ b/tests/test_repository_signals.py @@ -860,6 +860,19 @@ def test_every_v0_10_name_the_spec_freezes_is_importable_with_the_parameter_it_n # Item 1 (SPEC-v0.11 §5.2). ("ctrlrun.receipt", "UnreadableReceipt", None, "name"), ("ctrlrun.state", "StateStore.receipts", "UnreadableReceipt", "returns"), + # Item 2 (SPEC-v0.11 §3). + ("ctrlrun.anchor", "AnchorProvider", None, "name"), + ("ctrlrun.anchor", "ANCHOR_BREAKS", None, "name"), + ("ctrlrun.anchor", "verify_anchors", None, "name"), + ("ctrlrun.anchor", "AnchorReport", None, "name"), + ("ctrlrun.control", "Control", "anchor", "parameter"), + ("ctrlrun.state", "StateStore.put_anchor", None, "name"), + ("ctrlrun.state", "StateStore.anchors", None, "name"), + # A **membership** claim on a named symbol, because a migration id can never be an attribute + # path: `hasattr(ctrlrun.migrations, "0008_...")` cannot be true, since a name beginning with + # a digit is not an identifier. This is the row §9 says a bare third element cannot express, + # and the reason every row here carries a `kind`. + ("ctrlrun.migrations", "MIGRATIONS", "0008_anchor_checkpoint_hold", "member"), ) diff --git a/tests/test_verify.py b/tests/test_verify.py index 2a9ecf83..8834efa5 100644 --- a/tests/test_verify.py +++ b/tests/test_verify.py @@ -178,8 +178,9 @@ def test_T101_a_policy_with_no_approve_rule_makes_G1_and_G2_not_applicable(tmp_p # section, G13, which is N/A on every SQLite run: SQLite has no clock of its own, and G15, # because this document names no `max_attempts` (SPEC-v0.7 §8.9). The rest are applicable, # G14 among them, and the count is over those. - # 12 since v0.11 item 4's `G31`, which needs only an action to build a chain from. - assert report.applicable == 12 + # 13 since v0.11 item 2's `G28`, on top of item 4's `G31`: both need only an action, so + # both are applicable wherever this document's others are. + assert report.applicable == 13 # Derived: every guarantee is applicable or not, exactly once. The literal moved with every # milestone that added an id (G19, then G25), and the invariant never did. assert report.applicable + report.not_applicable == len(reg.GUARANTEES) @@ -865,7 +866,7 @@ 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_twelve_over_twelve(): +def test_the_v1_payments_template_reports_thirteen_over_thirteen(): """The definition of done, dogfooded rather than described (SPEC-v0.4 §4.1). Ten and not nine since v0.8 item 6, and nine and not eight since item 2. G18 is graded @@ -878,10 +879,10 @@ def test_the_v1_payments_template_reports_twelve_over_twelve(): assert report.exit_code == 0 # 12 since v0.11 item 4 added `G31`, which needs only an action to build a chain from # and is therefore applicable wherever this template's other eleven are. - assert (report.passed, report.applicable) == (12, 12) + assert (report.passed, report.applicable) == (13, 13) assert report.applicable + report.not_applicable == len(reg.GUARANTEES) text = report.to_text() - assert "12/12 declared guarantees pass." in text + assert "13/13 declared guarantees pass." in text # G13 is N/A on SQLite, which has no clock of its own; G14 and G15 join G3, G4 and G5 where # the effect template lives in the @protect decorator verify does not read, and where the # document names no `max_attempts`. G16 and G18 are graded: verify brings its own provider diff --git a/tests/test_verify_action.py b/tests/test_verify_action.py index 8d0f1cd3..425d98b3 100644 --- a/tests/test_verify_action.py +++ b/tests/test_verify_action.py @@ -139,8 +139,8 @@ 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 25/25"' in script - assert 'test "$TEMPLATES" = "verified 12/12"' in script + assert 'test "$AUTHORITY" = "verified 26/26"' in script + assert 'test "$TEMPLATES" = "verified 13/13"' in script assert 'test "$AUTHORITY_NA" = "3"' in script assert 'test "$TEMPLATES_NA" = "16"' in script @@ -158,7 +158,7 @@ def test_T118_the_two_configurations_really_do_report_those_shapes(): # `examples/authority/payments.yaml` is the one with a delegable grant to hop from. This # pin stays a literal on purpose (it is a CI pin on a shipped example, which exists to fail # when a shape changes) while the N/A counts above are derived. - assert authority.badge["message"] == "verified 25/25" + assert authority.badge["message"] == "verified 26/26" # G13 and G15: SQLite has no clock of its own to diverge from, and the document declares # no `max_attempts` (SPEC-v0.7 §8.9). **And G27**, because no action entry in this document # pins an upstream: SPEC-v0.10 §7.3's exit criterion wants a shipped example that does, and @@ -166,7 +166,7 @@ def test_T118_the_two_configurations_really_do_report_those_shapes(): # it demonstrates the refusal rather than a working call. That example is the release item's. assert authority.not_applicable == 3 assert templates.badge is not None - assert templates.badge["message"] == "verified 12/12" + assert templates.badge["message"] == "verified 13/13" assert templates.applicable + templates.not_applicable == len(reg.GUARANTEES) @@ -213,7 +213,7 @@ def test_T119_the_denominator_is_applicable_and_never_the_catalogue_size(): # applicable wherever this configuration's other eleven are. The number is pinned rather # than derived because the claim under test is that the denominator moves with what was # *graded* and not with the catalogue's size, and a derived number could not fail. - assert report.applicable == 12 + assert report.applicable == 13 assert report.applicable < len(reg.GUARANTEES) assert f"/{len(reg.GUARANTEES)}" not in badge["message"] @@ -299,7 +299,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 12/12" + assert report.badge["message"] == "verified 13/13" def test_T120_a_failing_run_writes_a_red_badge_and_a_non_zero_exit(tmp_path, monkeypatch): diff --git a/tests/test_verify_report.py b/tests/test_verify_report.py index b1a569b6..062c2086 100644 --- a/tests/test_verify_report.py +++ b/tests/test_verify_report.py @@ -149,8 +149,8 @@ def test_T113_a_failing_report_names_the_subject_and_prints_the_counterexample( # where nothing does. So the first two gain a pass and the third gains an N/A. # The passes are literal because they are the point; the N/A count is derived, for the # reason above. `len(reg.GUARANTEES)` moves with the catalogue and these fixtures do not. - (ALL_APPLICABLE, "17/17 declared guarantees pass."), - (WITH_NOT_APPLICABLE, "12/12 declared guarantees pass."), + (ALL_APPLICABLE, "18/18 declared guarantees pass."), + (WITH_NOT_APPLICABLE, "13/13 declared guarantees pass."), (EMPTY, "0/0 declared guarantees pass."), ], ids=["passing", "some-na", "all-na"],