Skip to content

v0.11 item 3, after review: seven defects #203 merged with - #205

Merged
rohanrkamath merged 3 commits into
mainfrom
v0.11/3a-retention-review
Sep 14, 2026
Merged

rohanrkamath merged 3 commits into
mainfrom
v0.11/3a-retention-review

Conversation

@arpanghoshal

Copy link
Copy Markdown
Member

What happened

#203 merged before its independent review landed. The review found seven defects, each demonstrated against the merged code with a script, and main carries all seven right now. This is those fixes, plus the tests that fail without them.

The one that matters most is the first.

The seven

1. The checkpoint was launderable. §4.6 says a checkpoint that is itself anchored supersedes the anchors below it, so that pruning and anchoring do not cancel. verify_anchors built the set of anchored checkpoints from the union of what the provider returned and what the store's own anchors table held:

anchored_checkpoints = {anchor.seq for anchor in held if anchor.kind == CHECKPOINT} | {
    anchor.seq for anchor in cached.values() if anchor.kind == CHECKPOINT
}

An attacker who erases a prefix, writes a checkpoint row to explain it, and writes one row into the local anchors table beside it gets superseded instead of anchor_broken. The hash in that row is never read, so sha256:not-a-hash-at-all works. The whole of what §4.6 is supposed to buy is that the prune leaves a record outside the store, and it bought nothing.

Supersession now comes from the provider alone, and the anchor's (seq, hash) must be the pair the checkpoint claims:

anchored_checkpoints = {
    anchor.seq
    for anchor in held
    if anchor.kind == CHECKPOINT
    and checkpoint is not None
    and anchor.seq == checkpoint[0]
    and anchor.hash == checkpoint[1]
}

T547b passed through all of this, because it writes the checkpoint row and no anchors row beside it. T550 and T559 are the tests that fail without the fix.

2. The SQLite prune dropped its lock at the first write. pruning() opened BEGIN IMMEDIATE, and then every put_anchor and put_checkpoint went through with connection: and committed it. The prune held the lock for one statement. Postgres had the guard and SQLite, the default backend, did not, because the defect was found on Postgres and fixed only where it was found. A failed prune left [('missing', 4), ('link_broken', 1)] on a chain that was intact when it started. T551.

3. A hold placed during a prune was ignored and its receipts deleted: put_hold on Postgres took no row lock, so it landed between the prune's validation and its delete. T558b.

4. --through above the head was decided by receipt_chain. One UPDATE receipt_chain SET seq = 99 turned --through 8 into a full-chain delete that both readers called clean. The bound now comes from the receipts themselves, which is the row §2.1 assumes is rewritten. T552.

5. Rule 2's simulation was not the store. It dropped UnreadableReceipt rows and re-derived the head, so a store with one unparseable row refused every prune. T553.

6. The checkpoint could assert a (seq, hash) pair that never existed and then anchor it: the seq came from --through rather than from the boundary receipt. T554.

7. A refused prune and a successful one left byte-identical receipts, and --older-than was in neither. The prune now writes a request staged proposed and an outcome staged completed or refused. T555.

Mutation

22 of 22 caught, against a git archive HEAD copy with PYTHONDONTWRITEBYTECODE=1.

The last one is worth naming. R1 deletes rule 2's comparison outright, if caused: to if False:, and every test in the file still passed. That is a real finding about the code, not only the test: once #6 makes the checkpoint name a pair that exists and #5 makes the simulation the store, no store state reaches that branch any more. Every construction is caught earlier, by the head bound, the forward-only checkpoint, or the missing-hash refusal. T556 looked like it covered it and did not, because its assertion is an or the earlier refusal satisfies.

The comparison stays as a backstop and T556c keeps it honest, feeding it a simulation that reports one break more than the store does and asserting the refusal names the seq, deletes nothing, and leaves no checkpoint.

Gate

