Skip to content

test(golang): check EQL v4 order with random values in PostgreSQL - #1344

Open
coderdan wants to merge 3 commits into
nextfrom
test/go-eql-order-properties
Open

coderdan wants to merge 3 commits into
nextfrom
test/go-eql-order-properties

Conversation

@coderdan

Copy link
Copy Markdown
Contributor

Summary

EQL is the SQL library CipherStash installs into a customer's PostgreSQL. An order term is a value derived from a plaintext so that comparing two terms gives the same answer as comparing the plaintexts. EQL v4 compares them with SQL operators such as < and >=, using one of three schemes: OPE, CLLW ORE or block ORE. Until now the Go SDK's PostgreSQL test checked order with four fixed values per type. A comparator that is only partly wrong would pass that.

This adds a property test. For every ordered EQL v4 type, the Go SDK stores a few hundred random values plus edge values. Random queries through <, <=, > and >= in PostgreSQL must return exactly the rows that plaintext comparison selects, and ORDER BY must return the rows in plaintext order. A broken comparator, term derivation or SQL operator now fails CI, with a seed that replays the failure.

Changes

  • Go tests (languages/golang/encrypt/eql_order_postgres_test.go): new TestEQLOrderMatchesPlaintextInPostgres.
    • Types: every producible EQL v4 type with an ope, ore or ore_block index, read from eql.Types (29 today), over int16, int32, int64, float32, float64, text, date, timestamp and decimal.
    • Stored values: about 360 per type, in one temporary table, inserted 100 rows per statement and encrypted in one codec call. That is 300 random draws, the edges of the type's order, and 30 duplicates. Edges include min and max, zero, -0.0, ±Inf and NaN, values that differ only in their low bits or last digit, long shared text prefixes, text either side of block ORE's 16, 32 and 64 byte padding sizes, 1.5/1.50, and instants less than a microsecond apart.
    • Queries: 20 query values per type (random, stored and edge), each through all four operators. ORDER BY eql_v4.ord_term(v) or eql_v4.ord_term_ore(v) is checked ASC and DESC. These domains have no operator class, so a bare ORDER BY v would sort the jsonb.
    • Plaintext order is the engine's: text by its fold (case and accents dropped), -0.0 equal to 0.0 and every NaN one value above +Inf, timestamps truncated to the microsecond, decimals by value whatever their scale.
    • Seed: fixed (1343) and logged. EQL_ORDER_SEED replaces it. Each type has its own generator, seeded from the seed and the type's name, so one subtest replays on its own. A failure names the type, operator, seed, and the stored and query values that disagree. It stops after five disagreements per type.
  • Go tests (eql_postgres_test.go): the connection and the "EQL v4 is installed" check move into eqlPostgresConn, which both tests share. Without EQL v4, the new test fails rather than skips.
  • mise (go:test:postgres): the task runs the new test, and its --- PASS: check requires it. The eql-postgres CI job runs this task, so the job needs no change. Its comment in tests-golang.yml now describes the new check.

Verification

  • mise run go:test:postgres against a throwaway PostgreSQL 17 container, after rebasing onto next: all three tests pass. The task took 58 s in all, including the bundle build. The new test took 46.8 s. Block ORE text costs the most: TextOrdOre and TextSearchOre take about 11 s each, almost all of it encrypting in the WASM guest (about 10 ms per value at 16 bytes, plus about 0.5 ms per extra byte). Every other type takes 4.4 s or less.
  • Seeds 1, 2, 42 and 99999 (EQL_ORDER_SEED) also pass.
  • It can fail:
    • With the plaintext predicate for < flipped to <=: BigintOrdOre: v < q missed row 205, but in plaintext -93 < -93 is true (seed 1343; replay with EQL_ORDER_SEED=1343). NumericOrd failed the same way.
    • With row 1's stored OPE term (op) replaced in SQL by row 2's: TextOrd: v < q returned row 1, but in plaintext "ß" < "Élan" is false (seed 1343; …) and NumericOrd: v < q returned row 1, but in plaintext 441323043518896555732877.30309 < 0.875 is false (…).
    • Against a database without EQL v4: EQL v4 is not installed in DATABASE_URL: … (FAIL, not SKIP).
  • go vet ./... and gofmt -l . under languages/golang: clean.
  • EQL_DIST_REQUIRED=1 pnpm run test:scripts: 81 files passed.
  • No real ordering bug found.

