Skip to content

feat(stack-encrypt): store short TextMatch text as an empty filter, refuse only short queries - #1206

Open
coderdan wants to merge 1 commit into
feat/stack-encrypt-target-limitfrom
feat/stack-encrypt-short-match-empty-filter
Open

coderdan wants to merge 1 commit into
feat/stack-encrypt-target-limitfrom
feat/stack-encrypt-short-match-empty-filter

Conversation

@coderdan

Copy link
Copy Markdown
Contributor

Summary

Stacked on #1192. This PR implements the stored/query split of ADR-0009 for short TextMatch text: a value with no tokens is stored as the empty filter, and only a query with none is refused.

  • The stored side stores it. match_term derives the empty filter (bf: []) for text that cuts no token under the index's tokenizer: empty or separator-only text, or text shorter than the n-gram length. No PRF call is made. MatchTerms as a target, KeysetCipher::match_terms and the dynamic record path all store it, so a two-letter name no longer fails a whole row, and the value seals and opens as any other. No query matches the empty filter, because a query always sets at least one bit.
  • The query side refuses it. op::Supports::check now takes the query's value. The Match index refuses token-less text there with TermError::EmptyTermText, when the query is awaited, before any term is derived and before any keyset is loaded. The chain runs the same check in prepare, so a chain prepared on its own is held to it too. dynamic::term refuses it for a match kind, so the Go guest's term export behaves as before.
  • The error is reworded, not renamed. TermError::EmptyTermText keeps its code, stack_encrypt::empty_term_text, so a binding's mapping holds; its message and help now describe the query.

What this reaches outside the crate

The Go module builds the stack-encrypt guest by path, so this lands there at once rather than with a release. Its round-trip test of the old refusal is rewritten to the new behaviour: a short TextMatch value seals, opens, and matches no query, and a short match query is still ErrTerm. The package docs and the term-failure message follow. The guest's own leak test, which expects term to refuse separator-only match text, passes unchanged.

eql-bindings builds no TextMatch type through stack-encrypt yet, so nothing changes there.

Two tests that used token-less text as the leaf that fails before any I/O now use a match configuration with invalid options, which still fails during the synchronous build.

Tests

  • tests/op.rs: a short value stores as the empty filter, opens as written, is found by its equality term and by no match query; a match query with no tokens is refused through on, through via and in an all batch, with the named keyset never loaded; the same text is a fine equality query and a fine stored value.
  • tests/sem_terms.rs: the empty filter for every token-less input, under both tokenizers, holds no query's positions and round-trips through its bytes.
  • dynamic::term unit test: the same scalar is refused as a query and stored empty through Index<Value>.
  • Go: TestAMatchIndexStoresShortText.

Test plan

  • cargo nextest run -p stack-encrypt --all-features: 578 passed.
  • Doctests, clippy with -D warnings with and without --all-features, rustdoc with warnings as errors, cargo fmt --check.
  • The guest's native tests (58 passed) and go test ./encrypt/ against a rebuilt deterministic guest, where the one failure is the EQL guest not being built locally.
  • CI, including mutants on the diff and the Go jobs.

The issue's last checkbox, a stack-encrypt release so the Go SDK can stop refusing short text, turned out not to apply: the Go module builds the guest by path, so the Go side changes in this PR.

Closes #1185

https://claude.ai/code/session_01VYKAWbd361xDLmqBdLoUcB

…er, refuse only the query

A match value that cuts no token under the index's tokenizer (empty or
separator-only text, or text shorter than the n-gram length) used to fail
term derivation on both the stored and the query side, so one two-letter
name failed a whole row. ADR-0009 splits the two: the stored term is the
empty filter, which no query matches, and only a query of such text is
refused, because its filter would match every row.

`match_term` now derives the empty filter for such text, with no PRF call,
so `MatchTerms` as a target, `KeysetCipher::match_terms` and the `dynamic`
record path all store it, and the value seals and opens as any other. The
refusal moves to the query paths: `op::Supports::check` takes the query's
value, the `Match` index refuses token-less text there with
`TermError::EmptyTermText` when the query is awaited, before any term is
derived or keyset loaded, and `dynamic::term` refuses it for a match kind.
The error keeps its code and is reworded for the query.

The Go module builds the stack-encrypt guest by path, so its round-trip test
of the old refusal is rewritten to the new behaviour: a short `TextMatch`
value seals, opens and matches no query, and a short match query is still
`ErrTerm`. The package docs and the term-failure message follow.

Two tests that used token-less text as a leaf that fails before any I/O now
use a match configuration with invalid options, which still fails during
the synchronous build.

Closes #1185

Claude-Session: https://claude.ai/code/session_01VYKAWbd361xDLmqBdLoUcB
@coderdan
coderdan requested a review from a team as a code owner October 14, 2026 06:50
@changeset-bot

