Skip to content

docs(specs): pilot a Quint model of statement routing - #485

Open
tobyhede wants to merge 15 commits into
claude/project-thread-98m2dcfrom
claude/project-thread-p8sn3b
Open

tobyhede wants to merge 15 commits into
claude/project-thread-98m2dcfrom
claude/project-thread-p8sn3b

Conversation

@tobyhede

@tobyhede tobyhede commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

Requested by Toby

Stacked on #490, which adds the Quint agent skills, the mise tasks and their docs. Merge #490 first.

This is a proof-of-concept formal model, in Quint, of how Proxy decides to map, forward or refuse each statement.
It checks that plaintext never reaches an encrypted column, except when the client uses UNSAFE_DISABLE_MAPPING.
The model is design-level only: it changes no production code, and no Rust code is tested against it yet.

Start with specs/quint/routing/requirements.md, which covers the scope, assumptions, properties, findings and open decisions, and maps each model element to its Rust code.
verification.md has the exact results.

What it models

Results (Quint 0.33.0, TLC, exhaustive for each finite instance)

  • S1–S3 hold as built, including with UNSAFE_DISABLE_MAPPING and concurrent DDL.
    With a stable schema, Proxy's routing alone keeps them true, even with PostgreSQL's domain check switched off.
  • TLC catches each of eight injected faults (M1 to M8), including no batch rule (main's behaviour before refactor(proxy): route each statement through one refuse-by-default decision #484), no Bind settings check, and no COPY ... FROM refusal.
  • Every action has a witness that only it can reach, and simulation fails if any witness is reached in 0 traces, so a dead action fails CI.

Findings to confirm against real Proxy before acting

These are recorded as expected counterexamples, so CI fails if one stops reproducing.

  • F3 (retracted): the first version of the model left out the Bind settings check (PreparedSettingsChanged), so it reported a plaintext path that Proxy does not have. The model now includes the check, and mutation M4 covers it.
  • F1: routes go stale under concurrent DDL. Prepared statements are routed once, at Parse, transactions pin the schema, and DDL from elsewhere is published only by the periodic reload. Only PostgreSQL's type and domain checks stop the plaintext write.
  • F2: DO, CALL and SQL EXECUTE are opaque to Proxy, so nothing in routing stops them writing plaintext. COPY ... FROM is no longer part of F2: fix(proxy): refuse COPY FROM into encrypted columns, check statements after COPY FROM STDIN #488 refuses it.

PostgreSQL's domain check should reject the value in every case, so none of these is a confirmed leak. Each would need a failing test first.

Requirements changes for review

No property (S1 to S4) changes. These assumptions change:

  • A3 now covers only DO, CALL and SQL EXECUTE; COPY ... FROM and nested writes are modelled.
  • A5 is documented as an over-approximation: publication follows the commit, and DDL from elsewhere waits for the periodic reload.
  • New A6: a nested write takes no parameters, so a nested write prepared with mapping disabled carries values sent with mapping disabled.
  • New decision D4: Bind and Execute stay one step.

Tooling

  • specs/quint has a pinned package-lock.json. check.sh covers typecheck, scenario tests, simulation with every witness required to be reached, TLC and mutations.
  • .github/workflows/quint.yml runs only on changes under specs/quint. It isn't a required check, and a full run takes about 7 minutes.
  • npm audit flags adm-zip, which Quint uses only to unpack its own Apalache download, as the README notes.

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

@tobyhede tobyhede self-assigned this Oct 22, 2026
A design-level model of how Proxy maps, forwards or refuses each
statement, checked with TLC: plaintext must never reach an encrypted
column except with UNSAFE_DISABLE_MAPPING. Covers the refuse-by-default
route from #484, simple Query batches, Parse and Bind, transactions and
concurrent DDL.

TLC confirms the safety properties for clients that never disable
mapping, and records three findings as expected counterexamples:
stale routes under concurrent DDL, statement kinds Proxy never type
checks, and a Parse under UNSAFE_DISABLE_MAPPING bound after mapping is
enabled again. Three injected faults are each caught.

Adds a non-required CI workflow. No production code changes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Claude <noreply@anthropic.com>
S3 tracks writes into columns that are encrypted when the write
happens; values already stored before DDL makes a column encrypted are
out of scope. The README and verification record no longer imply that
a change to Proxy's Rust code alone changes the TLC results.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Claude <noreply@anthropic.com>
@tobyhede
tobyhede force-pushed the claude/project-thread-p8sn3b branch from a2c955f to dd25d54 Compare October 22, 2026 23:51
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Claude <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Claude <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Claude <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Claude <noreply@anthropic.com>
Proxy refuses a Bind with PreparedSettingsChanged when the mapping
setting differs from the one at Parse, for any statement it type checks.
The model left that check out, so it reported a plaintext path (F3) that
Proxy built from main does not have. Model the check, drop the
candidate-fix instances, and add mutation M4, which removes it and is
caught by S1.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Claude <noreply@anthropic.com>
@tobyhede
tobyhede marked this pull request as ready for review October 25, 2026 22:39

@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.

Review attestation

Source: Claude Code, /code-review medium plus company-skills:cross-check-review-findings (loaded from cipherstash/skills). Reviewed head 1d3997f.

Result: No critical or blocking issues. 3 minor findings, inline.

Cross-check: The PR had no prior reviews, review threads or comments, so all 3 findings are new.

What I checked

  • The model against requirements.md: properties S1–S4, assumptions A1–A5 and findings F1–F3.
  • The four mutation patterns in check.sh. Each from string occurs once in routing.qnt.
  • The scenario tests' expectations traced against the actions.
  • The workflow and tooling files.

Not done: I did not run Quint or TLC. I did not compare the model with Frontend::route or the other Rust sources it cites (#484 and related). It is a design-level model with no Rust adapter, so that comparison is the open risk, as F3 showed.

The lockfile was skimmed, not audited.


Generated by Claude Code

atRest.forall(r => r.payload == Cipher or r.unsafe)

/// S4 (open decision D1): Proxy never forwards plaintext for a column its
/// schema says is encrypted at the moment it sends the write.

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 (stale comment). This says "S4 (open decision D1)", but requirements.md records D1 as decided on 2026-10-25 (S4 is not a requirement). Suggest "S4 (not required; D1 decided)" so the model and the contract agree.

Source: specs/quint/routing/requirements.md (Decisions, D1; Properties, S4) at head 1d3997f.


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.

Fixed in 8791e17: the comment now reads "S4 (not required; D1 decided)".


Generated by Claude Code

- '.github/workflows/quint.yml'
workflow_dispatch:

jobs:

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 (hardening). The workflow sets no permissions:, so the job gets the repository's default GITHUB_TOKEN scope. It only checks out and runs checks, so a top-level permissions: contents: read is enough and keeps the token minimal.


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.

Fixed in 8791e17: the workflow now sets a top-level permissions: contents: read.


Generated by Claude Code

Comment thread specs/quint/check.sh Outdated
echo "$out" >&2
fail "$main should satisfy $invariant"
fi
grep -E "distinct states found|No violation found" <<<"$out"

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. If this grep matches nothing (for example Quint changes its output wording), set -e and pipefail end the script here with no FAIL: message, even though TLC passed. Add || true, or fail explicitly with the captured output, so a wording change is not mistaken for a model failure.


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.

Fixed in 8791e17: the summary grep now ends with || true, so if Quint changes its wording a passing check still passes.


Generated by Claude Code

- check.sh: the TLC summary grep no longer fails a passing check if
  Quint changes its output wording.
- routing.qnt: S4 comment says D1 is decided.
- quint.yml: restrict GITHUB_TOKEN to contents: read.

Signed-off-by: Claude <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Claude <noreply@anthropic.com>
check.sh reported witness counts without enforcing them, so a dead action
would pass. Each simulated instance now fails when any witness is reached
in 0 traces. Add witnesses for Parse storing a route, an open transaction,
a transaction snapshot made stale by concurrent DDL, and a stale prepared
route.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Add a source-file correspondence table to the routing model's
requirements, so a change to one of those functions prompts a model
check.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@tobyhede
tobyhede changed the base branch from main to claude/project-thread-98m2dc October 26, 2026 01:48
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Address the skill-driven review of the routing model.

- Split the unchecked statement kind into CopyFrom, NestedWrite and
  Opaque, and route them as Frontend::route does since #488:
  check_copy_into refuses COPY ... FROM into an encrypted or unresolvable
  column, and a nested write is refused while encrypted columns are
  configured. The Bind settings check is gated by requires_schema.
- A3 and F2 now cover only DO, CALL and SQL EXECUTE. New assumption A6:
  a nested write takes no parameters. A5 is documented as an
  over-approximation, which widens F1. Decision D4 keeps Bind and Execute
  as one step.
- Parse is only of an unused name, a refused Parse leaves it unused, and
  an unknown Bind under UNSAFE_DISABLE_MAPPING is forwarded, as in Proxy.
- New witnesses for Bind, Close, transaction end and the mapping setting,
  so every action has one only it can reach. New mutations M5 to M8.
- Two-statement batches hold type-checked statements only, which keeps
  check.sh all at about 6 minutes.
- Fill in the source-file correspondence table and the AGENTS.md pointer.

No property changes. TLC: asBuilt 56,994 states, concurrentDdl 445,094,
safety holds in both; all expected violations and M1 to M8 reproduce.

Signed-off-by: Claude <noreply@anthropic.com>

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