-
Notifications
You must be signed in to change notification settings - Fork 5
fix(stack): retract the include_original substring-search fiction (CIP-3483) #615
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
coderdan
merged 4 commits into
feat/eql-v3-text-search-schema
from
fix-contains-include-original
Jul 12, 2026
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
5aef872
fix(stack): retract the include_original substring-search fiction
tobyhede 2528c21
Merge remote-tracking branch 'origin/feat/eql-v3-text-search-schema' …
coderdan bad39eb
test(stack): drive match-bloom substring proof with encryptQuery, not…
coderdan 58d7439
chore: add stash changeset for the stash-supabase skill correction
coderdan File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| --- | ||
| "stash": patch | ||
| --- | ||
|
|
||
| Correct the bundled `stash-supabase` agent skill: EQL v3 `contains()` matches | ||
| substrings. The skill previously carried the reverse — that `contains()` matched | ||
| only exact values because the query's bloom filter appended the whole search term | ||
| as an extra token. That was never true: `include_original` is inert in | ||
| protect-ffi (the match bloom is trigram-only either way), so any substring of at | ||
| least the tokenizer's `token_length` (3 characters) matches, and shorter terms are | ||
| rejected rather than silently matching every row. The skills directory ships | ||
| inside the `stash` tarball and is copied into the user's `.claude/skills/` / | ||
| `.codex/skills/` (or inlined into `AGENTS.md`) at handoff time, so the stale | ||
| sentence was shipping wrong guidance into customer repos. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,140 @@ | ||
| import 'dotenv/config' | ||
| import { encrypt, encryptQuery, newClient } from '@cipherstash/protect-ffi' | ||
| import { beforeAll, describe, expect, it } from 'vitest' | ||
| import { defaultMatchOpts } from '@/schema/match-defaults' | ||
|
|
||
| /** | ||
| * Pins what protect-ffi's match index ACTUALLY emits, against real ffi. | ||
| * | ||
| * This suite exists because a claim about ffi's bloom filter — that | ||
| * `include_original: true` appends a whole-value token, so a substring `contains` | ||
| * can never match — was asserted in a code comment, propagated into the docs and | ||
| * the changeset, and then "confirmed" by `supabase-v3-pgrest-live.test.ts`, whose | ||
| * credential-free fake built the extra token itself. Nothing tied that fake to | ||
| * ffi, so the fiction round-tripped. It cost a bug report (CIP-3483) and a | ||
| * limitation callout in the published docs. | ||
| * | ||
| * The invariant can only be observed where the bloom is actually built, so these | ||
| * tests call ffi directly rather than going through a stack builder. They need | ||
| * CipherStash credentials but no database. | ||
| */ | ||
| const TABLE = 'match_bloom_probe' | ||
| const COLUMN = 'col' | ||
|
|
||
| /** A match column, tokenized exactly as the shared defaults specify. */ | ||
| const encryptConfigWith = (includeOriginal: boolean) => ({ | ||
| v: 1, | ||
| tables: { | ||
| [TABLE]: { | ||
| [COLUMN]: { | ||
| cast_as: 'text' as const, | ||
| indexes: { | ||
| match: { ...defaultMatchOpts(), include_original: includeOriginal }, | ||
| }, | ||
| }, | ||
| }, | ||
| }, | ||
| }) | ||
|
|
||
| type Client = Awaited<ReturnType<typeof newClient>> | ||
|
|
||
| describe('protect-ffi match bloom', () => { | ||
| let withOriginal: Client | ||
| let withoutOriginal: Client | ||
|
|
||
| beforeAll(async () => { | ||
| withOriginal = await newClient({ | ||
| encryptConfig: encryptConfigWith(true), | ||
| eqlVersion: 3, | ||
| }) | ||
| withoutOriginal = await newClient({ | ||
| encryptConfig: encryptConfigWith(false), | ||
| eqlVersion: 3, | ||
| }) | ||
| }) | ||
|
|
||
| const bloomOf = async (client: Client, plaintext: string) => { | ||
| const payload = await encrypt(client, { | ||
| plaintext, | ||
| table: TABLE, | ||
| column: COLUMN, | ||
| }) | ||
| return (payload as { bf?: number[] }).bf ?? [] | ||
| } | ||
|
|
||
| // The bloom of a QUERY term, produced the way a `contains` query actually | ||
| // produces its needle — `encryptQuery` with the `match` index type, not | ||
| // `encrypt`. This is the operand `eql_v3.contains` binds on the right of `@>`, | ||
| // so a subset test against a stored `bloomOf` value is the real query path, | ||
| // not a same-function artefact comparing `encrypt` to itself. | ||
| const queryBloomOf = async (client: Client, plaintext: string) => { | ||
| const term = await encryptQuery(client, { | ||
| plaintext, | ||
| table: TABLE, | ||
| column: COLUMN, | ||
| indexType: 'match', | ||
| }) | ||
| return (term as { bf?: number[] }).bf ?? [] | ||
| } | ||
|
|
||
| /** | ||
| * ffi emits the bloom's bits in a nondeterministic ORDER — two encrypts of the | ||
| * same plaintext on the same client differ in sequence while carrying the same | ||
| * bits. Only the bit SET is meaningful (`@>` is `smallint[]` containment), so | ||
| * every comparison here sorts first. | ||
| */ | ||
| const sorted = (bits: number[]) => [...bits].sort((a, b) => a - b) | ||
|
|
||
| // The claim, killed at its source. `include_original` is accepted by the | ||
| // config and ignored: the bloom is the tokenizer's trigrams, nothing more. | ||
| // If a future ffi starts honouring the flag, THIS fails — and the v3 domains | ||
| // already emit `false` (see `eql/v3/columns.ts`), so `contains` keeps working. | ||
| it.each([ | ||
| 'Ada Lovelace', | ||
| 'ada@example.com', | ||
| ])('emits the same bloom for %j whether include_original is on or off', async (plaintext) => { | ||
| const on = await bloomOf(withOriginal, plaintext) | ||
| const off = await bloomOf(withoutOriginal, plaintext) | ||
|
|
||
| expect(sorted(on)).toEqual(sorted(off)) | ||
| // A trigram-only bloom, not one carrying an extra whole-value token: 6 | ||
| // bits (k) per DISTINCT trigram, so never more than 6 × the trigram count. | ||
| expect(on.length).toBeLessThanOrEqual(6 * (plaintext.length - 2)) | ||
| expect(on.length).toBeGreaterThan(0) | ||
| }) | ||
|
|
||
| // The corollary, and the reason `matchNeedleError` must reject short needles | ||
| // instead of trusting `include_original` to make them matchable: below | ||
| // `token_length` there are no trigrams, so the bloom is EMPTY under either | ||
| // setting — and `stored_bf @> '{}'` is true for every row. | ||
| it.each([ | ||
| ['with include_original', true], | ||
| ['without include_original', false], | ||
| ] as const)('blooms a sub-trigram value to nothing, %s', async (_label, on) => { | ||
| const bloom = await bloomOf(on ? withOriginal : withoutOriginal, 'ad') | ||
|
|
||
| expect(bloom).toEqual([]) | ||
| }) | ||
|
|
||
| // The query needle's bloom is a subset of the STORED value's bloom — which is | ||
| // exactly what `eql_v3.contains` (`match_term(stored) @> query_term(needle)`) | ||
| // tests. The haystack is a stored `encrypt` value; each needle is an | ||
| // `encryptQuery` `match` term, the real operand the containment query binds — | ||
| // so this exercises the query path, not `encrypt` compared to itself. This is | ||
| // the substring case CIP-3483 reported as broken, proven at the bloom layer; | ||
| // `drizzle-v3/operators-live-pg.test.ts` proves the same thing through SQL. | ||
| it('blooms a substring query needle into a subset of the stored value bloom', async () => { | ||
| const haystack = new Set(await bloomOf(withOriginal, 'ada@example.com')) | ||
|
|
||
| for (const needle of ['ada', 'example', 'ada@example.com']) { | ||
| const bits = await queryBloomOf(withOriginal, needle) | ||
| expect(bits.length).toBeGreaterThan(0) | ||
| expect(bits.filter((bit) => !haystack.has(bit))).toEqual([]) | ||
| } | ||
|
|
||
| // A needle sharing no trigram must NOT be a subset, or the assertion above | ||
| // would hold for anything. | ||
| const absent = await queryBloomOf(withOriginal, 'zzz') | ||
| expect(absent.some((bit) => !haystack.has(bit))).toBe(true) | ||
| }) | ||
| }) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.