Skip to content

v0.11 item 1a: a row that does not parse is one row too - #200

Merged
rohanrkamath merged 2 commits into
mainfrom
v0.11/1a-unparseable-row
Sep 14, 2026
Merged

rohanrkamath merged 2 commits into
mainfrom
v0.11/1a-unparseable-row

Conversation

@arpanghoshal

Copy link
Copy Markdown
Member

Follow-up to #199, on a new branch because #199 was green and merged before this landed. An independent review of #199 found a hole in item 1's own claim, and it is a real one.

The defect

Item 1's reader is _read_receipt, which catches what Receipt.from_dict can raise and returns an UnreadableReceipt instead of blinding every caller. But both stores called json.loads(row["json"]) in the generator expression that fed it, outside the guard.

So a row whose stored json is not JSON at all raised straight through, exactly as before v0.11. Measured at main (7fb2068), one UPDATE receipts SET json = 'not json at all' WHERE seq = 2 on a four-receipt chain:

store.receipts()  RAISES JSONDecodeError : Expecting value: line 1 column 1 (char 0)
verify_chain      RAISES JSONDecodeError : Expecting value: line 1 column 1 (char 0)
receipts   exit=1  Traceback (most recent call last):
stats      exit=1  Traceback (most recent call last):

Worse than before item 1, on one count: JSONDecodeError is not a CTRLRunError, so cli/main.py's handler did not turn it into a clean message either, and the command printed a traceback where 0.10.0 printed Error: ....

The same store on this branch:

store.receipts()  OK, 4 rows: ['Receipt', 'UnreadableReceipt', 'Receipt', 'Receipt']
verify_chain      ok= False [('content_altered', 2), ('link_broken', 3)]
receipts   exit=0  2026-01-01T12:00:00.000Z  ctr_cb8bf386b0f6…  stripe.refund …
stats      exit=0  CTRLRun — 2026-01-01T12:00:00.000Z .. (enforce mode)

Why item 1's tests missed it

Every tamper they ran changed a row's content. {} and [1.5] among the controls are both valid JSON, so the parse was never on trial. T514b was written precisely to cover "a row that is not a receipt at all" and reached for {}, which parses.

That is the same failure mode SPEC-v0.10.md §11 records in a different costume: a test that covers the case you thought of covers the case you thought of. The fix is not only the code but T520, which parametrizes over seven ways a row can fail to parse rather than one.

The fix

_read_receipt takes the stored text and parses inside its own guard. JSONDecodeError subclasses ValueError, which the caught tuple already carried, so the change is where the parse happens and not what is caught.

A row that parses to something that is not an object is refused rather than trusted: json.loads("3") is an int, and _stored_receipt would have raised TypeError on it a line later. Naming it at the parse says what is wrong with the row rather than what the next line tripped over.

Tests

T520, parametrized over seven tampers, each asserting one row is lost, the other three still read, the break lands at the right seq, and no CLI prints a traceback:

stored json refused as
not json at all JSONDecodeError
(empty) JSONDecodeError
{"receipt_id": "ctr_1 JSONDecodeError
[1, 2, 3] TypeError
3 TypeError
"a receipt" TypeError
null TypeError

T520b is the same against Postgres, because the amendment is to StateStore and a backend that raised here would blind every reader in a deployment that uses it.

Mutations

# mutation result
N1 parse outside the guard again, the reported defect caught, 4 failed
N2 a non-object row is trusted instead of refused caught, 4 failed
N3 ValueError drops out of the caught set caught, 8 failed
N4 Postgres parses outside the guard caught, 1 failed

Counts

./scripts/check.sh with Postgres: 4420 passed + 66 serial. main at 7fb2068 is 4412 + 66, so this adds 8 (seven T520 rows plus T520b). mypy --strict clean, ruff clean.

Docs

None. No public name changes, no CLI surface changes, no schema changes: the only externally visible difference is that a command which used to print a traceback now prints its output.

🤖 Generated with Claude Code

An independent review found json.loads running outside _read_receipt's guard.

Signed-off-by: arpan <contact@arpanghoshal.com>
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 40 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: 61b2ac80-0c20-4627-a3d9-e9756107a5cc

📥 Commits

Reviewing files that changed from the base of the PR and between 7fb2068 and be67837.

📒 Files selected for processing (5)
  • CHANGELOG.md
  • src/ctrlrun/postgres.py
  • src/ctrlrun/receipt.py
  • src/ctrlrun/state.py
  • tests/test_unreadable_receipt.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 e72de1e into main Sep 14, 2026
15 of 16 checks passed
@rohanrkamath
rohanrkamath deleted the v0.11/1a-unparseable-row branch September 14, 2026 14:12
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