Skip to content

feat(stack-encrypt): verify a match query's rows after decrypt - #1191

Draft
coderdan wants to merge 2 commits into
feat/stack-encrypt-operator-queryfrom
feat/stack-encrypt-match-verify
Draft

coderdan wants to merge 2 commits into
feat/stack-encrypt-operator-queryfrom
feat/stack-encrypt-match-verify

Conversation

@coderdan

@coderdan coderdan commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Stacked on #1190. This PR implements ADR-0009 decision 4 for Matches: a match query can say, after decrypt, whether a returned row really matches.

use stack_encrypt::op::{self, Probabilistic};

let query = cipher.query(op::Matches, "ali").on(&name_plan).await?;
// ... run the SQL, decrypt the rows ...
let hits: Vec<_> = people.into_iter().filter(|p| query.verify(&p.name)).collect();
  • The trait. op::Probabilistic has type Plaintext and fn verify(&self, &Plaintext) -> bool. It is infallible, because every outcome other than a match is false.
  • How MatchQuery verifies. It keeps the query's tokens (sem::QueryTokens), cut from the query's value by the index's own tokenizer and case folding. They are taken through a new Supports::carry, before the query's value is consumed. verify cuts the decrypted value the same way and checks that every query token is among its tokens. That is the meaning a Bloom match approximates, so verify keeps every row the filter would keep if it had no false positives.
  • The tokens are plaintext. They never leave the process, are zeroized on drop, and MatchQuery's Debug shows only the filter's positions.
  • The operator bounds the term. Supports::Query is now op::TermFor<Op>. Any query term answers Eq; only a probabilistic one answers Matches. So none of this crate's indexes can claim a text match without saying how its answer is checked. A term in another crate can opt in by hand, so there the bound is a guide.
  • The tokenizer zeroizes its own copies of the text, which verify runs on every decrypted row.
  • Exact terms have no verify. Calling verify on an EqualityTerm does not compile.

Tests

  • verify keeps real matches, folds case only when the index does, and rejects other text.
  • A planted false positive: a long value under a 32-bit filter, plus a search for a digit string whose filter it holds by chance. verify drops that row.
  • MatchQuery's Debug shows no query token.
  • A unit test covers the empty-tokens guard, which the API cannot reach.
  • tests/ui/op_verify_exact_term.rs and tests/ui/op_inexact_operator_needs_probabilistic.rs pin the two compile errors.

Test plan

  • cargo nextest run -p stack-encrypt --all-features: 561 passed. The review fixes are in a second commit; see the review comment.
  • Doctests, clippy with -D warnings with and without --all-features, and rustdoc with warnings as errors.
  • CI, including wasm32 clippy and mutants.

Closes #1183

https://claude.ai/code/session_01VYKAWbd361xDLmqBdLoUcB

@changeset-bot

changeset-bot Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 36ecd73

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@coderabbitai

coderabbitai Bot commented Oct 13, 2026

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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

@github-actions

github-actions Bot commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Mutation testing (cargo-mutants, --in-diff, stack-auth + stack-encrypt)

caught missed unviable timeout
28 0 39 0

Every mutant in the changed lines was caught by a test.

@coderdan

Copy link
Copy Markdown
Contributor Author

Review (high effort): findings and how each was addressed

An independent review of the verify commit found no false negatives. Write time, query time and verify all call one tokenize with one O::options(). The reviewer ran Unicode cases (İ, final sigma, ß/ẞ, Dž, ligatures) and found none either. The fixes for its findings are in 2d33d6c90, rebased onto #1190's review fix.

  1. A caller could pair a needle with another value's filter, and verify would then drop real rows. Supports::carry and Supports::query were callable from outside the crate, and a probe crate confirmed this. Fixed: query now takes the sealed op::QueryToken (added in feat(stack-encrypt)!: a query names an operator, not an index #1190's fix), so only a query chain can join a needle to a filter. Needle's doc says so.
  2. TermFor binds only this crate's terms. A term in another crate can implement TermFor<op::Matches> by hand, and coherence allows it. Documented: TermFor's rustdoc and the ADR say the rule is a proof for this crate's indexes and a guide elsewhere.
  3. Plaintext copies in the tokenizer were not zeroized. It left an unzeroized folded copy and a Vec<char> of the whole text. verify runs it on every decrypted row. Fixed: the folded string and its characters are Zeroizing, and each n-gram is built at its final size, so it never reallocates. This also covers the write path, which calls the same function.
  4. The ADR showed target_limit on Probabilistic before this PR adds it. Decision 4's code now matches this PR, and feat(stack-encrypt): size a match query's fetch with target_limit #1192 adds the method.
  5. MatchQuery's PartialEq compares only the filter. Documented: two needles can share a filter and still verify differently.
  6. Two tests were missing:
    • verify is a token test, not a substring test: "abcd" verifies "abc bcd".
    • The standard tokenizer verifies whole words in any order, and "worldly" is not "world".

