Skip to content

feat(stack-encrypt): order text over ADR-0002's folded order key - #1235

Merged
coderdan merged 3 commits into
nextfrom
feat/stack-encrypt-text-ordering
Oct 19, 2026
Merged

coderdan merged 3 commits into
nextfrom
feat/stack-encrypt-text-ordering

Conversation

@coderdan

@coderdan coderdan commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Stack Encrypt is CipherStash's Rust encryption engine; the Go SDK runs it as a WebAssembly guest. Next to each ciphertext it can store order terms, which let the database answer <, >, BETWEEN and ORDER BY without decrypting. There are two kinds: ORE (order-revealing encryption, compared by a special function) and OPE (order-preserving encryption, compared as plain bytes).

Until now Stack Encrypt could not order text at all. This PR makes text orderable, so a Go string field with index=ore or index=ope now gets Lt, Lte, Gt, Gte and Between. Text orders by ADR-0002's fold: case and accents are folded, under Unicode 16. So Café, cafe and CAFÉ tie, alice comes before Zoe, and é sorts with e rather than after z.

How it works changed since the first draft. The ORE crate cllw-ore now encodes and encrypts each type for ordering itself, through vitaminc-ore. That shipped as a pre-release, 0.5.2-rc.1, with a vitaminc feature. Stack Encrypt says which type a value is and passes it straight through. It no longer folds text or builds order keys of its own.

Changes

Engine (packages/stack-encrypt)

  • Dependency. cllw-ore is now =0.5.2-rc.1 with the vitaminc feature, in the root workspace. The root, packages/eql, encrypt guest and stack-encrypt fuzz lockfiles are refreshed and pass --locked.

  • sem::Orderable is one constant. A sealed trait with a single item, const TARGET: OrderTarget, the vitaminc-ore target a type's values are encoded as. That is the one thing vitaminc's OrderEncrypt::column(target, context) leaves to the caller; everything else goes through vitaminc's own Encrypt trait. (The first draft had three methods; the other two re-implemented what Encrypt and Zeroizing already do, and are gone.) The table:

    Rust type Target
    u16 to u128, i16 to i128 their own width
    f32, f64 F64
    String Text
    Vec<u8> Bytes
    bool, u8, i8 none: OreTerm<bool>, OreTerm<u8> and an Ore/Ope index on either do not compile (a trybuild case holds it)

    A value is encrypted by reference, as the Encrypt source it already is (&String, &i32, ...), and the engine holds the one copy it was handed in zeroize::Zeroizing and wipes it when the term is derived or refused. There is no custom Drop. The heap-hygiene test fails if that wrap is removed. Because the source must own its bytes to be wiped, &'static str, &'static [u8], Zeroizing<String> and Zeroizing<Vec<u8>> are no longer order sources; pass a String or Vec<u8>.

  • 8-bit integers have no order term. An order term over 256 values ranks every row among 256, which a few known rows resolve to the values: the leak the crate already refuses for a boolean's two. So u8 and i8 leave op::Ordered and sem::Orderable, dynamic::admits refuses ore and ope on int8 and uint8 (as on bool), a data plan naming the pair is refused when built, and a frame with an 8-bit scalar under an order index yields no term. Both keep their equality term. The value_kinds.json fixture's i8 and u8 fields carry equality alone (regenerated), and the frozen stored_rows_v1.json loses its four 8-bit order terms under the README's order-term exception, with every other byte as written (the README records the edit). Go: stashgen refuses ore/ope on int8, uint8 and byte as it does on bool, with tests against the engine and the fake; the README's type table and the Go CHANGELOG say so.

  • Order terms.

    • The per-index key derivation is unchanged: a PRF over the field's context.
    • Inside the visitor, the derived key now keys cllw_ore::vitaminc::Ore or Ope and is wiped. The visitor opens a column with the value's target and an empty context, since the key already belongs to this index, and encrypts the value.
    • The key labels move to ore-key/v2 and ope-key/v2.
  • Removed. OrderSource, text_order_key, the Any downcasts, the CllwOreEncrypt/CllwOpeEncrypt re-exports, and TermError::NoOrderTerm (code stack_encrypt::no_order_term): the boolean refusal it carried at run time is a compile error now.

  • Term types.

    • Every source type's term is now one ciphertext type: OreCllw8VariableV1 or OpeCllw8VariableV1.
    • OreTerm<T> and OpeTerm<T> keep T to name the source, so typed callers compile unchanged.
    • KeysetCipher::ore_term and ope_term return the ciphertext types.
  • Between.

    • op::Ordered covers String.
    • Between no longer refuses a range whose low bound is above its high bound. As in SQL, BETWEEN 20 AND 10 is a valid query that matches nothing. TermError::InvertedRange (code stack_encrypt::inverted_range) is gone; it was unreleased.
    • The data-plan path still refuses order text with no fold before any key is used, for one value and for each bound of a range. It asks vitaminc_ore::text, the encoding the Text target uses.
  • Unencodable text. Text with a code point Unicode 16 doesn't assign has no order term (TermError::Normalize), on write and on query.

  • Breaking: every order term changes bytes. The causes are the /v2 labels, the column salt (which binds the scheme and the target), and cllw-ore's one marker byte in front of text and bytes. stack-encrypt 0.1.0 and 0.2.0 and the Go module's v0.1.0-alpha.1 shipped the old terms, and nothing refuses a stale one: a <, BETWEEN or ORDER BY over a column holding them silently misses rows, so the CHANGELOGs say to encrypt such a column again. The pins in term_bytes.rs and frozen_bytes.rs are re-recorded, as are both cross-path fixtures; the frozen stored_rows_v1.json re-freezes only its ore and ope passthroughs, under STACK_ENCRYPT_REFREEZE_ORDER_TERMS=1, which the check accepts only when nothing else moved. That exception runs until stack-encrypt 1.0 (tests/fixtures/README.md).

  • Docs. ADR-0009's text-ordering section names the crate, and the CHANGELOG has the Breaking entry.

Go (languages/golang)

  • Generated methods. stashgen writes the ordering methods on string fields with ore/ope. Generated files and goldens are regenerated, and the README, doc.go and the stashgen README describe the fold. These are unchanged from the first draft.
  • Guest test. A guest test that pinned a text ORE term's length now expects the marker byte: 48 bytes for alice, not 40.

