Skip to content

feat(stack-encrypt, eql-bindings, eql): EQL v4 order terms are typed, one hex string, checked when written - #1340

Merged
coderdan merged 4 commits into
nextfrom
feat/eql-v4-order-term-types
Oct 23, 2026
Merged

coderdan merged 4 commits into
nextfrom
feat/eql-v4-order-term-types

Conversation

@coderdan

Copy link
Copy Markdown
Contributor

Summary

EQL (the SQL extension that stores and queries encrypted columns) keeps each searchable term of an encrypted value in a JSON payload. Order terms let the database sort and compare encrypted values: ob holds a block ORE term (order-revealing encryption, from the ore-rs crate) and op an OPE term (order-preserving encryption, from clww-ore).

Until now, EQL v4 reused EQL v3's Rust types for these terms. ob was OreBlock256(Vec<String>), a one-element array of hex, and op was OpeCllw(String). Neither said which scheme or which kind of value it held, and nothing checked them until a comparison failed.

This PR makes each v4 order term Stack Encrypt's own typed term, stored as one hex string, and checks its shape both when the Rust bindings read a payload and when the database stores one. EQL v3 is untouched: its bundle is byte-for-byte what it was.

Changes

stack-encrypt

  • Serde on the order terms. OreTerm, OpeTerm and OreBlockTerm serialize as lowercase hex in JSON and as raw bytes in binary formats. This goes through serdect, whose hex encoding (base16ct) is constant-time. Deserializing goes through from_bytes, and the errors give a length, never the term's bytes.
  • Breaking: from_bytes takes the Vec<u8> and keeps it, with no copy. TryFrom<Vec<u8>> is added, and TryFrom<&[u8]> stays, copying once.
  • OpeTerm checks its leading carry byte is 0 or 1. clww-ore's comparator already refuses any other value, but its from_bytes checks only the length.

eql-domains and eql-codegen

  • New catalog entry, Term::v4_format. For each v4 order term it names the Stack Encrypt type, the JSON shape and the hex prefixes a term starts with: 0202 or 0203 for block ORE (the fixed-width and chained engines), 00 or 01 for OPE.
  • Everything is generated from that one entry: the Rust field, the JSON Schema pattern and the v4 domain CHECKs. So the schema and the CHECK can't disagree.

eql-bindings

  • v4 fields hold Stack Encrypt's types. A v4 ob field is stack_encrypt::sem::OreBlockTerm<T> and an op field is OpeTerm<T>, over the type's plaintext. For example, DateOrdOre::ob is an OreBlockTerm<Date>.
  • The generated types OreBlockHex and OpeHex describe that JSON to TypeScript and JSON Schema.
  • v4::terms no longer re-exports v3's order types. The old v4 visitors and impls for them are removed.

EQL SQL (v4 only)

  • ob is read as one hex string. has_ore_block_v2 tests for a string, and the extractor decodes it straight into a composite that now holds one term instead of an array. The one-element array version of the comparator is gone. The comparator logic itself is unchanged.
  • New CHECKs on every v4 domain carrying ob or op (stored, query and range bound). The value must be a JSON string starting with one of the term's prefixes. The CHECK reads only the prefix, so a write costs the same whatever the term's length; the comparator still checks the full structure.

Docs

  • U-003 in the 3.1 upgrade guide covers the new shape, the CHECKs, and what a database that installed an earlier v4 bundle must do.
  • A changeset for @cipherstash/eql, CHANGELOG entries for stack-encrypt, eql-bindings and the Go module, and updates to the bindings README and the catalog reference.

Verification

  • Rust (root workspace): cargo nextest run --workspace --all-features: 1285 passed, 1 skipped. Clippy passes with all features and with none, as do rustdoc and the fuzz crate check. Every detached lockfile passes --locked.
  • stack-encrypt gates:
    • CRAP: 984 functions, none over 30.
    • Mutation check over the serde commit: 3 mutants, all unviable because OreBlockTerm has no Default. cargo-mutants doesn't mutate code inside macros, so the macro-generated serde impls are pinned by the new tests/term_serde.rs.
  • EQL workspace:
    • every crate's tests pass, except the SQLx crate, which needs generated fixtures this worktree doesn't have (CI generates them);
    • clippy and fmt pass;
    • mise run types:check and mise run codegen:parity show no drift;
    • doc-anchors, check:encryption:wasi.
  • v3 bundle: built from this branch and from its base, then compared with cmp. cipherstash-encrypt.sql is byte-for-byte identical; only the v4 bundle changes.
  • PostgreSQL 17, in a throwaway container: mise run test:encryption:postgres passes every suite. The new postgres_domains_take_an_order_term_only_as_its_hex_string checks that real terms cast into stored, query and range domains. It also checks that each of these is refused with check_violation: an EQL v3 array, an unknown engine, wire version 1, an OPE term in ob, a carry of 2, a number, and a block ORE term in op.
  • Without a database: a_v4_type_reads_an_order_term_only_as_its_hex_string checks that the bindings refuse the same shapes when they deserialize, and that the errors don't repeat the term.
  • Upgrade behaviour, checked by hand: I installed the base branch's v4 bundle, then this one, in a throwaway container. public.eql_v4_integer_ord_ore keeps its old array CHECK, as U-003 describes.
  • Go: mise run go:test and mise run wasm:guest:test (75 passed); go generate ./... makes no diff.
  • Scripts and packages: pnpm run test:scripts (78 files) and pnpm --filter @cipherstash/eql test pass.

