Skip to content

fix(stack-encrypt)!: an order index on bytes is refused - #1611

Open
coderdan wants to merge 5 commits into
refactor/stack-encrypt-remove-transitional-apifrom
fix/stack-encrypt-order-index-on-bytes
Open

coderdan wants to merge 5 commits into
refactor/stack-encrypt-remove-transitional-apifrom
fix/stack-encrypt-order-index-on-bytes

Conversation

@coderdan

@coderdan coderdan commented Nov 10, 2026 •

Copy link
Copy Markdown
Contributor

Summary

A field of bytes can no longer declare an order index, in Rust or in Go. It used to be accepted, and it leaked: each row stored a term that reveals its order, and no query could ever use that term.

Stack Encrypt (packages/stack-encrypt) is the Rust encryption engine under every CipherStash SDK. An order index (Ore, Ope or OreBlock) stores a term beside the ciphertext, so the database can answer <, > and BETWEEN on encrypted values. The term reveals the order of the rows to anyone who holds them. That cost is accepted where a query needs it.

There were two ways to declare one, and both had the same gap:

  • Typed Rust. An order index needed sem::Orderable, which covers Vec<u8>. The ordering operators need op::Ordered, which does not. So the compiler accepted the index on bytes and refused every query on it.
  • The dynamic interface, which the Go SDK uses. Two hand-kept lists disagreed: one admitted the declaration, the other refused every ordering query on it.

To upgrade: remove the order index from the bytes field. Rows stored with the term still open, because opening ignores a stored term the plan does not declare. The old terms stay in the database until the row is written again.

This PR is stacked on #1610. The base branch is the branch of #1610, so the diff shows only this change.

Changes

Typed Rust

  • Index<S> for Ore, Ope and OreBlock is bounded on op::Ordered. The index and its operators now share one trait, so they cannot disagree.
  • The same bound is on the three order terms' TermSet, EncryptFrom and EncryptTarget impls. Those are the other ways to declare an order term for a field.
  • These no longer compile: .with::<Vec<u8>, _>(Ore) on a plan, a record field Encrypted<OreTerm<Vec<u8>>>, and an order term over bytes as a target.
  • The low-level operations target::ore, ope and ore_block are bounded on op::Ordered too. An index or a target of another crate is built from them, so without the bound it could still declare IndexSpec::Ore over bytes.
  • The keyset's ore_term, ope_term and ore_block_term still take bytes. Each returns one term, not a description, so no plan, record or target can be built from it. Existing tests pin how bytes order at that level.
  • A record that only opens (DecryptInto, DecryptSource) can still name Encrypted<OreTerm<Vec<u8>>>, so rows stored before stay readable from Rust.

The dynamic interface

  • dynamic::admits refuses ore, ope and ore_block on a bytes field. A data plan that declares one is refused with index_not_admitted, before any key is requested.
  • The second list, in dynamic::query, is removed. Op::answered_by now reads admits alone, so the two cannot disagree again.
  • IndexSpec::defines is unchanged. It reports whether the scheme has a term for a value, and the low-level term operations still use it.

Go

  • stashgen refuses index=ore, ope or ore_block on a []byte field as a field error. It asks the engine, so this follows from the engine change.
  • stashgen no longer prints a notice for "an index no generated method queries". The order index on bytes was the only case left that produced it.
  • The test types Probe and ProbeOpe lose their bytes field, and their generated files are regenerated.

Conformance corpus

  • record-declarations-v1: type-bytes-ore, type-bytes-ope and type-bytes-ore_block are now refused with index_not_admitted.
  • record-values-v1: the unanswered list is removed from the corpus and from both runners. bytes-binary declares equality alone.
  • record-query-refusals-v1: the bytes field declares equality alone. The six *-on-bytes-order-index cases are renamed *-on-bytes. They are still refused as operator_not_answered.

