Skip to content

ctrlrun 0.11.0

Choose a tag to compare

@github-actions github-actions released this 14 Sep 21:19
· 18 commits to main since this release
8a81289

One question: can the record be trusted after the fact, and kept?

Every milestone so far added something the receipt records. None asked whether the receipt is
still worth reading a year later, on a database an administrator can write to, after somebody
pruned it. This is the first milestone whose subject is the evidence itself rather than the
decision, and the first that opens by admitting a defect in the thing it is about: the chain has
never detected truncation or append, both reachable in two SQL statements, and both written down
since SPEC-v0.6.md §6.4.

Added

  • docs/SPEC-v0.11.md, the v0.11 "Evidence" contract. Documentation only; the version bump is
    the release item's. It answers one question, can the record be trusted after the fact and kept,
    and it opens by demonstrating the defect it exists to close: a truncation and a forged append both
    verify as intact after two SQL statements, because the head that would catch them is a row in the
    same database. Transcribed from a real store rather than argued.

  • A reader that names a bad row and blinds nothing else (SPEC-v0.11.md §5, rule 3). A single
    malformed value of a declared key raised out of Receipt.from_dict, and because both stores
    build every row before any caller sees one, that one UPDATE stopped ctrlrun receipts,
    receipts --verify-chain, ctrlrun inspect, ctrlrun stats and the operator MCP server's
    receipts and stats tools together. inspect on an action the tamper never touched is what
    the blast radius really was: not "this receipt is unreadable" but "this store is unreadable".
    SPEC-v0.7.md §12.5 recorded it and deferred it twice.

    One tampered row now costs one row. ctrlrun.receipt.UnreadableReceipt is what a store hands
    back for a row it cannot construct, carrying the row's seq, its receipt_id where that field
    alone is readable, and the type of what refused it, never the message. CHAIN_BREAKS did not
    change: §12.5 offered a new break name as one of two candidates and SPEC-v0.11.md §5.1 declines
    it, because content_altered already names a document that cannot be canonicalized and a second
    name for one fact would be two names for one break.

  • A row that does not parse is one row too. The first implementation of the reader above
    called json.loads in the generator expression that fed it, outside the guard, so a row
    whose stored json is not JSON at all raised through every reader exactly as before v0.11, and
    worse: JSONDecodeError is not a CTRLRunError, so the CLI's handler did not catch it either
    and ctrlrun receipts printed a traceback. One UPDATE receipts SET json = 'not json' was
    enough. Parsing now happens inside the refusal's own guard, and a row that parses to something
    that is not an object (3, "a receipt", [1,2,3], null) is refused as one row rather than
    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.

  • Enforcement coverage: what this deployment has never exercised (SPEC-v0.11.md §7).
    ctrlrun scan --coverage reads a store and reports the policy entries, gateway tools and
    @protect actions that no receipt in it names.

    From what is already written: no new event type and no new column. The action name lives on
    the receipt rather than on the event, and every action that reached a decision leaves one, a
    denial included — so an action that is always denied counts as exercised, because the deny
    rule firing is the action being enforced rather than ignored.

    It is a list and not a score. No percentage, no ratio, no badge, and it does not move the
    exit code: a number that ranked a deployment would be verify grading an operator's document
    in a new costume, which SPEC-v0.4.md §3.9 forbids. Every entry carries a reason that states
    what was not found, and the report says in every rendering, empty or not, that a policy entry
    nothing exercised may be correctly unused
    — a quarterly job, a deny rule that exists so the
    action is refused rather than unknown, a tool nobody has needed yet.

  • Retention: a prune that leaves the chain verifiable across the gap, a checkpoint, and a
    hold
    (SPEC-v0.11.md §4 and rule 2). There has been no retention policy until now, and
    ../ctrlrun-docs/docs/postgres.md said so in the same breath as the reason one is hard:
    deleting receipts from the middle or the end of the chain is detected as a break by design.

    A prune removes a prefix, never a suffix and never a middle, and leaves a checkpoint
    the chain reader seeds from. ctrlrun prune --through --older-than --provider --by --reason.

    It refuses rather than warns, and there is no --force, no --allow-gap and no setting
    that admits a break the prune caused. Refused: a prune that would leave a (kind, seq) pair
    the store did not already report; one through the chain's head; one moving the checkpoint
    backwards; one overlapping a held range; and one that would delete a ledger row whose charge
    is still held, or a COMMITTED row inside SPEC-v0.9.md §7.3's window, because pruning that
    hands back authority nobody granted.

    Rule 2 is a delta, not "the chain verifies afterwards." unchained is a pre-existing
    condition on any store migrated from v0.1 to v0.5 and can never be inside a prefix, so the
    absolute version would make retention permanently impossible on the oldest and largest stores,
    which are the ones it is for.

    The prune anchors its checkpoint before it deletes anything. An attacker who erases a
    prefix and writes a checkpoint to explain it must also anchor it, through the provider, which
    is outside the store, so a prune stays visible in the anchor history even though the receipts
    are gone. An anchor at or below an anchored checkpoint is then superseded, not broken:
    without that, every anchor older than the retention window would be permanently
    anchor_broken and an anchoring deployment would have to choose between pruning and a
    permanent tamper signal.

    "Anchored" means the provider says so, at the pair the checkpoint claims. The first
    implementation took the union of what the provider returned and what the store's own anchors
    table held, so one INSERT beside a forged checkpoint row bought supersession and the row's
    hash was never compared to anything. Supersession now comes from provider.since() alone, and
    the anchor's (seq, hash) must be the pair the checkpoint asserts. A local row the provider
    does not confirm buys nothing. Found by the independent review the build order required for
    this item, which also gave SPEC-v0.11.md §4.6 the sentence that says which reading is meant.

    A prune leaves two receipts, and they are distinguishable. The first records the request,
    --through, --older-than and --reason, staged proposed; the second records what became
    of it, completed or refused. They were byte-identical at first, and --older-than was in
    neither, which made the record of a refusal worth nothing.

    The bound comes from the receipts, not from receipt_chain. That row is the one
    SPEC-v0.11.md §2.1 assumes an attacker rewrites, and deciding --through from it meant one
    UPDATE turned a prefix prune into a full-chain delete that both readers called clean.

    The prune's lock is held across the validation and the delete on both backends. SQLite's
    pruning() opened BEGIN IMMEDIATE and then every put_anchor went through with connection:
    and committed it, so the prune held the lock for one statement; a failed prune could leave
    missing and link_broken on a chain that was intact when it started. The defect had been
    found on Postgres during the item and fixed only there, and SQLite is the default backend.

    A checkpoint is a row, not a receipt field. A receipt naming itself a checkpoint is a
    string in a document, and SPEC-v0.3.md §4.3.1 settled that shape. A prune writes a receipt
    for a human; the row is what the walk reads.

  • ctrlrun hold place / release / list. A hold names a range and refuses to prune it.
    No expiry: a hold that lapsed on a timer would release evidence on a schedule nobody
    reviewed, which is SPEC-v0.9.md §4's rule about a budget hold applied unchanged.

  • G29, G30 and G32. G32 grades the interaction: an honestly pruned chain leaves a
    clean anchor report. G28 grades a truncation against an anchor and G29 grades a prune
    against the chain, and the pair was graded by neither.

  • 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
    that verify_chain walks such a chain hash by hash, each row hashed by the rule its own
    version wrote
    . v0.10's release pass proved the v6/v7 boundary against the released 0.9.0
    and stopped there.

    scripts/five_schema_chain.py builds the chain from the released wheels rather than from
    fixtures: five environments, pip install ctrlrun==0.6.1, 0.7.0, 0.8.0, 0.9.0, 0.10.0,
    one store, then this build verifies across the whole thing. A fixture is this build's opinion
    of what 0.6 wrote; the wheel is what it wrote.

    And a receipt whose schema label this binary does not know is named, not reported as a
    break: SPEC-v0.6.md §3.2's distinction, and the difference between this evidence is from a
    future version
    and this evidence is tampered with. Relabelling a stored row without
    rehashing it is still content_altered, because that is somebody editing evidence.

  • G31, five receipt schemas verify, and ctrlrun.guarantees/v6 becomes v7, moved
    once. G28 to G30 and G32 are not in the catalogue yet: SPEC-v0.11.md §8 assigns ids in
    item order so that splitting the milestone renumbers nothing, and a row whose check does not
    exist would report something before it could.

Changed

  • StateStore.receipts() returns tuple[Receipt | UnreadableReceipt, ...], amending
    SPEC-v0.6.md §9.2's frozen protocol. Before, it raised. Both backends change, and a second
    backend could not implement §5 without it. A store with no bad row is unaffected: every row still
    reads back as a Receipt.
  • A receipt's position now comes from the seq column, which is what verify_chain's docstring
    has claimed since v0.6 and what was not true as shipped. Both stores selected json, hash and
    ordered by a column they never read, so every Receipt.seq came out of document.get("seq"), the
    one field a tamperer controls. Rewriting one document's seq from 2 to 99 reported missing 2,
    content_altered 99, missing 100 and link_broken 3: four breaks at three positions, two of
    them rows that do not exist. The same tamper now reports content_altered once, at 2.
  • ctrlrun stats reports unreadable receipts and the ctrlrun.stats/v1 document carries
    unreadable_receipts, omitted entirely where there is none, on ledger_rows' precedent. A
    total that silently dropped a row nobody could read would be SPEC-v0.4.md §3.8's false green.