fix(stack): close v3 drizzle fail-open paths, fix false-negative tests#607
Conversation
An audit of the EQL v3 Drizzle adapter found the index/query-type wiring
correct, but several tests unable to detect the bug they guard. Three stayed
green under the exact regression they existed to catch, and two source paths
failed open.
Source:
- `contains` now rejects a needle shorter than the tokenizer's `token_length`.
A short needle builds an EMPTY bloom filter and `stored_bf @> '{}'` holds for
every row. Measured live: needles "ad", "a" and "x" each returned 3/3 seeded
rows -- "x" occurs in none of them. The floor lives in `schema/match-defaults`
so v2 and v3 share it; they build byte-identical blooms.
- `v3FromDriver` throws the new `EqlV3CodecError` rather than surfacing a raw
SyntaxError on malformed JSON or passing a wrong-shape payload through:
`v3FromDriver('5')` returned `5` typed as `Encrypted`. The shape guard accepts
SteVec documents, whose ciphertext is at `sv[0].c` with no top-level `c`.
- Removed the unreachable `sql`false`` fallback in `inArray`/`notInArray`; the
empty-list guard throws first.
Tests (each verified RED against the bug it guards, then reverted):
- `and` was indistinguishable from `or`: both predicates were true for ROW_B
alone, so swapping the operator still passed. Now paired over disjoint rows
and asserted from both directions.
- bigint filters ran against a one-row table, where `gt` returning every row
passed. Seeded a negative row they must exclude.
- `between` was only ever called with identical bounds against a constant
encrypt stub, so a min/max transposition in `range` was invisible. Added an
echoing stub that pins operand order.
- `contains` had no needle that must NOT match, and none longer than
`token_length`. The unit test searched `TEXT_S[0]` -- the empty string --
i.e. it asserted the fail-open. Now drives four needles through a substring
oracle, including a negative one.
- Seeded a third matrix row. With two, every predicate could only return [A],
[B], [A,B] or [] -- ordering ties were untestable and `eq` over-matching
undetectable. Row `i` takes `samples[min(i, len-1)]`, the scheme
`v3-matrix/matrix-live-pg` already uses.
Lock context:
The suite supplied the same context on seed and query in every test, so a
regression dropping it from the index term would drop it identically on both
sides and stay green. Added the negatives obtainable with one JWT: an
identity-bound row must not match an `eq` issued with no lock context, and must
not decrypt without it. A cross-identity proof needs a second JWT with a
different `sub` and remains a follow-up.
`lock-context.test.ts` wrapped `decryptModel` in try/catch, but it reports
denial as a `Result` and never throws -- the test ran zero assertions and would
also have passed had decryption wrongly succeeded.
Smaller pins: sql-dialect asserts operands BIND rather than interpolate;
schema-extraction uses toStrictEqual; the barrel is pinned exhaustively; codec
fixtures are realistic envelopes; the column's customType mappers are exercised.
Not included, per plan: the SQL `bloom_filter` empty-array change (contradicts
a documented semantic; needs sign-off) and the Supabase needle guard (lives on
another branch).
|
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 ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
🦋 Changeset detectedLatest commit: 2bc4872 The changes in this PR will be included in the next version bump. This PR includes changesets to release 6 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 |
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (2)
packages/stack/__tests__/drizzle-v3/sql-dialect.test.ts (1)
61-101: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueWell-structured parameter-binding tests.
The three test groups correctly pin the security-critical property that operand values are bound as positional parameters rather than inlined into SQL text:
- Hostile operands: The
paramsequality check,$1::jsonbpresence, andnot.toContain('OR 1=1')assertions comprehensively guard against injection.- Range: The expected SQL
(eql_v3.gte("users"."x", $1::jsonb) AND eql_v3.lte("users"."x", $2::jsonb))matches the parenthesised min-first output fromv3Dialect.range.- Large ciphertext: The 16KB payload test confirms no truncation or inlining occurs.
One minor note: the
it.eachtitle'%s binds a hostile operand as $1'produces slightly redundant names (e.g., "equality binds a hostile operand as equality") since$1substitutes the first array element. If the intent was to evoke the SQL$1placeholder, consider a title like'%s binds a hostile operand as a positional parameter'for clarity.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/stack/__tests__/drizzle-v3/sql-dialect.test.ts` around lines 61 - 101, The parameter-binding tests are good, but the `it.each` title in `sql-dialect.test.ts` is misleading because `%s` expands to the case name rather than the SQL placeholder, producing redundant descriptions. Update the `it.each` test name in the `operand values are bound, never interpolated into SQL text` block to describe positional binding clearly, and keep the assertions in `renderFull`, `v3Dialect.equality`, `v3Dialect.comparison`, `v3Dialect.contains`, and `v3Dialect.range` unchanged.packages/stack/src/eql/v3/drizzle/codec.ts (1)
69-91: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueConsider preserving the original parse error as
cause.The
EqlV3CodecErrorthrown on JSON parse failure embeds the cause's message in the string but doesn't pass it as a structuredcause. If the project targets ES2022+, usingsuper(message, { cause })would preserve the original stack trace and improve debuggability.♻️ Optional refactor
throw new EqlV3CodecError( `Failed to parse an EQL v3 encrypted envelope from the driver: ${cause instanceof Error ? cause.message : String(cause)}`, + // If your tsconfig targets ES2022+, pass cause for better stack traces: + // { cause }, )🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/stack/src/eql/v3/drizzle/codec.ts` around lines 69 - 91, The JSON parse failure path in v3FromDriver currently only copies the parse error message into EqlV3CodecError, but drops the original error as structured context. Update the catch block in v3FromDriver to pass the caught error through as a cause when constructing EqlV3CodecError, so the original stack and metadata are preserved while keeping the existing message from the parse failure.Source: Coding guidelines
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@packages/stack/__tests__/drizzle-v3/operators-lock-context-live-pg.test.ts`:
- Around line 224-254: The two decrypt-denial tests in
operators-lock-context-live-pg.test.ts can falsely pass when the SQL query
returns no row because `row.value` throws inside `decryptDenied`. In both test
cases that destructure `[row]` after the `sqlClient.unsafe` query, add an
explicit assertion that `row` exists before calling `client.decrypt(...)`, so
the failure is reported as a missing fixture rather than a denied decrypt. Use
the existing `decryptDenied` helper and the `client.decrypt`/`withLockContext`
calls as the location to update.
---
Nitpick comments:
In `@packages/stack/__tests__/drizzle-v3/sql-dialect.test.ts`:
- Around line 61-101: The parameter-binding tests are good, but the `it.each`
title in `sql-dialect.test.ts` is misleading because `%s` expands to the case
name rather than the SQL placeholder, producing redundant descriptions. Update
the `it.each` test name in the `operand values are bound, never interpolated
into SQL text` block to describe positional binding clearly, and keep the
assertions in `renderFull`, `v3Dialect.equality`, `v3Dialect.comparison`,
`v3Dialect.contains`, and `v3Dialect.range` unchanged.
In `@packages/stack/src/eql/v3/drizzle/codec.ts`:
- Around line 69-91: The JSON parse failure path in v3FromDriver currently only
copies the parse error message into EqlV3CodecError, but drops the original
error as structured context. Update the catch block in v3FromDriver to pass the
caught error through as a cause when constructing EqlV3CodecError, so the
original stack and metadata are preserved while keeping the existing message
from the parse failure.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: c17aa09e-17cf-4925-9fc0-6875b0e02f81
📒 Files selected for processing (14)
.changeset/eql-v3-drizzle-fail-open-guards.mdpackages/stack/__tests__/drizzle-v3/codec.test.tspackages/stack/__tests__/drizzle-v3/column.test.tspackages/stack/__tests__/drizzle-v3/exports.test.tspackages/stack/__tests__/drizzle-v3/operators-live-pg.test.tspackages/stack/__tests__/drizzle-v3/operators-lock-context-live-pg.test.tspackages/stack/__tests__/drizzle-v3/operators.test.tspackages/stack/__tests__/drizzle-v3/schema-extraction.test.tspackages/stack/__tests__/drizzle-v3/sql-dialect.test.tspackages/stack/__tests__/lock-context.test.tspackages/stack/src/eql/v3/drizzle/codec.tspackages/stack/src/eql/v3/drizzle/index.tspackages/stack/src/eql/v3/drizzle/operators.tspackages/stack/src/schema/match-defaults.ts
Review of #607 found the needle guard it added was itself incomplete, and two claims it made were false. Three agents verified each finding against live Postgres before anything changed here. The guard counted UTF-16 code units; the ngram tokenizer counts CODEPOINTS. A needle of 1-2 astral-plane characters cleared the floor, produced no trigram, and so built an EMPTY bloom filter -- `stored_bf @> '{}'` matches every row. Measured live against three seeded rows: "👍👍" utf16=4 cp=2 -> 3/3 rows <- passed the guard "👍👍👍" utf16=6 cp=3 -> 0/3 rows "joh" utf16=3 cp=3 -> 1/3 rows Codepoints, not graphemes: NFD "e+combining-acute" twice is 4 codepoints but 2 grapheme clusters, and does build a non-empty filter (0/3 rows live). A grapheme floor would wrongly reject it; a byte floor would wrongly accept the emoji. The three measurements are consistent only with codepoint counting. Also rejects the empty needle under ANY tokenizer. `matchNeedleMinLength` returns undefined for the `standard` tokenizer, so the guard short-circuited and `contains(col, '')` tokenized to nothing and matched every row. Unreachable via the v3 `types` namespace today, which hardcodes ngram, but the schema permits it. Tests, written first and watched fail: - `__tests__/match-needle-guard.test.ts` pins the unit. The astral and empty-needle cases failed before the fix; the NFD case passed before and after, proving the fix does not over-correct into rejecting usable needles. Unicode is built from explicit escapes -- the precomposed NFC form is a different, 2-codepoint string, so a bare literal would silently test the opposite case depending on the file's on-disk normalisation. - The live `contains` rejection now asserts the guard's own message, not just `EncryptionOperatorError`. `operandFailure` throws the same class, and that is not hypothetical: `text_search` carries an `ore` index and cannot encrypt any non-ASCII operand ("Can only order strings that are pure ASCII"), so the astral case was passing there for the wrong reason. That constraint now has its own test, and the codepoint complement runs only on match-only columns. Two false claims corrected rather than papered over: - The changeset and the doc comment both said the floor was "shared with the v2 adapter". It is not -- `matchNeedleError` has exactly one caller, in v3. v2's `like`/`ilike` reduces to `eql_v2.bloom_filter(a) @> eql_v2.bloom_filter(b)` (cipherstash-encrypt.sql), native array containment, so v2 has the same fail-open and is NOT fixed here. Wiring it in needs its own measurement first: v2 needles carry SQL wildcards, so the floor may belong on the unwrapped term. - `decryptDenied` returned a bare boolean and treated any throw as denial, so a ZeroKMS outage read as "the identity boundary held". It now returns the message and the call sites assert /^Failed to retrieve key/, matching the assertion this PR already made in `lock-context.test.ts`. The `as SQL` cast in `inArrayOp` was reviewed and kept: drizzle returns undefined only for an empty filtered list, and the empty-list guard throws first. Restoring the old `?? sql`false`` fallback would be worse -- it would mask a `notInArray` that silently matches everything.
v3FromDriver flattened the JSON.parse failure into a message string,
discarding the SyntaxError and its stack. Widen EqlV3CodecError to accept
ErrorOptions and forward { cause }, so a caller debugging a corrupt column
keeps the original error. The package targets ES2022, so Error.cause is
available; assertEnvelope's call sites have no upstream error and keep
working since options is optional.
Also fix a misleading it.each title in sql-dialect.test.ts: vitest consumed
the `$1` as a tuple index and interpolated the builder function, rendering
"binds a hostile operand as [Function]". Reword to name positional binding
directly.
freshtonic
left a comment
There was a problem hiding this comment.
Reviewed against a local checkout. Unit suites (297 tests across the 7 touched files) and pnpm --filter @cipherstash/stack test:types both pass. I did not run the live-pg suites.
The two source fixes are correct and the test work is genuinely good — the mutation-verification table in the description is the right way to argue that a test can fail, and the and/between/contains false negatives it turned up were real. A few things below.
Dead-code removal is sound
I checked the claim rather than take it on faith. In inArrayOp, values.length === 0 throws, and encryptOperands rejects any bulk response whose length differs from values.length. So conditions is provably non-empty, and(...)/or(...) cannot return undefined, and the as SQL cast is not hiding a fail-open. Removing the sql\false`` fallback is safe.
Similarly, matchNeedleMinLength can't silently skip the floor: token_length is a required field of the ngram variant in tokenizerSchema (schema/index.ts:102-105), not an optional one, so the kind === 'ngram' narrowing always yields a number.
The v2 like/ilike fail-open is the same bug, and it's the one that ships
The changeset notes this and defers it, with a good reason — v2 needles carry SQL wildcards, so the floor has to be measured against what the tokenizer actually receives. That reasoning is right and I'm not asking for the fix here.
But I want to be precise about what's being deferred. In src/drizzle/operators.ts:825, a freeTextSearch column encrypts right verbatim and renders eql_v2.ilike(col, $1), which is the same bloom containment. ops.ilike(users.email, 'ad') — no wildcards, two characters — builds an empty filter and returns the whole table. The wildcard caveat doesn't cover that call; it's unguarded today, in a published 0.18.0, on the adapter that has users. notIlike fails closed (returns nothing), so only the positive operators leak.
v3 Drizzle is new surface. The severity ordering is inverted from the fix ordering here.
Concretely: could you link the tracking issue from the changeset and the matchNeedleError docblock? Both say "tracked separately" with no reference. Given this PR just built the mechanism, a v2 needle guard that only applies when the term contains no wildcard characters would close the concrete case above without needing the tokenizer archaeology.
assertEnvelope accepts an empty SteVec
sv: [] satisfies envelope.sv !== undefined and passes, but the docblock's premise is that the root ciphertext lives at sv[0].c. Array.isArray(sv) && sv.length > 0 would make the guard match what it documents. Low stakes — I can't construct a path that produces it — but the whole point of the change is that a wrong value here reaches decrypt as garbage.
needleFor counts UTF-16 code units
__tests__/drizzle-v3/operators.test.ts:
const needle = spec.samples.find(
(sample) => typeof sample === 'string' && sample.length >= 3,
)This is the exact check the guard exists to reject. It's harmless today because every match-domain sample is ASCII, but if someone adds an astral-plane sample to the catalog, needleFor selects a needle the guard then throws on, and the failure will point at the wrong place. [...sample].length >= 3 for consistency with matchNeedleError.
The live positive path for ops.and is gone
The old and combines encrypted predicates asserted [ROW_B]. Replacing it with the disjoint pair asserting [] is the right call — the old one couldn't distinguish and from or. But now no live test proves ops.and ever returns rows.
An and that emitted a constant false predicate would pass and → [], and or → [A,B,C] wouldn't catch it because it exercises or. The unit test and ignores undefined conditions and keeps the encrypted predicates does pin the rendered SQL contains and, so this is defended, just not live. Cheapest fix is to keep both: the disjoint pair for the operator-swap mutation, plus the old intersecting pair (eq(text_eq, 'ada@example.com') and lt(integer_ord, 0) → [ROW_B]) for the positive path.
KEY_DENIAL on the ['email'] claim test
.withLockContext({ identityClaim: ['email'] } as never)asserted against /^Failed to retrieve key/.
If the test JWT carries no email claim, does ZeroKMS report a key-derivation denial, or does resolution fail earlier with a different message? The suite doesn't run in CI today (the USER_JWT gap you flagged), so this won't bite until it does — but that's exactly when a message-shape assumption is most expensive to debug. Worth confirming against a real token, or loosening to "denied, and not an infrastructure fault" if the claim-missing message differs.
Formatting
codec.test.ts fails biome check — the it.each in preserves the underlying SyntaxError as \cause`needs reflowing. Looks like the7a76041fixup wasn't formatted.pnpm code:fixbefore merge. Biome isn't intests.yml`, so CI won't catch it.
Minor
exports.test.ts now has exports exactly the public surface, which strictly subsumes both exports the public surface and re-exports the codec and column helpers as callables. The latter two check typeof === 'function', which the exhaustive key check doesn't, so they aren't pure duplication — but three tests over one barrel is a lot of surface for what a single toEqual on a sorted key list plus one typeof sweep would cover.
Nothing here blocks. The changeset is honest about the breaking change and minor is the right bump pre-1.0. The main ask is the tracking link for v2, and confirming the contains/asc/desc proofs go green on CI's superuser Postgres — as your table says, that's the thing to watch.
freshtonic
left a comment
There was a problem hiding this comment.
Approved with non-blocking feedback
Address review findings on the v3 Drizzle adapter.
assertEnvelope accepted `sv: []`, which satisfies `sv !== undefined` but has
no `sv[0].c` — the guard's own premise. An empty, non-array, or null `sv`
carries a ciphertext key but no ciphertext, and reached decrypt as garbage.
Require a non-empty array. No encrypt path produces this, so it is hardening
against a corrupt or hostile row rather than a live fail-open.
needleFor selected samples with `sample.length >= 3`, counting UTF-16 code
units where the production guard counts codepoints. An astral sample ('👍👍':
4 units, 2 codepoints) would be picked here and then rejected by the guard,
blaming the wrong line. Latent only because every match-domain sample is
currently ASCII. Extract it to v3-matrix/needle-for.ts and select with
matchNeedleError itself, so the two cannot diverge; it now also honours a
domain's own token_length.
Pairing `and` over disjoint rows proved it is not `or`, but left every live
`and` assertion expecting []. A constant-false `and` would satisfy that, and
the `or` tests exercise a different operator. Restore the intersecting pair
alongside it, so one live test proves `and` returns its row.
The ['email'] lock-context test pinned /^Failed to retrieve key/. Naming a
claim does not assert the token carries it: resolveLockContext is a
passthrough and ZeroKMS resolves the value server-side, so a token with no
`email` claim may be refused by authorization before key derivation is
attempted. Accept either denial class, reject infrastructure faults
explicitly. Kept loose because CI never runs this path (USER_JWT unset, #530).
Collapse exports.test.ts from three tests to two: the two typeof sweeps were
one sweep artificially split. The remaining sweep iterates Object.entries, so
it cannot silently skip a name the key list forgot.
The v2 like/ilike path has the same bloom-containment fail-open and remains
unfixed here, as the changeset already notes.
decryptOutcome counts a throw as denial, so an unseeded row makes `row.value` raise a TypeError that reads as "denied". The message assertions already reject that string, so the tests fail either way — but they fail blaming the denial regex rather than the fixture. Assert the row exists at all three `[row]` sites, not just the two decrypt-denial tests: the symmetric decrypt test has the same destructure, and leaving it unguarded is inconsistent. Raised by CodeRabbit on #607, against the older `decryptDenied` helper that returned a bare boolean. Under that version the false pass was real; the message assertions have since closed it. This is diagnostic clarity, not a correctness fix.
An assertion of absence proves nothing unless something in the same test shows the row was reachable to begin with. A sibling `it` does not couple them: vitest runs on past a failure, so the control can go red while the negative test stays green in the same run. beforeAll already throws on a failed seed, so an unseeded fixture is not the live risk. The risk it cannot catch is an operator that silently matches nothing — a constant-false predicate, or encryption no-op'ing — which produces exactly the empty result each negative test asserts. - operators-live-pg: fold the intersecting `and` pair into the disjoint block, so [] cannot pass via a constant-false `and`. Add a present-needle control to the `contains` no-match block, whose only proof that contains can match lived in a sibling test. - operators-lock-context-live-pg: assert the same eq WITH the lock context finds ROW_A before asserting it is unreachable without one. - matrix-live-pg: two-sample domains (date, timestamp) have an empty strict interior, so `gt`/`lt` were unproven there. Add one-sided bounds, which are non-empty for every domain. - operators-null-live-pg: guard the [row] destructure so a missing fixture reads as a missing fixture, not a null-handling bug. Found by audit while triaging CodeRabbit's decrypt-denial comment on #607; same defect class, sites it did not reach.
Audit of the EQL v3 Drizzle adapter's test coverage, and the fixes it turned up.
The index/query-type wiring is correct — but several tests could not detect the bug they exist to guard, and two source paths failed open.
Two hypotheses the audit refuted
Recorded so nobody re-derives them:
encryptOperand/encryptOperandscallclient.encrypt(value, { table, column })with no operator argument. The full term envelope is emitted; the operator selects a term server-side via theeql_v3.*function it renders. Guards are sound:contains→match, ordering→ore,eq/nereject match-only columns.eqon anore-only column is intentional — numeric/date domains answer equality through the ORE term.schema-extraction.test.tsalready assertsextracted.build()equalsauthored.build()across all 38 domains.Source fixes
containsfail-open. A needle shorter than the tokenizer'stoken_length(3) builds an empty bloom filter, andstored_bf @> '{}'is true for every row. Measured live against 3 seeded rows, with the guard removed:A user searching
"ad"silently got the whole table. The length floor lives inschema/match-defaultsand is shared with v2, which builds byte-identical blooms.v3FromDriverfail-open. Malformed JSON surfaced a rawSyntaxError; a wrong-shape payload passed through unchecked (v3FromDriver('5')returned5typed asEncrypted, reachingdecryptas garbage). Now throwsEqlV3CodecError, exported so callers can catch it. The shape guard accepts SteVec documents, whose ciphertext is atsv[0].cwith no top-levelc.Dead code. Removed the unreachable
sql\false`fallback ininArray/notInArray`.The false-negative tests
Each fix was verified by mutating the source, confirming the new test goes RED, and reverting.
andcombines predicates[ROW_B]— swapping the operator still passedand→or: failsgtreturning every row passedcomparison→true: failsbetweenrangewas invisiblecontainstoken_length. The unit test searchedTEXT_S[0]— the empty string — i.e. it asserted the fail-opencontains→true: all 8 cases failThird matrix row. With two rows every predicate could only return
[A],[B],[A,B]or[]: ordering ties were untestable andeqover-matching undetectable (no near-miss row). Rowinow takessamples[min(i, len-1)]— the schemev3-matrix/matrix-live-pgalready uses. This also inserts'Ada Lovelace', the only uppercase sample, so thedowncasetoken filter finally runs.Lock context
Every test supplied the same context on seed and query. Because lock context alters only the FFI ciphertext term and never the SQL, a regression dropping it from the index term would drop it identically on both sides and stay green. The file header documented this as intentional.
Added the negatives obtainable with one JWT: an identity-bound row must not match an
eqissued with no lock context, and must not decrypt without it.Separately,
lock-context.test.tswrappeddecryptModelintry/catch— but it reports denial as aResultand never throws, so the test ran zero assertions and would also have passed had decryption wrongly succeeded.Known-failing locally, expected green in CI
The live
asc/descproofs fail on a non-superuser Postgres:ORDER BY eql_v3.ord_term(col)'s ORE-aware btree opclass is superuser-gated and silently falls back to raw-byte order. This is pre-existing and already root-caused indocs/eql-v3-ord-term-ordering-defect.md.Count rises because the third row exposes more domains to the same defect. CI runs superuser, so these should pass there — that is the main thing to watch on this PR.
Not included
Both deliberately out of scope:
containsfix (bloom_filterreturningNULLon an empty array). It contradicts a documented semantic — "an emptybfarray yields an empty filter (contains nothing, contained by everything)" — so it is a spec decision with an owner, not a drive-by edit. Shipping it alone would also turn "returns everything" into "returns nothing, silently".feat/eql-v3-supabase-adapter.Follow-ups
The lock-context suite never runs in CI.
tests.ymlprovisions theCS_*secrets andDATABASE_URLbut notUSER_JWT, and the suite soft-skips — reporting "6 passed" while executing nothing. The new negatives inherit that.A true cross-identity test (sealed under A, queried under B) is writable today:
USER_2_JWTalready exists (AGENTS.md, and used in__tests__/bulk-protect.test.tsto decrypt mixed lock-context payloads). Through the operator path it needs a second client with its ownOidcFederationStrategybuilt from that token — a lock context only names the claim, and ZeroKMS resolves its value from the authenticating JWT, so a different claim object cannot fabricate identity B.Correction: an earlier revision of this description claimed no second-JWT env var existed. That was wrong — it came from a keyword search for
USER_JWT_B/OTHER_JWT/SECOND_JWT, which missed the actual name. The cross-identity test is not blocked on provisioning a new secret.Bloom saturation: at
m=2048, k=6,containsdegrades past ~500 chars of stored text. Separate concern, tracked in the design doc.Summary by CodeRabbit
New Features
Bug Fixes