Related

Closes #1343
Refs #1291 (merged into next; this builds on its fixed-value test and its eql-postgres CI job)

Review notes

  • No changeset: this is test-only.
  • The text check orders by the fold of a known set of strings. Random strings use characters whose fold is the ASCII lower case (foldedAlphabet plus A–Z), and the strings with accents or ß come from a fixed table with their folds. So the test needs no Unicode normalisation library.

https://claude.ai/code/session_01XQN1QpaWVDGjHEg39KEJF3

TestEQLTypesRoundTripAndQueryInPostgres runs <, <=, > and >= over four
fixed values per type. A comparator that is only partly wrong (one that
reads only a block ORE term's first blocks, mishandles a sign bit, or
orders a decimal by its scale) still agrees with plaintext order on most
pairs, so four values say little. The Go property tests compare terms in
Go, and the SQLx property suite covers EQL v3, so nothing ran random Go
SDK values through EQL v4's operators.

TestEQLOrderMatchesPlaintextInPostgres stores about 360 values in a
temporary table for every producible ordered EQL v4 type (29 today): 300
random draws, the edges of the type's order, and thirty duplicates. Each
of 20 query values goes through each ordering operator and must find
exactly the rows plaintext comparison selects, and ORDER BY the type's
order term must return the rows in plaintext order, both ways. Plaintext
order is the engine's: text by its fold, -0.0 equal to 0.0 and NaN above
+Inf, timestamps to the microsecond, decimals by value.

The seed is fixed and logged, EQL_ORDER_SEED replaces it, and each type
draws from its own generator so one subtest replays alone. A failure
names the type, the operator, the seed and the value pair.

The connection and EQL-installed check move into a helper both tests
share, and go:test:postgres runs and requires the new test.

Closes #1343

Claude-Session: https://claude.ai/code/session_01XQN1QpaWVDGjHEg39KEJF3
@changeset-bot

changeset-bot Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: ccc7bdc

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@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: 8b3d5b09-66ac-4a10-b431-829c159528ff

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.

@github-actions

Copy link
Copy Markdown
Contributor

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

No mutants were generated for the changed lines.

@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 #1344 at medium effort.

Head: 1cdfb943a544133b83503a2b3bdd6563380941d8

Read: the whole diff (eql_order_postgres_test.go, eql_postgres_test.go, mise.toml, tests-golang.yml), eql/eql_gen.go (the 29 ordered producible types and their index names), order_test.go (floatOrder, floatValues, foldedAlphabet), and the order rules in packages/stack-encrypt/src/value.rs. The plaintext model matches those rules: floor truncation of timestamps, decimals by value, -0.0 == 0.0, NaN above +Inf.

Ran: mise run go:test:postgres against a PostgreSQL 17 container. Everything passed, and the order test took 46 s. I also ran the order test with a deliberately wrong plaintext model, to see what it catches. It caught timestamp truncation toward zero, -0.0 < 0.0 in the model, and 0 < 0.00 in the model. It missed a tie the engine splits (finding 1).

Not covered: the Rust side of the order terms, the cross-language fixtures, and other PostgreSQL versions.

Findings: 2 (1 Major, 1 Minor).

