Skip to content

docs: EQL v3 as the sole documented approach#658

Merged
coderdan merged 34 commits into
docs/skills-identity-and-corrections-refreshfrom
docs/eql-v3-sole-approach
Jul 15, 2026
Merged

docs: EQL v3 as the sole documented approach#658
coderdan merged 34 commits into
docs/skills-identity-and-corrections-refreshfrom
docs/eql-v3-sole-approach

Conversation

@coderdan

@coderdan coderdan commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Stacked on #642 (base is its branch — retarget to main after #642 merges; this branch also has origin/main merged in, so the #651/#653-era skill content is reconciled, verified marker-by-marker).

What

EQL v3 becomes the only documented approach across the shipped docs surface:

Doc Before After
skills/stash-encryption v2 chains primary, v3 appendix v3 typed surface throughout (970 lines), every API verified against source + built d.ts
skills/stash-drizzle v2 encryptedType body, v3 section /v3 surface throughout (625 lines)
skills/stash-supabase v2 factory body, v3 sections encryptedSupabaseV3 throughout, incl. main's JSON-querying + ordering content
packages/stack/README.md v2 quickstart/API EncryptionV3 + types.* primary, TOC/anchors verified

Each document keeps ONE short Legacy: EQL v2 section (the API remains for existing deployments; docs-site link) — no v2 examples or API tables elsewhere.

Deliberate carve-outs (as decided)

Also

https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w

coderdan and others added 30 commits July 13, 2026 21:02
…flakes

The Supabase and Drizzle EQL v3 integration jobs bring up docker-compose
stacks that bind fixed host ports (55430 / 55432 / 55433) on shared
Blacksmith runners, with no concurrency guard. Two jobs binding the same
port on one host fail at `docker compose up` with "address already in
use" — a transient flake unrelated to the code under test (e.g. the
Supabase job on #586).

Add a job-level concurrency group keyed by the compose variant
(`integration-live-db-<db>`, NOT the ref, since two PRs contend for a
host port as much as two pushes to one PR). The Drizzle `supabase` leg
shares the Supabase workflow's key, so those two queue instead of
colliding on 55430/55433; the Drizzle `postgres` leg (55432) keeps its
own key and still runs in parallel. `cancel-in-progress: false` queues —
a live-DB job sharing one ZeroKMS workspace should finish, not be cut off.

Also add a pre-`up` `docker compose down -v --remove-orphans || true` to
clear any container leaked onto a reused runner by a hard-killed prior
run that skipped its teardown.
Add anonymous, opt-out usage analytics to the stash CLI, plus a
`stash telemetry [status|enable|disable]` command to manage it.

- Coarse events only (command, version, OS/arch, success, duration);
  a property allowlist at the emitter boundary makes it impossible for
  plaintext, schema, table/column names, connection strings, or argument
  values to leave the machine.
- Opt-out by default, Homebrew-style: nothing is sent until the one-time
  first-run notice has been shown once. Four opt-out gates in precedence
  order: DO_NOT_TRACK, STASH_TELEMETRY_DISABLED, CI auto-detect, and the
  persisted `stash telemetry disable` flag in ~/.cipherstash/telemetry.json.
- Events go through a first-party Cloudflare proxy (infra/telemetry-proxy),
  so a future US->EU PostHog move is a proxy-target change with no CLI
  re-release. posthog-node, events-only (no person profiles), lazy client,
  flush bounded by a timeout so it never blocks or slows the CLI.
- Ships dormant: no events are sent until a real PostHog key is embedded
  at release (STASH_POSTHOG_KEY overrides for testing).

Updates the stash-cli skill and adds a changeset.

Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w
…n-guard

ci(integration): serialize live-DB jobs to stop host-port collisions
Review findings:
- shutdownTelemetry: clear the flush-timeout timer so a fast flush no longer
  leaves a dangling setTimeout that hangs the process for the full timeout; also
  set fetchRetryCount:0 so an unreachable endpoint fails fast instead of
  retrying with backoff (which kept internal timers alive and hung the CLI for
  seconds). Enabled commands now exit in ~140ms even when the endpoint is down.
