Repository navigation
feat(stack-encrypt): make two-letter words such as "Ng" searchable in the match index - #1397
Conversation
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 detectedLatest commit: 93fd33e The changes in this PR will be included in the next version bump. This PR includes changesets to release 12 packages
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 |
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the
|
Mutation testing (cargo-mutants,
|
| 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
coderdan
left a comment
There was a problem hiding this comment.
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
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
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
left a comment
There was a problem hiding this comment.
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), anda_word_token_and_an_ngram_of_the_same_characters_share_no_positionpins that. validate()refusesshort_wordsbeside the standard tokenizer and beside n-grams shorter than 3.- The wire form writes
short_wordsonly when it is on, so existing plans keep their bytes. - Empty-token handling in
match_termandbloom_positionsstill skips the PRF call. QueryTokens::verifycompares 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.rslabels (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
wordshelper).
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
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 aTextMatchvalue 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 separatorsTokenizer::Standardsplits 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".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.TermError::InvalidOptions) beside the standard tokenizer, which already emits whole words, and beside n-grams shorter than 3, where no word is short enough.PlanError::IndexOptions), as a mismatchedkis.{"match": {"short_words": true, ...}}, written only when on, so every other plan serializes to the same bytes as before. TheIndexOptionserror payload follows the same rule.expected_tokensfetch prior's docs say it counts word tokens.semmodule 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) setsshort_words: true. Text with no two-letter word derives the filter it did; the pinned "alice" positions do not move.@@overTextMatchandTextSearchcolumns.@cipherstash/eqlminor.Go SDK (
languages/golang)encrypt_into=TextMatch/TextSearch/TextSearchOregets the word token. Tests updated to match ("Ng" stores and is a query; one letter is refused), README and CHANGELOG updated.index=matchfield still takes no options:stashgenrefuses every index option until the guest derives query terms under a field's own options (Go SDK: stashgen keeps its own copy of engine rules (index options, index outputs, a second lowering) — ask the engine instead #1112). So the option reaches Go through the EQL types only.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.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.mise run test:encryption: 527 passed.mise run test:encryption:postgresagainst a fresh database on localhost:5400: 43 passed, includingpostgres_finds_two_letter_surnames.wasm:guest:build,wasm:guest:build:eqland both deterministic builds,wasm:auth-guest:build;mise run go:testpassed;mise run go:lint: 0 issues;go generate ./...changed nothing;mise run go:test:postgrespassed.cargo metadata --lockedin the root, EQL, protect-ffi and both guest workspaces: clean.cargo mutants --in-diffover stack-auth and stack-encrypt (the CI gate): 31 caught, 6 unviable, 1 missed on the first push (Tokens::zeroizereplaced 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
index=matchfield does not take the option.ng.Review notes
MatchOptionsgains 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.Tokenizer::Standard, so "Ng." and "Ng-Smith" carry nongtoken. 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