Skip to content

fix(stack): retract the include_original substring-search fiction (CIP-3483)#615

Open
tobyhede wants to merge 1 commit into
feat/eql-v3-text-search-schemafrom
fix-contains-include-original
Open

fix(stack): retract the include_original substring-search fiction (CIP-3483)#615
tobyhede wants to merge 1 commit into
feat/eql-v3-text-search-schemafrom
fix-contains-include-original

Conversation

@tobyhede

Copy link
Copy Markdown
Contributor

Closes CIP-3483. Closes #610.

TL;DR

The bug doesn't exist. v3 contains() matches substrings correctly, and always has. What existed was an unverified code comment that propagated into the docs, a changeset, and a test that manufactured its own confirmation.

What CIP-3483 claims

.contains() on a v3 text_search / text_match column matches only when the needle equals the stored value or is exactly 3 characters. Cause: include_original: true appends a whole-value token to the needle's bloom filter, which the haystack's bloom cannot contain.

What I measured

include_original is inert in protect-ffi. Encrypting the same plaintext with the flag on and off yields the same bloom bits — verified against 0.24.0 (which main pins, so it is the version the issue was filed against) and 0.29.0, under both EQL v2 and v3. The bloom is trigram-only either way, and a value shorter than token_length blooms to nothing under both settings.

The database agrees. Against the live eql-3.0.0 bundle, calling the exact overload both adapters reach — eql_v3.contains(a public.eql_v3_text_search, b jsonb) with storage envelopes on both sides:

needle "alice@example.com"  db_contains=true   expected=true   ok
needle "ali"                db_contains=true   expected=true   ok
needle "alice"              db_contains=true   expected=true   ok
needle "example"            db_contains=true   expected=true   ok
needle "zzzzz"              db_contains=false  expected=false  ok

"alice" and "example" are the issue's own failing examples. "zzzzz" correctly returns false, so this is not a fail-open masquerading as success. Same result for eql_v3_text_match.

How a non-bug got this far

It began as a comment on EncryptedQueryBuilderV3Impl ("KNOWN BROKEN for real substrings … Tracked in EQL; do not paper over it here") and spread to supabase/types.ts, match-defaults.ts, the reference doc, the stash-supabase skill, and the unreleased changeset.

Then supabase-v3-pgrest-live.test.ts "confirmed" it. That suite has no CipherStash credentials and mints its own envelopes — and its bloomTokens() helper prepended the whole-value token itself, to both the seeded row and the query needle:

const tokens = [hash(lower)] // include_original

So the substring failure it asserted was its own invention. Nothing tied that fake to ffi. It was also internally inconsistent: under a real include_original the seeded rows would carry the token too, and "example"'s trigrams would still be contained.

Meanwhile drizzle-v3/operators-live-pg.test.ts:532 has been asserting the truth against real ffi and real Postgres the whole time ('lovelace', 'grace' match). The two tests contradicted each other and the fake won, because it was the one people read.

What changed

  • bloomTokens() emits trigrams only, mirroring real ffi. Sub-trigram values now bloom to nothing, as they actually do. The does not match a longer substring test inverts: 'example' matches 'ada@example.com'. Adds a 'zzz' negative — every other assertion in that suite is satisfied by a contains() that matches everything.
  • v3 domains emit include_original: false. Inert today, but it's the value a substring-search domain wants (and the zod schema's own default) if an ffi release ever starts honouring the flag. v2 keeps its historical true, so no v2 wire moves. Verified there is no other consumer: the CLI SQL's config_match_default() is a v2-only DB-side helper that never reads the client-emitted field.
  • New match-bloom-live.test.ts pins the invariant where it actually lives — on ffi's bloom output, not through a stack builder. This is the test that would have caught the fiction.
  • Drops the false claims from query-builder-v3.ts, supabase/types.ts, match-defaults.ts (include_original does not make sub-trigram values matchable — that fail-open is exactly what matchNeedleError guards), the reference doc, the skill, and the changeset.

Left alone: the columns.ts / stash-encryption note that index inference picks unique before match, so encryptQuery without an explicit queryType builds an equality term. That one is real, and unrelated.

Two things found while implementing

