Skip to content

feat(eql-bindings): produce the OPE-ordered EQL v4 types - #1266

Draft
coderdan wants to merge 4 commits into
feat/stack-encrypt-order-keysfrom
feat/eql-v4-ordered-ope-types
Draft

coderdan wants to merge 4 commits into
feat/stack-encrypt-order-keysfrom
feat/eql-v4-ordered-ope-types

Conversation

@coderdan

@coderdan coderdan commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Go developers can now store numbers and text in EQL columns they can sort and range-filter: IntegerOrd, BigintOrd, RealOrd, DoubleOrd, TextOrd and their OrdOpe twins. EQL is the SQL library we install into a customer's PostgreSQL database to store and search encrypted values. Stack Encrypt is our Rust encryption engine, which the Go SDK runs as a WebAssembly guest. These types store an OPE term (order-preserving encryption: an encrypted value whose bytes sort in the same order as the plaintexts), and EQL compares it as plain bytes. So <, <=, >, >= and ORDER BY eql_v4.ord_term(col) work on them. #1235 and #1242 already made the engine derive these terms over ADR-0002's order keys. What was missing was the EQL side: a list in the code generator, and the step that writes the engine's OPE term into EQL's op field.

Stacked on #1242 (which is stacked on #1235). Review those first.

Changes

