-
Notifications
You must be signed in to change notification settings - Fork 0
v0.11 item 2: the anchor, and the truncation it makes detectable #202
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
d51ecf3
The anchor: the chain's head, recorded outside the store
arpanghoshal e14d6aa
Control(anchor=), and ctrlrun anchor
arpanghoshal f1e460e
Acceptance tests T530-T538 for the anchor
arpanghoshal 380d0bd
G28: a truncation past an anchor fails
arpanghoshal 63a29a7
T530b and T535e, and make_anchor(at=) which a mutation showed was mis…
arpanghoshal 43e4a87
SPEC-v0.11 §9's frozen rows for item 2, and its CHANGELOG lines
arpanghoshal d26c91b
A shipped example that exercises an anchor, and states what it does n…
arpanghoshal 69f4a8f
G28 moves every hardcoded guarantee count, including CI's
arpanghoshal ba7bf9a
ctrlrun anchor joins the frozen CLI surface
arpanghoshal 3f4d367
Three tests item 2 makes stale: the frozen command list, HEAD, and G2…
arpanghoshal File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 = '<the hash at 3>'; | ||
|
|
||
| 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() | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
Persist the anchor records to
_path.FileAnchorProviderstores every anchor only in_held. It never reads or writesanchors.json.After a process restart, the provider loses the external record.
verify_anchorsthen cannot verify the durable anchor that this example claims to create.Load existing anchors during initialization. Atomically persist
_heldaftermake()succeeds.🤖 Prompt for AI Agents