All checks passed!            (ruff, ruff format, mypy --strict: 52 files)
4565 passed, 6 warnings in 160.40s

Postgres tests ran against a real server. T549 races two prunes in separate OS processes.

Pairing

Docs are v0.11/3a-retention-review, which stacks on the still-open ctrlrun-docs#34.

🤖 Generated with Claude Code

§4.6's laundering hole: verify_anchors built its anchored-checkpoint set from
held | cached, and cached is the local table its own docstring calls a cache and
never the record. One INSERT bought supersession, with a hash that was not a hash
of anything, and the row was never checked against the provider. Supersession now
comes from the provider alone and the pair must match, not merely the seq.

On SQLite the prune held no lock through its destructive half: put_anchor and
put_checkpoint use 'with connection:', which commits, so BEGIN IMMEDIATE ended at
the first of them. Postgres had the guard from the start; SQLite did not, because
the defect was found on Postgres and the fix was applied where it was found.
SQLite is the default backend. A prune that failed after the checkpoint left the
row behind and the reader announced a gap in an intact chain.

A hold placed while a prune was in flight was ignored and its receipts deleted.
The holds table does not contend with the receipt_chain row lock, so consulting
inside the transaction closed nothing; put_hold now takes the same lock.

The prune's bound came from the receipt_chain row, which is the row §2.1 assumes
an attacker rewrites. One UPDATE turned --through 8 into a delete of every
receipt, after which both readers reported clean.

The rule-2 simulation filtered to Receipt and re-derived the head, so one
unreadable row cost the whole retention feature and a damaged head row refused
honest prunes for a break the store already had.

The checkpoint could name a (seq, hash) pair that never existed, and anchor it
through the provider, which corrupts the external record §4.6 rests on.

A refused prune and a successful one left byte-identical receipts, and
--older-than was not in the record at all.

Signed-off-by: arpan <contact@arpanghoshal.com>
T546 asserted a constant rather than the branch that reads it, T544's forced
refusal stopped reaching the refusal once the simulation became faithful, three
Postgres guards had no test at all, and T550's forged row carried a hash that the
pair check alone refuses, so the source check never fired.

Signed-off-by: arpan <contact@arpanghoshal.com>
R1 was the last surviving mutation in item 3's table: delete rule 2's
comparison entirely, `if caused:` to `if False:`, and all 34 tests in
`test_retention.py` still passed.

The reason is worth stating rather than papering over. Once `T554` made
the checkpoint name the pair that really exists, and `T553` made the
simulation the store rather than a tidier version of it, a prefix prune
can no longer introduce a break. Every construction that would is caught
before rule 2 runs: by the head bound, by the forward-only checkpoint, or
by the missing-hash refusal. `T556` looked like it covered this and did
not, because its assertion is an `or` that the earlier hash refusal
satisfies.

So the comparison is a backstop, and `T556c` keeps it honest by feeding
it a simulation reporting one more break than the store does, then
asserting the refusal names the `seq` and that nothing was deleted and no
checkpoint left behind. A guard nothing can trigger is still a guard
somebody will edit.

Mutation table for item 3: 22 of 22 caught.

Signed-off-by: arpan <contact@arpanghoshal.com>
@coderabbitai

coderabbitai Bot commented Sep 14, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 27 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: d6d350c1-fad7-428b-b661-9bc4d405e5de

📥 Commits

Reviewing files that changed from the base of the PR and between a1c9633 and 735f98b.

📒 Files selected for processing (6)
  • src/ctrlrun/anchor.py
  • src/ctrlrun/cli/main.py
  • src/ctrlrun/postgres.py
  • src/ctrlrun/retention.py
  • src/ctrlrun/state.py
  • tests/test_retention.py

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@rohanrkamath
rohanrkamath merged commit 07adf30 into main Sep 14, 2026
15 of 16 checks passed
@rohanrkamath
rohanrkamath deleted the v0.11/3a-retention-review branch September 14, 2026 17:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants