Skip to content

feat(stack-encrypt): make two-letter words such as "Ng" searchable in the match index - #1397

Merged
coderdan merged 8 commits into
nextfrom
feat/match-short-words
Oct 26, 2026
Merged

coderdan merged 8 commits into
nextfrom
feat/match-short-words

Conversation

@coderdan

@coderdan coderdan commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

Summary

A two-letter surname such as "Ng" can now be stored and searched. Stack Encrypt's match index (the encrypted full-text index: text is cut into 3-character pieces, "trigrams", each piece is hashed under a secret key, and the hashes set bits in a fixed-size bit array, a Bloom filter) gains an option, MatchOptions::short_words. With it on, every word too short to hold a trigram (exactly two characters under the default 3-grams) becomes one more token, the word itself, beside the trigrams of the whole text. So "Ng" stores one token instead of an empty filter, and the query "Ng" is accepted and finds "Ng", "Mike Ng" and "NG, Anna", but not "Nguyen".

EQL v4's match types (TextMatch, TextSearch, TextSearchOre; EQL is the SQL library that stores and queries encrypted values in PostgreSQL) turn the option on, so the EQL v4 match format now includes short-word tokens. "Ng" was refused as a TextMatch value before; it now stores, and @@ with the query "ng" finds it.

This differs from the fix #1309 proposed (one whole-text token, only when the whole text is shorter than a trigram). The per-word token also makes "Mike Ng" findable by "ng", which the whole-text token would not. A comment on #1309 says the same.

What it reveals. One more token per short word, hashed by the same keyed function (under a label of its own), in the same filter, matched by the same rule, so no new class of leakage. The token is specific to the word: someone who clusters tokens across rows can tell the rows holding the word "Ng" from those holding "Nguyen", where before only the boundary trigram " ng" they share was visible. No bigram of a longer word is ever indexed.

Changes

Stack Encrypt (packages/stack-encrypt)

  • MatchOptions::short_words: bool, default off. A word is a run of characters between the separators Tokenizer::Standard splits on (space, comma, semicolon, colon, exclamation mark), so a short word is exactly a token the standard tokenizer would cut; length is counted in characters of the NFC text. The comma matters for names written "Ng, Mike".
  • The word token is hashed under its own domain, stack-encrypt/sem/match/word/v1, framed with PAE (the crate's length-prefixed encoding of hash inputs) beside the column context, as every other term kind is. A word token and a trigram of the same characters never share a hash.
  • Refused (TermError::InvalidOptions) beside the standard tokenizer, which already emits whole words, and beside n-grams shorter than 3, where no word is short enough.
  • Part of the index's identity: a query asked under the other setting is refused (PlanError::IndexOptions), as a mismatched k is.
  • Data-plan wire form: {"match": {"short_words": true, ...}}, written only when on, so every other plan serializes to the same bytes as before. The IndexOptions error payload follows the same rule.
  • A query's verifier checks each kind of token against the same kind in the row. The expected_tokens fetch prior's docs say it counts word tokens.
  • The sem module docs gain a "What a match index reveals" section; MatchOptions::short_words's rustdoc states the leakage; CHANGELOG entry.

EQL (packages/eql)

  • EqlMatch (the pinned match configuration of the v4 match types) sets short_words: true. Text with no two-letter word derives the filter it did; the pinned "alice" positions do not move.
  • Tests that used two-letter text as "too short for a match term" now use one letter. New tests store and find sixteen real two-letter surnames through the typed path and the by-name dispatch, and a PostgreSQL test runs @@ over TextMatch and TextSearch columns.
  • One SQL doc comment reworded; no SQL function changes. Changeset: @cipherstash/eql minor.

Go SDK (languages/golang)

Not touched: the TypeScript packages carry no Stack Encrypt match options (they use EQL v3 through protect-ffi), so there is nothing to plumb there. No skill covers EQL v4 or Stack Encrypt, so no skills/ change.

Verification

All run locally on this branch:

  • cargo fmt --check (root and EQL workspaces): clean.
  • RUSTFLAGS=-D warnings cargo clippy --workspace --tests, with and without --all-features: clean.
  • mise x --env test -- cargo nextest run --workspace --all-features: 1525 passed, 1 skipped.
  • mise run test:doc: 90 doc tests passed. mise run doc: clean.
  • New Stack Encrypt tests: tokenizer cases (two-letter words at the start, middle and end; one-letter words ignored; 4-grams taking 2- and 3-letter words; separators; two-character words outside ASCII counted by characters; NFC), proptests (every short word is exactly one word token and no other word is; the option moves no trigram; the stored filter holds the query filter of any run of its whole words; the two domains never derive one hash), the round trip ("Ng" finds "Ng" and "Mike Ng", not "Nguyen"), the option-mismatch refusal, the one-letter refusal, a pinned PRF framing (a word token and a trigram of the same characters share no position), and a pin of the word token's positions.
  • False-positive calibration (3000 random rows, 60 queries of each kind, k = 3, m = 256): a one-word query matched falsely at 0.00445 (801 of 180000 row checks), a one-trigram query at 0.00456 (820 of 179910), against 0.00430 predicted from the filters' fill; both set 2.98 bits on average. A five-letter control query matched falsely 0 times.
  • EQL mise run test:encryption: 527 passed. mise run test:encryption:postgres against a fresh database on localhost:5400: 43 passed, including postgres_finds_two_letter_surnames.
  • Go: wasm:guest:build, wasm:guest:build:eql and both deterministic builds, wasm:auth-guest:build; mise run go:test passed; mise run go:lint: 0 issues; go generate ./... changed nothing; mise run go:test:postgres passed.
  • cargo metadata --locked in the root, EQL, protect-ffi and both guest workspaces: clean.
  • cargo mutants --in-diff over stack-auth and stack-encrypt (the CI gate): 31 caught, 6 unviable, 1 missed on the first push (Tokens::zeroize replaced with a no-op). A test now pins that zeroizing clears both kinds of token, and a local rerun of that mutant reports it caught.

Related

Closes #1309

Review notes

  • Design difference from Match: text shorter than the 3-gram length can't be stored in TextMatch or found by match #1309: the issue proposed a whole-text token for text shorter than a trigram. This PR uses a per-word token instead, so "Mike Ng" is found by "ng" as well as "Ng" alone. The cost is a narrowing for mixed queries: a query's two-letter words must be whole words of the row, so "Mike Ng" does not find "Mike Nguyen" (it did with the option off, through trigrams alone). The option's rustdoc and the changeset say so.
  • Stored format: EQL v4 is unreleased, so the v4 match format simply includes short-word tokens from here. A v4 value written by an earlier build that holds a two-letter word lacks the word's bits, and a query naming that word does not find it until re-encrypted. Text with no two-letter word is unchanged.
  • Deferred, deliberately: how a text-processing pipeline (tokenizer and filters) should be carried through EQL in general, so a column could choose its own, is an open question the owner has deferred; this PR only turns the one option on for the v4 match types.
  • MatchOptions gains a public field, so a struct literal naming every field must name it. stack-encrypt 0.3.0 is unreleased (crates.io has 0.2.0), so this lands in the same release.
  • The separator set is the standard tokenizer's, not Unicode whitespace: a tab, newline, full stop or hyphen does not end a word, as for Tokenizer::Standard, so "Ng." and "Ng-Smith" carry no ng token. Widening it, and the query-side narrowing above, are flagged in review threads as owner decisions; one alternative raised there is to cut a query's word tokens only when the query cuts no n-gram.

https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu

A word shorter than the n-gram length cuts no n-gram of its own, so under
the default 3-grams the query "Ng" was refused and a stored "Ng" was the
empty filter: a two-letter surname could not be searched (#1309).

MatchOptions::short_words, off by default, adds one token per word of 2
characters up to the n-gram length, the word itself, beside the n-grams of
the whole text. Words split on the standard tokenizer's separators, so a
short word is exactly a token Tokenizer::Standard would cut. The word token
is PRF'd under its own PAE domain, stack-encrypt/sem/match/word/v1, so it
never sets the bits of an n-gram of the same characters, and folds into the
same filter. Bigrams in general stay out: they would change the index's
leakage class.

The option is part of the index's identity (PlanError::IndexOptions), has
a wire form written only when on, so every other plan serializes as
before, and is refused beside the standard tokenizer or n-grams under 3.
Verification keeps the two token kinds apart.

Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
EQL v4's TextMatch refused "Ng" on store as well as query: it cuts no
3-gram, and an empty filter is a row no operator can find. So a two-letter
surname could not be written to a TextMatch column, and TextSearch stored
it with a filter no @@ query reaches (#1309).

EqlMatch, the match configuration of TextMatch, TextSearch and
TextSearchOre, now turns on Stack Encrypt's short_words: each two-letter
word is one more token, the word itself, under its own PRF domain in the
same filter. EQL v4 is unreleased, so this is the format it ships with; a
value written by an earlier build that holds a two-letter word lacks the
word's bits until it is re-encrypted. Text with no two-letter word derives
the filter it did, so the pinned "alice" positions do not move.

The tests that used two-letter text as "too short for a match term" now
use one letter, and new tests store and find sixteen real two-letter
surnames, through the typed path, the dispatch and PostgreSQL's @@.

Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
The EQL guest links eql-bindings by path, so a Go field typed
encrypt_into=TextMatch, TextSearch or TextSearchOre picks up the
two-letter word token with no Go change: "Ng" now stores and "ng" is a
query (#1309). The hermetic tests that used two-letter text as "cuts no
match token" now use one letter, and new checks store "Ng", query "ng"
and verify it against "Mike Ng" and "Nguyen".

A native index=match field still takes no options: stashgen refuses
them until the guest derives query terms under a field's own options
(#1112), so the option reaches Go through the EQL types only, as the
CHANGELOG and READMEs now say.

Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
Tokenizer::Ngram is public and its rustdoc linked the crate-private
Tokens, which `mise run doc` refuses under -D warnings.

Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
@changeset-bot

changeset-bot Bot commented Oct 26, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 93fd33e

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 12 packages
Name Type
@cipherstash/eql Minor
stash Patch
@cipherstash/stack-prisma Patch
@cipherstash/basic-example Patch
@cipherstash/e2e Patch
@cipherstash/prisma-example Patch
@cipherstash/stack-drizzle Patch
@cipherstash/stack-supabase Patch
@cipherstash/stack Patch
@cipherstash/wizard Patch
@cipherstash/bench Patch
@cipherstash/test-kit Patch

Not sure what this means? Click here to learn what changesets are.

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

@coderabbitai

coderabbitai Bot commented Oct 26, 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
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Organization UI; Enforced overrides: Organization
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: c08b48fe-92c6-4819-a9c2-fa20263567fd

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.

@github-actions

github-actions Bot commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

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

caught missed unviable timeout
39 0 6 0

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

The mutants gate found Tokens::zeroize replaceable with a no-op and no
test failing: a query's tokens are plaintext held in Zeroizing<Tokens>,
so a zeroize that skipped either kind would leave them in memory.

Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
Comment thread packages/stack-encrypt/src/sem/tokenize.rs
Comment thread packages/stack-encrypt/src/sem/tokenize.rs
Comment thread packages/stack-encrypt/src/sem/mod.rs
Comment thread packages/eql/crates/eql-bindings/src/encryption.rs
Comment thread languages/golang/encrypt/README.md Outdated
Comment thread packages/stack-encrypt/tests/match_short_words.rs Outdated
Comment thread packages/stack-encrypt/src/sem/tokenize.rs Outdated
Comment thread packages/stack-encrypt/src/sem/tokenize.rs Outdated

@coderdan coderdan left a comment

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.

Human review pass.

From review of #1397. match_term split the tokens into two plain Vecs, so
a failure deriving the n-grams freed the short words unwiped; they are now
held in Zeroizing until the PRF takes them. The standard tokenizer and the
short-word filter now share one word split, so "a short word is a standard
token" holds by construction. Docs say length is counted after case
folding and which characters do not end a word; the eql-bindings README
and the Go README describe the v4 match format as it now is; a test checks
the filter alone, not the filter and verify together.

Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
@coderdan
coderdan marked this pull request as ready for review October 26, 2026 11:15
@coderdan
coderdan requested a review from a team as a code owner October 26, 2026 11:15
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 26, 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 🔄 Running since 2026-10-26T11:15:19.509839Z 5eaabb5 Draft marked ready
🔒 Security Review ✅ Completed 2026-10-26T11:19:42.735997Z 5eaabb5 Draft marked ready
ℹ️ 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.

@cipherstash-agent cipherstash-agent 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 attestation — source: Claude Code /code-review medium followed by company-skills:cross-check-review-findings, run against head 5eaabb57.

Critical / blocking: none.

New findings: none. Reviewed stack-encrypt sem/mod.rs, sem/tokenize.rs, dynamic/{term,record,query,kind}.rs, plan/error.rs, the eql-bindings change and the new tests. I checked these against the head commit:

  • Word tokens use their own PRF domain (stack-encrypt/sem/match/word/v1), and a_word_token_and_an_ngram_of_the_same_characters_share_no_position pins that.
  • validate() refuses short_words beside the standard tokenizer and beside n-grams shorter than 3.
  • The wire form writes short_words only when it is on, so existing plans keep their bytes.
  • Empty-token handling in match_term and bloom_positions still skips the PRF call.
  • QueryTokens::verify compares each kind of token against the same kind in the row.

Already covered by existing threads (no repeat posted):

  • Query-side word tokens narrow substring queries, e.g. @@ 'Mike Ng' no longer finds "Mike Nguyen". Open, deferred to the owner (tokenize.rs ~L167).
  • The separator set splits only on ,;:!, so "Ng.", "Ng-Smith" and newline-separated text get no word token (tokenize.rs ~L182).
  • Plaintext short words on the error path (fixed in 5eaabb57e).
  • Stale README and op.rs labels (fixed).
  • The Go README "substring match" wording (fixed).
  • The test that asserted only verify, not the filter (fixed).
  • Length counted after case folding (doc fixed).
  • The duplicated split logic (fixed with a shared words helper).

The two substantive threads, query-side narrowing and the separator set, are still open and need an owner decision before merge. They are a deliberate trade-off documented in the PR, not a defect I found independently.


Generated by Claude Code

Brings in the Go json_tree stack's first pull requests (#1369, #1370,
#1372). The only conflicts were adjacent CHANGELOG entries in the Go
module and stack-encrypt, resolved by keeping both.

Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
Brings in the rest of the Go json_tree stack (#1373, #1374, #1378). The
only conflicts were adjacent CHANGELOG entries in the Go module and
stack-encrypt, resolved by keeping both.

Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
@coderdan
coderdan merged commit b0c1513 into next Oct 26, 2026
53 checks passed
@coderdan
coderdan deleted the feat/match-short-words branch October 26, 2026 14:27
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