Skip to content

Generated contracts compare their own tables, so the recheck sees a late look-alike change - #1681

Open
abrichr wants to merge 1 commit into
mainfrom
fix/generator-recheck-named-tables
Open

abrichr wants to merge 1 commit into
mainfrom
fix/generator-recheck-named-tables

Conversation

@abrichr

@abrichr abrichr commented Oct 31, 2026

Copy link
Copy Markdown
Member

Item 2 of #1677. This is a fail-open fix, so it ships alone.

The fail-open

The held-out test found a late write that the recheck missed. A retried request checked out a look-alike record about two minutes after the verdict. The recheck read the database 300 s after the write and said CONFIRMED.

Cause: for an update, the generator gave the main record's table (and the tables of rows that hold values) allow_tables: {mode: skip} in the whole-database frame, and left them to the contract's own read. After the save that read judges every row correctly: only the declared target may change, and only in its excused columns. A recheck reads no surface, though, only the database. So at the recheck a skipped table was not read at all, and a change to any row of it passed, including a change to the excused columns on a row the write never declared. For a create, the same tables were append-only, so a late look-alike added at the recheck passed too.

The fix

  • frame.database.judged_by (new, runtime/effects/frame.py): table -> a surface that reads every row and every column of it. After the save, the rows the surface saw change, appear or go (matched by primary key) are left to that surface, which judges them with the effects' selectors and target_fields_by_surface. Every other row of the table is compared by the database frame. A recheck reads no surface, so there the whole table is compared and any change since the post-save read refutes.
  • Fail closed: when the surface lacks a column of the table, when a record's key can't be read, when two records share a key, or when the surface couldn't be read after the save, nothing is left to it. The table then judges every moved row itself, and a change only the surface declares stops the write. A judged_by table can't also be in allow_tables or views, and must name a real surface.
  • judge_run_frame reads the database frame before the surfaces. A change can't then land between the surface read and the settled read that a recheck compares with.
  • The generator emits judged_by for the main record's table and the tables of rows that hold values, for every flavor, in place of skip or append-only. An excused column is excused only on the rows the write declares (the target, or the rows it adds).

Docs: VERIFY_RUN.md (the field), GENERATE_CONTRACTS.md (the frame), CAPABILITIES.md.

Tests

  • tests/test_frame_judged_by.py (SQLite, public API): a correct checkout holds and its recheck confirms. A look-alike checked out in the window refutes. A look-alike checked out after the verdict refutes at the recheck. A late second change to the target refutes at the recheck. A surface that misses a column leaves the table to the frame (refuted). The same contract without judged_by refutes. Load-time refusals for an unknown surface and for an allow-listed table.
  • tests/test_generate_verify_contract.py (real PostgreSQL, generated contracts): every generated contract compares its own tables. A late update of the look-alike issue, a late status change on the look-alike only, and a late second change to the target are each REFUTED at the recheck. A late duplicate create is REFUTED at the recheck. A correct create and a correct update with nothing late are CONFIRMED at the recheck.
  • Before the fix (main, same tests): the late look-alike update and the late duplicate create were both CONFIRMED at the recheck.
  • Ran locally: the generator, attack, frame, recheck and verifier suites (about 1,300 tests). All pass.

Real-app evidence

Running on a held-out app (instance 1) once the shared VM has room; this section will hold the commands and outputs.

…ate change to a look-alike row

The generator skipped the main record's table in the whole-database frame
for an update (and kept it append-only for a create), leaving it to the
contract's own read. The recheck reads no surface, so a late write to any
row of that table, such as a look-alike record changed after the verdict,
passed the recheck.

frame.database.judged_by names a table a surface reads in full. After the
save, the rows that surface saw move are left to it (the target, or the
rows the write adds, with only their declared fields); every other row is
compared by the database frame. A recheck compares the whole table. The
database is now read before the surfaces, so no change falls between the
two reads.

This branch has not been deployed

No deployments
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.

1 participant