Skip to content

test(proxy): check a connection's mirrored state against a PostgreSQL model - #497

Open
tobyhede wants to merge 3 commits into
claude/project-thread-0sjl5mfrom
claude/project-thread-0sjl5m-proptest
Open

tobyhede wants to merge 3 commits into
claude/project-thread-0sjl5mfrom
claude/project-thread-0sjl5m-proptest

Conversation

@tobyhede

@tobyhede tobyhede commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

Requested by Toby

Stacked on #496. I'll retarget it to main once #496 merges.

This adds a model-based property test for Proxy's mirror of a connection, and fixes the one bug it found.

Property test

postgresql/middleware/tests/connection_model.rs drives one connection through the middleware harness with random traffic:

  • Parse, Bind, Execute, Close and Sync, over named and unnamed statements and portals.
  • Simple Queries: SELECT, SET and SET LOCAL CIPHERSTASH.UNSAFE_DISABLE_MAPPING, BEGIN, COMMIT, ROLLBACK, DISCARD ALL, DEALLOCATE, and creating or dropping a temporary table.
  • Random execution failures.

A small model of PostgreSQL answers each message Proxy forwards.
It covers implicit and explicit transactions, failed transactions, skipping until Sync, duplicate names, the unnamed statement and portal, DISCARD ALL refused in a transaction block, SET LOCAL outside one, and prepared statements not being transactional.
Messages Proxy rewrites so that PostgreSQL fails them fail in the model too.

After every ReadyForQuery the test checks four things:

  • Proxy holds no statement PostgreSQL released.
  • At idle, Proxy holds no operation, portal, portal operation or suspended execution.
  • Proxy agrees with PostgreSQL on UNSAFE_DISABLE_MAPPING.
  • Proxy agrees with PostgreSQL on whether the temporary table exists.

It runs on a plaintext connection and on one with an encrypted column, at 256 cases each by default (PROPTEST_CASES raises that).
The module header lists what the model leaves out, mainly pipelining, partial Execute and savepoints.

I checked the test is sensitive by planting faults in the model.
Three of them were caught: DISCARD ALL keeping the temporary table, a failed batch not rolling back, and ROLLBACK keeping changes.
20,000 cases per config pass with the fix below.

Bug found: a simple Query kept the unnamed statement

PostgreSQL drops the unnamed prepared statement before it runs any simple Query. Proxy kept its copy.
The impact was small: one stale entry, and PostgreSQL rejects any Bind to it.

The fix is test-first:

  • 7023ad0 adds two failing harness tests. One covers the plain case. The other covers a pipelined failing batch whose skipped unnamed Parse is rolled back after Proxy has read the following Query.
  • 70f85aa makes Context release the unnamed statement when it reads a simple Query (mapped, refused, or with mapping disabled). It also clears earlier rollback snapshots for that statement, the same rule SQL DEALLOCATE already follows.

One existing test changed. sql_deallocate_all_keeps_the_unnamed_statement sent DEALLOCATE ALL as a simple Query, so it only passed because of this bug. It now runs DEALLOCATE ALL through the extended protocol, which is where the unnamed statement survives it.

Acknowledgment

By submitting this pull request, I confirm that CipherStash can use, modify, copy, and redistribute this contribution, under the terms of CipherStash's choice.


Generated by Claude Code

PostgreSQL runs every simple Query in the unnamed statement, so it drops
the client's unnamed prepared statement first. Proxy keeps its copy.

One test shows the copy surviving a simple Query. Another pipelines a
failing batch whose skipped unnamed Parse is rolled back after Proxy has
read the following Query, which must not bring the old copy back.

The DEALLOCATE ALL test ran the statement as a simple Query, so it only
held because of this gap. It now runs DEALLOCATE ALL through the
extended protocol, which is where the unnamed statement survives it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Context now releases the unnamed statement when it reads a simple Query,
mapped, refused or with mapping disabled, as PostgreSQL does before it
runs the Query.

PostgreSQL releases it whatever happens to earlier operations, so the
rollback snapshots of earlier operations that changed the unnamed
statement forget it too, as they do for a SQL DEALLOCATE.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… model

A property test drives one connection with random extended-protocol and
simple Query traffic, and answers each message Proxy forwards from a
small model of PostgreSQL: implicit and explicit transactions, failed
transactions, skipping until Sync, duplicate names, the unnamed
statement and portal, DISCARD ALL, SET LOCAL and temporary tables.

After every ReadyForQuery it checks that Proxy holds no statement
PostgreSQL released, holds no operation or portal state at idle, and
agrees with PostgreSQL on UNSAFE_DISABLE_MAPPING and on whether the
temporary table exists. It runs on a plaintext connection and on one
with an encrypted column. The module lists what the model leaves out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@tobyhede tobyhede self-assigned this Oct 28, 2026

@cipherstash-agent cipherstash-agent left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated review attestation (Claude Code /code-review medium + cross-check against head 0e35fc1; no existing PR feedback to dedupe against).

Critical/blocking: none. No correctness bug found in the production change; the unnamed-statement release on a simple Query matches PostgreSQL's ordering. Static review only; I did not run the tests or wait on CI.

Non-blocking:

  • minor packages/cipherstash-proxy/src/postgresql/context/lifecycle.rs:59: no CHANGELOG.md [Unreleased] entry for the unnamed-statement fix, which CLAUDE.md asks for on notable changes.
  • Inline: one minor on the model's one-directional invariant.

Generated by Claude Code

@cipherstash-agent cipherstash-agent left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inline comment from the review above.


Generated by Claude Code

for name in &held {
assert!(
postgres.statements.contains_key(name),
"Proxy holds statement {name:?}, which PostgreSQL released"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

minor check() only asserts Proxy holds no statement PostgreSQL released, never that Proxy still holds every statement PostgreSQL holds. Proxy dropping a statement PostgreSQL kept (e.g. an over-eager release_unnamed_statement) lets the next Bind through unencrypted and still passes. Also assert postgres.statements.keys() ⊆ held, at least on the encrypted connection.


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. I checked the reverse direction locally over 3,000 cases per config and it holds, so the assertion goes in with this PR's next code push.


Generated by Claude Code

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.

3 participants