The reviewer judged the planted false-positive test deterministic enough: missing 1000 times in a row is effectively impossible at that fill. 561 tests pass locally, with clippy and rustdoc clean.

@coderdan
coderdan added this pull request to stack #1195 October 13, 2026 21:05
type Query: QueryTerm; // what this index sends for `Op`
fn query(term: Self::Term) -> Self::Query; // made from the term it derived
type Query: TermFor<Op>; // what this index sends for `Op`
type Carry; // what the query keeps of the value

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.

The name of this type doesn't make it clear what it's doing. Consider a rename and add some better docs to explain.

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.

An example of a query type that has a carry would be good.

Comment thread packages/stack-encrypt/src/sem/mod.rs Outdated
/// never from a target.
///
/// A Bloom filter can hold every bit of the query without the value holding
/// the needle, so a match query is

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.

Don't use coded words like "needle". Its a query term or search query.

@coderdan
coderdan force-pushed the feat/stack-encrypt-match-verify branch from 2d33d6c to 4187894 Compare October 13, 2026 23:57
@coderdan

Copy link
Copy Markdown
Contributor Author

Rebased onto #1190's chain rework (operator first, .via(..).on(&plan), op::Matches as a value, CaseSensitive/CaseInsensitive, Match as a value). On this PR that also renames sem::Needle to sem::QueryTokens and takes "needle" out of the prose: a match query is a filter over the query's value.

@coderdan
coderdan force-pushed the feat/stack-encrypt-match-verify branch from 4187894 to 4c17100 Compare October 14, 2026 03:53
A Bloom filter can hold every bit of a query without the value holding
the needle, so a `Matches` query returns false positives. ADR-0009
decision 4 makes such a term `Probabilistic`, with `verify` as the
operator's own meaning run on a decrypted value.

- `op::Probabilistic { type Plaintext; fn verify(&self, &Plaintext) -> bool }`.
  Infallible: every outcome other than a match is `false`.
- `MatchQuery` keeps the needle's tokens as the index cut them, through a
  new `Supports::carry` taken before the value is consumed. `verify` cuts
  the decrypted value the same way and checks every needle token is among
  its tokens. The tokens are zeroized on drop and never shown by `Debug`.
- `Supports::Query` is bounded by `op::TermFor<Op>`: any query term answers
  `Eq`, only a probabilistic one answers `Matches`, so an index cannot
  claim a text match without saying how its answer is checked
  (tests/ui/op_inexact_operator_needs_probabilistic.rs).
- `verify` on an exact term does not compile (tests/ui/op_verify_exact_term.rs).

The tests plant a real Bloom false positive under a 32-bit filter and
check `verify` drops it.

Closes #1183

Claude-Session: https://claude.ai/code/session_01VYKAWbd361xDLmqBdLoUcB
- `Supports::query` takes the sealed `QueryToken` (from the #1190 fix),
  so no caller can pair a needle with another value's filter and have
  `verify` drop the real rows. `Needle`'s doc says how one is joined.
- `TermFor`'s doc and ADR-0009 say the "only a probabilistic term answers
  `Matches`" rule binds this crate's terms; a term in another crate can
  opt in by hand.
- The tokenizer zeroizes its own copies of the text: the folded string,
  its characters, and each n-gram, built at its final size so it does not
  reallocate. `verify` runs it on every decrypted row.
- `MatchQuery`'s `PartialEq` says two needles can share a filter and still
  verify differently.
- ADR-0009 decision 4's code shows the trait as this PR ships it;
  `target_limit` joins it with #1184.
- Tests: `verify` is a token test, not a substring test ("abcd" verifies
  "abc bcd"), and the standard tokenizer verifies whole words in any
  order.

Claude-Session: https://claude.ai/code/session_01VYKAWbd361xDLmqBdLoUcB
@coderdan
coderdan force-pushed the feat/stack-encrypt-match-verify branch from 4c17100 to 36ecd73 Compare October 14, 2026 04:15
@coderdan

Copy link
Copy Markdown
Contributor Author

Rebased onto the updated base after ADR-0009's second review round (#1177 at f703fe0); head is now 36ecd73. No code changed in the rebase.

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.

stack-encrypt: verify a Matches query's rows after decrypt (Probabilistic)

1 participant