Dropped candidates:

  • Risk of the 10-minute go test timeout: dropped. Measured 46 s locally, and the CI job took 1m58s in total.
  • field.Term is derived once per operator rather than once per query value: speed only, not correctness.
  • ore is listed in eqlOrderIndex with no extractor: it fails loudly, which is the right behaviour. It is covered by finding 2's fix.
  • The decimal cmp re-parses through big.Rat on every call: speed only.
  • eqlFloat32Order duplicates eqlFloat64Order: style.
  • %#08x / %#016x print too few digits: wrong. Go does not count the 0x prefix in the width (checked: 0x00000001).
  • The fixed default seed means CI never sees new values: deliberate, so runs do not vary, and EQL_ORDER_SEED covers exploration.

Generated by Claude Code

Comment thread languages/golang/encrypt/eql_order_postgres_test.go Outdated
Comment thread languages/golang/encrypt/eql_order_postgres_test.go
The edges hold the ties the order rules make: -0.0 and 0.0, 1.5 and
1.50, two instants in one microsecond. An engine that splits one of those
ties orders the tied rows one way every time, and the ORDER BY check
accepts tied rows in either order, so only a query on one of the tied
values can show it. Four edges were picked at random as query values, and
with the default seed four of the six float types passed with a plaintext
model that tied two values the engine keeps apart. Every edge is now a
query value, and all six fail on that model.

Refs #1343.

Claude-Session: https://claude.ai/code/session_01XQN1QpaWVDGjHEg39KEJF3
eqlOrderIndex kept its own list of order index names, and a type whose
index was not on it was skipped, with only a zero-types guard behind it.
The round-trip test fails on an index missing from eqlIndexOperators, so
a new order index would be added there and this test would then pass
without ever running the new types. An order index is now any index
whose operators in eqlIndexOperators include lt, so a new one reaches
runEQLOrder and fails at the missing extractor until it has one.

Refs #1343.

Claude-Session: https://claude.ai/code/session_01XQN1QpaWVDGjHEg39KEJF3
@coderdan
coderdan marked this pull request as ready for review October 23, 2026 14:17
@coderdan
coderdan requested a review from a team as a code owner October 23, 2026 14:17
@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-23T14:19:34.475576Z ccc7bdc Draft marked ready
🔒 Security Review ✅ Completed 2026-10-23T14:19:50.436791Z ccc7bdc 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: /code-review medium + cross-check-review-findings (Claude Code), head ccc7bdc

Result: no critical or blocking issues. No new findings.

Scope: the full diff (eql_order_postgres_test.go, eql_postgres_test.go, mise.toml, tests-golang.yml), checked against the PR head.

Candidates raised (7), all dropped after checking against head:

  • ORDER BY cannot detect split ties. This is already raised in the earlier review, and fixed in 5532dc935 by running every edge as a query value (thread resolved). Only the comment at eql_order_postgres_test.go:310, "so equal values come out next to each other", still overstates what the ORDER BY loop checks. That is wording only.
  • Extractor names eql_v4.ord_term / ord_term_ore unverified. Dropped. The PR reports the run passing against the bundle built from the tree.
  • go test 10-minute timeout risk. Dropped. The test took 46–60 s locally, and the eql-postgres job took about 2 minutes in CI.
  • "thirty" in a comment vs eqlOrderRandom / 10. Dropped. It equals 30 today, which is a style point only.
  • Seed replay depends on draw order. Dropped. A seed replays on the same file, and the PR does not claim replay across edits.
  • foldedAlphabet type and fold assumptions. Refuted. foldedAlphabet is a []rune (order_test.go:133), and its non-ASCII characters (ж, ω, 中) are already lowercase, so ASCII lowercasing is the correct fold.
  • Timestamp full-range draw relies on unsigned wraparound. Dropped. The arithmetic is correct and the seeds listed in the PR description pass.

Not covered: the Rust side of the order terms, other PostgreSQL versions, and a local run of the suite.

Note: pr_feedback.py could not run here because GraphQL is blocked, so existing feedback was read through the REST/MCP tools instead.


Generated by Claude Code

@cipherstash-bot cipherstash-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.

Recommendation: 🟢 merge as it is

Nothing must change before merging. This PR adds only tests, and the new property test covers every ordered EQL v4 type with random values, edges and ties. The three comments are extra edge values the test does not store today. All can wait for a follow-up.

How this review was made
Agent Model Review type Result
claude claude-opus-5-5 test-gap 2 found, 2 posted
codex gpt-5.6-terra test-gap 1 found, 1 posted

Synthesis: claude-sonnet-5-5 merged the findings, removed duplicates and dropped findings it could not confirm in the code. 0 posted finding(s) were raised by two or more models.

Plain language: claude-sonnet-5-5 read every comment as a new reader would. No comment had a problem that stopped the reader acting, so none was rewritten.

Stack: 1 earlier and 0 later pull request(s) (#1229, *️⃣ #1344). *️⃣ marks this pull request.

Context loaded: the description, 1 linked issue(s) and 10 discussion entries.

// the folds above. The edges add strings either side of block ORE's padding
// buckets (16, 32 and 64 bytes) that differ only in their last byte. Block
// ORE's cost grows with the padded length, about 0.5 ms a byte under the
// test build, so nothing here is near its 1023-byte limit.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Fix in a follow-up: block ORE text ordering has no test near its 1023-byte input limit.

Impact: The longest stored text is 65 bytes. A comparator bug in a later block, or at the last valid byte, can pass CI.

Evidence: The comment above eqlTextOrder says no value is near the 1023-byte limit. TextOrdOre and TextSearchOre use block ORE.

Fix: Add two ASCII values of exactly 1023 bytes that differ only in the last byte to the text edges:

max := strings.Repeat("m", 1022)
edges = append(edges, max+"a", max+"b")

At about 0.5 ms per byte, the extra encryption cost is small. The existing query and ORDER BY checks then cover the final block.

Found by 1 model: codex

edges = append(edges, float32(v))
}
edges = append(edges, math.SmallestNonzeroFloat32, -math.SmallestNonzeroFloat32, math.MaxFloat32, -math.MaxFloat32,
-1, 1, math.Nextafter32(1, 0), math.Nextafter32(1, 2))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Optional: the float32 edges do not include a NaN with a non-default payload or a signalling NaN.

Impact: The rule is that every NaN is one value, above +Inf. An engine that recognises only the canonical float32 NaN (0x7fc00000) would order other NaNs wrongly, and this test could pass.

Evidence: The float32 edges come from float32(v) over floatValues(). The conversion drops the low payload bits, so no edge has a signalling NaN.

Fix: Add NaNs as bit patterns, so the conversion cannot change them:

math.Float32frombits(0x7fc0_beef), math.Float32frombits(0x7f80_0001), math.Float32frombits(0xff80_0001)

The test expects each of these to tie with every other NaN.

Found by 1 model: claude

eqlDecimalText(eqlDecimalMaxMantissa, 28, false), eqlDecimalText(eqlDecimalMaxMantissa, 28, true),
eqlDecimalText(new(big.Int).Sub(eqlDecimalMaxMantissa, big.NewInt(1)), 0, false),
"0.0000000000000000000000000001", "0.0000000000000000000000000002", "-0.0000000000000000000000000001",
} {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Optional: no decimal edge ties a value at scale 0 with the same value at scale 28.

Impact: Decimals must order by value whatever their scale. The scale ties in the test are small (1.5/1.50/1.500, and at most 5 extra digits in the random draws). A scale bug that shows only at a large scale difference would pass.

Evidence: 1 at scale 28 has the mantissa 10^28, which is below 2^96 - 1, so it is valid. No edge or draw stores it.

Fix: Add these edges:

"1", "1.0000000000000000000000000000", "-1", "-1.0000000000000000000000000000",
"7", "7.0000000000000000000000000000",

The test expects each pair to tie.

Found by 1 model: claude

@auxesis auxesis 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.

Love to see it, thanks @coderdan.

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.

4 participants