changeset-bot Bot commented Oct 14, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: b05caf6

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 14, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration
  • Configuration used: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: af9f59e5-4c69-4aa7-9fae-0cc46de28e0c

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-14T06:54:00.450316Z b05caf6 PR opened
🔒 Security Review ✅ Completed 2026-10-14T06:55:36.404490Z b05caf6 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@github-actions

Copy link
Copy Markdown
Contributor

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

caught missed unviable timeout
8 0 6 0

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

@chatgpt-codex-connector chatgpt-codex-connector Bot 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b05caf67fd

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

// A query with no tokens would match every row. The stored side
// keeps such a value as the empty filter; the query side refuses it
// here, before any term is derived.
if QueryTokens::of::<O>(value.as_ref()).is_empty() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Validate match options before tokenizing queries

When a custom MatchConfig supplies Tokenizer::Ngram { length: 0 }, this new preflight reaches slice::windows(0) before MatchOptions::validate, causing cipher.query(...) to panic instead of returning Error::Term(TermError::InvalidOptions) as term derivation does. Validate the options before QueryTokens::of (and before the analogous dynamic::term precheck) so malformed configurations remain recoverable errors.

Useful? React with 👍 / 👎.

@freshtonic freshtonic left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review of this PR's diff against #1192.

What I checked. A short stored value cannot cause a false negative. A query must cut at least one n-gram, so its text has at least n characters, and it cannot be a substring of shorter stored text. Case folding runs before the tokenizer on both sides, so the two sides measure length the same way. verify on a short row returns false, which is correct. The Go refusal of a short match query (#1164/#1165) stays, through dynamic::term. The EQL payload shape does not change: the stored domain checks only that bf exists, so bf: [] is valid. Mutants: 8 caught, 0 missed. Codex already raised the Ngram { length: 0 } panic in the new pre-checks; I do not repeat it.

Blocking

  1. A caller who builds a query with KeysetCipher::match_terms now gets a match-all query in SQL with no error. See the inline comment at src/sem/mod.rs:1297.

Should fix (not blocking)

  1. The Go locateTermFailure now tests a stored value with the query rule, so it can name the wrong row. See the inline comment at records.go:485.

Suggested follow-up PR

  • Length leak. A stored bf: [] is visible to a database reader. That reader can now select every row whose value is empty, only separators, or shorter than the n-gram length. Before this PR, such values could not be stored. The Bloom fill already leaks approximate length, but this is an exact class. Add one sentence to ADR-0009 (the verify paragraph, line 284) and to languages/golang/encrypt/doc.go:107.
  • Server guard. The EQL query_text_* domains check only that bf exists (packages/eql/src/v3/scalars/text/query_text_types.sql:45). An empty bf is "contained by everything" (packages/eql/src/v3/sem/bloom_filter/functions.sql:45). A CHECK for a non-empty bf on the query domains closes the match-all case at the server for every client. File an issue if it does not go in this stack.
  • CI. The Test EQL PG17 shards fail on SteVec selector drift (v3_doc_integer $.field op-selector drifted from the pinned constant). The base #1192 fails the same way, and this PR touches no EQL file. It is not caused by this PR.

/// yields no tokens: empty or separator-only text, or an n-gram probe
/// shorter than the gram length (which could never match; see
/// [`Tokenizer::Ngram`]).
/// This derives the *stored* filter. Text that yields no tokens (empty

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Blocking. On main (0.2.0), this doc says the same call serves write time and query time, and that short text fails with EmptyTermText. After this PR, short text returns bf: [] and no error.

A caller who follows the 0.2.0 doc sends this filter as a query. EQL treats an empty bf as contained by every stored filter (packages/eql/src/v3/sem/bloom_filter/functions.sql:45), and the query domain does not refuse it. So the query returns every row. MatchTerms::contains guards the Rust side, but SQL has no guard. The caller gets no compile error and no runtime error.

The CHANGELOG puts this under "Changed" (CHANGELOG.md:98). Please:

  1. Move the entry to "Breaking", and say plainly that the output of match_terms must not be used as a query now; a query comes from cipher.query(op::Matches, ..).
  2. Either add the EQL query-domain CHECK for a non-empty bf, or file an issue for it and link it here.

@@ -483,7 +483,7 @@ func (cph *Cipher) locateTermFailure(ctx context.Context, p *record.Plan, rows [
continue
}
if _, derr := cph.Derive(ctx, p, f.Name, o, row[f.Name]); errors.Is(derr, ErrTerm) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Derive calls the guest term export, which is the query path (guest/src/ops.rs:102 calls dynamic::term). After this PR, dynamic::term refuses short match text, but the record path stores it. So if a record call still fails with ErrTerm for another reason, this loop can stop at an earlier row whose email is "ab". The error then names the wrong row and field.

The new comment at line 474 says a stored value is never refused for its text, but this loop tests each value with the query rule. Skip a match output whose value cuts no token, or derive with the stored rule here. A test for this: a batch with a short email in row 0 and a term failure in row 1.

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.

2 participants