Verification

  • mise x --env test -- cargo nextest run --workspace --all-features: 1218 passed.
  • cargo nextest run -p stack-encrypt --all-features: 679 passed. This includes the trybuild UI binary: one new case (order_term_of_an_8_bit_integer), and two snapshots re-recorded for the changed Ordered diagnostic.
  • tests/heap_hygiene.rs: passes, and fails when the order visitor's Zeroizing wrap is removed (checked by hand).
  • tests/stored_rows.rs passes without the re-freeze variable at the final head.
  • cargo clippy --workspace --all-targets --all-features -- -D warnings, cargo fmt, mise run doc (rustdoc with warnings as errors) and mise run test:doc (stack-encrypt: 40 doctests): all clean.
  • cargo mutants --no-shuffle -p stack-encrypt --in-diff against next (CI's form), at the final head: 38 mutants, 25 caught, 13 unviable, none missed.
  • EQL: cargo test --locked -p eql-bindings --all-features (197 passed), and packages/eql/tests/encryption (37 passed, 3 ignored; those need a database).
  • All five guests build. mise run wasm:guest:test passes (68 and 71 tests).
  • mise run go:test passes, mise run go:lint reports 0 issues, and go generate ./... leaves no diff.
  • New or changed Rust tests:
    • re-pinned ORE/OPE bytes for u32 and for text;
    • folded equivalents are one term in every owned text form;
    • a text term differs from the same bytes stored as bytes;
    • unencodable text has no order term;
    • typed Lt/Between on text, with folded ties;
    • an inverted range derives its terms without error, on the typed and data-plan paths;
    • data-plan boundary refusals for text with no fold.
  • Not run locally: the Postgres query test (needs DATABASE_URL).

Related

Review notes

  • Where to look: start with sem/mod.rs's ORE/OPE section: Orderable (one constant) and the visitors, which hold the plaintext in Zeroizing and encrypt it by reference through vitaminc's Encrypt.
  • The type-to-target rule belongs in vitaminc. Orderable::TARGET stays here until vitaminc-ore owns the rule (vitaminc-ore: order index widths are only checked at runtime, and every call needs a target and context — add typed targets and a chained API vitaminc#470); then it goes.
  • The type-to-target table is a stored format. Changing a type's target changes its terms.
  • Pre-release dependency. cllw-ore is pinned to the pre-release on purpose. When 0.5.2 (or later) is final, bump the pin; the terms shouldn't change.
  • Not addressed here: the stack-auth and stack-kms fuzz lockfiles are already stale on next, so --locked fails there. This PR leaves them alone.

https://claude.ai/code/session_01BZhkvKLpNtKuzj3HCfMXoL

@changeset-bot

changeset-bot Bot commented Oct 17, 2026 •

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 3004048

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 17, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

Only developers with an assigned seat can use this organization's usage-based review budget, and seats here are assigned manually. Ask an admin to assign a seat, or change the review continuation mode in Billing.

Next included review available in 59 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used all 2 included reviews currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration
  • Configuration used: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: e2910dcf-ab10-4ee2-ac6f-dadab9ad4177
📥 Commits

Reviewing files that changed from the base of the PR and between 0800375 and 3004048.

⛔ Files ignored due to path filters: 4
  • Cargo.lock is excluded by !**/*.lock
  • languages/golang/encrypt/guest/Cargo.lock is excluded by !**/*.lock
  • packages/eql/Cargo.lock is excluded by !**/*.lock
  • packages/stack-encrypt/fuzz/Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing: 58
  • Cargo.toml
  • docs/adr/0002-one-value-model-three-encodings-and-eql-v4-by-producer.md
  • languages/golang/CHANGELOG.md
  • languages/golang/cmd/stashgen/README.md
  • languages/golang/encrypt/README.md
  • languages/golang/encrypt/doc.go
  • languages/golang/encrypt/guest/src/abi.rs
  • languages/golang/encrypt/guest/tests/native_ops.rs
  • languages/golang/encrypt/internal/testusers/kinds.go
  • languages/golang/encrypt/internal/testusers/kinds_stash.go
  • languages/golang/encrypt/internal/testusers/probe_stash.go
  • languages/golang/encrypt/internal/testusers/valuekinds_stash.go
  • languages/golang/encrypt/order_test.go
  • languages/golang/encrypt/query.go
  • languages/golang/encrypt/query_test.go
  • languages/golang/encrypt/value_kinds_test.go
  • languages/golang/stashgen/emit.go
  • languages/golang/stashgen/enginetest/enginetest.go
  • languages/golang/stashgen/refusal_test.go
  • languages/golang/stashgen/testdata/cases/orders/order_stash.go.golden
  • packages/stack-encrypt/CHANGELOG.md
  • packages/stack-encrypt/Cargo.toml
  • packages/stack-encrypt/docs/adr/0007-bindings-enter-through-a-plan-never-a-second-executor.md
  • packages/stack-encrypt/docs/adr/0009-operators-select-the-index-and-a-query-term-is-not-the-stored-term.md
  • packages/stack-encrypt/examples/search_terms.rs
  • packages/stack-encrypt/src/codes.rs
  • packages/stack-encrypt/src/dynamic/kind.rs
  • packages/stack-encrypt/src/dynamic/query.rs
  • packages/stack-encrypt/src/dynamic/term.rs
  • packages/stack-encrypt/src/op/mod.rs
  • packages/stack-encrypt/src/sem/mod.rs
  • packages/stack-encrypt/src/target/index.rs
  • packages/stack-encrypt/src/target/operations.rs
  • packages/stack-encrypt/src/target/source.rs
  • packages/stack-encrypt/src/target/transcode.rs
  • packages/stack-encrypt/tests/derive.rs
  • packages/stack-encrypt/tests/fixtures/README.md
  • packages/stack-encrypt/tests/fixtures/query_operators.json
  • packages/stack-encrypt/tests/fixtures/record_lowering.json
  • packages/stack-encrypt/tests/fixtures/stored_rows_v1.json
  • packages/stack-encrypt/tests/fixtures/value_kinds.json
  • packages/stack-encrypt/tests/frozen_bytes.rs
  • packages/stack-encrypt/tests/heap_hygiene.rs
  • packages/stack-encrypt/tests/index.rs
  • packages/stack-encrypt/tests/op.rs
  • packages/stack-encrypt/tests/sem_terms.rs
  • packages/stack-encrypt/tests/stored_rows.rs
  • packages/stack-encrypt/tests/term_bytes.rs
  • packages/stack-encrypt/tests/ui/op_between_takes_a_pair.stderr
  • packages/stack-encrypt/tests/ui/op_order_on_bytes.rs
  • packages/stack-encrypt/tests/ui/op_order_on_bytes.stderr
  • packages/stack-encrypt/tests/ui/op_order_on_text.rs
  • packages/stack-encrypt/tests/ui/op_order_on_text.stderr
  • packages/stack-encrypt/tests/ui/order_term_of_an_8_bit_integer.rs
  • packages/stack-encrypt/tests/ui/order_term_of_an_8_bit_integer.stderr
  • packages/stack-encrypt/tests/ui/pass/owned_without_clone.rs
  • packages/stack-encrypt/tests/ui/pass/plan_chain.rs
  • packages/stack-encrypt/tests/value_kinds.rs
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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

@github-actions

github-actions Bot commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

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

caught missed unviable timeout
425 0 342 0

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

Copilot AI 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.

🔵 Needs a closer look

Changes to cryptographic term derivation, key framing, and stored-byte compatibility warrant final human review.

No open issues 🎉

2 files not reviewed
  • languages/golang/encrypt/internal/testusers/kinds_stash.go: Generated file
  • languages/golang/encrypt/internal/testusers/probe_stash.go: Generated file
What changed in this PR

Enables folded text ordering in the Rust encryption engine and Go SDK, preparing for EQL text-order types.

Changes:

  • Derives text order terms using Unicode 16 case-and-accent folding.
  • Enables text comparisons and validates folded range bounds.
  • Updates generated Go methods, regression tests, documentation, and dependencies.
File Description
packages/​stack-encrypt/​tests/​ui/​pass/​plan_chain.rs Adds compiling text queries.
packages/​stack-encrypt/​tests/​ui/​op_order_on_text.stderr Removes obsolete rejection snapshot.
packages/​stack-encrypt/​tests/​ui/​op_order_on_text.rs Removes text-order rejection test.
packages/​stack-encrypt/​tests/​ui/​op_order_on_bytes.stderr Adds bytes-order rejection snapshot.
packages/​stack-encrypt/​tests/​ui/​op_order_on_bytes.rs Checks bytes ordering remains unavailable.
packages/​stack-encrypt/​tests/​ui/​op_between_takes_a_pair.stderr Updates ordering diagnostics.
packages/​stack-encrypt/​tests/​term_bytes.rs Pins folded terms and rejection behavior.
packages/​stack-encrypt/​tests/​op.rs Tests folded comparisons and ranges.
packages/​stack-encrypt/​src/​sem/​mod.rs Folds text and separates its key framing.
packages/​stack-encrypt/​src/​op/​mod.rs Enables typed text ordering.
packages/​stack-encrypt/​src/​dynamic/​term.rs Updates text-order documentation.
packages/​stack-encrypt/​src/​dynamic/​query.rs Enables and validates dynamic text queries.
packages/​stack-encrypt/​fuzz/​Cargo.lock Locks the folding dependency.
packages/​stack-encrypt/​docs/​adr/​0009-operators-select-the-index-and-a-query-term-is-not-the-stored-term.md Records text ordering as decided.
packages/​stack-encrypt/​CHANGELOG.md Documents changed text term bytes.
packages/​stack-encrypt/​Cargo.toml Adds vitaminc-ore.
packages/​eql/​Cargo.lock Updates engine dependencies.
languages/​golang/​stashgen/​testdata/​cases/​orders/​order_stash.go.golden Adds expected string ordering methods.
languages/​golang/​stashgen/​refusal_test.go Updates unused-index notice coverage.
languages/​golang/​stashgen/​enginetest/​enginetest.go Mirrors text-order capability.
languages/​golang/​stashgen/​emit.go Updates unused-index explanations.
languages/​golang/​encrypt/​README.md Explains folded ordering and refusals.
languages/​golang/​encrypt/​order_test.go Tests folded ordering and generated queries.
languages/​golang/​encrypt/​internal/​testusers/​probe_stash.go Regenerates string ordering methods.
languages/​golang/​encrypt/​internal/​testusers/​kinds_stash.go Adds named-string ordering methods.
languages/​golang/​encrypt/​guest/​Cargo.lock Updates guest dependencies.
languages/​golang/​encrypt/​doc.go Documents text ordering semantics.
languages/​golang/​cmd/​stashgen/​README.md Documents generated string comparisons.
languages/​golang/​CHANGELOG.md Records text ordering support.
Cargo.toml Declares workspace folding dependency.
Cargo.lock Locks vitaminc-ore and dependencies.

Review effort: Balanced

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 17, 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-17T10:37:20.784296Z 54a204d Draft marked ready
🔒 Security Review ✅ Completed 2026-10-17T10:38:11.490082Z 54a204d 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.

Automated review attestation

Source: Claude Code scheduled review (/code-review medium + /cross-check-review-findings) at head 54a204d. This is an automated review, not a human approval.

Critical / blocking issues: none.

Findings: none survived cross-check.

Reviewed (packages/stack-encrypt/src):

  • sem/mod.rs: OrderSource and text_order_key. Text goes through the folded key. The PRF context adds the frozen TEXT domain label only for text, so integer, float and bytes terms keep their bytes. The key is zeroizing and moved, not copied.
  • op/mod.rs: sealed Ordered::inverted. The String impl compares folded keys. Integers keep lo > hi.
  • dynamic/query.rs: ordered(kind), bound_order and the check_input fold refusal. They agree with the typed path (("b","A") inverted, ("A","a") a tie, an unassigned code point refused before a keyset).
  • Docs, ADR-0009, trybuild snapshots, lockfiles and the Go generated/golden files, checked for consistency with the Rust change.

Cross-check against CI on this head:

  • fmt/clippy/nextest/doctests/rustdoc, the Go matrix (Linux/macOS/Windows), Go lint and live tests, the EQL suites and the fuzz regressions are green.
  • The mutants gate caught 24 of 24 mutants (0 missed).
  • Copilot raised no issues.
  • The Codex code and security reviews were still running when this review was posted.

Notes (non-blocking):

  • OrderSource::of selects the text path by runtime Any downcast on String, Zeroizing<String> and &'static str. A new owned text type passed to an order term would silently take the non-folded Value path. This is fine today because Ordered is sealed, and the PR notes it. A test asserting every Ordered text form takes the folded path would guard it.
  • Text order terms change bytes (the CHANGELOG notes this as Breaking, with none deployed). Confirm no stored text ORE/OPE terms exist outside tests before merge.
  • Changeset-bot reports no changeset. That looks acceptable here since the touched crates and the Go module use their own CHANGELOGs, but a maintainer should confirm.

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: 54a204d924

ℹ️ 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 packages/stack-encrypt/src/sem/mod.rs Outdated
@coderdan

Copy link
Copy Markdown
Contributor Author

Parked until new cllw-ore and ore-rs releases.

This PR folds text inside Stack Encrypt (OrderSource in sem/mod.rs) and hands the folded bytes to cllw-ore 0.5. That's the wrong layer. The ORE crates own how each type is encoded for ordering, and the plan is for them to implement vitaminc-ore 0.6's OrderScheme and fold text internally. Stack Encrypt should pass the String to their encrypt unchanged. Folding in front of them duplicates their encoding, and the Any downcasts throw away the types their traits carry.

No released or built version of either crate implements vitaminc-ore 0.6 yet. Once they do, this PR gets reworked:

  • Bump the crates.
  • Delete OrderSource and text_order_key.
  • Pass text through, and judge Between's inverted range in the crate's order.

The engine surface stays: op::Ordered for String, the data path answering orderings on text, and the Go methods. #1234's proposal is updated to say so.

https://claude.ai/code/session_01BZhkvKLpNtKuzj3HCfMXoL

@coderdan
coderdan force-pushed the feat/stack-encrypt-text-ordering branch from 54a204d to f915cf5 Compare October 18, 2026 02:45
@coderdan

Copy link
Copy Markdown
Contributor Author

Unparked, and reworked onto cllw-ore 0.5.2-rc.1.

cllw-ore 0.5.2-rc.1 is a pre-release with a vitaminc feature that implements vitaminc-ore 0.6's OrderScheme. So this PR no longer folds text, or builds any order key, inside Stack Encrypt. What changed (f915cf5, rebased onto the current next):

  • Removed: OrderSource, text_order_key and the Any downcasts.
  • New mapping: each Rust type maps to its vitaminc order target through the sealed sem::Orderable. The per-index PRF key keys cllw_ore::vitaminc::Ore/Ope, and the value goes straight through.
  • Between: it compares the bounds in the order the stored terms compare, using OPE under a fixed comparison key whose terms are thrown away.
  • Breaking: every order term changes bytes (key labels /v2). Nothing was deployed. Pins and fixtures are re-recorded.

The Go surface and the tests from the first draft stay. The description is rewritten to match.

coderdan added a commit that referenced this pull request Oct 18, 2026
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`. That settles what the earlier
draft refused with `TermError::NarrowFloat`, which is gone. `Between`
judges a float range in the same order (`(0.0, -0.0)` is one value,
`(NaN, 1.0)` is inverted), by the comparison #1235 added.

Float pins, the float ordering baseline, the integer-width lengths and
the `score` field in the cross-path fixture carry over from the earlier
draft; the fixtures and UI snapshots are re-recorded.

Claude-Session: https://claude.ai/code/session_01BZhkvKLpNtKuzj3HCfMXoL
@coderdan

Copy link
Copy Markdown
Contributor Author

Mutants re-run after removing the equivalent Backend arm: 32 mutants, 13 caught, 19 unviable, none missed.

@coderdan

Copy link
Copy Markdown
Contributor Author

Removed the inverted-range check from Between (commit ccd4431).

A range whose low bound is above its high bound is now encrypted like any other and matches nothing, as x BETWEEN 20 AND 10 does in SQL. The check gave little: an empty result leaks nothing and corrupts nothing. It also cost a lot: judging text and float ranges correctly meant reproducing the index's order before encryption, which the previous version did by encrypting both bounds under a fixed public OPE key. That order belongs to the ORE crates, not to stack-encrypt.

TermError::InvertedRange and its stack_encrypt::inverted_range code are gone. Both were unreleased. The data path still refuses order text with no fold before any key is used, now through vitaminc_ore::text.

https://claude.ai/code/session_01BZhkvKLpNtKuzj3HCfMXoL

@coderdan
coderdan force-pushed the feat/stack-encrypt-text-ordering branch from ccd4431 to ec45e28 Compare October 18, 2026 12:34
@coderdan

Copy link
Copy Markdown
Contributor Author

Reworked onto the current next.

The branch is now 3 commits:

  • d277e3d67 docs: ADR-0009 orders text (unchanged).
  • 35b382d5d feat!: let an inverted Between range through. This now comes before text ordering, so no commit adds the fixed-OPE-key range check only for a later commit to delete it. On next it also removes the frame-based integer_order/integer_of, and the PartialOrd bound on op::Ordered that the check needed.
  • ec45e28fe feat!: order text, encrypting every order term through cllw-ore's vitaminc schemes. This is the old 029549d, 300d307 and f915cf5 squashed, so OrderSource is never added and then removed.

What changed beyond the rebase:

  • No new FfiValue. The data path's order-text check (order_text) reads the ValueFrame through OneScalar. The new and changed tests build their inputs as frames of Rust values. The test helper query takes impl Encrypt.
  • The new kinds from next order at their own width. i8, i16, i128, u8, u16 and u128 go through Orderable. value_kinds.json is re-recorded: only its ORE/OPE terms and the sealed leaves change.
  • Review finding (unassigned code points): this is vitaminc-ore's fold, and next's equality and match terms already refuse the same way. ADR-0002 still said such code points pass through, so it now carries a dated amendment saying vitaminc 0.6 refuses them, for all three term kinds.
  • Docs: the Go README type table lists the orderings on string. The CHANGELOG no longer says query_index(Ore, text) has no replacement. A #NNNN ref was removed from a code comment.

Needs your decision: stored_rows_v1.json fails, by design of this PR. Every order term changes bytes, and the frozen fixture stores ORE/OPE terms for every kind (text, floats, integers, bytes). So every_frozen_row_opens_and_seals_as_it_did fails on the re-sealed terms. The stored leaves still open. I have not touched the fixture or its test. The fixtures README describes a route for a deliberate format change: add stored_rows_v2.json, keep v1, and write the migration in the CHANGELOG. That route needs the v1 test to stop re-deriving order terms, which is your call. CI will be red on stored_rows until then.

Verification:

  • stack-encrypt nextest: 676/677 pass; the only failure is stored_rows.
  • clippy -D warnings, fmt, rustdoc -D warnings and 42 doctests: clean.
  • Mutants on the diff against next: 24 tested, 9 caught, 15 unviable, 0 missed. To get a green baseline, stored_rows was ignored for that run only.
  • Guests: all 5 build, and guest tests pass (68 and 71).
  • Go: go test ./..., go:lint (0 issues) and go generate (no drift) all pass.
  • packages/eql mise run test:encryption: passes.

@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 at ec45e28

Reviewed the full diff against next (merge-base c612ed402), the cllw-ore 0.5.2-rc.1 vitaminc module and vitaminc-ore 0.6.0 sources the terms now go through, PR #1243's diff for the inverted-range claim, and CI at this head. Findings first, most severe first; then what I checked and found sound.

Findings

1. High: the frozen stored-rows fixture fails at this head, and CI is red because of it.
stack-encrypt::stored_rows every_frozen_row_opens_and_seals_as_it_did fails in the nextest job and the CRAP gate, and the mutants gate exits 4 because its unmutated baseline fails (the "0 / 0 / 0" comment above is that). tests/fixtures/stored_rows_v1.json was frozen on next by 7ca7ba3, which is in this PR's merge-base, and it holds 37 ORE and 28 OPE terms. This PR changes the bytes of every order term (the /v2 labels, the column salt, the marker byte), so the "sealing the source today stores the same terms" half of that test fails for every case. The PR's verification section (644 passed) predates the rebase onto this next. The fixture's README says: never regenerate or edit v1; for a deliberate change to the stored format add stored_rows_v2.json and a write_stored_rows_v2 beside it, keep v1, and say in the CHANGELOG how v1 rows migrate. That rule and this PR's "nothing was deployed, re-pin everything" stance pull in different directions, and that is a call for the maintainers, but it has to be made in this PR: as it stands the PR cannot merge green, and the mutants result the description quotes was not produced at this head.

2. Medium: PR #1243 and two docs in this repo still say the client refuses lo > hi.
Commit 35b382d removes TermError::InvertedRange and both Supports::<Between>::check and check_input now let an inverted range through. PR #1243 (open, also based on next) was reviewed on the opposite claim: its range_functions.sql.j2 template (rendered into all ten query_<T>_range_functions.sql) says "The client refuses a range whose low bound is above its high bound; one that reaches the database anyway matches no row", packages/eql/docs/reference/eql-functions.md says "the client refuses to build one", and tests/encryption/tests/v4_range.rs has the same comment. The SQL semantics agree (an inverted range matches no row), so there is no correctness bug, but whichever PR merges second needs to fix the other's wording, and #1243's README hunk sits a few lines below this PR's README edit, so expect a conflict there too. Within this PR's own scope the same stale claim survives in languages/golang/encrypt/query.go:112 ("a range the wrong way round, and match text that cuts no token, are [ErrTerm]") and languages/golang/encrypt/guest/src/abi.rs:477; both files' tests were updated here, the doc comments were not.

3. Medium: "ADR-0002's fold" is not the fold that ships.
ADR-0002's "Text fold, v1" says the accent step removes "every code point with General_Category Mn". vitaminc-ore 0.6.0's fold.rs (what OrderTarget::Text encodes, domain vitaminc/ore/value/text-folded/unicode-16/v1) removes a mark of category Mn, Mc or Me with a non-zero canonical combining class and keeps class-zero marks. Thai U+0E31 MAI HAN-AKAT and Devanagari U+0901 CANDRABINDU are both Mn with class 0: the ADR's transform drops them, the shipped one keeps them, so two Thai strings the ADR predicts tie do not. The PR tells users in six places (Go README, doc.go, op::Ordered, dynamic/query.rs, both CHANGELOGs) that text orders by ADR-0002's fold, and amends the paragraph right after the step list, so the step list should be amended in the same way to describe what vitaminc-ore actually pins. The encoding belongs to vitaminc-ore, as this PR says; the ADR just has to stop describing a different one.

4. Low: the Go CHANGELOG understates the break and files it under Added.
languages/golang/CHANGELOG.md:73-77 says "Text order terms change bytes from the alpha's" under ### Added. Every ORE and OPE term changes, integers included (value_kinds.json re-pins i8/i16/u8 ORE and u16/u128 OPE), and the Go file's header says breaking changes go under ### Breaking. The stack-encrypt CHANGELOG gets this right.

5. Nit: the typed path refuses unfoldable order text one keyset load later than the data-plan path.
check_input refuses a text bound with an unassigned code point before any keyset; the orders! check is a no-op, so cipher.query(op::Lt, "a\u{378}").on(&plan).keyset("tenant") loads the tenant keyset first and refuses in the visitor. Nothing leaks and no term is produced, so this is consistency only.

Checked and sound

  • Key derivation and separation. The per-index key is unchanged in shape: HMAC PRF over PAE(label, context), with the labels bumped to ore-key/v2 and ope-key/v2 and still distinct. The block is copied into a Protected<[u8; 32]>, the visitor's copy is zeroized, and cllw's Key wipes on drop with the scheme. The column salt PAE("vitaminc/ore/v1", scheme, target domain, "") goes into cllw-ore's keyed BLAKE3 as a prefix, so ORE and OPE terms are separated twice (key label and scheme name), and text and bytes of the same UTF-8 are separated by the target domain; a_text_order_term_is_keyed_apart_from_bytes pins that.
  • The ORE crate owns the encoding. Orderable::encrypt_in hands the value to OrderColumn::encrypt; stack-encrypt no longer folds, frames or derives a text key. cllw-ore's vitaminc schemes use its byte-slice entry points, so cllw's own orderize_secret collation is not applied on top of the fold. The only stack-encrypt call into the fold is check_input's pre-check through vitaminc_ore::text, whose result is dropped.
  • Fold applied identically on every path. Typed (sem::ore/ope), data-plan (ore_of/ope_of through Zeroizing<String>) and Go (through the guest) all reach the same visitor. Pinned by text_order_terms_fold_case_and_accents (every owned form and &'static str), the data-plan Lt("CAFÉ") == Lt("cafe") operand check, and Go's TestTextOrderingMethodsFoldTheValue (Lt(v) equals the stored ORE term for every folded form).
  • Order is actually pinned. text_orders_by_its_folded_key checks OPE byte order and ORE comparison over alice < eclair < Élan < emu < Zoe; the Go property test orders hundreds of random strings over the folded alphabet and the adjacency list covers "" < "a" < "aa" < "ab" < "b", Cyrillic, Greek and CJK, with ties for case, accent forms and ß/ss/SS.
  • Boundaries. The empty string is keyed thanks to the marker byte (cllw-ore's own an_empty_value_is_bound_to_its_key_and_column). No truncation anywhere; a term is 8 x (folded UTF-8 length + 1) bytes, so the length leak is of the same kind as before (folded length rather than raw length). u8 goes in as Raw(Int::U8) at target U8, matching how the other widths are placed. bool is refused before any PRF call. Unassigned code points are refused on write and on query, in line with how next already treats equality and match terms, and the ADR-0002 amendment says so.
  • Breaking-change marking. The stack-encrypt CHANGELOG entry sits under ### Breaking, names the three causes, and the pins in term_bytes.rs, frozen_bytes.rs and the three regenerable fixtures are re-recorded. See finding 1 for the one fixture that is not regenerable.
  • Changeset. None needed: no npm package consumes stack-encrypt (protect-ffi's Cargo.lock carries cllw-ore 0.4.2 and no stack-encrypt), and the crate and the Go module keep their own CHANGELOGs. The root, EQL, encrypt-guest and stack-encrypt fuzz lockfiles are updated.
  • Codex's earlier P2 (pass unassigned code points through) is addressed by the ADR-0002 amendment in ec45e28; the earlier automated review's note about the Any downcast is moot now that OrderSource is gone.

Checks run

  • Read the diff, cllw-ore 0.5.2-rc.1 src/vitaminc.rs and src/impls/string.rs, vitaminc-ore 0.6.0 src/scheme.rs, src/order.rs and src/fold.rs, and PR #1243's diff.
  • cargo nextest run -p stack-encrypt --all-features locally at this head: 677 tests run, 676 passed, 1 failed, 1 skipped. The one failure is stored_rows::every_frozen_row_opens_and_seals_as_it_did (every_kind: every ore and ope passthrough differs from the frozen row; every eq and match term is unchanged), the same failure CI reports.
  • CI at this head: everything green except the three jobs in finding 1.

Comment thread packages/stack-encrypt/CHANGELOG.md
Comment thread languages/golang/CHANGELOG.md Outdated
Comment thread languages/golang/encrypt/query_test.go
Comment thread languages/golang/encrypt/README.md
Comment thread packages/stack-encrypt/src/op/mod.rs
@coderdan

Copy link
Copy Markdown
Contributor Author

Force-pushed: a fix folded into ba29e03. An order term takes its plaintext by value: the ORE and OPE visitors carried it into visit_block, encrypted it by reference and dropped it. For an owned String or Vec<u8> that drop freed the plaintext without wiping it, on every order term of text or bytes, stored or queried. The typed ore_term/ope_term pass the caller's value straight in, and the target path passes a clone. The visitors now hold the value in a private Carried<T> that wipes it on drop, through a new required, hidden Orderable::wipe. That is a zeroize for the four owned forms and a no-op for scalars and 'static borrows. The value is wrapped before order_target can refuse it, so a refused value is wiped too. New test: an_order_term_of_owned_text_or_bytes_leaves_no_plaintext_on_the_heap in tests/heap_hygiene.rs, which fails without the fix. cllw-ore and vitaminc wipe their own copies; the leak was only in this crate. Found by #1274's wider heap test. #1242, #1266, #1272 and #1274 are restacked on this.

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

Re-review of the update to ba29e03.

Compared with 4392b57, only commit 3 changed, and the change is heap hygiene for order terms. It moves no bytes: stored_rows_v1.json and the term fixtures are untouched.

  • Orderable gains a required, #[doc(hidden)] wipe. The owned sources (String, Vec<u8> and their Zeroizing forms) zeroize. Scalars and 'static borrows are no-ops. Because the method is required on a sealed trait, any new source type has to say which kind it is.
  • OreVisitor and OpeVisitor now hold the plaintext as Carried<T>, which wipes it on drop. ore_term and ope_term wrap the value before order_target() can fail, so a refused value is wiped too. encrypt_in reads through &self, so the drop is the last owner. Vec's Zeroize also clears spare capacity, which covers a String built with extra capacity.
  • The new an_order_term_of_owned_text_or_bytes_leaves_no_plaintext_on_the_heap test covers both terms, for String and Vec<u8>.

This closes the unwiped plaintext clone on every OPE derivation. It was found while reviewing #1274, where the eql heap-hygiene test had excluded stack_encrypt::sem::OpeVisitor by backtrace. #1274's latest push removes that exclusion, so the leak is now covered by a test rather than excused.

Unchanged from review 5565622638: the fixture-exception premise and trigger, re-freeze tooling, the stale "never edit" wording, the InvertedRange docs conflict with #1243, the ADR-0002 fold step list, and the Go CHANGELOG section. CI is green at this head.

@coderdan
coderdan force-pushed the feat/stack-encrypt-text-ordering branch from ba29e03 to 7a8c8ae Compare October 19, 2026 05:38
@coderdan

Copy link
Copy Markdown
Contributor Author

Rebased onto next after #1267 merged (the only conflict was the Go CHANGELOG, where both entries are kept), and force-pushed. Besides the review fixes, stored_rows.rs gains a gated order-term re-freeze path (STACK_ENCRYPT_REFREEZE_ORDER_TERMS=1). The README now ends the order-term exception at stack-encrypt 1.0 or the Go module's first non-pre-release, whichever comes first. The CHANGELOGs name 0.1.0, 0.2.0 and Go v0.1.0-alpha.1 as the releases whose order terms no longer compare.

@coderdan
coderdan force-pushed the feat/stack-encrypt-text-ordering branch from 7a8c8ae to e086fcf Compare October 19, 2026 05:49

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

Re-review at e086fcf

Incremental review against the previous head ba29e03. The branch was rebased from next at c612ed4 onto next at 5887218ce (the #1267 smallint merge), and the top commit was then amended twice (7a8c8aeee, where the review fixes landed, then e086fcf, which only narrows one README sentence). git range-diff across the two heads shows commit 1 unchanged, commit 2 gaining four doc fixes, and commit 3 gaining the fixture README exception, the re-freeze path in stored_rows.rs, the ADR-0002 bullet, both CHANGELOG entries, the _comment and the term_bytes.rs doc. Status of the open items first, then one new finding.

Earlier findings

# Was Now
1 Medium: fixture-exception premise false, trigger unobservable Fixed. The README names stack-encrypt 0.1.0, 0.2.0 and Go v0.1.0-alpha.1 as having shipped order terms, says the exception rests on those being pre-1.0 and pre-release rather than on nobody holding a term, ends it at stack-encrypt 1.0 (a crate version, so observable), and says why a stale order term is worse than a stale leaf. The stack-encrypt CHANGELOG's Breaking entry names the three releases and says to encrypt the column again. One loose end, Low, below.
2 Medium: no re-freeze tooling, "nothing else moved" unenforced Fixed, and I watched it fail. STACK_ENCRYPT_REFREEZE_ORDER_TERMS=1 rewrites only ore/ope passthroughs after without_order_terms has asserted the rest of the tree is identical; the write is after the loop, so any other difference panics first. Checked by hand at this head: one corrupted ore term fails the normal run with the hint and is restored byte for byte by the re-freeze run; one corrupted eq term fails the re-freeze run at the "same shape, terms and passthroughs" assert and nothing is written; re-freezing the committed file is a no-op. Nothing in .github or the mise files sets the variable.
3 Low: stale "never edit" wording Fixed. The JSON _comment (both copies), the #[ignore] reason (stored_rows.rs:589) and the writer's refusal (:594) now defer to the README.
4 Medium: InvertedRange docs Fixed within this PR (query.go, guest/src/abi.rs, ADR-0007, ADR-0009, all in 5024415ed). The #1243 side is declined with "whichever merges second fixes the other", which is sound; #1243 is still open (now ready for review, base next), so that is still owed by one of the two.
5 Medium: ADR-0002 fold step list Fixed. The bullet now says a mark of Mn/Mc/Me with non-zero canonical combining class is removed and a class-zero mark is kept, with a dated amendment. Checked against vitaminc-ore 0.6.0 src/fold.rs: is_mark is canonical_combining_class(c) != 0 && category in {Mn, Mc, Me}, and the pipeline is NFD, case fold, NFD, drop marks, NFC.
6 Low: Go CHANGELOG under Added Fixed. languages/golang/CHANGELOG.md:38 is a ### Breaking entry covering every width, floats, text and bytes, naming the alpha, and saying the miss is silent.
7 Nit: typed path refuses unfoldable text one keyset later Declined: the typed Eq path has the same shape. Agreed.

New finding

1. High (blocks merge, not a code defect): the branch does not merge into the current next.
The rebase landed on next at #1267. Since then #1262, #1264 (remove FfiValue), #1263 and the vitaminc 0.6.1 bump (b5baedba0) merged, and GitHub reports the PR as conflicting. git merge-tree origin/next e086fcf conflicts in five files: Cargo.toml (the new cllw-ore/vitaminc-ore lines sit beside the lines the 0.6.1 bump rewrote), src/dynamic/query.rs (four hunks: #1264 spelled the test inputs as Tree, this PR as ValueFrame::of, and next still carries the integer_order test that commit 2 deletes), src/dynamic/term.rs (one), tests/heap_hygiene.rs (the import block: use stack_encrypt::nonempty; next to the FfiValue import #1264 replaced) and guest/tests/native_ops.rs (one). All of it is test inputs and imports, so mechanical, but the green CI at this head was produced against the pre-#1264 base. Two things worth knowing for the rebase: vitaminc-ore 0.6.0 and cllw-ore 0.5.2-rc.1 both take caret 0.6.0 requirements, so they resolve against vitaminc 0.6.1, and vitaminc-ore 0.6.1's src/ is byte-identical to 0.6.0's, so the fold and the text term bytes should not move; stored_rows will say.

2. Low: the PR description and the last comment lag the tree.
The description's Breaking bullet still says "Nothing was deployed (ADR-0002)", which the CHANGELOG no longer claims; Verification quotes "644 passed" (678 at this head); and nothing in it mentions the fixture exception or the re-freeze path. The comment at the last push says the exception ends at "stack-encrypt 1.0 or the Go module's first non-pre-release, whichever comes first", but the e086fcf amend dropped the Go clause, so the README now ends it at stack-encrypt 1.0 alone. That is a defensible choice (a Go v0.x is unstable by Go's own convention, and the Go CHANGELOG carries the Breaking entry), but the comment should say which it is, since a Go v0.x release holding order terms could now be invalidated under the exception.

3. Nit: var_os(..).is_some() enables the rewrite on any value. STACK_ENCRYPT_REFREEZE_ORDER_TERMS= (empty) re-froze the corrupted fixture in my run just as =1 did. The README says =1; matching that (== Some("1")) is a one-liner.

Rebase adaptations, checked

  • The only conflict the author reports, languages/golang/CHANGELOG.md, is resolved with both entries kept: the Smallint entry at :80 and the text-ordering entry at :84, in ### Added.
  • next gained nothing under packages/stack-encrypt between the two bases, so no smallint rows or terms entered any stack-encrypt fixture; smallint lives in eql-bindings and is equality-only, so there is nothing of it to re-freeze.
  • stored_rows_v1.json against origin/next's copy, by structural diff: exactly 19 ore and 14 ope passthroughs plus the _comment differ; no leaf, eq, match, plain passthrough, key or shape difference. Against ba29e03 the only change is the _comment.
  • record_lowering.json, value_kinds.json and query_operators.json are byte-identical to ba29e03; against the base only c leaves (fresh nonce), ore/ope terms and the five age order queries differ.

Checks run

  • git range-diff across the two heads; git merge-tree --write-tree origin/next refs/prwatch/1235; structural JSON diffs of the four fixtures with a script.
  • vitaminc-ore 0.6.0 and 0.6.1 and cllw-ore 0.5.2-rc.1 sources from the local registry.
  • mise x --env test -- cargo nextest run -p stack-encrypt --all-features at this head: 678 passed, 1 skipped (stored_rows, term_bytes, frozen_bytes and heap_hygiene included); then the three re-freeze experiments above, with the fixture restored afterwards.
  • gh pr checks 1235: every job passes at this head; mergeable is CONFLICTING.

Comment thread Cargo.toml
Comment thread packages/stack-encrypt/tests/heap_hygiene.rs
Comment thread packages/stack-encrypt/tests/fixtures/README.md
Comment thread packages/stack-encrypt/tests/stored_rows.rs Outdated
Comment thread packages/stack-encrypt/CHANGELOG.md
Comment thread languages/golang/CHANGELOG.md
coderdan added a commit that referenced this pull request Oct 19, 2026
A non-superuser install skips the ore_block_256 btree operator class, and
ore_fallback.sql then poisons every single-bound ORE domain so a value cast
to one fails loudly with an OPE hint. It could not reach the nine
query_<T>_range_ore domains: it is shared with EQL v3 and they are v4-only.
So on such an install a range operand was accepted, and eql_v4.between ran
as an unindexed seq scan with no hint. Each v4-only range types file now
adds the same NOT VALID poison CHECK to its own ORE range, behind the same
opclass check, calling the function ore_fallback.sql creates, and names the
family's OPE range in the hint.

A new PostgreSQL test installs the built v4 bundle as a NOSUPERUSER role in
a database of its own and checks every ORE range is refused with that hint
while its OPE range is accepted. Stripping the new ALTER DOMAINs from the
bundle makes it fail.

Also: the generated ORE between note now lists the family's own OPE column
domains and OPE range instead of a fixed `_ord`, `_ord_ope`, `_search` (only
text has `_search`), and the inverted-range wording no longer says the
client refuses such a range, which #1235 makes untrue; it says what holds
either way: such a range matches no row and the SQL does not reorder it.
@coderdan
coderdan force-pushed the feat/stack-encrypt-text-ordering branch from e086fcf to b98bad2 Compare October 19, 2026 06:32
@coderdan

Copy link
Copy Markdown
Contributor Author

Rebased onto next after #1262, #1263 and #1264 merged.

Conflicts, and how each was resolved:

  • dynamic/query.rs, dynamic/term.rs, tests/heap_hygiene.rs: kept next's typed control messages and its Tree test inputs (no FfiValue), with this PR's Orderable imports, nonempty! and the inverted-range removal on top. The new inverted-range test now builds its input as a Tree, and the test helper query takes impl Encrypt, so it accepts both Tree and the text inputs this PR's tests pass.
  • Cargo.toml: next's vitaminc 0.6.1, with this PR's vitaminc-ore moved to 0.6.1 too. The guest, EQL and stack-encrypt fuzz lockfiles move vitaminc-ore to 0.6.1 to match.
  • guest/tests/native_ops.rs: the inverted-range refusal case is dropped, as this PR intends.

No production logic changed in the resolution; only imports and tests. stored_rows passes unchanged: no frozen bytes moved.

@coderdan
coderdan removed the request for review from a team October 19, 2026 06:39
Comment thread packages/stack-encrypt/src/sem/mod.rs Outdated
Comment thread packages/stack-encrypt/src/sem/mod.rs Outdated
@coderdan

Copy link
Copy Markdown
Contributor Author

Force-pushed b98bad2 → 21659d1; the content changed, all in the text-ordering commit:

  • sem::Orderable is one constant, const TARGET: OrderTarget. Values are encrypted by reference through vitaminc's Encrypt, held in zeroize::Zeroizing; the encrypt_in/wipe methods and the custom Drop are gone. &'static str, &'static [u8] and the Zeroizing<_> forms are no longer order sources.
  • u8 and i8 have no order term (256 values rank every row, the leak a boolean is refused for). They leave op::Ordered and Orderable; dynamic::admits refuses ore/ope on int8/uint8; stashgen refuses the tag as it does on bool; value_kinds.json is regenerated with equality alone on i8/u8; stored_rows_v1.json loses its four 8-bit order terms under the order-term exception (README records it); TermError::NoOrderTerm is gone, the boolean refusal being a compile error now. A trybuild case holds OreTerm<u8>/OreTerm<i8>/.with::<i8, _>(Ore) not compiling.
  • STACK_ENCRYPT_REFREEZE_ORDER_TERMS must be exactly 1; the exception runs until stack-encrypt 1.0; the PR body matches.

At the tip: fmt, nextest (1218), clippy -D warnings, rustdoc, guest tests with and without eql, EQL crate tests and types:check, all five guests, the Go suite, go vet, no go generate drift; every Cargo.lock --locked at every commit; cargo mutants --in-diff against next: 38 tested, 25 caught, 13 unviable, none missed.

@coderdan

Copy link
Copy Markdown
Contributor Author

Force-pushed 21659d1 → 7840c3a, two changes inside the text-ordering commit: the Orderable docs now link the vitaminc issues that take over its job (cipherstash/vitaminc#470 for the type's own target, which retires this table and the private encrypt_in; cipherstash/vitaminc#479 for removing the one-byte targets), and stashgen/enginetest no longer trips staticcheck QF1001 (the orderable condition is written as a positive orderable := … instead of a negated conjunction), which was the Go lint failure on the previous head.

Text ordering is decided: the order key is the system ADR-0002's text fold, which vitaminc 0.6 ships in vitaminc-ore, and the ORE and OPE indexes answer the orderings on text over it. The ADR no longer says "if the catalog keeps text ordering" or that the paragraph goes if text ordering is dropped, and decision 7 names text beside the integer types.
A `Between` whose low bound is above its high bound was refused with
`TermError::InvertedRange` before either term was derived. SQL's own
`BETWEEN` refuses nothing: `x BETWEEN 20 AND 10` is a valid query that
matches no row, and an encrypted range now behaves the same way. The
empty result leaks nothing and corrupts nothing, so a refusal bought
little.

It would also cost more than it buys once text and floats are ordered.
Judging their ranges correctly means reproducing, before encryption, the
order the index compares in: text by its fold, floats by their canonical
encoding. That order belongs to the ORE crates, not to this one.

So `TermError::InvertedRange` (code `stack_encrypt::inverted_range`)
goes, with the typed path's `lo > hi` check (and the `PartialOrd` bound
`op::Ordered` carried for it) and the data path's `integer_order`. The
variant is under [Unreleased] and never shipped.
… 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.
@coderdan
coderdan force-pushed the feat/stack-encrypt-text-ordering branch from 7840c3a to 3004048 Compare October 19, 2026 09:27
@coderdan

Copy link
Copy Markdown
Contributor Author

Rebased onto next at 0800375 (after #1283 merged main back in). Only lockfiles changed, by exactly the crate moves that merge brought; the commits' content is otherwise identical to the previous head. Every Cargo.lock passes --locked at every commit, and the full Rust, guest, EQL and Go suites pass at the chain's tips against the merged lock.

@coderdan
coderdan removed this pull request from stack #1282 October 19, 2026 09:41
@coderdan
coderdan merged commit 9c65e7e into next Oct 19, 2026
51 checks passed
@coderdan
coderdan deleted the feat/stack-encrypt-text-ordering branch October 19, 2026 10:00
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.

Stack Encrypt cannot order text: answer Lt, Gt and Between on text over ADR-0002's folded order key

3 participants