- First-run notice: persist noticeShownAt ONLY when the notice was actually
  displayed (stderr is a TTY), so a non-interactive first run no longer consumes
  the freebie and starts sending without the user ever seeing the disclosure.
- Broaden DO_NOT_TRACK / STASH_TELEMETRY_DISABLED to opt out on any non-empty
  value except 0/false (matches the DO_NOT_TRACK convention).
- Reuse the shared isCiEnv() from config/tty.ts instead of a second, divergent
  CI detector; widen isCiEnv() with provider markers (GITHUB_ACTIONS, GITLAB_CI,
  Azure TF_BUILD, Jenkins, TeamCity, …) so telemetry never fires in CI even when
  a provider doesn't set CI=true. Export CI_ENV_VARS and make the region /
  database-url tests hermetic against those ambient vars.
- Drop the dead resolveStatus() re-resolve after writing the notice.

Copilot:
- Sanitize the combined event+base props so the allowlist is the single enforced
  boundary (a future baseProps field can't bypass it).
- Make the first-run opt-out hint runner-aware (npx stash …) so it's actionable
  before stash is on PATH.
- Restore process.env.HOME in the state test so it can't leak across test files.
- Worker returns 500 when POSTHOG_HOST is unset instead of forwarding to an
  invalid host.

Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w
feat(stack,cli): EQL v3 typed schema, typed client, Supabase/Drizzle integrations on eql-3.0.0 + protect-ffi 0.29
Widening isCiEnv() with provider markers (GITHUB_ACTIONS, GITLAB_CI, …) made the
pty e2e harness leak this repo's own CI env into the spawned CLI: the harness
defaults CI=true and interactive tests override with `CI: ''`, but that no longer
fully de-CIs the environment once GITHUB_ACTIONS=true is also present, so
`auth login` skipped the region prompt and the test timed out.

Strip every CI signal (CI_ENV_VARS) from the child env before applying the
harness's CI default and per-test overrides, so CI detection is fully controlled
by the harness — matching how the unit tests were made hermetic. Verified the
full e2e suite passes with GITHUB_ACTIONS=true CI=true exported.

Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w
Enter changesets pre mode with the `rc` tag and add a major changeset for the
Stack 1.0 family: @cipherstash/stack, @cipherstash/stack-drizzle,
@cipherstash/stack-supabase, and stash. The changesets action will produce a
Version Packages PR at 1.0.0-rc.0 for these; merging that PR publishes under the
`rc` npm dist-tag (latest stays on the current stable).

Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w
chore: enter pre-release mode (rc) → Stack family 1.0.0-rc.0
Add ops.selector(col, '$.path') to the v3 Drizzle integration (#623): comparison
methods (eq/ne/gt/gte/lt/lte) bound to a JSONPath into a types.Json column,
emitting `eql_v3.<op>(eql_v3."->"(col,'<sel>'), eql_v3."->"('<needle>'::eql_v3_json,'<sel>'))`.
Its unique power over `contains` is ORDERING at a path (col->'$.age' > 21).

- Selector hash from encryptQuery(path, searchableJson) (string needle → ste_vec_selector).
- Comparison entry from a STORAGE encrypt of {path: value} — its ciphertext `c`
  is required by the eql_v3_jsonb_entry domain (a query term can't carry it).
- One new dialect fn (selectorEntry); reuses equality/comparison for the compare.
- v1: dot-notation object paths; array/wildcard rejected with a clear error.

Core needs no change. Supabase is a follow-up (#650) — blocked by PostgREST's
inability to cast a filter value.

Refs #623
Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w
…aint

Rework the selector path to be entirely ciphertext-free (the storage-payload RHS
was wrong — it leaked ciphertext into the WHERE clause). Both operands are query
terms; the comparison reduces to the encrypted term on each side:

  eql_v3.<term>(eql_v3.jsonb_path_query_first(col,'<sel>')) <cmp> eql_v3.<term>(<operand>)

where <term> is eq_term (=/<>) or ord_term (</<=/>/>=) — both collapse to
bytea (hmac_256 / ope_cllw) and compare natively.

- selector hash: encryptQuery(path, searchableJson) (string → ste_vec_selector).
- RHS: a scalar encryptQuery(value, { queryType: equality|orderAndRange }) term
  cast to eql_v3.query_<T>_<eq|ord>, T inferred from the leaf value's JS type.
- Drops the client `encrypt` requirement and the doc reconstruction entirely.

Assumes protect-ffi keys the scalar query term with the column's key (verified by
the live tests). The bundle lacks a (jsonb_entry, query_<T>) operator, so the
direct-function form is used; adding it upstream is an EQL follow-up.

Refs #623
Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w
The ciphertext-free approach was non-functional: encryptQuery can't mint a
scalar/ordering query term against a ste_vec (JSON) column, so the value call
threw "Index type not configured" (caught by CI's live integration job).

Verified via FFI probes: the stored ste_vec entry at the JSONPath selector
carries c + op (ordering) / hm (equality), and encryptQuery('$.path') returns
the selector that matches it. So the RHS is a STORAGE encryption of {path: value}
whose entry is extracted with jsonb_path_query_first and compared entry-vs-entry
(eq_term/ord_term read only hm/op). Ciphertext-free ordering needs a protect-ffi
change (cipherstash/protectjs-ffi#137); until then the value's ciphertext is in
the WHERE clause — documented inline.

Also from the code review: use Promise.all for the two independent encrypts;
throw (not silently mis-bind) if the selector term isn't a bare string; fix path
normalization (leading $/.) and reject the empty/root path; drop the fragile
JS-type→query-domain inference entirely.

Refs #623 · protect-ffi gap cipherstash/protectjs-ffi#137 · EQL operator #404
Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w
Update stash-encryption + stash-drizzle skills for the new ops.selector(col,
'$.path').{eq,ne,gt,gte,lt,lte} surface — they previously said JSONPath selector
queries were 'not yet implemented'. Clarify that direct eq/gt/asc on a Json
column still throw; selector-with-constraint is the path-scoped form, with
ordering-at-a-path its unique power over containment. Skills ship in the stash
tarball, so this carries a stash patch changeset.

Refs #623
Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w
- reconstructSelectorDocument: use null-prototype objects so a `__proto__` path
  segment becomes an own key instead of invoking the prototype setter (which
  would mis-serialize the needle → no matches).
- Fix stale selectorCompare JSDoc: it still described the reverted ciphertext-free
  approach ('No ciphertext, no storage encryption') and referenced the renamed
  selectorConstraint. Now documents the storage-needle interim consistently.
- Disclose the interim ciphertext-in-WHERE behavior in both stash-drizzle and
  stash-encryption skills (each must be self-sufficient).

Skipped: CodeRabbit's DROP-TABLE-IF-EXISTS suggestion — the sibling
json-contains integration test uses the same DROP+CREATE pattern and the RUN
discriminator isolates rows; hardening should be repo-wide, not one file.

Refs #623
Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w
Must-fixes:
- test:types was broken (operators.test-d.ts): adding the required `encrypt`
  member to OperandEncryptionClient made the `{ encryptQuery }`-only doubles
  unassignable. Add `encrypt` to both doubles — and to the `erased` double too,
  so it's rejected ONLY for the #622 encryptQuery-erasure reason (restoring the
  guard's teeth), not for a missing member.
- Re-add the scalar-leaf-type guard dropped in the storage rewrite: reject
  non-scalar leaf values (object/array → use contains()) and ordering a boolean,
  up front as EncryptionOperatorError instead of a deferred opaque DB error.
- Define + test + document `ne` absent-path semantics: a row lacking the path is
  now INCLUDED in `.ne(x)` (OR jsonb_path_query_first(...) IS NULL); eq/ordering
  keep SQL's exclude-on-missing. Integration test seeds a row with no `$.age`.

CI gap (root cause the type break slipped through): the adapter packages'
`test:types` was never wired into CI after the #627 split — only @cipherstash/stack
and test-kit ran. Add stack-drizzle + stack-supabase type-test steps to tests.yml
(both green today).

Toby: path-validation errors now surface as EncryptionOperatorError with column
context; add unit tests for the pure helpers (parseSelectorSegments,
reconstructSelectorDocument) + the selector guards.

Also from freshtonic: harden path parsing — reject `..`/malformed/empty-segment
paths (no longer silently query a different path) and prototype-pollution keys
(__proto__/prototype/constructor); surface the interim ciphertext-in-WHERE caveat
in the skill capability-table row.

Follow-ups (noted, not in this PR): share core's path helpers via adapter-kit;
memoize the value-independent selector hash per (column, path).

Refs #623 · protect-ffi gap cipherstash/protectjs-ffi#137
Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w
Symmetric with STASH_POSTHOG_KEY: the host now defaults to the Cloudflare proxy
(telemetry.cipherstash.com) but can be overridden for testing against a real
PostHog ingestion endpoint before the proxy is deployed, or for self-hosting.
Verified the CLI POSTs the event batch to the configured host.

Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w
Add a coarse, fixed-enum `caller` property to command telemetry so we can
gauge how much stash usage is agent-driven — a lot of it is our own skills
being run by a customer's coding agent.

resolveCaller() recognises Claude Code (CLAUDECODE / CLAUDE_CODE_ENTRYPOINT),
Cursor (CURSOR_TRACE_ID), and Codex (CODEX_SANDBOX) by the env var each sets,
falls back to `editor` for a VS Code-family terminal (TERM_PROGRAM=vscode),
then to `interactive`/`non-interactive` by stdin TTY. Only the fixed label is
emitted, never the raw env value — some carry session/trace ids — so the
anonymity contract holds. `caller` is added to the emitter allowlist.

Updates the stash-cli skill disclosure and the changeset field list to match.

Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w
…repo

The Cloudflare Worker that terminates telemetry.cipherstash.com is deployed
platform infrastructure, not part of the published stash package, so it belongs
with the rest of our infra in cipherstash-suite (infra/telemetry-proxy) rather
than shipping in this repo. No CLI behaviour changes: the CLI still targets
https://telemetry.cipherstash.com and stays dormant until a key is embedded.

Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w
Wire up the injection the placeholder always promised. tsup's esbuild `define`
replaces the `__STASH_POSTHOG_KEY__` identifier with the key from the
STASH_POSTHOG_KEY env var; release.yml feeds it from a public repo *variable*
(vars.STASH_POSTHOG_KEY), so ONLY the official release build embeds a key. Dev,
fork, and CI builds bake in an empty string and ship telemetry-dormant. Applied
to both bundles; turbo.json lists the var on `build` so the cache key tracks it.

Also fixes a latent bug the injection would have exposed: resolveStatus compared
the resolved key against EMBEDDED_KEY, which once injected holds the real key —
so an embedded build with no env override would have read as `unconfigured`.
It now compares against a dedicated PLACEHOLDER_KEY sentinel (the un-rewritten
string literal), so an injected build is correctly enabled. New tests cover the
placeholder-passthrough (dormant) and injected-key (enabled) paths.

The go-live switch is setting the repo variable; until then every release stays
dormant. Verified both ways: dormant build reports "not active in this build",
injected build reports "Telemetry is enabled" with no runtime env override.

Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w
Ten findings from the high-effort review, all fixed:

1. subcommand value leak (privacy): trackCommand emitted argv[1] verbatim, so
   `stash wizard "<prompt>"` sent the natural-language prompt (table/column
   names) to PostHog. New classify-command.ts coerces command/subcommand to the
   registry's known verbs before emit; anything else becomes `<other>`.
2. non-TTY never activated: the first-run notice only persisted noticeShownAt on
   a TTY, so agents/piped callers stayed firstRun forever and never sent — the
   exact population the `caller` dimension measures. Now the disclosure is shown
   (to stderr) and persisted on every first run, TTY or not.
3. process.exit bypassed the tracking finally: commands exiting via process.exit
   emitted nothing and never flushed. run() now intercepts process.exit with an
   ExitSignal sentinel that unwinds through the finally (track + flush) before
   the real exit, honouring the exit code. Degrades gracefully if a command
   catch swallows it.
4. empty-string key enabled telemetry: STASH_POSTHOG_KEY='' slipped past the
   nullish fallback; projectKey() now trims and treats empty as unset.
5. first-run notice could show twice: maybeShowFirstRunNotice didn't reassign the
   state cache, so a same-run setTelemetryDisabled clobbered noticeShownAt. Fixed.
6. isCiEnv widening leaked into prompt gating: reverted the shared isCiEnv to its
   narrow (CI-only) behaviour used by region/database-url; telemetry uses a new
   isCiEnvBroad. Reverted the now-unnecessary test neutralizations.
7. eager cost on every invocation: posthog-node is now a dynamic import inside
   getClient (loaded only when an event is actually sent), and the state file
   read is lazy — both off the --version/--help fast paths.
8. shutdown could linger: pass requestTimeout to PostHog so a black-holed
   endpoint can't keep the socket alive past the flush window.
9. dormant tests depended on ambient STASH_POSTHOG_KEY: clearGateEnv now stubs it.
10. lifecycle path was untested: new telemetry-lifecycle.test.ts covers the
    emitter/flush contract (no-op when disabled/firstRun, swallows throws, safe
    double-shutdown, non-TTY notice persistence, bounded flush on a hung endpoint)
    and classify-command.test.ts covers the value allowlist.

Verified end-to-end against a local capture: a process.exit command is now
tracked with its exit code preserved, and a wizard prompt containing a
table.column is scrubbed to `<other>` (0 leakage). 453 unit tests pass.

Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w
… harden telemetry per review round 2

The round-1 fix intercepted process.exit globally with a thrown signal. The
second review pass proved that unsafe: @clack/core calls process.exit(0) from a
keypress handler (Ctrl+C during any spinner became an uncaughtException crash),
and five broad catches (init's install-eql, plan, status, impl, the jiti config
loader) swallowed the signal — init continued past an explicit cancel, impl
skipped the cutover deploy-gate, and commands that recovered by design exited
with a stale latched code.

New design: no global interception. First-party sites verified to unwind
cleanly to run() throw `CliExit(code)` (new cli/exit.ts) — main.ts's own
unknown-command/subcommand defaults, requireValue, requireStack, the telemetry
command, and the outermost CancelledError handlers of init/plan/impl (cancels
are now tracked). Deep process.exit sites revert to pre-branch behaviour:
immediate, uninterceptable, untracked — documented as a known measurement gap
in the telemetry module doc and packages/cli/AGENTS.md.

Also fixed from the review:
- shutdownTelemetry: the capture enqueue (containing the dynamic posthog-node
  import) now sits INSIDE the Promise.race, so the 1500ms bound covers
  everything that can stall; the flush promise carries its own rejection
  handler so a timeout win can't leave an unhandledRejection.
- First-run notice persists BEFORE printing: an unwritable HOME no longer
  prints the notice on every run forever — no persist, no print, no telemetry.
- pty e2e harness sets STASH_TELEMETRY_DISABLED=1 so an ambient
  STASH_POSTHOG_KEY can't make tests send real events against the developer's
  real ~/.cipherstash.
- errorType is now a closed vocabulary (classifyErrorType): user-defined error
  class names from jiti-loaded config code collapse to `<other>`.
- Wizard analytics aligned with the CLI's privacy contract: honors
  DO_NOT_TRACK / STASH_TELEMETRY_DISABLED / CI, random per-session id instead
  of sha256(username@hostname), geoip off, and error events carry fixed
  labels / class names instead of raw messages.
- Docs de-staled: packages/cli/AGENTS.md telemetry section rewritten (it said
  the CLI had no telemetry), CI_ENV_VARS/resolveStatus/pty.ts comments now
  describe the isCiEnv (narrow, prompts) vs isCiEnvBroad (telemetry) split,
  and the changeset names the value-coercion boundary.
- Tests: narrow-vs-broad isCiEnv contract pinned; classifyErrorType covered;
  notice write-failure covered; stderr.isTTY save/restore no longer creates a
  read-only own property; smoke e2e covers `telemetry status` and the
  bogus-subcommand CliExit path.

Verified: 460 unit + 57 pty e2e tests pass; local capture shows unknown
command and telemetry bogus-sub tracked with correct exit codes, wizard
free-text still scrubbed to `<other>` (0 leakage), and a read-only-HOME first
run prints nothing.

Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w
CodeRabbit flagged that the SKILL.md disclosure lists event fields but never
mentions the distinct_id. Add it — with accurate wording: it is a locally
generated random UUID stored in ~/.cipherstash/telemetry.json, used only to
de-duplicate events in aggregate, and NOT a salted/derived machine id (the
review's suggested phrasing described a mechanism we deliberately don't use).
Mirrored in the changeset so the release notes carry the same disclosure.

Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w
feat(cli): anonymous opt-out usage analytics + `stash telemetry`
…electors)

Closes #650. The v3 Supabase adapter gains encrypted-JSON querying on
types.Json columns:

- contains(col, subDocument) — exact encrypted ste_vec containment. A live
  PostgREST probe established the wire form: PostgREST casts the `cs.` filter
  operand to the column's own domain, so a storage-encrypted needle reaches
  eql_v3."@>"(eql_v3_json, eql_v3_json) → ste_vec_contains. The operand is a
  full storage envelope, the same INTERIM shape (and GET-query-string caveat)
  as every other v3 filter operand.
- selectorEq/selectorNe(col, path, value) — JSONPath equality at a
  dot-notation path, compiled to containment of the reconstructed needle
  ({user:{role:'admin'}}); ne compiles to not.cs and INCLUDES rows lacking
  the path, mirroring the Drizzle selector's documented semantics (verified
  live). Paths/leaves share the Drizzle adapter's validation.
- Selector ORDERING is not expressible over PostgREST: arrow paths get
  to_jsonb-wrapped (domain stripped) and bare-column comparison is blocked by
  design. Filed cipherstash/encrypt-query-language#407 proposing a
  needle-comparison overload; until then the methods don't exist and the
  skill points at Drizzle's ops.selector() for ordering.

Core: QueryTypesForColumn gains the searchableJson arm (types.Json no longer
resolves to never, so the typed key sets stop erasing JSON columns), and the
JSONPath selector-path helpers move from stack-drizzle to
@cipherstash/stack/adapter-kit (drizzle re-exports them unchanged) so both
adapters share one validation surface.

Adapter plumbing: V3SearchableJsonKeys key set + typed contains overload +
selector methods on both builder interfaces; the capability resolver re-types
the cs-collected term to searchableJson on JSON columns (free-text and JSON
capabilities are mutually exclusive by construction); matches() steers to
contains()/selectorEq() on JSON columns; not(col,'contains',…) is allowed on
JSON (honest negated exact containment) and stays rejected on text.

Tests: 17 unit (operand routing, guards, path validation), 6 type-level
(key-set narrowing, operand shapes), 11 live integration with real crypto
against real PostgREST as anon (containment, selector eq/ne incl. absent-path
inclusion, raw cs, capability rejections). Full suites: stack 816, drizzle
368 unit + 16 live JSON, supabase 468 unit + 677 integration — all green.

Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w
…uerying

Ten findings from the high-effort review of #653, all fixed:

1. Guard bypass on non-method spellings: the freeTextSearch→searchableJson
   re-typing now enforces the operand boundary AT THE RESOLVER — raw
   .filter(col,'cs',string), .or() string/structured conditions, and
   not(col,'contains',scalar) get the actionable sub-document steer instead of
   silently storage-encrypting a JSON scalar; not(col,'matches',…) on a JSON
   column gets the same steer matches() gives.
2. Type-surface regression: scalar predicates (eq/gt/in/…) are compile-excluded
   again for types.Json columns (V3FilterableKeys now excludes columns with no
   scalar capability); dedicated filter('cs')/not('contains') overloads keep the
   raw spellings typed. Pinned with @ts-expect-error tests.
3. Empty/non-plain needles: contains('payload', {}) and [] are rejected
   ("matches every row" — Drizzle parity), and Date/Map/RegExp instances get
   the sub-document steer instead of serializing to scalars or {}.
4. Array-leaf semantics: the live probe showed finder-claimed ANY-element
   matching is WRONG with real protect-ffi — a scalar needle does NOT match an
   array at the path (elements are encoded under their own selectors). Docs
   corrected to the observed truth and pinned by live fixtures (scalar needle
   misses the array row; the full array through contains() hits it).
5. SQL-NULL documents: selectorNe now compiles to a structured OR
   (payload.is.null, payload.not.cs.<needle>) so NULL-document rows are
   INCLUDED, matching Drizzle's ne for both absence cases — verified live.
6. Integration test: removed the phantom two-arg from(TABLE, schema) (v3 from()
   takes one argument) and the identity casts; tsc over integration/ is clean.
7. Exposure honesty: the skill now states the JSON-needle ciphertext scales
   per-node (Drizzle containment ships none) and cites the new tracking issue
   #654; empty-needle rejection documented.
8. Changeset gains the required 'stash': patch for the skills change.
9. stash-encryption skill now names the Supabase route beside Drizzle's for
   both containment and selectors (with the ordering caveat).
10. Live decrypt round-trip added (select payload → toEqual original doc), plus
    live not-contains, and the unit not-contains asserts the encrypted envelope.

Also from below-the-fold: the INTENTIONAL FORK JSDoc re-attached to
EncryptionOperatorError (the re-export had split them); parseSelectorSegments
rejects whitespace at segment boundaries (was silently addressing 'user ');
unsupportedLeafReason handles null explicitly (was steering null to
contains()); the as-unknown-as double-cast replaced with a plain widening;
dead V3ContainsValue removed; core's matrix test-d pins
QueryTypesForColumn<Json> = 'searchableJson'; stale "three scalar kinds"
comment fixed.

Suites: supabase 476 unit + 31 type + 681 live integration (15 in the JSON
suite incl. NULL-doc and array-leaf fixtures), stack 62 type tests, drizzle
368 — all green.

Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w
…rface

- SelectorLeafValue narrowed to the JSON scalar kinds (string|number|boolean):
  a stored JsonDocument cannot contain Date/bigint, so such a needle could
  never match a stored leaf. The runtime guard rejects them too, with a
  serialization steer (pass date.toISOString() / the stored form) — stricter
  than the shared unsupportedLeafReason, whose Date/bigint arms serve the
  Drizzle surface (its operand is unknown-typed). Pinned by @ts-expect-error
  type tests and unit tests.
- Integration Row type declares payload: JsonDocument | null — the suite seeds
  a SQL-NULL document row, so the type must not promise non-null.
- Removed the unreachable 'an array' branch from the containment operand error
  message (Array.isArray is false on that path by construction).

Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w
The bullet still said order() is rejected on every encrypted v3 column — the
supabase-v3-order-by-ope-term change made that false: OPE-backed ordering
columns sort via order=col->op (ORE and term-less columns stay rejected).
Caught while auditing ordering coverage; the skill ships to customers, so the
stale claim was steering users away from a capability they have.

Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w
feat(stack-supabase): EQL v3 encrypted-JSON querying (containment + selector predicates)
coderdan added 3 commits July 16, 2026 02:40
Rewrites stash-encryption, stash-drizzle, and stash-supabase skills and the
stack README so the EQL v3 typed surface (EncryptionV3, types.* concrete
domains, stack-drizzle/v3, encryptedSupabaseV3) is the only approach taught.
EQL v2 shrinks to one short Legacy section per document (the API remains for
existing deployments; docs-site link), with two explicit carve-outs:

- DynamoDB still requires the v2 schema surface — noted, tracked in #657.
- The encrypt rollout lifecycle (stash encrypt backfill/cutover,
  @cipherstash/migrate) currently targets v2 columns — sections kept
  (launch-critical) under a #648 callout that scopes their eql_v2_* names.

Also fixes the legacy packages/drizzle README still recommending the removed
@cipherstash/stack/drizzle subpath (now names @cipherstash/stack-drizzle),
the README's stale sorting-not-supported claim (superseded by ord_term /
OPE-backed order()), the stale ops.contains free-text guidance (matches), and
Node >= 18 → >= 22 in Requirements.

Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w
…oach

# Conflicts:
#	skills/stash-drizzle/SKILL.md
#	skills/stash-encryption/SKILL.md
#	skills/stash-supabase/SKILL.md
@coderdan
coderdan requested a review from a team as a code owner July 15, 2026 16:42
@coderabbitai

coderabbitai Bot commented Jul 15, 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: 19384a24-19e1-4f26-b9d6-22a1c21eed07

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 docs/eql-v3-sole-approach

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.

@changeset-bot

changeset-bot Bot commented Jul 15, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 3fdd740

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

This PR includes changesets to release 12 packages
Name Type
stash Minor
@cipherstash/drizzle Patch
@cipherstash/stack-drizzle Minor
@cipherstash/stack Minor
@cipherstash/stack-supabase Minor
@cipherstash/wizard Patch
@cipherstash/basic-example Patch
@cipherstash/e2e Patch
@cipherstash/bench Patch
@cipherstash/prisma-next Patch
@cipherstash/test-kit Patch
@cipherstash/prisma-next-example 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

Fixes from the #658 review (10 verified findings + below-the-fold):

Correctness:
- Quick Start + mid-doc examples now narrow the Result union before reading
  .data (failure branches throw) — they previously failed strict TS (TS2339).
- Supabase EQL install: docs now show `--supabase` (grants) and stop implying
  it is obsolete; without it encrypted queries hit permission-denied.
- Removed the false "Supabase cannot ORDER BY encrypted columns" claim from
  stash-encryption (it contradicted stash-supabase, the README, and the
  shipped OPE-backed order()).
- #648 rollout dead-end fixed: the Supabase skill now EMBEDS the interim
  v2-encrypted-twin recipe (schema + encryptedSupabase + dual-write) that
  backfill/cutover need today, instead of pointing at a deleted section.

Reference rot & stale claims:
- Removed the #602 "known type error" callout (fixed by protect-ffi 0.29; #602
  closed) from stash-encryption; corrected the stale #447 citation in all three
  skills; fixed the "EQL v3 Typed Schema" dead cross-reference and the
  generate-eql-migration misattribution in stash-drizzle.
- stash-cli: --eql-version default 2→3; corrected the Supabase ORDER-BY and
  read-path (encryptedSupabaseV3) claims.
- AGENTS.md Key Concepts now teaches the v3 typed surface (rule 7).

Scoping corrections: like/ilike (matches, with the untyped-only shim noted),
empty-needle rejection (adapter-level, not core encryptQuery), SQL-NULL ne
inclusion (both adapters), Supabase selector leaf types (JSON scalars only),
null-operand handling (forwarded, not rejected), per-column (not per-query)
ZeroKMS batching, #137-vs-#654 exposure attribution, drizzle operator opts
(async encrypting ops + selector methods only).

README polish: init flag table (--region etc.) + removed the redundant
post-init eql install step; wasm-inline in Requirements; returnType values
note restored; markdown `--`→`---` rules. Added the missing
@cipherstash/drizzle changeset for its README fix.

The doc claims about the Supabase short-needle guard, withLockContext({identityClaim}),
and the StackError discriminated union are made true by the companion code PR
(short-needle guard, LockContextInput widening, EncryptionErrorTypes as const)
— both are RC-gated to ship together.

Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w
@coderdan
coderdan merged commit a432545 into docs/skills-identity-and-corrections-refresh Jul 15, 2026
15 checks passed
@coderdan
coderdan deleted the docs/eql-v3-sole-approach branch July 15, 2026 18:20
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