eql-codegen and eql-bindings

  • The ten types join ENCRYPTION_DOMAINS. Their v4 structs derive EncryptFrom and DecryptInto, and the by-name dispatch handles them. That takes the producible types from 12 of 51 to 22.
  • OpeCllw (the op term) implements EncryptFrom for any orderable plaintext. It writes the hex of Stack Encrypt's OPE term.
  • TextSearch stays unproducible with a new reason. Its query value has to carry a match term, and text shorter than one 3-gram can't make one. So = or < on short text would be refused. How a query that isn't a match leaves that term out still needs deciding (stashgen refuses TextMatch, TextSearch, TextOrd and TextOrdOpe — the engine derives their terms, but no EQL type is built from them #1131).
  • Regenerated v4/{integer,bigint,real,double,text,targets}.rs and Go's encrypt/eql/eql_gen.go.
  • Updated the README and the CHANGELOG. A new changeset, .changeset/eql-v4-ordered-ope-types.md (@cipherstash/eql minor), is kept separate from the shared eql-v4-bindings.md so this PR and feat(eql-bindings): produce Smallint and SmallintEq from i16 #1267 don't conflict.

Tests (packages/eql/tests/encryption, new tests/v4_order.rs)

  • The stored terms sort as the order keys do:

    • integers across their whole range;
    • floats as PostgreSQL's float8 sorts them: -0.0 ties 0.0, and every NaN is one value after +Infinity;
    • an f32 orders as the f64 it widens to;
    • text by its fold: case and accents tie, and a prefix sorts first.
  • Each query value carries the terms the stored value holds. A family's Ord and OrdOpe store one term for one value. Text with no fold (an unassigned code point) is refused on both paths.

  • PostgreSQL, against the v4 bundle built from the tree. On every new column, <, <=, >, >=, =, the query on the left of the comparison, ORDER BY eql_v4.ord_term(col) and eql_v4.max each return exactly the expected rows:

    • on numbers, = reads the order term, so -0.0 = 0.0;
    • on text, = reads TextEq's equality term, so Café and cafe tie in order but aren't =.
  • test:encryption:postgres now also runs v4_order.

  • New fixtures:

    • v4_ordered_queries.json: one query per type, derived in Rust and read by Go;
    • v4_ordered_rows.json: rows sealed by Go, then opened and compared in Rust and PostgreSQL.

    v4_rows.json, stored_v4_rows_v1.json and the Profile model are untouched. A separate Ranking model holds the ordered fields.

Go

  • stashgen writes Lt, Lte, Gt and Gte on these fields, plus Eq on the text ones. The guest already answered these through each type's query value, so the generator needed no change.
  • New Ranking test model. eql_v4_order_test.go checks:
    • the keys each field stores;
    • that the terms sort as the rows do;
    • the round trip;
    • that the generated methods match the Rust fixture byte for byte.
  • The guest's and the SDK's catalog tests list the new producible types and their operators. The refusal test now uses IntegerOrdOre, which is still unproducible.
  • Updated the README (including how to index eql_v4.ord_term(col) with a plain btree), doc.go, the stashgen README and the CHANGELOG.

Verification

  • packages/eql: mise run build, test:codegen, test:crates, test:encryption (432 passed), types:check (on the commit) and codegen:parity all passed.
  • mise run test:encryption:postgres passed against a disposable PostgreSQL 17.5 (initdb cluster) with the v4 bundle built from the tree: text_eq 1, v4_types 3 and v4_order 2 tests.
  • cargo clippy -p eql-bindings -p eql-codegen -p eql-encryption-tests --all-features --all-targets -- -D warnings and eql-bindings rustdoc with -D warnings were clean, under --all-features and under stack-encrypt alone.
  • cargo publish --dry-run --all-features -p eql-bindings can't resolve stack-encrypt ^0.3.0 from crates.io. That's the same as on the base, and CI reports it as a warning.
  • All five guests rebuilt. mise run wasm:guest:test passed (68 and 71).
  • In languages/golang: mise run go:test passed, go vet ./... was clean and mise run go:lint reported 0 issues. go generate ./... leaves no diff.
  • Stack Encrypt itself is unchanged, so there are no mutants to run on it.
  • CI: every check on this PR passed on the first push. (feat(stack-encrypt)!: derive every order term over its ADR-0002 order key, and order floats #1242's own run fails stored_rows_v1.json and the jobs that depend on it, by design.) Nothing here touches that fixture or its test.

Related

Refs #1131: makes TextOrd and TextOrdOpe producible and checks step 1 for OPE (EQL's SQL compares the engine's OPE terms correctly). TextSearch is left with its reason.
Refs #1130: the OPE-ordered number types.
Refs #1132: the *OrdOre types still wait on block ORE.

Review notes

… cllw-ore's vitaminc schemes

Text could not be ordered: no ordering operator was answered on a text
field, and the ORE and OPE terms stored for text went through cllw-ore's
own collation, which keeps case, so Zoe ordered before alice and é after
z. ADR-0002 defines text's order key as its fold (Unicode 16 NFC with
case and accents folded), and vitaminc 0.6 ships it in vitaminc-ore.

cllw-ore 0.5.2-rc.1 implements vitaminc-ore's `OrderScheme` behind its
`vitaminc` feature, so Stack Encrypt encodes nothing for ordering itself.
An ORE or OPE term is now cllw-ore's `vitaminc::Ore` or `vitaminc::Ope`
encryption of the value: the engine names the value's order target
through the new sealed `sem::Orderable` (integers at their own width,
`f32`/`f64` as `F64`, text as `Text`, bytes as `Bytes`) and passes the
value straight through, on the typed path and for each scalar of a
dynamic value alike; cllw-ore encodes it with vitaminc-ore, text by the
fold. Café, cafe, CAFÉ and cafe with a combining accent are one term.
Text holding a code point Unicode 16 does not assign has no fold and no
order term, as it has no equality or match term; ADR-0002 is amended to
say vitaminc 0.6 refuses such a code point rather than passing it
through.

The per-index key derivation is kept: the PRF block keys the scheme
inside the visitor and is wiped there, and the column is opened with the
value's target and the empty context, since the key is already the
index's own. The key labels move to `/v2` because every order term
changes bytes (new column salt, cllw-ore's marker byte for text and
bytes). Every source type's term is now one ciphertext type
(`OreCllw8VariableV1`, `OpeCllw8VariableV1`); `OreTerm<T>`/`OpeTerm<T>`
keep `T` as a marker so typed callers compile unchanged, and
`sem::CllwOreEncrypt`/`CllwOpeEncrypt` are no longer re-exported. The
term pins and the record, query and value-kinds fixtures are re-recorded.

op::Ordered covers String, and the data path answers the orderings on
text too, refusing order text with no fold at its boundary, for one
value and for each bound of a range, through `vitaminc_ore::text`, the
encoding the `Text` target uses. The Go generator writes Lt, Lte, Gt,
Gte and Between on a string field with ore or ope; its notice is left
for order indexes on bytes or a float, and for a second order index.

Closes #1234.
An `f32` or `f64` order term is cllw-ore's vitaminc encryption of the
value at the `F64` target, whose vitaminc-ore encoding folds `-0.0` into
`0.0` and every NaN into one value above `+Infinity`: Postgres's
`float8` order. So the ordering operators are now answered over floats,
on the typed path (`op::Ordered` for `f32` and `f64`) and over a data
plan's `float32` and `float64` fields, and stashgen writes `Lt`, `Lte`,
`Gt`, `Gte` and `Between` on a float field with `ore` or `ope`.

An `f32` is ordered as the `f64` it widens to: its `Orderable` target is
`F64` and vitaminc widens it exactly, so an `f32` and its `f64` are one
term and `f32` can join `op::Ordered`. A float `Between` is not refused
when inverted (a range from NaN, which sorts last, matches nothing), as
SQL's `BETWEEN` is not.

The Go query profile gains a sealed `Score float64` with ORE, matching
the Rust query fixture, and the live PostgreSQL test stores the score and
its ORE term so the rows it reads back open. Float pins, the float
ordering baseline, the integer-width lengths and the `score` field in the
cross-path fixture are added; the fixtures and UI snapshots are
re-recorded.

Closes #1236.
@changeset-bot

changeset-bot Bot commented Oct 18, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 82ab168

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

@coderabbitai

coderabbitai Bot commented Oct 18, 2026

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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

@coderdan coderdan left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Review of 7b30b04 (this PR's diff against feat/stack-encrypt-order-keys only). No prior review comments existed. I found nothing that blocks; three low-severity notes are inline and summarised below.

Findings (most severe first)

  1. Low — the ordered fixtures pin order-term bytes that the base PRs may still move. v4_ordered_queries.json (Rust-derived, checked by Go) and v4_ordered_rows.json (Go-sealed, checked by Rust) both hold op hex that depends on how #1235/#1242 settle the stored_rows question. If either base changes the bytes before this merges, the Go TestV4OrderedQueriesMatchTheRustFixture/TestV4OrderedRowsMatchTheCommittedFixture and the Rust the_ordered_query_fixture_is_what_the_dispatch_derives/ordered_rows_the_go_sdk_sealed_open_in_rust all fail on this branch after a rebase. Regenerable (EQL_UPDATE_FIXTURES=1 on both sides, Go first then Rust, or the other way for the queries file), but worth a line in the PR's rebase notes. The PR already says there is no frozen stored_v4_ordered_rows fixture yet; the fixtures README (inline) should say so too so nobody treats these two as frozen.
  2. Low — encryption_gap's fallback comment is stale. The else branch comment says it is reached by an "equality-only domain"; with ord/ord_ope now in ENCRYPTION_DOMAINS, any unlisted OPE-only domain of a family with a plaintext (e.g. smallint's once it gains a kind) lands there too. The message itself is generic enough; only the comment is wrong. Inline.
  3. Low — a README note on indexing would close the loop with the non-superuser story. The Go README says to sort with ORDER BY eql_v4.ord_term(col) but not how to index it. Since the OPE term is a bytea domain, a plain functional btree on eql_v4.ord_term(col) works with no operator class, which is exactly why these types are the portable choice on a non-superuser install where the ORE opclass is skipped. One sentence would spare a reader reaching for the ORE opclass. Inline; optional.

Informational, no change asked: Eq on a TextOrd/TextOrdOpe field always derives op alongside hm, so =/<> with text that has no fold (an unassigned code point) is refused even though = reads only hm. Harmless in practice because the same text cannot be stored in that column, so no row could match; and the crate README already says "refused, stored or queried".

Verified as sound

  • Bytes written to op are what the SQL reads. OpeVisitor writes hex::encode(term.as_bytes()) of cllw-ore's OpeCllw8VariableV1; eql_v4_internal.ope_cllw(jsonb) is decode(val->>'op','hex')::bytea, and every lt/lte/gt/gte/eq/neq wrapper on the *_ord/*_ord_ope domains compares ord_term(a) <op> ord_term(b), i.e. PostgreSQL bytea order (memcmp, shorter prefix first). cllw-ore documents the OPE ciphertext as exact under lexicographic byte order (+128 signal dominates the backward carry) and its own Ord is derived byte order, so < on terms is < on order keys. The query side uses the same target::ope() plan under the same (table, column) CallerContext, and each_ordered_type_stores_its_catalog_terms pins op(query) == op(stored) for all ten types.
  • Order equals plaintext order per family. Integers: i32/i64 go in at their own OrderTarget width (I32/I64), and integer_terms_order_by_value covers MIN < -70000 < -1 < 0 < 1 < ... < MAX, so signed values cross zero correctly. Floats: both f32 and f64 map to OrderTarget::F64; float_terms_order_as_postgres_float8_does pins -inf < MIN < ... < -0.0 = 0.0 < ... < +inf < NaN = NaN(other payload), matching float8 sort, and a_real_orders_as_the_double_it_widens_to pins the widening. Text: text_terms_order_by_the_fold pins the empty string, prefix-first (al < alice), case/accent ties (alice=ALICE, cafe=Café=CAFE+combining), and text_order_ties_are_not_equal pins that hm still differs and equals TextEq's. Fixture lengths agree with cllw-ore's 8n+1: 66 hex for i32, 130 for i64/f64 and for widened f32, and folded-text length + 1 for text.
  • Widths and refusal. run_target goes through Plaintext::from_frame, which refuses any other kind rather than narrowing or widening; the generated structs pin #[stash(plaintext = i32|i64|f32|f64|String)]; the SQL does not care about width, only the domain name, so an integer_ord column and a bigint_ord column cannot be cross-compared by accident. Go range-checks wider integer fields before sealing (pre-existing).
  • Twins. IntegerOrd/IntegerOrdOpe (and the other families) both declare indexes: ["ope"] and store one op; ord_and_ord_ope_store_one_term pins it. *OrdOre stays unproducible with the block-ORE reason. TextOrd/TextOrdOpe store hm + op, matching the text_ord domain CHECK (v,i,c,hm,op) and the query_text_ord CHECK (v,i,hm,op, no c); =/<> on text read eq_term, orderings read ord_term.
  • between cannot leak through. target_operators filters takes_pair() and answer() refuses an operator the target does not list as OperatorNotAnswered, so a hand-built between plan on an OPE target is refused rather than producing a one-bound operand. The Go/guest catalog tests assert exactly lt,lte,gt,gte (+ eq first on text).
  • Docs vs non-superuser installs. Nothing here points at the ORE opclass; the Go README's term table already says Ope compares in SQL with <, >, ORDER BY and Ore must not. Consistent with OPE being the portable path.
  • Changeset. The bot's "No Changeset found" is a false alarm: the PR extends the existing unreleased @cipherstash/eql minor changeset rather than adding a file. That is the right package: eql-bindings ships in lockstep with the npm package, and the Go module has no changeset (its CHANGELOG is edited by hand here). No TS package or skill surface changes.

Checks run (no credentials, worktree at 7b30b04)

  • packages/eql: mise run types:check (regenerates the 27 binding files and languages/golang/encrypt/eql/eql_gen.go: no drift) and mise run codegen:parity: both clean.
  • cargo test --locked -p eql-codegen, -p eql-bindings --all-features, and -p eql-encryption-tests --test v4_order --test targets --test v4_types: all pass (v4_order 12 passed, the 2 PostgreSQL tests ignored without a database; CI runs them via test:encryption:postgres in test-eql.yml).
  • cargo clippy --locked -p eql-bindings -p eql-codegen -p eql-encryption-tests --all-features --all-targets -- -D warnings: clean.
  • Built all four Go guests (wasm:guest:build, :eql, :deterministic, :eql:deterministic), then go generate ./... in languages/golang: no diff; go vet ./... clean; go test ./encrypt/... ./stashgen/ ./cmd/...: pass, including the three new TestV4Ordered* tests against the deterministic EQL guest.
  • Not run: the PostgreSQL half of v4_order (no local EQL v4 database). I read the generated SQL it exercises instead.

cannot make, so `=` or `<` on short text would be refused; how a query that is not a \
match leaves it out is not decided (#1131)"
} else {
// Reached by an equality-only domain of a family whose plaintext is

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Low: this comment is now stale. With ord and ord_ope in ENCRYPTION_DOMAINS, the fallback is reached by any unlisted domain of a family whose plaintext is specified and whose terms are neither ORE nor match-and-order, not only an equality-only one (a future smallint ord once that family gains a kind, for instance). The message string itself is fine; just the comment needs widening.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in cc3ea66. The comment now says the fallback is reached by any unlisted domain of a family whose plaintext is specified and whose terms are neither ORE nor match-and-order.

Comment thread languages/golang/encrypt/README.md Outdated
`float8` sorts them (`-0.0` ties `0.0`, every NaN is one value after
`+Infinity`), text by its fold (case and accents folded, under Unicode 16).
A field gets `Lt`, `Lte`, `Gt` and `Gte`, and a text field `Eq` too; sort
with `ORDER BY eql_v4.ord_term(col)`. On a number column SQL's `=` compares

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Low, optional: a sentence on indexing would help here. Because ord_term returns a bytea domain, a plain functional btree index on eql_v4.ord_term(col) serves <, > and ORDER BY with no operator class, which is the reason these types are the portable choice on a non-superuser install where the ORE opclass is skipped. As written a reader has to work that out from the Ope vs Ore bullets further up.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added in cc3ea66. The Go README now says ord_term returns a bytea domain, so a plain functional btree, CREATE INDEX ON t (eql_v4.ord_term(col)), serves <, > and ORDER BY with no operator class. It also says that's why these types work on an install without superuser rights.

| `v4_ordered_queries.json` | the by-name dispatch in `tests/v4_order.rs` | yes, with `EQL_UPDATE_FIXTURES=1` |
| `v4_ordered_rows.json` | the Go SDK's `TestV4OrderedRowsMatchTheCommittedFixture` | yes, with `EQL_UPDATE_FIXTURES=1` |
| `text_eq_query.json`, `ste_vec_selectors.json` | `tests/text_eq.rs`, `tests/ste_vec_selectors.rs` | yes, see each test |
| `stored_v4_rows_v1.json` | copied from `v4_rows.json` at the next build | **never: frozen** |

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Low: the two new rows above are regenerable, and their op bytes depend on how #1235/#1242 settle the stored-rows question. Since this table is also where the frozen fixture is explained, it would be worth one line saying that no frozen stored_v4_ordered_rows_* exists yet and that both ordered fixtures must be regenerated together (Go writes v4_ordered_rows.json, Rust writes v4_ordered_queries.json) after a rebase that moves the order-term bytes. Otherwise the first sign is four cross-language tests failing at once.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 82ab168. The fixtures README now says neither ordered fixture is frozen, and that both are regenerated together when the order-term bytes move: Go writes the rows, Rust writes the queries. It also says no frozen stored_v4_ordered_rows_* exists yet.

IntegerOrd, BigintOrd, RealOrd, DoubleOrd, TextOrd and their OrdOpe twins
join ENCRYPTION_DOMAINS, so their v4 structs derive EncryptFrom and
DecryptInto and the by-name dispatch runs them. OpeCllw implements
EncryptFrom for any orderable plaintext: the hex of Stack Encrypt's OPE
term over ADR-0002's order key, which EQL compares as bytea.

The engine already answers the four single-bound orderings on a target
through its query twin (and Eq on text, through TextEq's equality term),
so Go fields typed with these get Lt, Lte, Gt and Gte from stashgen.
Between is not offered: a range needs EQL v4's range query domains.

Tests: the stored terms sort as the order keys do (integers, floats with
-0.0 tied and NaN last, text by its fold, prefixes first), each query
twin carries the stored terms, Ord and OrdOpe store one term, text with
no fold is refused, and against an EQL v4 database <, <=, >, >=, = and
ORDER BY eql_v4.ord_term find exactly the expected rows. A new
v4_ordered_queries.json pins one query per type for the Go SDK.

TextSearch stays unproducible with its own reason: its query carries a
match term, which short text cannot make, so = and < on short text would
be refused. The *OrdOre types still wait on block ORE.
…Go's rows in PostgreSQL

A Ranking test model holds one field of each OPE-ordered type. Go seals
it through generated code over the deterministic EQL guest: each value
stores its catalog keys, the order terms sort as the rows do (with the
-0.0 and folded-text ties), and everything opens back. Its Lt, Gte and
Eq methods derive v4_ordered_queries.json byte for byte.

TestV4OrderedRowsMatchTheCommittedFixture writes v4_ordered_rows.json,
which the Rust suite opens field by field and inserts into eql_v4
columns, where < with the fixture's query operands finds the rows the
plaintexts say. Profile and its frozen rows are untouched.
@coderdan
coderdan force-pushed the feat/eql-v4-ordered-ope-types branch from 7b30b04 to 82ab168 Compare October 18, 2026 14:30

@coderdan coderdan left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Incremental re-review of 82ab168 against the previous review on 7b30b04 (review 5563357370). Between the two heads the code diff is five files: the changeset split, the Go README indexing note, the encryption_gap comment and the fixtures README note. The Go and PostgreSQL test commit was already on 7b30b04, but I read it again closely here, as asked, and ran its PostgreSQL half locally, which the earlier review could not do.

Prior findings

  1. Fixtures README note on the ordered fixtures: fixed. fixtures/README.md now says that neither ordered fixture is frozen, that both are regenerated together (Go writes the rows, Rust writes the queries), and that no stored_v4_ordered_rows_* exists yet. One wording nit is inline.
  2. Stale encryption_gap comment: fixed. The else branch in eql-codegen/src/bindings.rs now says it is reached by any unlisted domain whose terms are neither ORE nor match-and-order.
  3. Go README indexing sentence: fixed. encrypt/README.md now says ord_term returns a bytea domain, so a plain CREATE INDEX ON t (eql_v4.ord_term(col)) serves <, > and ORDER BY with no operator class, and that this is why these types work on a non-superuser install.

The changeset is also cleaner now. The PR adds its own .changeset/eql-v4-ordered-ope-types.md (@cipherstash/eql minor) and no longer edits eql-v4-bindings.md, which matches the base branch exactly.

New findings

  1. Low: the PostgreSQL test of the Go-sealed rows checks only <, so it never sorts Go's rows in SQL. postgres_compares_rows_the_go_sdk_sealed runs one < query per column. The probe ties row 2, so every column expects [1]. That catches a Go term for row 1 or row 2 on the wrong side of the probe. It cannot catch a row-3 term that ties the probe or row 2, because the only check on row 3 is that it is not below the probe. ORDER BY eql_v4.ord_term(col) over Go's bytes is never run either. The Rust-sealed test covers those operators, and the Go test checks the byte order of Go's terms, so this is a coverage gap rather than a known bug. Adding the other operators costs nothing: I ran them against the committed fixtures (details inline) and they give the expected answers. Details inline.
  2. Nit: "the base build" in the fixtures README will be unclear once this merges. Inline.

Informational, no change asked: unlike v4_rows.json, the ordered rows have no CI step that seals them fresh in Go and hands them to Rust (the EQL_V4_ROWS_OUT / EQL_V4_ROWS step in tests-golang.yml). Rust only opens the committed ciphertexts. I don't think that matters here. TestV4OrderedRowsMatchTheCommittedFixture compares everything except c (so op and hm are drift-checked) and checks that the committed c still opens. A change in how Go writes the plaintext width would also move the op length (66 hex vs 130), which that check catches. The fresh hand-off for v4_rows already covers the AEAD agreement between the Go guest and native Rust.

Verified as sound

  • CI runs it. packages/eql/mise.toml's test:encryption:postgres now loops over text_eq v4_types v4_order and refuses an empty --ignored selection. The Rust workspace crates job in test-eql.yml runs it against the EQL v4 bundle built from the tree. On this head, the job log shows both postgres_compares_and_orders_ope_rows ... ok and postgres_compares_rows_the_go_sdk_sealed ... ok.
  • The keys match. The query fixture is derived under FakeDataKeySource. The rows are sealed under the deterministic guest and testSeed. TestV4OrderedQueriesMatchTheRustFixture derives the same query bytes from Go's Lt/Gte/Eq under the guest, so both sides use the same index key and < across them means something.
  • The expectations tell a fold from raw bytes. Zucchini vs Mango and Carol/Alice vs carol sort differently by byte than by fold, so a case-sensitive term would fail the title/handle checks. The float plaintexts are exact in f32, so f64::from(p.speed) < plain.as_f64() has no rounding edge. !expected.is_empty() && expected.len() < rows.len() stops a column from passing on an empty or full result.
  • The fixtures are deterministic and drift-checked. The Go side compares the fixture with c stripped and only rewrites the file under EQL_UPDATE_FIXTURES when something other than c moved. The Rust side asserts the query fixture byte for byte. tests-golang.yml's path filter includes packages/eql/tests/encryption/**, so regenerating the Rust query fixture triggers the Go check.
  • TestV4OrderedFieldsSealToTheirEQLTypesAndOpenBack covers the cases JSON cannot hold (the infinities and -0.0): strict term order between consecutive rows, the row 2/3 tie, and the sign of zero after a round trip.

Checks run (worktree at 82ab168, no credentials)

  • cargo test --locked -p eql-codegen, -p eql-bindings --all-features, -p eql-encryption-tests: all pass (v4_order: 12 passed, 2 ignored).
  • mise run test:encryption:postgres against a throwaway local PostgreSQL 17 with the tree's v4 bundle: text_eq 1, v4_types 3, v4_order 2, all pass.
  • Ad hoc SQL over the committed v4_ordered_rows.json and v4_ordered_queries.json, per column: ORDER BY eql_v4.ord_term(col) DESC gives 3,2,1, > gives [3] and >= gives [2,3]. = gives [2] on the eight number columns and nothing on title/handle, as expected, because = reads the case-sensitive hm and row 2 is mango/Carol against Mango/carol.
  • Built the four encrypt guests and the auth guest. In languages/golang: go generate ./... leaves no diff, go vet ./... is clean, and go test ./encrypt/... ./stashgen/... ./cmd/... passes, including the three TestV4Ordered* tests run with -v.
  • Not run: the repo's scripts/__tests__ vitest suite (no node_modules in this worktree). CI is green on this head.

),
queries[*column]["query"].as_str().unwrap(),
);
assert_eq!(found, expected, "< over Go-sealed {column}");

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Low: this is the only SQL comparison of Go-sealed rows, and with the probe tying row 2 it expects [1] in every column. It catches row 1 or row 2 landing on the wrong side of the probe. Row 3 only has to be not below the probe, so a Go-sealed row-3 term equal to row 2's would still pass. ORDER BY eql_v4.ord_term(col) over Go's bytes is never run.

The extra assertions are free. I ran them by hand against the committed fixtures on a local EQL v4 install: ORDER BY eql_v4.ord_term(col), id gives [1, 2, 3] in every column, > gives [3] and >= gives [2, 3]. = gives [2] on the number columns and [] on title/handle, because = reads the case-sensitive hm and row 2 is mango/Carol against Mango/carol. To cover them, derive an above set from the plaintexts the same way below is derived, assert it for > (and its complement for <=), and assert that SELECT id FROM go_rankings ORDER BY eql_v4.ord_term({column}), id returns the ids sorted by plaintext. That would make this test hold the commit message's claim that Go's rows order in PostgreSQL, rather than just split around one probe.

| `stored_v4_rows_v1.json` | copied from `v4_rows.json` at the next build | **never: frozen** |

`v4_ordered_queries.json` and `v4_ordered_rows.json` are not frozen: their
`op` bytes are the order terms of the base build, so a change that moves

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Nit: "the order terms of the base build" will be unclear once this merges, because a reader of main has no base build to look at. Something like "the order terms Stack Encrypt writes today" would say the same thing.

This branch has not been deployed

No deployments
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.

1 participant