Safe SQL Execution
supabase/supabase
A skill your agent uses whenever code will build, return, fetch, or execute SQL that runs against a user's real Postgres database — even when the request reads like an ordinary feature or bug fix…
Query EQL v3 encrypted columns from hand-written Postgres SQL over pg (node-postgres) or postgres (postgres-js) — no ORM.
$ npx skills add cipherstash/stack --skill stash-postgres -a claude-codeProject install by default; add -g for ~/.claude/skills/.
$ gh skill install cipherstash/stack stash-postgres --agent claude-codeProject scope by default; add --scope user for a personal install. Needs GitHub CLI 2.90.0 or later (public preview).
$ git clone --depth 1 https://github.com/cipherstash/stack.git skills-src && mkdir -p .claude/skills && cp -r skills-src/skills/stash-postgres .claude/skills/stash-postgres && rm -rf skills-srcUse ~/.claude/skills/ instead of .claude/skills for a personal install. The folder must contain SKILL.md.
Claude Code skills documentation · loads skills from .claude/skills/
Install the "stash-postgres" agent skill from https://github.com/cipherstash/stack/tree/main/skills/stash-postgres into .claude/skills/stash-postgres/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "stash-postgres", then confirm the skill loads.Claude Code copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
$skill-installer install https://github.com/cipherstash/stack/tree/main/skills/stash-postgresType this inside Codex. $skill-installer <name> installs a curated skill from openai/skills. The installer writes to $CODEX_HOME/skills (default ~/.codex/skills). Restart Codex if the skill does not show up.
$ npx skills add cipherstash/stack --skill stash-postgres -a codexProject install goes to .agents/skills/; add -g for ~/.codex/skills/.
$ gh skill install cipherstash/stack stash-postgres --agent codexProject scope by default (.agents/skills/); add --scope user for a personal install.
$ git clone --depth 1 https://github.com/cipherstash/stack.git skills-src && mkdir -p .agents/skills && cp -r skills-src/skills/stash-postgres .agents/skills/stash-postgres && rm -rf skills-srcUse ~/.agents/skills/ instead of .agents/skills for a personal install.
Codex skills documentation · loads skills from .agents/skills/
Install the "stash-postgres" agent skill from https://github.com/cipherstash/stack/tree/main/skills/stash-postgres into .agents/skills/stash-postgres/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "stash-postgres", then confirm the skill loads.Codex copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
$ npx skills add cipherstash/stack --skill stash-postgres -a cursorProject install goes to .agents/skills/; add -g for ~/.cursor/skills/.
$ gh skill install cipherstash/stack stash-postgres --agent cursorProject scope by default (.agents/skills/); add --scope user for a personal install.
$ git clone --depth 1 https://github.com/cipherstash/stack.git skills-src && mkdir -p .cursor/skills && cp -r skills-src/skills/stash-postgres .cursor/skills/stash-postgres && rm -rf skills-srcUse ~/.cursor/skills/ instead of .cursor/skills for a personal install.
Cursor skills documentation · loads skills from .cursor/skills/, .agents/skills/, .claude/skills/, .codex/skills/
Install the "stash-postgres" agent skill from https://github.com/cipherstash/stack/tree/main/skills/stash-postgres into .cursor/skills/stash-postgres/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "stash-postgres", then confirm the skill loads.Cursor copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
$ gemini skills install https://github.com/cipherstash/stack.git --path skills/stash-postgres--scope user (default) or --scope workspace; --path is the subfolder of the repo that holds the skill; --consent skips the security confirmation prompt.
$ npx skills add cipherstash/stack --skill stash-postgres -a gemini-cliProject install goes to .agents/skills/; add -g for ~/.gemini/skills/.
$ gh skill install cipherstash/stack stash-postgres --agent gemini-cliProject scope by default (.agents/skills/); add --scope user for a personal install.
$ git clone --depth 1 https://github.com/cipherstash/stack.git skills-src && mkdir -p .gemini/skills && cp -r skills-src/skills/stash-postgres .gemini/skills/stash-postgres && rm -rf skills-srcUse ~/.gemini/skills/ instead of .gemini/skills for a personal install, then run /skills reload.
Gemini CLI skills documentation · loads skills from .gemini/skills/, .agents/skills/
Install the "stash-postgres" agent skill from https://github.com/cipherstash/stack/tree/main/skills/stash-postgres into .gemini/skills/stash-postgres/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "stash-postgres", then confirm the skill loads.Gemini CLI copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
$ gh skill install cipherstash/stack stash-postgresInstalls for Copilot at project scope by default; add --scope user for a personal install. Preview a skill first with gh skill preview. Needs GitHub CLI 2.90.0 or later (public preview).
$ npx skills add cipherstash/stack --skill stash-postgres -a github-copilotProject install goes to .agents/skills/; add -g for ~/.copilot/skills/.
$ git clone --depth 1 https://github.com/cipherstash/stack.git skills-src && mkdir -p .github/skills && cp -r skills-src/skills/stash-postgres .github/skills/stash-postgres && rm -rf skills-srcUse ~/.copilot/skills/ instead of .github/skills for a personal install. Commit .github/skills so cloud agent and code review can use it.
GitHub Copilot skills documentation · loads skills from .github/skills/, .claude/skills/, .agents/skills/
Install the "stash-postgres" agent skill from https://github.com/cipherstash/stack/tree/main/skills/stash-postgres into .github/skills/stash-postgres/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "stash-postgres", then confirm the skill loads.GitHub Copilot copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
$ npx skills add cipherstash/stack --skill stash-postgres -a opencodeOpenCode documents no install command of its own. Project install goes to .agents/skills/; add -g for ~/.config/opencode/skills/.
$ gh skill install cipherstash/stack stash-postgres --agent opencodeProject scope by default (.agents/skills/); add --scope user for a personal install.
$ git clone --depth 1 https://github.com/cipherstash/stack.git skills-src && mkdir -p .opencode/skills && cp -r skills-src/skills/stash-postgres .opencode/skills/stash-postgres && rm -rf skills-srcUse ~/.config/opencode/skills/ instead of .opencode/skills for a personal install.
OpenCode skills documentation · loads skills from .opencode/skills/, .claude/skills/, .agents/skills/
Install the "stash-postgres" agent skill from https://github.com/cipherstash/stack/tree/main/skills/stash-postgres into .opencode/skills/stash-postgres/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "stash-postgres", then confirm the skill loads.OpenCode copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
stash-postgresQuery EQL v3 encrypted columns from hand-written Postgres SQL over pg (node-postgres) or postgres (postgres-js) — no ORM.
Stash Postgres is an agent skill from cipherstash/stack. Query EQL v3 encrypted columns from hand-written Postgres SQL over pg (node-postgres) or postgres (postgres-js) — no ORM. Covers the column-domain-to-query-domain operator matrix (which of =, <, <, <=, , =, @@, @ each encrypted domain accepts), minting search needles with encryptQuery, the per-driver parameter-binding rules for encrypted payloads, and the double-encoding failure that trips the domain CHECK with a message naming neither JSON nor encoding. Use when writing INSERT/SELECT against an encrypted column…
Its SKILL.md is about 6.3k tokens, which your agent loads only when the skill is triggered. It is a single SKILL.md file with no bundled scripts.
It sits in Databases, covering ORMs and data access and SQL. It works with PostgreSQL, SQL, Prisma and Supabase. The repository describes itself as: Searchable, application-level encryption for building privacy-first apps. The licence is MIT.
2 steps, taken from the first numbered list in SKILL.md.
Read from SKILL.md and the folder at commit 3eb459b. It shows what the files ask for, not the result of running them.
Pre-approves nothing: there is no allowed-tools line, so your agent's usual permission prompts apply.
From allowed-tools in the SKILL.md frontmatter.
No scripts in the folder and no shell commands in SKILL.md (its code samples are typescript and sql).
From the folder's file list and the shell code blocks in SKILL.md.
Links to these hosts (documentation or services it may open):
github.comFrom URLs in SKILL.md, links to its own repository left out.
Names no API keys, tokens, secrets or passwords.
From names ending in _API_KEY, _TOKEN, _SECRET, _KEY or _PASSWORD in SKILL.md.
Stash Postgres loads about 6.3k tokens when it runs. Until then it costs about 212 tokens; SKILL.md has 2,650 words of instructions outside code blocks.
Estimates: characters ÷ 4, the usual rule of thumb; real counts depend on the model's tokenizer. Scripts and assets cost tokens only if the agent reads them.
The automated check found no risky patterns in SKILL.md.
Automated static check — not a guarantee. Review scripts before installing. It scans the text of SKILL.md for risky patterns (piping downloads into a shell, reading credential files, hidden Unicode, destructive commands); files beside SKILL.md are not scanned.
The full file from cipherstash/stack at commit 3eb459b, republished under its MIT licence (© cipherstash). 2,650 words, ~6,298 tokens.
.claude/skills/stash-postgres/SKILL.md (or your agent's skills folder).An EQL v3 encrypted column is a Postgres domain over jsonb (public.eql_v3_text_search,
public.eql_v3_bigint_ord, …). Reading and writing it from raw SQL is two rules:
Encrypted payload your client produced as a
JSON object parameter. How you do that differs per driver, and getting it
wrong trips a domain CHECK with an unhelpful message.encryptQuery and cast it to the column's matching eql_v3.query_*
domain. That cast is what selects the right operator overload: leave the
operand as bare jsonb and you get a different overload, one that
expects a full storage envelope.This covers the pg and postgres-js drivers with no ORM — plain Node
services, Hono, edge functions. If you use Drizzle, Prisma Next, or the
Supabase client, those integrations emit correct operands for you: see
stash-drizzle, stash-prisma, stash-supabase instead.
Using CipherStash Proxy? None of this applies. This skill assumes the app connects to Postgres directly and encrypts client-side: Stack mints the payloads and the query terms, and your SQL carries them. Through CipherStash Proxy the split is the opposite — you write plaintext SQL and Proxy encrypts on write and decrypts on read, so there is no
encryptQuery, noeql_v3.query_*cast, and no payload to bind.Proxy's former schema lifecycle belongs to EQL v2 and is no longer installed or mutated by
stash; legacy state remains visible through status diagnostics only. This skill is EQL v3, where a column's encryption config lives in its own domain and there is no configuration table to push. ThestashCLI targets the direct-connection path this skill describes.
INSERT / UPDATE / SELECT against an encrypted column by hand.operator does not exist.value for domain eql_v3_… violates check constraint.Assume users.email is declared in the database as public.eql_v3_text_eq —
a Postgres domain over jsonb. The domain is the authority: it is what
the column actually is, what the CHECK constraint enforces, and what decides
which operators the column admits.
Everything else is a mapping onto that domain:
types.TextEq('email') — the schema factory from
@cipherstash/stack/eql/v3 that declares the column. A TypeScript builder,
not the column's type.TextEq / TextEqQuery — the wire shapes, exported as TypeScript types
by @cipherstash/eql. These and the JSON Schemas are generated from the Rust
eql-bindings crate, and the SQL bundle is built from the same commit, so
the payload shape and the domain CHECK cannot drift apart. Each type's doc
comment names the domain it maps to and the operators that domain admits.eql_v3.query_text_eq — the query domain, also a database type, that a
search needle is cast to.That TextEq / TextEqQuery pairing is precisely the split this section is
about. The domain names below follow the column, so a
public.eql_v3_text_search column would use eql_v3.query_text_search in
exactly the same places.
// 1. Encrypt for storage — the payload includes the ciphertext (`c`).
const enc = await client.encrypt('alice@example.com', { table: users, column: users.email })
if (enc.failure) throw new Error(enc.failure.message)
await sql`INSERT INTO users (email) VALUES (${sql.json(enc.data)})`
// 2. Mint a search needle — CIPHERTEXT-FREE, terms only.
const term = await client.encryptQuery('alice@example.com', {
table: users, column: users.email, queryType: 'equality',
})
if (term.failure) throw new Error(term.failure.message)
const rows = await sql`
SELECT * FROM users WHERE email = ${sql.json(term.data)}::jsonb::eql_v3.query_text_eq`Storage payloads and query terms are different shapes with different
domains. A storage payload carries c (the ciphertext); a query term
deliberately omits it and the eql_v3.query_* CHECKs require its absence.
Binding a storage payload where a query term belongs fails the CHECK, and vice
versa.
queryType is one of 'equality', 'freeTextSearch', 'orderAndRange',
'searchableJson'. Omit it only for single-index columns (types.TextEq);
be explicit on multi-index domains like types.TextSearch.
Strip public., insert query_, move to the eql_v3 schema:
public.eql_v3_text_eq → eql_v3.query_text_eq
public.eql_v3_text_search → eql_v3.query_text_search
public.eql_v3_bigint_ord → eql_v3.query_bigint_ord
public.eql_v3_timestamp_ord → eql_v3.query_timestamp_ordOne irregular case: types.Json builds public.eql_v3_json_search, but
its query domain is eql_v3.query_json — not query_json_search.
| Schema factory | Column domain (public.) | Query domain (eql_v3.) |
|---|---|---|
types.TextEq | eql_v3_text_eq | query_text_eq |
types.TextMatch | eql_v3_text_match | query_text_match |
types.TextOrd | eql_v3_text_ord | query_text_ord |
types.TextOrdOre | eql_v3_text_ord_ore | query_text_ord_ore |
types.TextSearch | eql_v3_text_search | query_text_search |
types.<N>Eq | eql_v3_<n>_eq | query_<n>_eq |
types.<N>Ord | eql_v3_<n>_ord | query_<n>_ord |
types.<N>OrdOre | eql_v3_<n>_ord_ore | query_<n>_ord_ore |
types.Json | eql_v3_json_search | query_json |
types.Text, types.<N>, types.Boolean | eql_v3_text / eql_v3_<n> / eql_v3_boolean | none — storage only |
<N> ranges over Integer, Smallint, Bigint, Numeric, Real,
Double, Date, Timestamp. The storage-only domains carry no query terms
by design — there is no query domain and nothing to search server-side.
For the same mapping written out one factory per row, with the predicates and
index each one supports, see the capability matrix in the
stash-encryption skill (### The types Namespace). That table is the
canonical type→predicate→domain→index lookup; this one is the SQL-side view of
it, expanded only far enough to build a cast.
Which operators each column domain accepts against its query domain. Anything not listed does not exist as an encrypted operator.
Confirm types against EQL before relying on them. This table — and every other domain/operator table in this skill — is a snapshot of a versioned surface that is defined elsewhere. Do not treat it as the last word. Consult EQL for the precise current types, in the order given under Where this surface is defined below; the first two checks need nothing but
node_modules.
| Column domain | Operators | Query domain operand |
|---|---|---|
eql_v3_<n>_eq, eql_v3_text_eq | = <> | query_<n>_eq / query_text_eq |
eql_v3_<n>_ord, eql_v3_text_ord | = <> < <= > >= | query_<n>_ord / query_text_ord |
eql_v3_<n>_ord_ore, eql_v3_text_ord_ore | = <> < <= > >= | query_<n>_ord_ore / query_text_ord_ore |
eql_v3_text_match | @@ | query_text_match |
eql_v3_text_search | = <> < <= > >= @@ | query_text_search |
eql_v3_json_search | @> | query_json |
eql_v3_json_entry (from col -> 'selector') | = <> < <= > >= | any query_<n>_ord / query_text_ord / query_text_search |
Note what is absent: there is no < on an _eq domain, and no @@
outside the match-capable text domains. Asking for one raises
operator does not exist — which is the good failure. The bad failure is
leaving the operand as bare jsonb (see Traps).
Every operator has a function twin, useful when an operator is awkward to
emit: eql_v3.eq(col, term), eql_v3.matches(col, term), and the comparison
functions. col = term and eql_v3.eq(col, term) are equivalent.
Nothing in the matrix above is authored by the client library. The domains,
operators, CHECKs, and extractor functions all come from the EQL SQL
bundle that stash eql install applies — published as the @cipherstash/eql
package, whose source lives in
cipherstash/stack under
packages/eql/.
A given stash release carries one resolved bundle, so the same CLI version
always installs the same SQL. A database, though, is on whatever bundle was
last applied to it, and different databases drift apart until each is upgraded
— and the Prisma Next adapter never goes through the CLI at all, installing
and upgrading the bundle pinned by @cipherstash/stack-prisma through its own
migrations. Ask the database (3 below) rather than the client.
That makes every table in this skill a snapshot of a versioned surface. Go to EQL for the precise current types rather than trusting these tables alone — in this order:
The generated TypeScript types in @cipherstash/eql. Every per-domain
type's doc comment names its domain and that domain's operators — the
TextEqQuery type, for instance, is documented as the
eql_v3.query_text_eq equality query operand admitting = and <>. They
are generated from the same Rust eql-bindings commit as the SQL bundle,
so the two cannot disagree.
The install SQL, shipped at
@cipherstash/eql/dist/sql/cipherstash-encrypt.sql (under node_modules,
wherever your package manager resolves it). Its CREATE OPERATOR
statements are the last word on which overloads exist — each names its
LEFTARG, RIGHTARG, and implementing FUNCTION.
The database itself, which is the runtime truth and the right check when a query is failing right now:
SELECT eql_v3.version(); -- which bundle is actually installedThe stash CLI depends on @cipherstash/eql, so 1 and 2 are available in any
project that has the CLI installed, with no database connection required.
If an operator is absent from all of these, that is an EQL question, not a
client-library one — the operator set is defined by the SQL bundle in
packages/eql/.
This differs between drivers. Both encrypted payloads and query terms are
plain JS objects, and the two drivers disagree about how to put a JS object
into a jsonb-backed domain.
postgres (postgres-js) — always sql.json(...)| Binding form | INSERT into a domain column | Query operand with ::jsonb::eql_v3.query_* |
|---|---|---|
${sql.json(payload)} | ✅ | ✅ |
${payload} (bare object) | ❌ invalid input syntax for type json | ✅ |
${JSON.stringify(payload)}::jsonb | ❌ CHECK violation | ❌ CHECK violation |
Use sql.json(...) in both positions — it is the only form that works in
both, so there is no reason to track which position you are in.
await sql`INSERT INTO users (email) VALUES (${sql.json(enc.data)})`
await sql`SELECT * FROM users
WHERE email = ${sql.json(term.data)}::jsonb::eql_v3.query_text_eq`pg (node-postgres) — pass the objectnode-postgres serialises a JS object to JSON exactly once, so all three forms happen to work. Pass the object and let the driver do it:
await client.query('INSERT INTO users (email) VALUES ($1)', [enc.data])
await client.query(
'SELECT * FROM users WHERE email = $1::eql_v3.query_text_eq', [term.data])Do not pre-stringify even though pg tolerates it — it is the one habit that
silently breaks if the project ever moves to postgres-js.
${JSON.stringify(payload)}::jsonb on postgres-js produces:
value for domain eql_v3_text_search violates check constraint "eql_v3_text_search_check"The message names neither JSON nor encoding, which is why this one costs an
afternoon. What happened: the explicit ::jsonb makes postgres-js infer a
jsonb parameter, so it JSON-encodes the value — which was already a JSON
string. The result is a jsonb string scalar, not an object:
SELECT jsonb_typeof($1::jsonb) -- 'string', not 'object'Every EQL domain CHECK opens with jsonb_typeof(VALUE) = 'object', so it
fails on the very first clause. Diagnose any CHECK-violation-on-write by
running jsonb_typeof on the parameter; 'string' means double-encoded.
Assume sql is a postgres-js tag; for pg use numbered placeholders as above.
The recipes below omit the Result guard for brevity — your code must not.
encryptQuery returns { data } | { failure }, so reading .data without
first checking .failure binds undefined into the query, which fails as a
domain CHECK violation rather than as the encryption error it actually is.
Every recipe should be read as though it were written:
const term = await client.encryptQuery(/* … */)
if (term.failure) throw new Error(term.failure.message)const term = await client.encryptQuery(email, {
table: users, column: users.email, queryType: 'equality',
})
await sql`SELECT * FROM users
WHERE email = ${sql.json(term.data)}::jsonb::eql_v3.query_text_eq`On a types.TextSearch column the cast is ::eql_v3.query_text_search — the
query domain always matches the column's domain, not the query type.
// `bio` is a types.TextSearch column, so its query operand is
// eql_v3.query_text_search. A types.TextMatch column uses query_text_match.
const term = await client.encryptQuery('needle', {
table: users, column: users.bio, queryType: 'freeTextSearch',
})
await sql`SELECT * FROM users
WHERE bio @@ ${sql.json(term.data)}::jsonb::eql_v3.query_text_search`Match is one-sided: a hit may be a false positive, a miss never is. Filter
client-side after decryption if exactness matters — and never build a negated
match (NOT (bio @@ …)), which would drop true rows. Needles must be at least
3 characters; shorter ones tokenize to nothing and are rejected.
const term = await client.encryptQuery(new Date('2026-01-01'), {
table: events, column: events.createdAt, queryType: 'orderAndRange',
})
await sql`SELECT * FROM events
WHERE created_at >= ${sql.json(term.data)}::jsonb::eql_v3.query_timestamp_ord
ORDER BY eql_v3.ord_term(created_at) DESC
LIMIT 20`ORDER BY must use the extractor form. ORDER BY created_at sorts the
raw encrypted payload — which is neither meaningful nor index-backed. Sorting
on eql_v3.ord_term(col) is both. Ordering is available on _ord,
_ord_ore, and text_search columns; use ord_term_ore for _ord_ore.
Encrypted ordering cannot evaluate abs(encrypted_column): the server can
compare encrypted order terms, but cannot transform their plaintext values.
Decompose an absolute-value bucket into its positive and negative signed
ranges. For non-negative bounds min and max, the half-open predicate
min <= abs(amount) < max is:
(amount >= min AND amount < max)
OR
(amount <= -min AND amount > -max)Encrypt all four bounds separately against the same column. For example, when
amount is a types.DoubleOrd() column:
const query = async (value: number) => {
const term = await client.encryptQuery(value, {
table: payments, column: payments.amount, queryType: 'orderAndRange',
})
if (term.failure) throw new Error(term.failure.message)
return term.data
}
const [min, max, negativeMin, negativeMax] = await Promise.all([
query(10), query(20), query(-10), query(-20),
])
await sql`SELECT * FROM payments WHERE
(amount >= ${sql.json(min)}::jsonb::eql_v3.query_double_ord
AND amount < ${sql.json(max)}::jsonb::eql_v3.query_double_ord)
OR
(amount <= ${sql.json(negativeMin)}::jsonb::eql_v3.query_double_ord
AND amount > ${sql.json(negativeMax)}::jsonb::eql_v3.query_double_ord)`The inequalities deliberately reverse on the negative branch. At min = 0
the two branches overlap at zero; that does not duplicate rows because this is
one WHERE predicate. Validate 0 <= min < max before constructing it.
const needle = await client.encryptQuery({ role: 'admin' }, {
table: users, column: users.prefs, queryType: 'searchableJson',
})
await sql`SELECT * FROM users
WHERE prefs @> ${sql.json(needle.data)}::jsonb::eql_v3.query_json`An object value produces a containment needle. Note the containment needle
is a bare { sv: [...] } shape with no version field — unlike the scalar
terms, which are full v3 envelopes. Bind it the same way regardless.
A string value produces a JSONPath selector, and v3 has no encrypted-selector
envelope: encryptQuery returns the bare selector-hash string. Bind it as
the plain text argument of -> / ->>, with no domain cast:
const sel = await client.encryptQuery('$.role', {
table: users, column: users.prefs, queryType: 'searchableJson',
})
await sql`SELECT prefs -> ${sel.data} FROM users`The extracted value is an eql_v3_json_entry, which accepts the ordering
operators — so a field inside an encrypted document can be ranged and ordered:
await sql`SELECT * FROM orders
WHERE data -> ${sel.data} >= ${sql.json(term.data)}::jsonb::eql_v3.query_integer_ord
ORDER BY eql_v3.ord_term(data -> ${sel.data})`Field-level = between extracted entries is not supported (an extracted
entry carries no value selector) — use document containment for exact field
equality.
SELECT returns the stored payload as an object; hand it straight to
decrypt — do not JSON.parse it, and do not cast it to ::jsonb in the
query (see the projection trap below).
const [row] = await sql`SELECT id, email FROM users WHERE id = ${id}`
const dec = await client.decrypt(row.email)
if (dec.failure) throw new Error(dec.failure.message)For whole rows, decryptModel / bulkDecryptModels walk the schema and
decrypt every declared column in one ZeroKMS round trip. They match by JS
property name, so a raw SELECT returning snake_case DB column names will
not match a schema keyed by camelCase properties — alias in the query
(SELECT last_login AS "lastLogin") or decrypt the columns individually.
A bare ::jsonb operand picks a different operator, not a missing one.
EQL also defines overloads with jsonb on the right — and they coerce that
operand to the storage domain:
-- what `col = $1::jsonb` actually resolves to:
eql_v3.eq_term(a) = eql_v3.eq_term(b::public.eql_v3_text_search)The storage domain's CHECK requires the ciphertext key c, which query terms
deliberately omit — so binding a query term without the domain cast raises a
CHECK violation rather than doing what you meant. Those overloads exist so you
can compare against a full storage envelope (an already-encrypted value).
For a needle from encryptQuery, always cast to the eql_v3.query_* domain.
Cast to the query_* domain, not the column domain. $1::public.eql_v3_text_eq
fails for the same reason — the column domain's CHECK requires c.
The value::jsonb projection trap. SELECT email::jsonb … ORDER BY email
folds the cast into the scan and sorts on (email)::jsonb, matching no index.
Project the column raw.
GROUP BY / DISTINCT on the raw column hashes the whole encrypted
payload (1–2 KB per row) and spills. Group on the extractor —
GROUP BY eql_v3.eq_term(email) — which is small and deterministic.
Predicates are not indexes. Everything here works without an index and
sequential-scans. Adding the functional index over the extractor is a separate
step — see stash-indexing.
Every writer and query reader must resolve to the same keyset — the
credential strings themselves may differ. Index terms come from a per-keyset
key, so any client bound to the keyset produces matching terms. Decrypt is
looser: it follows each payload's keyset and needs only a grant, which makes
one silent case possible — a reader granted the writer's keyset but bound to
a different one decrypts fine while its queries return zero rows. If decrypt
works but a query returns zero rows, check the reader's bound keyset against
the writer's (stash-zerokms), then the operand cast or predicate form on
this page, then the index (stash-indexing) — never the credential strings.
operator does not exist: public.eql_v3_… = eql_v3.query_… — the domain
pair has no such operator. Check the matrix: the
column's domain may not support that predicate (e.g. < on an _eq column),
or the query domain does not match the column's domain. If the matrix says
the operator should exist, check the installed bundle version
(SELECT eql_v3.version()) — an older EQL install can predate an overload
documented here. See Where this surface is defined.
value for domain … violates check constraint on write — double-encoded
payload; run SELECT jsonb_typeof($1::jsonb) and see
above. On a query operand, the
same error usually means a storage payload (with c) was bound where a query
term belongs.
Zero rows, no error — in order of likelihood: (1) the rows were written
under different CS_* credentials (see the trap above — this is the common
one, and it is completely silent); (2) the column's domain does not carry the
term the predicate needs (a types.Text column carries none); (3) a
free-text needle under 3 characters tokenized to nothing. A missing domain
cast raises an error rather than returning zero rows, so it is not a candidate
here.
Slow but correct — no index. See stash-indexing; confirm with
EXPLAIN (COSTS OFF) that the plan shows an Index Cond on the extractor
rather than a Seq Scan.
Checking what a column actually is, when the schema and the database may have drifted:
SELECT eql_v3.version(); -- which bundle defines the operators available
SELECT column_name, domain_schema, domain_name
FROM information_schema.columns
WHERE table_name = 'users' AND domain_name LIKE 'eql_v3%';stash-encryption — schema authoring, the types.* catalog, encryptQuery
and the client API, the rollout/cutover lifecycle.stash-indexing — functional indexes over the term extractors, and the
EXPLAIN checklist.stash-edge — the WASM entry and running encryption from edge runtimes.stash-zerokms — keysets, clients, and grants (canonical for keyset scoping).stash-auth — credentials, auth strategies, and lock context (canonical).stash-cli — stash eql install, stash eql validate (schema-vs-database domain drift, and the eql_v3.* functional indexes this skill's predicates need), stash encrypt backfill.Upstream:
cipherstash/stack, packages/eql/
— EQL itself: the definition of every domain, operator, CHECK, and extractor
named in this skill. Operator gaps and domain-level bugs are EQL issues
rather than client-library ones, and they are filed here, against
cipherstash/stack. The tables in this skill are a snapshot; the generated
types and install SQL in @cipherstash/eql (see Where this surface is
defined) track the bundle you actually have.cipherstash/encrypt-query-language
— the repository EQL is still published from, shipped as
@cipherstash/eql, pending a trusted-publishing cutover. Source and issues
moved to cipherstash/stack; nothing else here points at this repository.cipherstash/proxy — CipherStash
Proxy, the alternative to this entire skill: plaintext SQL, encryption on
the wire.© cipherstash, MIT. Rendered from Markdown: HTML in the file is shown as text, images as links, and headings moved down two levels. Raw file
Just SKILL.md in skills/stash-postgres of cipherstash/stack.
Open the folder on GitHubat commit 3eb459b
Stash Postgres next to the 5 skills that share the most tags, products or categories with it. Stars are the repository's; “used in” counts other GitHub owners with a copy.
| Skill | Stars | Used in | Tokens | Auto-check | Licence | Repo updated |
|---|---|---|---|---|---|---|
| Stash Postgres this skillcipherstash/stack | 157 | — | ~6.3k | Automated safety check: Pass | MIT | |
| Safe SQL Executionsupabase/supabase | 111k | — | ~4.2k | Automated safety check: Pass | Apache-2.0 | |
| Database FundamentalsDanielPodolsky/ownyourcode | 290 | 1 repos | ~1.6k | Automated safety check: Pass | MIT | |
| DB Review312362115/claude | 107 | — | ~1.8k | Automated safety check: Pass | MIT | |
| SQL Database Assistantalirezarezvani/claude-skills | 28k | — | ~4k | Automated safety check: Pass | MIT | |
| Tsh SQL And Database UnderstandingTheSoftwareHouse/copilot-collections | 284 | — | ~11k | Automated safety check: Pass | MIT |
supabase/supabase
A skill your agent uses whenever code will build, return, fetch, or execute SQL that runs against a user's real Postgres database — even when the request reads like an ordinary feature or bug fix…
DanielPodolsky/ownyourcode
Reviews schema design, SQL queries, ORM patterns. An agent skill from DanielPodolsky/ownyourcode.
312362115/claude
数据库代码审查 + Migration 安全检查. An agent skill from 312362115/claude.
alirezarezvani/claude-skills
A skill your agent uses when the user asks to write SQL queries, optimize database performance, generate migrations, explore database schemas, or work with ORMs like Prisma, Drizzle, TypeORM, or…
TheSoftwareHouse/copilot-collections
SQL writing and database engineering patterns, standards, and procedures.
ahpxex/open-dashboard
Everything about the data layer — pick one of six ready-to-run backend templates (TanStack Start + Drizzle + better-auth, Hono + Drizzle + better-auth, Hono + Prisma + better-auth, Hono + Drizzle +…
cipherstash/stack
How an agent files a GitHub issue on cipherstash repos — required structure (Background / Problem / Proposal), dumbed-down wording rules, and pre-filing checks.
cipherstash/stack
How an agent authors branches, commits, and pull requests on cipherstash/stack — naming, signed commits, the changeset/skills/meta-file checklist, and PR body structure with dumbed-down wording.
cipherstash/stack
The ZeroKMS key model — keysets, clients, client keys, and the grant/revoke lifecycle.
cipherstash/stack
Deploy a CipherStash encryption rollout to a live environment without losing data — the multi-deploy ladder (schema-add + dual-write → backfill → read cutover → stop dual-writes → drop plaintext)…
cipherstash/stack
Supply-chain security controls for the @cipherstash/stack monorepo.
cipherstash/stack
Integrate CipherStash encryption with Drizzle ORM using @cipherstash/stack-drizzle (EQL v3).
Works with
Categories
Query EQL v3 encrypted columns from hand-written Postgres SQL over pg (node-postgres) or postgres (postgres-js) — no ORM. Stash Postgres is an agent skill from cipherstash/stack. Query EQL v3 encrypted columns from hand-written Postgres SQL over pg (node-postgres) or postgres (postgres-js) — no ORM.
Stash Postgres fits situations like: writing INSERT/SELECT against an encrypted column without an ORM; A predicate returns zero rows; raises operator does not exist; A domain CHECK constraint rejects an encrypted value on write.
Run `npx skills add cipherstash/stack --skill stash-postgres -a claude-code`. Or copy the skill folder (skills/stash-postgres in cipherstash/stack) into .claude/skills/stash-postgres in your project. Claude Code loads it when a task matches its description.
Run `npx skills add cipherstash/stack --skill stash-postgres -a codex`. Or copy the skill folder (skills/stash-postgres in cipherstash/stack) into .agents/skills/stash-postgres in your project. Codex loads it when a task matches its description.
Cursor, Gemini CLI, GitHub Copilot and OpenCode also load SKILL.md folders. With the skills CLI, run `npx skills add cipherstash/stack --skill stash-postgres -a cursor` (or -a gemini-cli, github-copilot or opencode for the others). To copy it by hand, put the folder in .cursor/skills/stash-postgres, .gemini/skills/stash-postgres, .github/skills/stash-postgres and .opencode/skills/stash-postgres in your project.
SKILL.md names no scripts, command-line tools or credentials: Stash Postgres is instructions for the agent only.
SKILL.md names 1 domain. As links in the text: github.com. This is read from the text; nothing was executed.
Our automated static check of SKILL.md found no risky patterns, such as piping downloads into a shell, reading credential files or hidden Unicode. It is not a guarantee. Review the folder before installing.
Stash Postgres is published under the MIT licence (the repository's licence). It allows redistribution, so the full SKILL.md is shown on this page.
About 6.3k tokens (SKILL.md is roughly 25k characters). Agents keep only the skill's name and description in context until a task matches; then they load SKILL.md in full.
Skills that share tags, products or a category with Stash Postgres: Safe SQL Execution (supabase/supabase, 111k stars), Database Fundamentals (DanielPodolsky/ownyourcode, 290 stars), DB Review (312362115/claude, 107 stars) and SQL Database Assistant (alirezarezvani/claude-skills, 28k stars). The comparison table on this page puts their stars, adoption, token cost, safety result and licence side by side.
cipherstash (a GitHub organization) maintains it in cipherstash/stack, which has 157 GitHub stars. The repository holds 14 skills in this directory. The repository was last updated on October 8, 2026.
Source: cipherstash/stack on GitHub. Facts on this page come from the repository at the commit we read; the author's words are quoted as theirs.