Tests

  • tests/ui/order_index_on_bytes.rs is a new compile-fail case. It covers a plan with each of the three indexes, a derived record field, each term as an owned target, EncryptFrom for each term, and the three low-level operations.
  • tests/ui/pass/order_indexes.rs is a new pass case: every Ordered type takes each order index through each of those paths.
  • op_order_on_bytes now names the index with .via(Ore), since a plan cannot declare it on bytes. It still pins the query refusal. order_term_of_an_8_bit_integer reports the new trait for the same refusal.
  • tests/stored_rows.rs: the frozen file stored_rows_v1.json is not changed. Its every_kind plan declares an order index on two bytes fields. The test removes those indexes from the plan, opens each row as stored under it, and compares the resealed row without the retired terms. This is the check that old rows still open after the upgrade.

Docs

  • The stack-encrypt CHANGELOG has an entry under Breaking, with what to change.
  • The API plan's step 1 now states the rule: an index admits exactly the plaintexts one of its operators answers, and the typed path enforces it with a trait bound.
  • conformance/stack-encrypt/values-contract.md, query-refusals-contract.md and exchange-contract.md describe the refusal.
  • The Go CHANGELOG has an entry under Breaking, and the Go README's type table lists equality alone for []byte.

Verification

I ran these locally, on Rust 1.94.1 and the repo's pinned Go:

Check Result
cargo nextest run -p stack-encrypt -p stack-encrypt-derive --all-features 1390 passed, 1 skipped. That includes the trybuild ui test, the frozen rows and the conformance runners.
cargo test -p stack-encrypt --all-features --doc 75 passed
cargo clippy --workspace --all-features --all-targets -- -D warnings clean
cargo doc -p stack-encrypt --all-features --no-deps, warnings as errors clean
Go guest: cargo test pass
go generate ./..., go vet ./..., go test ./... in languages/golang, with all four guests rebuilt pass

I did not run the EQL crates' tests, the Postgres suites, the live tests or mutation testing locally after the last commit. CI runs them.

Related

Closes #1577. The base branch is not main, so GitHub will not close the issue on merge. Stacked on #1610.