Related

Review notes

  • Existing installs need a manual step. Stored public.eql_v4_* domains are created only if they don't exist, so a database that installed an earlier v4 bundle keeps the old array CHECK and would refuse every new ob. As agreed, the installer doesn't rewrite existing domains; U-003 tells such a database to drop its stored order domains first. The only released v4 bundle is the one attached to the Go module's v0.1.0-alpha.1, which produced no ordered type.
  • T is the caller's claim when decoding. from_bytes checks a term's structure, not that it is a term of T: the bytes of an i64 term decode as OreBlockTerm<Date>. Checking that, and exact per-type CHECKs (block count and length, using a few constant-time string operations), wait on ore-rs, clww-ore: a key-free check that a term is a term of a given OrderTarget ore.rs#139. Until then, the CHECK checks the engine prefix only.
  • Order indexes are now declared. Stack Encrypt's term types declare their index (EncryptFrom::indexes), which v3's order types didn't. A typed plan field of an ordered v4 type therefore now admits its order index. The typed query chain still answers no ordered type; that gap is unchanged.
  • Equality compares by order. PartialEq on a v4 struct now compares ob by order, through the comparator, because each block ORE term carries a random nonce, so two terms for one value differ in bytes.
  • Two things deliberately not done:
    • renaming the v4 SQL function ope_cllw and the source directory ore_block_256: both are names only, and renaming would make the diff much bigger;
    • checking op inside a SteVec JSON entry: v4 has no SteVec producer yet.

https://claude.ai/code/session_01XQN1QpaWVDGjHEg39KEJF3

@coderabbitai

coderabbitai Bot commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

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
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Organization UI; Enforced overrides: Organization
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 52e572f1-3dd2-4933-8ed3-9bc5902f3fa6

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
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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

@changeset-bot

changeset-bot Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: eb415b4

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

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

@github-actions

github-actions Bot commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

Mutation testing (cargo-mutants, --in-diff, stack-auth + stack-encrypt)

caught missed unviable timeout
0 0 3 0

Every mutant in the changed lines was caught by a test.

@coderdan
coderdan added this pull request to stack #1339 October 23, 2026 10:48
@coderdan
coderdan force-pushed the feat/order-dates-timestamps-decimals branch from c753de7 to 7741dde Compare October 23, 2026 11:02
@coderdan
coderdan force-pushed the feat/eql-v4-order-term-types branch from 2bc7bb9 to b6f1c7a Compare October 23, 2026 11:02
@coderdan
coderdan marked this pull request as ready for review October 23, 2026 11:06
@coderdan
coderdan requested a review from a team as a code owner October 23, 2026 11:06
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-23T11:12:33.437303Z b6f1c7a Draft marked ready
🔒 Security Review ✅ Completed 2026-10-23T11:11:39.874257Z b6f1c7a Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@cipherstash-agent cipherstash-agent left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review attestation — source: automated /code-review medium plus company-skills:cross-check-review-findings, run against head b6f1c7a.

Critical / blocking issues: none.

Findings: none. No new defect survived verification, and the PR had no existing review threads or findings to cross-check against (only bot status comments).

Checked:

  • v4 SQL: ob extractor and has_ore_block_v2 now read one hex string, and no leftover .terms or array path remains in the v4 render. v3 render is unchanged.
  • Domain and range CHECKs: the prefixes come from the single Term::v4_format entry, so the generated CHECK, JSON Schema pattern and Rust types agree.
  • Rust: from_bytes takes Vec<u8>, the OPE carry-byte check matches the SQL prefixes (00/01), and serde errors report a length only. cargo test -p stack-encrypt --test term_serde --test ore_block passes (16 tests).
  • stack-encrypt is 0.3.0 in the tree and unpublished (crates.io has 0.2.0), so the breaking from_bytes change does not break the published eql-bindings requirement.

Not covered: the full workspace, SQLx and PostgreSQL suites were not re-run here; they rely on CI.