Bloom bit order is nondeterministic. Two encrypts of the same plaintext on the same client return the same bits in a different sequence. Only the bit set is meaningful (@> is smallint[] containment), so the new test sorts before comparing. Any future assertion on bf must do the same.

schema-v3.test.ts:14 was a LOAD-BEARING test asserting v3 builds byte-identically to v2, which the flip deliberately breaks. I didn't delete it — its real job is guarding the v2 wire from drift. It now asserts both sides of the one intentional divergence (v3 === false, v2 === true) and excludes only include_original from the byte-identity check, so neither side can drift unnoticed.

Verification

  • Full stack unit suite green: 69 files, 1682 tests.
  • match-bloom-live.test.ts: 5 tests green against real ffi + credentials.
  • Typecheck: 226 pre-existing errors before and after — none added.
  • Biome clean.
  • packages/drizzle's one failure is Missing env.DATABASE_URL, environmental; schema-extraction.test.ts passed untouched, confirming the v3 flip didn't reach the v2-override test.

Not run locally, and the highest-value thing for a reviewer to check:

  • supabase-v3-pgrest-live.test.ts needs PGRST_URL (unset here). Its four rewritten contains assertions were verified against the corrected bloom oracle by hand (example and ada contained, zzz not, ad[]).
  • drizzle-v3/operators-live-pg.test.ts needs stale protect_ci_v3_* tables dropped from the dev database first, or beforeAll fails on an older schema.

Follow-ups (not in this PR)

  • Remove the warn callout in cipherstash/docs#39, which documents the non-existent limitation.
  • File upstream: protect-ffi should either honour include_original or reject it. Silently accepting dead config across at least 0.24 → 0.29, in both EQL versions, is what made this plausible enough to survive.

CIP-3483 reports that v3 `contains()` cannot match substrings, blaming
`include_original: true` for appending a whole-value token to the needle's
bloom that the haystack's bloom cannot contain. Measured against real ffi,
none of that is true.

`include_original` is INERT in protect-ffi. Encrypting the same plaintext
with the flag on and off yields the same bloom bits (0.24 — main's pin, so
the version the issue was filed against — and 0.29, under EQL v2 and v3).
The bloom is trigram-only either way, and a value shorter than token_length
blooms to nothing under both settings. Against the live eql-3.0.0 bundle,
`eql_v3.contains(a public.eql_v3_text_search, b jsonb)` with storage
envelopes on both sides matches the issue's own failing needles ('alice',
'example') and correctly rejects an absent one.

The claim was never measured. It began as a comment on the v3 supabase
builder ("KNOWN BROKEN for real substrings ... do not paper over it here")
and propagated into types.ts, the reference doc, the skill, and this
changeset. supabase-v3-pgrest-live then "confirmed" it: that suite has no
credentials and mints its own envelopes, and its `bloomTokens` prepended
the whole-value token itself — to the seeded row AND the needle — so the
substring failure it asserted was its own invention. Nothing tied the fake
to ffi. drizzle-v3/operators-live-pg has been asserting the truth against
real ffi and real Postgres the whole time.

- bloomTokens emits trigrams only, mirroring ffi (sub-trigram values now
  bloom to nothing, as they really do). The `does not match a longer
  substring` test inverts: 'example' matches 'ada@example.com'. Adds a
  'zzz' negative — every other assertion in that suite is satisfied by a
  contains() that matches everything.
- The v3 domains emit `include_original: false`: inert today, but it is
  the value a substring-search domain wants (and the zod schema's own
  default) if an ffi release ever starts honouring the flag. v2 keeps its
  historical `true`, so no v2 wire moves. The v3-vs-v2 parity test now
  asserts both sides of that divergence rather than byte-identity, so
  neither can drift unnoticed.
- match-bloom-live pins the invariant where it actually lives — on ffi's
  bloom output, not through a stack builder. It also documents that the
  bit ORDER is nondeterministic across encrypts of the same plaintext:
  only the bit set is meaningful, so comparisons sort first.
- Drops the false claims from query-builder-v3, supabase/types,
  match-defaults (include_original does NOT make sub-trigram values
  matchable — that fail-open is exactly what matchNeedleError guards),
  the supabase reference doc, and the stash-supabase skill.