Review notes

  • Where to start. Read the three Index impls in packages/stack-encrypt/src/target/index.rs, then tests/ui/order_index_on_bytes.rs.
  • No .stderr quotes the standard library. CI has no rust-src, so a snippet from $RUST/... would differ there. grep -l RUST/ tests/ui/*.stderr prints nothing.
  • Why Orderable still covers bytes. I tried removing Vec<u8> from Orderable. It breaks tests/ore_block.rs and tests/term_bytes.rs, which pin how bytes order at the term level, and the dynamic path, which derives its terms through it. Keeping the term level leaves room to make bytes queryable by order later, which the issue names as an alternative.
  • No stored byte changes. The typed change is a trait bound. The dynamic change refuses a declaration. No term or ciphertext is derived differently.
  • The conformance cases are edited in place, not added as a new profile version. The conformance README says an incompatible expectation needs a new profile. I edited in place because the profiles are not released and the issue asks for the change.
  • One commit after the local review. The last commit, the dynamic half, has not had the sub-agent review the earlier commits had.

@coderabbitai

coderabbitai Bot commented Nov 10, 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: fa5dc335-0ec1-4fc5-ad15-906195c1bb74

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

@changeset-bot

changeset-bot Bot commented Nov 10, 2026 •

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: d8aeadb

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

@github-actions

github-actions Bot commented Nov 10, 2026 •

Copy link
Copy Markdown
Contributor

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

caught missed unviable timeout
6 0 0 0

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

@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 this PR's own diff (the typed Rust half of #1577). I compiled scratch programs against the branch for each claim below, and ran one scratch test against the frozen stored rows. None of the scratch files is committed.

Findings

  1. Blocker. tests/ui/op_order_on_bytes.stderr quotes the standard library (::: $RUST/alloc/src/vec/mod.rs). rustc prints that snippet only when rust-src is installed. The CI toolchain in the root mise.toml has no rust-src, so the ui test will fail in CI while it passes locally. It is the only .stderr file with a $RUST/ line.
  2. Should fix. op_order_on_bytes.rs no longer tests the query refusal it is named for. The plan now fails at .with(Ore), so the .on(..) error is gone from the .stderr. No test now pins that an ordering query over bytes does not compile.
  3. Should fix. The refusal can be bypassed through the public low-level operations. I compiled a user Index<Vec<u8>> whose operation() is target::ore() and whose spec() is IndexSpec::Ore. Plan::context(..).with::<Vec<u8>, _>((Equality, MyOre)).build() compiles. A user EncryptFrom<Vec<u8>> target that zips ciphertext() with target::ore() and returns IndexSpec::Ore from indexes() also builds as a plan. The Index rustdoc invites exactly this: "an index defined in another crate implements it by composing this module's public operations". So target::ore, ope and ore_block do declare an index in practice. The keyset's ore_term, ope_term and ore_block_term cannot be composed into a description, so bounding only the three target operations on op::Ordered closes the gap. The term-level byte tests (tests/term_bytes.rs, tests/ore_block.rs) and the dynamic path use the keyset functions, so they are not affected.
  4. Should fix. Rustdoc that is now false or silent. Ore says "Defined for every S the scheme can order", but the scheme orders bytes and the index no longer takes them (Ope and OreBlock say "the same S"). The Index trait says it is "Implemented for exactly the plaintexts the index's scheme is defined over", and its diagnostic note says Ore and Ope "take the types their schemes encode". Orderable does not say that Vec<u8> has an order term but no order index. Ordered does not say it is the bound on the order indexes.
  5. Should fix. Test gaps in order_index_on_bytes.rs. It pins the Index and TermSet bounds, and EncryptTarget for OpeTerm and OreBlockTerm. It does not pin the EncryptFrom bound on the three order terms, or EncryptTarget for OreTerm. I checked: Plan::value::<Vec<u8>>().encrypt_into::<OreTerm<Vec<u8>>>() and keyset.encrypt_as::<Vec<u8>, Encrypted<(EqualityTerm, OreBlockTerm<Vec<u8>>)>> are refused today, but reverting the EncryptFrom bounds would fail no test.
  6. Nit. No positive test that each Ordered type still takes each order index through a plan, an Encrypted<..> target and EncryptFrom. tests/ui/pass/indexes.rs covers u32 and String only. A pass case would catch a later narrowing of Ordered.
  7. Nit. CHANGELOG.md says the dynamic interface still admits an order index on bytes "so that rows the Go SDK stored stay readable". Reading does not need that (see the dynamic notes below). The reason is that the decision is open. The entry could also say that a record which only opens can still name Encrypted<OreTerm<Vec<u8>>>, so rows stored before stay readable from Rust.

Checked, and nothing to fix

  • Every ordinary typed declaration path is refused. I compiled each of these over Vec<u8> and each fails with the Ordered diagnostic: .with with Ore, Ope, OreBlock, (Equality, Ore) and vec![Ope]; Plan::value().with(..); Plan::value().encrypt_into::<Encrypted<(EqualityTerm, OreTerm<Vec<u8>>)>>() and ::<OreTerm<Vec<u8>>>(); the fields builder's encrypt_index, index, encrypt_into and target; cipher.encrypt(..).context(..).with(Ore); target::indexed(Ope); encrypt_as; Vec<..>, Option<..> and tuple targets; #[derive(EncryptTarget)]; and #[derive(EncryptFrom)] with an OreTerm<Vec<u8>> field. Markers come from the derive, so they are covered too. &[u8], Box<[u8]> and Protected<Vec<u8>> were never Orderable.
  • EQL v4 types. No eql-bindings v4 type stores an order term over bytes.
  • Decryption. Encrypted<OreTerm<Vec<u8>>> can still be named for DecryptInto and DecryptSource, and #[derive(DecryptSource)] with such a field compiles. That is right: rows stored before stay readable.
  • Query-side bounds. QueryTerm, LabelledTarget and OperatorAnswer for the order terms are still on Orderable. They cannot be reached for bytes, because Answers and DefaultIndex are already on Ordered and nothing can store an order term over bytes. Harmless, so I would leave them.
  • The other types implement trait lists in the new .stderr files are sorted by rustc and match the existing op_order_on_bytes output, so they are stable.
  • Mutation testing. The diff changes trait bounds, comments and tests only. cargo mutants --in-diff has no function body to mutate.
  • House rules. No issue numbers in code comments or rustdoc. The CHANGELOG and plan references are allowed.

The dynamic half (information for the decision, not a finding)

I read src/dynamic/record.rs and ran the frozen every_kind row from tests/fixtures/stored_rows_v1.json under edited plans:

  • record_row takes only the "c" node of a sealed field. It ignores every other output key, and it ignores stored fields the plan does not name. A field with no "c" (index only) is never opened (FieldSpec::opens).
  • With "ore" removed from blob, the row opens to the frozen opened value, byte for byte.
  • With "ore" removed from blob and the ["ope"]-only blob_ope field removed from the plan, the row still opens to the same value.
  • A field with "outputs": [] is refused when the plan is parsed (no outputs).

So option 2's stated cost ("its rows could not be read without a further rule") does not hold for reading. The rule is "remove the field", and the rows open unchanged. Option 2's real costs are: a Go program whose generated plan has the declaration fails to parse until it is regenerated; the frozen-row test parses the frozen plan as it is, so it would need a v2 fixture or a test change; and the record-declarations-v1 cases change. Option 1 keeps every plan parsing and every row opening, but it adds a write-time refusal, and the conformance cases still change meaning (accepted for reading only). Option 3 keeps writing the leaking term for every new row.

| pub struct Indexed<X>(X);
| --------------------- doesn't satisfy `Indexed<stack_encrypt::Ore>: ValueLayout<Vec<u8>>`
|
::: $RUST/alloc/src/vec/mod.rs

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.

1. Blocker. This snippet is printed only when rust-src is installed. The CI toolchain (root mise.toml: rustc,cargo,rustfmt,rust-std,clippy,rust-docs,llvm-tools-preview) has none, so the ui test fails in CI. It is the only .stderr with a $RUST/ line (grep -l 'RUST/' tests/ui/*.stderr). The E0599 on .build() is what prints it. Ending the plan before .build(), or using an index bytes do take, removes it.

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 a3073c5. op_order_on_bytes.rs no longer calls .build() on a plan that cannot build, so there is no E0599 and no $RUST/ snippet. grep -l RUST/ tests/ui/*.stderr prints nothing.

//! Bytes have no order key the ordering operators compare, so an ordering on
//! a bytes plan does not compile, even where the plan declares an ORE index.
//! Bytes have no order key the ordering operators compare. So a plan
//! cannot declare an ORE index on bytes, and an ordering on a bytes plan

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.

2. Should fix. With Ore on line 11 the plan fails first, and the query on line 14 is never type-checked: the .on(..) error is gone from the .stderr. So nothing pins that an ordering query over bytes does not compile any more. order_index_on_bytes.rs already covers the plan refusal. Declaring the plan with Equality keeps this file about the query.

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 a3073c5. The case now names the index with .context("users/blob").via(Ore), so the .stderr again pins the query refusal (Ore does not implement Supports<Lt, Vec<u8>>). I tried a plan with Equality first, but it added two noisy Select<Ore, _> errors; via gives the one error the file is about.

term_operation!(
/// The order-revealing term of `S` under the context the tree hands it.
/// The bound is the leaf's own: it says which `S` has an order target.
/// This is the low-level operation. It declares no index, so it also

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.

3. Should fix. "It declares no index" does not hold. I compiled a user Index<Vec<u8>> whose operation() is target::ore() and whose spec() is IndexSpec::Ore, and Plan::context("f/d").with::<Vec<u8>, _>((Equality, MyOre)).build() compiles. A user EncryptFrom<Vec<u8>> that zips ciphertext() with target::ore() and returns [IndexSpec::Ore] from indexes() builds as a plan too. The Index rustdoc tells users to build indexes this way. The keyset's ore_term and the others return a term, not a description, so bounding only target::ore, ope and ore_block on op::Ordered closes this. The byte tests and the dynamic path use the keyset functions.

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 a3073c5. target::ore, target::ope and target::ore_block are now bounded on op::Ordered, and order_index_on_bytes.rs pins all three. The keyset functions stay on Orderable. tests/ui/pass/owned_without_clone.rs now bounds its generic helpers on Ordered. The CHANGELOG entry says so.

matching()
}
}
// Each order index is declared over `Ordered`, the trait its operators

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.

4. Should fix. The rustdoc does not say this yet. Ore (line 228) says "Defined for every S the scheme can order", and the scheme orders bytes. The Index trait says it is "Implemented for exactly the plaintexts the index's scheme is defined over", and its on_unimplemented note says Ore and Ope "take the types their schemes encode". sem::Orderable and op::Ordered do not say which one bounds the order indexes.

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 a3073c5. Ore now says it is defined over op::Ordered, lists the types, and has a compile_fail example for bytes. The Index trait doc and its on_unimplemented note now say order indexes take the op::Ordered types. sem::Orderable and op::Ordered each say which one bounds the order indexes, and that Vec<u8> has a term but no index.

let _ = Plan::context("files/digest").with::<Vec<u8>, _>(Ore);
let _ = Plan::context("files/digest").with::<Vec<u8>, _>(Ope);
let _ = Plan::context("files/digest").with::<Vec<u8>, _>(OreBlock);
let _ = <OpeTerm<Vec<u8>> as EncryptTarget<Vec<u8>>>::encryption::<()>();

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.

5. Should fix. This pins EncryptTarget for OpeTerm and OreBlockTerm, but not for OreTerm, and not the EncryptFrom bound on any of the three. Reverting the three EncryptFrom bounds in operations.rs would fail no test. <OreTerm<Vec<u8>> as EncryptFrom<Vec<u8>>>::indexes() and the same for the other two would pin them.

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 a3073c5. The case now also pins EncryptTarget for OreTerm, EncryptFrom for all three order terms, and the three low-level operations. A new pass case, tests/ui/pass/order_indexes.rs, checks that every Ordered type takes each order index through a plan, an Encrypted target, a term target, EncryptFrom and the low-level operation (finding 6).

keyset, and the low-level operations `target::ore`, `ope` and
`ore_block`, still take bytes. They derive one term and declare no
index.
- The dynamic interface is unchanged. It still admits an order index on

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.

7. Nit. Reading does not depend on this. I ran the frozen every_kind row with "ore" removed from blob and with blob_ope removed from the plan, and it opens to the frozen value. The dynamic path is unchanged because the decision is open. It may also help to say that a record which only opens can still name Encrypted<OreTerm<Vec<u8>>>.

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 a3073c5. The entry now says the dynamic path is unchanged because the decision is open, and that a record which only opens can still name Encrypted<OreTerm<Vec<u8>>>, so rows stored before stay readable.

@coderdan coderdan changed the title fix(stack-encrypt)!: an order index on bytes does not compile fix(stack-encrypt)!: an order index on bytes is refused Nov 10, 2026
@coderdan

Copy link
Copy Markdown
Contributor Author

Pushed a51efdc41, which adds the dynamic half of #1577: a data plan now refuses an order index on bytes, the Go generator refuses the tag, and the conformance corpus follows. The description is rewritten to cover the whole issue. This commit has not had the local sub-agent review that the two earlier commits had.

@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 a51efdc41 ("a data plan refuses an order index on bytes") only. The two earlier commits were reviewed before.

I found no correctness defect in the engine change. The findings are stale documentation and weak tests. Most severe first.

  1. Low-medium. The declaration grammar still says bytes admit an order index. conformance/stack-encrypt/declaration-grammar.md:178, the admission table: | bytes | Admitted | Refused | Admitted |. The corpus now refuses type-bytes-ore, type-bytes-ope and type-bytes-ore_block with index_not_admitted. This file is the specification a new runner (Python, TypeScript) is written from. A runner built from the table admits the declaration, and then fails those three cases.

  2. Low. The Go CHANGELOG names a tag word that does not exist. languages/golang/CHANGELOG.md:15-16: "A []byte field tagged index=ore, index=ope or index=ore_block is now a field error". ore_block is not a tag word. stashgen/tag.go:317 refuses it as unknown index "ore_block" on every field, before and after this commit; stashgen/declaration.go:197-200 says a field reaches it only through encrypt_into=. A reader would think index=ore_block is accepted on other types. Only a hand-built record.Plan with record.OreBlock is newly refused.

  3. Low. coverage.md still records the unanswered order index. conformance/stack-encrypt/coverage.md:146-147: "Unsupported combinations the corpora record rather than skip: the order index on bytes stores a term no operator reads". The corpora no longer record it: the declaration is refused and the unanswered list is gone.

  4. Low. Two rustdoc lists of refused pairs leave bytes out. packages/stack-encrypt/src/dynamic/mod.rs:559-560 (Reason::IndexNotAdmitted: "match on an integer, equality or an order index on a boolean, any index on a composite") and packages/stack-encrypt/src/dynamic/record.rs:416-419 (FieldPlan::with_type, # Errors, which also names the 8-bit integer). Both are on docs.rs. A caller whose bytes field, accepted by rc.3, is now refused with index_not_admitted looks there and does not find the case.

  5. Low. A Go assertion can no longer fail. languages/golang/stashgen/refusal_test.go:389 checks that no notice contains "no generated method queries". This commit removed the only code that wrote that text (indexesAnswer in emit.go), so the check passes whatever the generator prints about those fields.

  6. Low. The Go fake engine keeps the list the Rust engine dropped. languages/golang/stashgen/enginetest/enginetest.go:221-225, in answeredBy: orderable := (integer(t) && !eightBit(t)) || logical(t) || ... is the Go copy of the ordered(kind) list this commit deleted from src/dynamic/query.rs. It is now redundant: declared already calls indexApplies, which refuses an order index on bytes, a boolean and an 8-bit integer. It is two lists that must agree, the pattern the commit removed from the engine. If indexApplies changes and orderable does not, the fake admits an index and answers nothing through it, and only the cases TestRefusalsHoldAgainstTheEmbeddedEngine lists would show it.

What I checked and found sound:

  • No other path stores an order term on bytes. FieldPlan::with_type is where a data plan checks its indexes against the kind, and a field's outputs are fixed in FieldPlan::new, before it. The guest's validate and encrypt_record both parse through record::plan. No EQL type in eql-bindings has a bytes plaintext, and a target field's kind must equal its descriptor's. The guest's se_term still derives an order term over bytes through IndexSpec::defines, but as one term, and the Go host calls it only for an output the plan declares.
  • Op::answered_by is unchanged for every kind but bytes. Order indexes answer only lt, lte, gt, gte and between. For those, the deleted ordered(kind) and admits(kind, Ore | Ope | OreBlock) agree on every other kind: both take the integers of 16 bits and wider, both floats, text, decimal, date and timestamp, and both refuse bool, int8, uint8, array, object and json. There is no uuid kind.
  • tests/stored_rows.rs. The stripping is keyed on a frozen field whose type is bytes and an output key of ore, ope or ore_block. Only blob and blob_ope in every_kind match, so every other field is compared as before. STACK_ENCRYPT_REFREEZE_ORDER_TERMS=1 passes and leaves stored_rows_v1.json byte-identical, retired terms included (git status clean after the run).
  • Mutation testing. cargo mutants -p stack-encrypt --in-diff over this commit's src diff: 6 mutants (admits to true and to false, both && to ||, == to !=, delete !), 6 caught.
  • Go. record-declarations-v1.inputs.go.json is consistent: its bytes cases take their outcome from the shared cases file. With all four guests rebuilt, go generate ./... changes no file, and go vet ./... and go test ./... pass.
  • Rust. cargo nextest run -p stack-encrypt -p stack-encrypt-derive --all-features: 1390 passed, 1 skipped.

I will fix all six in one commit on this branch.

@coderdan

Copy link
Copy Markdown
Contributor Author

All six findings from the review of a51efdc41 are fixed in 7044224.

  1. Declaration grammar table said bytes admit the order indexes: fixed in 7044224. The bytes row now refuses ore, ope and ore_block, with a paragraph on why.
  2. Go CHANGELOG named index=ore_block as a tag: fixed in 7044224. The entry now names index=ore and index=ope for tags, and ore_block only for a hand-built plan.
  3. coverage.md still listed the unanswered order index: fixed in 7044224. The clause is removed.
  4. Rustdoc for Reason::IndexNotAdmitted and FieldPlan::with_type left bytes out: fixed in 7044224. Both name an order index on bytes.
  5. The stashgen assertion that could not fail: fixed in 7044224. It now checks that no notice names User.Name, User.Blob or User.Score.
  6. The Go fake engine's second list: fixed in 7044224. answeredBy reads indexApplies alone, and the unused logical helper is removed.

Checks run on 7044224:

Check Result
cargo nextest run -p stack-encrypt -p stack-encrypt-derive --all-features 1390 passed, 1 skipped
cargo fmt --all --check clean
cargo clippy --workspace --all-features --all-targets -- -D warnings clean
cargo doc -p stack-encrypt --all-features --no-deps, warnings as errors clean
cargo test -p eql-bindings -p eql-encryption-tests --all-features (in packages/eql, no database) 28 test binaries pass, Postgres tests ignored
go generate ./... no file changed
go vet ./... clean
go test ./stashgen/... ./cmd/... pass

On a51efdc41, before the fix, with all four guests rebuilt: go test ./... passed in every package. The fix commit changes only Rust doc comments, Markdown and the two Go stashgen files, so I did not rebuild the guests for it. Mutation testing of the commit's src diff: 6 mutants, 6 caught.

@coderdan
coderdan marked this pull request as ready for review November 10, 2026 21:54
@coderdan
coderdan requested a review from a team as a code owner November 10, 2026 21:54
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 10, 2026 •

Copy link
Copy Markdown
Codex Review Summary: ✅ Completed

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

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-10T21:56:41.947776Z 7044224 Draft marked ready
🔒 Security Review ✅ Completed 2026-11-10T21:59:10.952730Z 7044224 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

Reviewed 7044224c · posted from Claude Code · claude-opus-5-5 · effort medium
Sources: code-review · claude-opus-5-5
Skills: code-review, company-skills:review-cross-check, company-skills:review-post

Findings (2): 2 minor, 2 inline, 0 below.

Seven findings were checked against 7044224c, and five were refuted before posting. Order terms over bytes are unreachable through query bounds and stored plans. ore_block is not a tag word. No Go caller can produce a bytes order term. The order-index lists agree today.


Generated by Claude Code

Comment on lines +137 to +139
- The dynamic interface is unchanged. It still admits an order index on
bytes. How it should treat a declaration that Go rc.3 accepted is not
decided yet.

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.

minor This bullet says the dynamic interface "still admits an order index on bytes", but the Breaking entry above ("A data plan refuses an order index on bytes") and dynamic::admits (src/dynamic/kind.rs:89-93) now refuse ore, ope and ore_block on bytes with index_not_admitted. Delete the bullet or point it at that entry.

Source: code-review · claude-opus-5-5


Generated by Claude Code

Comment on lines +23 to +24
with the case it described. To upgrade, remove the index from the tag
and generate again. Rows stored with the term still open: opening

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.

minor The upgrade note does not say that a _stash.go generated by rc.3 for such a field still compiles, but the guest refuses its whole plan at parse (FieldPlan::with_type → admits). Until the tag is edited and stashgen re-run, Encrypt and Decrypt of that record type, and its bytes field's Field.Encrypt, fail with index_not_admitted, including on rows that were stored correctly. Say so before "To upgrade".

Source: code-review · claude-opus-5-5


Generated by Claude Code

@coderdan
coderdan force-pushed the refactor/stack-encrypt-remove-transitional-api branch from 4b30382 to b24c0c7 Compare November 11, 2026 07:04
@coderdan
coderdan force-pushed the fix/stack-encrypt-order-index-on-bytes branch from 7044224 to bc8d1d2 Compare November 11, 2026 07:04
@coderdan
coderdan force-pushed the refactor/stack-encrypt-remove-transitional-api branch from b24c0c7 to 18cff59 Compare November 11, 2026 07:28
@coderdan
coderdan force-pushed the fix/stack-encrypt-order-index-on-bytes branch from bc8d1d2 to d07ee6f Compare November 11, 2026 07:28
@coderdan
coderdan force-pushed the refactor/stack-encrypt-remove-transitional-api branch from 18cff59 to 32519a5 Compare November 11, 2026 07:48
@coderdan
coderdan force-pushed the fix/stack-encrypt-order-index-on-bytes branch from d07ee6f to cad6572 Compare November 11, 2026 07:48
Ore, Ope and OreBlock were indexes over sem::Orderable, which covers
Vec<u8>. Their operators need op::Ordered, which does not. So a plan or
a record could declare an order index on bytes. Each row then stored a
term that reveals its order, and no query could use the term.

Each order index, its term set, and its EncryptFrom and EncryptTarget
impls are now over op::Ordered, so the index and its operators share
one trait. The low-level operations and the keyset's term functions
keep Orderable: they derive one term and declare no index.

The dynamic interface still admits an order index on bytes. The frozen
stored rows declare one, and rows the Go SDK stored must stay readable.
How such a declaration is handled is the open question in the issue.

Part of #1577
The low-level operations target::ore, target::ope and target::ore_block
are now bounded on op::Ordered, as the order indexes are. An index or a
target of another crate is built from them, and before this change one
could declare IndexSpec::Ore over Vec<u8> and store an order term for
bytes. The keyset's ore_term, ope_term and ore_block_term still take
bytes: each returns a term, not a description, so nothing can be built
from it.

op_order_on_bytes.stderr quoted the standard library, which rustc does
only when rust-src is installed. CI has no rust-src, so the ui test would
fail there. The case now names the index with via(Ore) and pins the
query refusal again, which the plan refusal had hidden.

order_index_on_bytes.rs now also pins EncryptFrom for the three order
terms, EncryptTarget for OreTerm, and the three low-level operations.
A new pass case checks that every Ordered type takes each order index.

The rustdoc on Index, Ore, sem::Orderable and op::Ordered now says which
trait bounds the order indexes and why bytes are left out. The CHANGELOG
says rows stored before stay readable, and why the dynamic path is
unchanged.
The typed path already refuses an order index on bytes at compile time.
The dynamic path, which the Go SDK uses through the guest, still
accepted the declaration: `dynamic::admits` read the table of values the
scheme has a term for, and the scheme has an order term for bytes. Each
row then stored a term anyone holding two rows can compare, and every
ordering query was refused as `operator_not_answered`, from a second
list in `dynamic::query`.

`admits` now refuses `ore`, `ope` and `ore_block` on bytes, and the
second list is gone: `Op::answered_by` reads `admits` alone, so the two
cannot disagree again. `IndexSpec::defines` is unchanged, because it
reports what the scheme defines, and the low-level term operations still
use it.

A row stored under such a declaration still opens once the index is
removed from the plan. The frozen stored rows declare one on two fields,
so their test now opens them under the plan with those indexes removed,
and the fixture file is untouched.

The conformance corpus changes with the rule: the three bytes
declaration cases are refused, the `unanswered` list is removed from the
value profile and from both runners, and the query-refusal profile's
bytes field declares equality alone.

In Go, `stashgen` refuses the tag as a field error, its notice for an
index no generated method queries is removed with the only case it had,
and the two probe types lose their bytes field.

Closes #1577
The engine change in the previous commit was sound. The review found
text and tests that still described the old rule, or that no longer
checked anything.

- The declaration grammar's admission table said bytes admit `ore`,
  `ope` and `ore_block`. A runner written from it would admit a
  declaration the corpus refuses. The table now refuses them, and says
  why.
- `coverage.md` listed the order index on bytes as a combination the
  corpora record. They no longer do.
- The Go CHANGELOG named `index=ore_block` as a tag that is now
  refused. It is not a tag word, and stashgen refused it before this
  change. Only a hand-built plan with an `ore_block` output on bytes
  is newly refused.
- The rustdoc for `Reason::IndexNotAdmitted` and `FieldPlan::with_type`
  lists the refused pairs. It now names an order index on bytes.
- A stashgen test checked that no notice held a text the generator no
  longer writes, so it could not fail. It now checks that no notice
  names one of the test's fields.
- The Go fake engine kept its own list of the kinds the orderings are
  answered over, beside `indexApplies`. That is the second list the
  previous commit removed from the engine. `answeredBy` now reads
  `indexApplies` alone, and the unused `logical` helper is removed.
The docs that explain why an order index refuses bytes now use short
sentences with one idea each. They say that an operator accepts a type,
where they said that an operator is answered over a type. The note on
the Index error lists what each index accepts.
@coderdan
coderdan force-pushed the refactor/stack-encrypt-remove-transitional-api branch from 32519a5 to 9916276 Compare November 11, 2026 10:29
@coderdan
coderdan force-pushed the fix/stack-encrypt-order-index-on-bytes branch from cad6572 to d8aeadb Compare November 11, 2026 10:29

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.

2 participants