ctrlrun 0.11.0
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 ofReceipt.from_dict, and because both stores
build every row before any caller sees one, that oneUPDATEstoppedctrlrun receipts,
receipts --verify-chain,ctrlrun inspect,ctrlrun statsand the operator MCP server's
receiptsandstatstools together.inspecton 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.UnreadableReceiptis what a store hands
back for a row it cannot construct, carrying the row'sseq, itsreceipt_idwhere that field
alone is readable, and the type of what refused it, never the message.CHAIN_BREAKSdid not
change: §12.5 offered a new break name as one of two candidates andSPEC-v0.11.md§5.1 declines
it, becausecontent_alteredalready 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
calledjson.loadsin the generator expression that fed it, outside the guard, so a row
whose storedjsonis not JSON at all raised through every reader exactly as before v0.11, and
worse:JSONDecodeErroris not aCTRLRunError, so the CLI's handler did not catch it either
andctrlrun receiptsprinted a traceback. OneUPDATE 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 --coveragereads a store and reports the policy entries, gateway tools and
@protectactions 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 beverifygrading an operator's document
in a new costume, whichSPEC-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.mdsaid 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-gapand 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 aCOMMITTEDrow insideSPEC-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."
unchainedis 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_brokenand 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 ownanchors
table held, so oneINSERTbeside a forged checkpoint row bought supersession and the row's
hash was never compared to anything. Supersession now comes fromprovider.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 gaveSPEC-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-thanand--reason, stagedproposed; the second records what became
of it,completedorrefused. They were byte-identical at first, and--older-thanwas 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--throughfrom it meant one
UPDATEturned 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()openedBEGIN IMMEDIATEand then everyput_anchorwent throughwith connection:
and committed it, so the prune held the lock for one statement; a failed prune could leave
missingandlink_brokenon 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, andSPEC-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 isSPEC-v0.9.md§4's rule about a budget hold applied unchanged. -
G29,G30andG32.G32grades the interaction: an honestly pruned chain leaves a
clean anchor report.G28grades a truncation against an anchor andG29grades 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 namedanchor_brokenat the anchoredseq.What an anchor proves, and what it does not. It freezes a prefix: anything at or below
an anchoredseqcan no longer be removed or altered without the anchored pair failing to
reproduce. An append is not detected, because it lands above every anchoredseq; 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 reasonSPEC-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, andanchor=onControl. -
ANCHOR_BREAKSis its own closed set andCHAIN_BREAKSdoes not change.anchor_broken,
anchor_missing,anchor_repudiated. Putting them inCHAIN_BREAKSwould failG11's control
withcontrol failedon every anchoring deployment, because that control reads the whole
ChainReport.anchor_unavailableis 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 migration0008_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:schemahas existed sinceSPEC-v0.3.md§12.2. What is new is the proof
thatverify_chainwalks such a chain hash by hash, each row hashed by the rule its own
version wrote. v0.10's release pass proved thev6/v7boundary against the released 0.9.0
and stopped there.scripts/five_schema_chain.pybuilds 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 stillcontent_altered, because that is somebody editing evidence. -
G31, five receipt schemas verify, andctrlrun.guarantees/v6becomesv7, moved
once.G28toG30andG32are 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()returnstuple[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 aReceipt.- A receipt's position now comes from the
seqcolumn, which is whatverify_chain's docstring
has claimed since v0.6 and what was not true as shipped. Both stores selectedjson, hashand
ordered by a column they never read, so everyReceipt.seqcame out ofdocument.get("seq"), the
one field a tamperer controls. Rewriting one document'sseqfrom 2 to 99 reportedmissing 2,
content_altered 99,missing 100andlink_broken 3: four breaks at three positions, two of
them rows that do not exist. The same tamper now reportscontent_alteredonce, at 2. ctrlrun statsreportsunreadable receiptsand thectrlrun.stats/v1document carries
unreadable_receipts, omitted entirely where there is none, onledger_rows' precedent. A
total that silently dropped a row nobody could read would beSPEC-v0.4.md§3.8's false green.