Generated by Claude Code

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b6f1c7a722

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread .changeset/eql-v4-order-term-shape.md Outdated
Comment thread packages/eql/docs/upgrading/v3.1.md
@coderdan
coderdan force-pushed the feat/order-dates-timestamps-decimals branch from 7741dde to 300a799 Compare October 23, 2026 11:24
@coderdan
coderdan force-pushed the feat/eql-v4-order-term-types branch from b6f1c7a to 8bfaeb1 Compare October 23, 2026 11:24
@coderdan
coderdan removed this pull request from stack #1339 October 23, 2026 11:39
EQL v4's bindings are to store stack-encrypt's own order terms, typed by
scheme and plaintext, rather than v3's untyped hex newtypes. That needs
the terms to be serde types. OreTerm, OpeTerm and OreBlockTerm now
serialize as lowercase hex in a human-readable format and as raw bytes in
a binary one, through serdect, whose hex (base16ct) is constant-time.
Deserializing goes through from_bytes, so a term of another scheme or a
malformed one is refused at the boundary, with an error that names a
length, never a byte.

from_bytes now takes the Vec<u8> it decodes and keeps it, so neither
deserializing nor a caller that owns the bytes pays a copy; TryFrom<&[u8]>
stays and copies once. OpeTerm also checks its leading carry byte is 0 or
1: clww-ore's comparator refuses any other, but its from_bytes checks only
the length.

Upstream serde for these types is proposed in cipherstash/ore.rs#137, and
constant-time FromHex in clww-ore in cipherstash/ore.rs#138.

Claude-Session: https://claude.ai/code/session_01XQN1QpaWVDGjHEg39KEJF3
… terms, stored as one hex string

EQL v4 reused v3's term newtypes for its order terms: `ob` was
`OreBlock256(Vec<String>)`, a one-element array of hex, and `op` an
`OpeCllw(String)`. Neither said which scheme or plaintext it held, and
neither checked anything when read.

A v4 `ob` field is now `stack_encrypt::sem::OreBlockTerm<T>` and an `op`
field `OpeTerm<T>`, over the type's plaintext (`DateOrdOre::ob` is an
`OreBlockTerm<Date>`). Each serializes as one lowercase hex string and is
decoded through `from_bytes` when a payload is read, so an EQL v3 array,
a term of the other scheme or a malformed term is refused there.
`v4::terms` no longer re-exports v3's order terms. `OreBlockHex` and
`OpeHex`, generated, describe the JSON to TypeScript and JSON Schema.

One catalog entry, `eql_domains::Term::v4_format`, names each term's
Stack Encrypt type, its JSON shape and the hex prefixes it starts with
(block ORE `0202`/`0203`, OPE carry `00`/`01`). Codegen writes the Rust
field, the JSON Schema pattern and the v4 domain CHECKs from it: every v4
domain carrying `ob` or `op`, stored, query or range bound, requires a
JSON string with one of those prefixes. The CHECK reads only the prefix,
so a write costs the same whatever the term's length; the comparator
still checks the full structure.

The v4 SQL reads `ob` as one hex string: `has_ore_block_v2` tests for a
string, the extractor decodes it straight to the composite, which now
holds one term rather than an array, and the one-element array overload
of the comparator is gone. The v3 bundle is byte-for-byte unchanged.

Exact per-type CHECKs (block count and length) wait on a key-free shape
API in ore-rs and clww-ore, cipherstash/ore.rs#139.

Claude-Session: https://claude.ai/code/session_01XQN1QpaWVDGjHEg39KEJF3
U-003 in the 3.1 upgrade guide says what changed for a hand-written
eql_v4 payload: `ob` is one hex string, and every v4 domain carrying `ob`
or `op` checks its prefix. A database that installed an earlier v4
bundle, such as the one attached to the Go module's alpha, keeps its
stored domains across an install, so it must drop its stored order
domains first; the guide gives the query that lists them.

A changeset for @cipherstash/eql, CHANGELOG entries for eql-bindings and
the Go module, and the bindings README and catalog reference describe the
v4 term types.

Claude-Session: https://claude.ai/code/session_01XQN1QpaWVDGjHEg39KEJF3
… range bounds

The changeset linked U-003 relative to the repository root, but
Changesets copies it into packages/eql/packages/eql/CHANGELOG.md, where
that path names nothing. It now uses ../../docs, as the U-001 and U-002
changesets do.

The v4 range reference still said a block ORE range's bounds are ob
arrays, which the range domains now refuse with check_violation. It
now says both bounds are one hex string, names the prefixes, and links
U-003. It also no longer says the envelope waits on the ordered types
becoming producible: they are, and a caller builds the envelope from
two single-bound queries.

Claude-Session: https://claude.ai/code/session_01XQN1QpaWVDGjHEg39KEJF3
@coderdan
coderdan force-pushed the feat/order-dates-timestamps-decimals branch from 300a799 to f8fc092 Compare October 23, 2026 12:10
@coderdan
coderdan force-pushed the feat/eql-v4-order-term-types branch from f699849 to eb415b4 Compare October 23, 2026 12:10
Base automatically changed from feat/order-dates-timestamps-decimals to next October 23, 2026 12:34
@coderdan
coderdan merged commit 5a7d19e into next Oct 23, 2026
57 checks passed
@coderdan
coderdan deleted the feat/eql-v4-order-term-types branch October 23, 2026 12:35
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