Left alone: the columns.ts / stash-encryption note that index inference
picks `unique` before `match`, so encryptQuery without an explicit
queryType builds an equality term. That one is real and unrelated.

Not verified here: the pgrest suite needs PGRST_URL (unset locally); its
four rewritten assertions were checked against the corrected oracle.
Upstream, protect-ffi should either honour `include_original` or reject
it — silently accepting dead config is what made this plausible.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@tobyhede tobyhede requested a review from a team as a code owner July 10, 2026 01:26
@changeset-bot

changeset-bot Bot commented Jul 10, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 5aef872

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 changesets to release 6 packages
Name Type
@cipherstash/stack Minor
@cipherstash/bench Patch
@cipherstash/prisma-next Patch
@cipherstash/basic-example Patch
@cipherstash/prisma-next-example Patch
@cipherstash/e2e Patch

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 Jul 10, 2026

Copy link
Copy Markdown

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: defaults

Review profile: CHILL

Plan: Pro

Run ID: 597c8d4d-eadc-4726-80d3-9d6f1e353839

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
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix-contains-include-original

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

Comment on lines +81 to +85
])('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))

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.

The test values are generated here with encrypt not encryptQuery so all this does is prove that there's a bug in protect-ffi: i.e. that include_original is ignored when it shouldn't be!

@coderdan

Copy link
Copy Markdown
Contributor

I don't think this is correct and Claude is confused. It isn't distinguishing between a bloom filter generated for storage and one generated for query. Queries must not set include_original = true (see below). However, because we use encrypt to generate both storage and query values, the only way to make things work is to never include the original values (which is a bug in the storage path).

Counter example

This is why original values can't be included in bloom filters for query payloads. A bloom filter is just a set with collisions so the easiest way to reason about this is to look at the plaintext term set and ignore collisions.

Broken case

Stored term is "Danger", query includes the original.

"Danger"
// stored value including original
S = [danger, dan, ang, ger]

Query: "Dang"
// queried value including original
Q = [dang, dan, ang, ger]

S does not contain Q (because S does not contain "dang")

Correct case

Stored term as above but the query doesn't include the original.

"Danger"
// stored value including original
S = [danger, dan, ang, ger]

Query: "Dang"
// queried value including original
Q = [dan, ang, ger]

S does contain Q

@coderdan coderdan 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.

The logic here is broken as per my comment.

2 separate issues:

  • include_query is being ignored by encrypt (protectjs-ffi bug)
  • encrypt is used for generating queries (instead of encryptQuery which should never set include_query=true even if the storage path does)

coderdan added a commit that referenced this pull request Jul 10, 2026
A single 3-character needle was insufficient coverage for free-text search, and
the reason is subtle: its whole-value token IS a trigram, so it matches whether
or not `include_original` is honoured. That is the degenerate case, and it was
the only case the suite exercised.

The match index blooms downcased 3-grams; `include_original: true` additionally
blooms the whole value as one token. Storage wants that. A QUERY operand must
not have it, or the needle's bloom carries a whole-needle token the haystack's
bloom cannot contain, and every strict-substring search returns zero rows.

Both v3 adapters build match operands with `encrypt` — the full storage envelope
— rather than `encryptQuery`, because PostgREST cannot cast a filter value to
`eql_v3.query_*`. Both are therefore structurally exposed. It is masked today
only because protect-ffi ignores `include_original` outright, so neither side
carries the token: two bugs cancel. When protect-ffi is fixed, substring search
breaks silently.

Two layers of coverage:

- The text family now searches four needles per match-capable domain: one
  trigram (degenerate), a strict interior substring longer than a trigram (the
  discriminating case), the whole stored value, and a needle absent from every
  value.

- `match-bloom.integration.test.ts` asserts the precondition directly on the
  wire — bloom(needle) must be a subset of bloom(haystack) for every substring —
  so a failure names its cause instead of surfacing as "a query returned no
  rows". It also proves its own sensitivity: the honest operand is contained,
  and unioning in the bits of one value the haystack never saw is enough to
  break the subset. An `include_original` token is exactly such a foreign token.

These go red the day protect-ffi honours the flag. That is the intent: the fix
is to stop the operand carrying the original token, not to relax the tests.
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