Repository navigation
chore(stack-encrypt)!: move to vitaminc 0.8, clww-ore 0.6 and ore-rs 0.10 - #1327
Conversation
|
Mutation testing (cargo-mutants,
|
| caught | missed | unviable | timeout |
|---|---|---|---|
| 461 | 0 | 376 | 0 |
Every mutant in the changed lines was caught by a test.
vitaminc-normalize 0.8 derives an equality term for a null: one term per context, shared by every null, typed or plain. #1327 dropped it to keep the old behaviour; this keeps it (#1298). - stack-encrypt: `EqualityTerm` of a `None`, and `IndexSpec::Equality` on a null, now derive that term. No other index has one. - Data plans: `"null": "sealed"` takes a field whose only index is equality, on the field or on the EQL type it names. The null is sealed as long as a value and stores the null term beside it, so the absent rows are one equality group, as any repeated value is, and the column holds no SQL NULL. An `eq` query whose input is a null derives that term (`query_term`, `record::query`); a null query of any other field or operator is the new `Reason::NullQuery`. - eql-bindings: a type whose only term is equality's (`DateEq`, `IntegerEq`, ...) and its query twin take an `Option` of the plaintext, as storage-only types do, and the by-name dispatch encrypts, opens and queries them through it. - Go: stashgen defaults to `null=sealed` for a fixed-width field whose only index is equality, and generates `EqNull`, the `eq` query of nil, through the new `gensupport.Field.NullTerm`. Verified in PostgreSQL: `=` with the query of a null finds exactly the rows of a DateEq column whose value was a sealed null. Refs #1298. Claude-Session: https://claude.ai/code/session_01XQN1QpaWVDGjHEg39KEJF3
vitaminc-normalize 0.8 derives an equality term for a null: one term per context, shared by every null, typed or plain. #1327 dropped it to keep the old behaviour; this keeps it (#1298). - stack-encrypt: `EqualityTerm` of a `None`, and `IndexSpec::Equality` on a null, now derive that term. No other index has one. - Data plans: `"null": "sealed"` takes a field whose only index is equality, on the field or on the EQL type it names. The null is sealed as long as a value and stores the null term beside it, so the absent rows are one equality group, as any repeated value is, and the column holds no SQL NULL. An `eq` query whose input is a null derives that term (`query_term`, `record::query`); a null query of any other field or operator is the new `Reason::NullQuery`. - eql-bindings: a type whose only term is equality's (`DateEq`, `IntegerEq`, ...) and its query twin take an `Option` of the plaintext, as storage-only types do, and the by-name dispatch encrypts, opens and queries them through it. - Go: stashgen defaults to `null=sealed` for a fixed-width field whose only index is equality, and generates `EqNull`, the `eq` query of nil, through the new `gensupport.Field.NullTerm`. Verified in PostgreSQL: `=` with the query of a null finds exactly the rows of a DateEq column whose value was a sealed null. Refs #1298. Claude-Session: https://claude.ai/code/session_01XQN1QpaWVDGjHEg39KEJF3
coderdan
left a comment
There was a problem hiding this comment.
Verdict: I found no correctness or security defects in the upgrade. There are three low-severity findings: a typed null sent by a host loses its padding, without any error, before it is sealed; some docs and messages became false after the rename; and the EQL timing harness is still on ore-rs 0.9.
All line numbers refer to the PR head, a1375ed38.
1. Low (confirmed, not reachable today): a typed null sent by a host is sealed as a plain, short null
Where: packages/stack-encrypt/src/dynamic/value_frame.rs:444-452 (Modeled::typed_null), reached from RowMap::entry (value_frame.rs:1184-1196) and from ValueFrame::from_bytes (value_frame.rs:110).
What is wrong: every value a host sends goes through Modeled<FrameWriter> before it is sealed:
- a record field goes through
split_rowsand thenRowMap::entry; - a single value goes through
decode_plaintext, which callsValueFrame::from_bytes.
Modeled::typed_null passes the typed null to FrameWriter::typed_null. In vitaminc-aead 0.8 that method writes the plain null. So the field's frame now holds a plain null, and the sealing sink seals it as the short null. The padding the host asked for is gone, and nothing reports it.
vitaminc's own docs say this should not happen:
FrameWriter::typed_null: "A frame a host sends may still carry a typed null, whichFramereads, so the host's absent values are padded when sealed."Sink::typed_null: "A sink that forwards to other sinks must forward it as it is, or the padding is lost and absent values show again."
The PR's own test pins the flattening: a_typed_null_in_the_model_is_a_null_in_its_frame (value_frame.rs:1287, assert at :1295).
Failure scenario: a future host, or a third-party binding that follows vitaminc's frame spec, sends [TYPED_NULL, DATE, 0, 0, 0] for a missing date. It expects that row's ciphertext to be as long as the others. Instead the row seals to the short null, and anyone who can read the column can tell which rows have no value. The same happens on a round trip: opening a padded typed null gives the host a plain null, and sealing that value again (an update, or a re-key) drops the padding.
Nothing reaches this today. The Go SDK never writes a typed null, and #1328 builds sealed nulls from the plan rather than from host frames. So it is latent, not live.
Suggested fix: pick one and write it down. Either:
- refuse a typed null in a host frame with
Expecteduntil the dynamic path can keep it; or - keep it: have
Modeled/RowMaphand the field to the sealer as the typed-null leaf instead of re-writing it throughFrameWriter.
At least say in ValueFrame's docs and in the CHANGELOG that a typed null from a host is accepted but sealed without padding. The CHANGELOG entry only covers the frame a binding receives.
2. Low (confirmed): docs and messages the rename made false
packages/stack-encrypt/src/sem/mod.rs:1394-1406and:1431-1433. TheOreBlockTermandore_block_comparedocs still name ore-rs'sBit6BlockOreandChainedBlockOre, which ore-rs 0.10 no longer has. They also say "the legacy 8-bit scheme (BlockOre, modulebit2) is not used here".ore_rs::BlockOreis now the one scheme this code does use (sem/mod.rs:1435,:1605). Someone new will read that line as "BlockOre must not be used". The routing and the wire headers (02 02/02 03, at most ten bytes for the 6-bit engine) are still right; only the names are wrong.Cargo.toml:86-88. The same two type names appear in the comment above theclww-oreandore-rspins.sem/mod.rs:210. "less the one marker byte it puts in front", where "it" is the chained scheme. That byte is nowvitaminc-ore's (the PR body and the CHANGELOG say so). The 1023-byte limit itself is right.packages/stack-encrypt/src/dynamic/term.rs:25and:1350. "clww-ore 0.5 does not encode them…" and "does not shim around clww-ore 0.5". The find-and-replace turnedcllw-ore 0.5into a crate version that never existed: clww-ore starts at 0.6.dynamic/mod.rs:536,dynamic/kind.rs:63/:90, andlanguages/golang/stashgen/enginetest/enginetest.go:244/:266. The user-facingorder_term_pendingtext still says order indexes on decimal, date and timestamp wait "for new clww-ore and ore-rs releases (#1236)". This PR consumes those releases, and the PR body says #1236 can follow. The refusal is still correct, but the reason it gives is now false. Suggest "an order index on a decimal, date or timestamp is not supported yet (#1236)".
Fix: update the wording. No code changes.
3. Low (confirmed): the EQL block-ORE timing harness is still on ore-rs 0.9 and says it matches the root
Where: packages/eql/tests/timing/ore_block_v2/ctgen/Cargo.toml:15-17 pins ore-rs = "=0.9.0". The comment there says "the same version as the root Cargo.lock", which is now 0.10.0. Its lockfile still resolves ore-rs 0.9.0.
Failure scenario: the harness times EQL v4's ore_block comparators on ciphertexts from the old release. The wire headers are the same, so the timings are probably still valid. But the comment is wrong, and a reader who trusts it will assume the harness measures what production writes.
Fix: bump it to =0.10.0 and port it to BlockOre, or change the comment to say it stays on 0.9 on purpose and why.
What I checked and found sound
- Block ORE length check,
ore_block_text(sem/mod.rs:1702-1717).vitaminc_ore::textnow returns the marker followed by the fold. ore-rs 0.10 refuses withbytes.len() > max_byteson those same bytes, and its own test shows a 7-byte text passing and an 8-byte text failing undermax_bytes = 8. So comparing the encoded length againstDEFAULT_MAX_STR_BYTES(1024) refuses exactly what ore-rs refuses. That still means a fold of at most 1023 bytes, and there is no off-by-one. The trailing-NUL check is on the same bytes ore-rs checks; empty text ends in the marker0x01, so it passes. The boundary test indynamic/query.rs:1527-1528(1023 passes, 1024 is refused) still holds. - Order terms (
sem/mod.rs).OrderSpace::encrypt_checkedunderscheme.target(T::TARGET)uses the empty context, ascolumn(T::TARGET, ())did.- The
Orderabletarget table is unchanged. - ore-rs 0.10's
BlockOreroutes targets of up to ten bytes to the 6-bit engine and everything else to the chained engine, the same splitore_block_is_bit6made. BlockOre::comparedispatches on the same headers and refuses a pair from two different engines, as the old match did.from_bytes's structural check is unchanged.- Key blocks are still zeroized immediately after use, and the plaintext stays in
Zeroizing.
- Fixtures and pins. I diffed the JSON of every regenerated fixture against
next:stored_rows_v1.json: onlypassthroughorder terms changed. Everycandhmis unchanged.query_operators.json: only order query terms changed.record_lowering.jsonandvalue_kinds.json: onlyore/opechanged, plus the tail ofc. That tail is the fresh per-leaf nonce under the deterministic key source; the deterministic prefix is unchanged, and the Go tests only open these values.v4_ordered_queries.json: onlyopchanged.v4_ordered_rows.json: onlyopchanged, plus the nonce tail ofc.- No equality term moved anywhere. Block-ORE pins keep their headers (
0202with 6 or 11 blocks,0203…0100 10).
- Version skew.
cargo metadata --locked --offlinepasses in the root, EQL, both Go guest, stack-encrypt fuzz, protect-ffi and ctgen workspaces. Every lockfile that resolves vitaminc 0.8 resolves no 0.6 or 0.7. The 0.5.1 copy comes fromcts-commonand was already onnext. No lockfile names a git source. - Sinks.
typed_nullis a required trait method, so the compiler forces every sink to implement it. I read each implementation:model_nulladmits the same typesmodeldoes (plain, date, decimal, timestamp mapped to nanoseconds/I128) and refuses uuid and unknown.ModelOnlyforwards the typed null.- The kind and row sinks treat it as a null.
- The
.cargo/mutants.tomlexclusion. It is equivalent:scalar_kind(None, &Physical::Null)returnsNone, which is whatOk(Default::default())gives. - Equality on a null.
sem::normalizedandIndexSpec::takesboth dropdomain::NULL, so behaviour matchesnext. - Tests.
cargo nextest run --locked -p stack-encrypt --all-featureson the PR head: 702 passed, 1 skipped.
ctgen still pinned ore-rs 0.9.0 while its manifest said it matched the root lockfile, which #1327 moved to 0.10.0. ore-rs 0.10 no longer exposes its engines directly: a value reaches them only through vitaminc-ore. So ctgen now opens `BlockOre` spaces for a u64 and for text and encrypts through them, as stack-encrypt does. vitaminc-ore puts a marker byte in front of text, so the prefix rows drop one padding NUL to keep the byte offsets the README describes. The rows keep their block counts (11 fixed, 43 chained, 22 against 43 for prefix), and ctgen still asserts ore-rs's comparator agrees with the plaintext order on every row before writing it. The README's recorded results were measured on 0.9.0; it now says so. Refs #1298 Claude-Session: https://claude.ai/code/session_01XQN1QpaWVDGjHEg39KEJF3
Review of #1327 found docs and one message that the move to clww-ore 0.6 and ore-rs 0.10 left wrong: - Block ORE docs named `Bit6BlockOre` and `ChainedBlockOre` and said `BlockOre` was the unused legacy scheme. `BlockOre` is now the one scheme, routing to its `OreAes128Bit6` and `OreAes128Bit6Chained` engines. The marker byte is vitaminc-ore's, not the scheme's. - The `order_term_pending` refusal, and the docs beside it in Rust and Go, said decimal, date and timestamp order indexes wait for new clww-ore and ore-rs releases. Those releases are here; what remains is #1236. The message now says the index is not supported yet (#1236). - Two comments named a clww-ore 0.5 that never existed. It also documents that `ValueFrame::from_bytes` reads a host's typed null as a plain null: a frame writer keeps no padding, and padding an absent value is for the field's plan to declare (#1298), not the host. Refs #1298 Claude-Session: https://claude.ai/code/session_01XQN1QpaWVDGjHEg39KEJF3
vitaminc-normalize 0.8 derives an equality term for a null: one term per context, shared by every null, typed or plain. #1327 dropped it to keep the old behaviour; this keeps it (#1298). - stack-encrypt: `EqualityTerm` of a `None`, and `IndexSpec::Equality` on a null, now derive that term. No other index has one. - Data plans: `"null": "sealed"` takes a field whose only index is equality, on the field or on the EQL type it names. The null is sealed as long as a value and stores the null term beside it, so the absent rows are one equality group, as any repeated value is, and the column holds no SQL NULL. An `eq` query whose input is a null derives that term (`query_term`, `record::query`); a null query of any other field or operator is the new `Reason::NullQuery`. - eql-bindings: a type whose only term is equality's (`DateEq`, `IntegerEq`, ...) and its query twin take an `Option` of the plaintext, as storage-only types do, and the by-name dispatch encrypts, opens and queries them through it. - Go: stashgen defaults to `null=sealed` for a fixed-width field whose only index is equality, and generates `EqNull`, the `eq` query of nil, through the new `gensupport.Field.NullTerm`. Verified in PostgreSQL: `=` with the query of a null finds exactly the rows of a DateEq column whose value was a sealed null. Refs #1298. Claude-Session: https://claude.ai/code/session_01XQN1QpaWVDGjHEg39KEJF3
|
Responses to the review above, all pushed: 1. A host's typed null is sealed as a plain null. This is documented now, not changed (44d430e). A 2. Docs and messages the upgrade made false. Fixed in 44d430e:
3. The timing harness was still on ore-rs 0.9. Fixed in 32a1e89. ore-rs 0.10 no longer exposes its engines directly, so ctgen now encrypts through
Checks: root nextest passes (1255 passed, 1 skipped), and so do clippy, rustdoc with warnings as errors, |
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
| # aead crate would be duplicated. | ||
| vitaminc-aead-frame = { version = "0.6.1", features = ["serde"] } | ||
| vitaminc-protected = "0.6.1" | ||
| vitaminc-aead-frame = { version = "0.8.0", features = ["serde"] } |
There was a problem hiding this comment.
Shouldn't this be a re-export from Stack Encrypt?
freshtonic
left a comment
There was a problem hiding this comment.
Approved. The upgrade is correct and the scope is tight. I have four small comments. None of them blocks the merge.
What I checked:
- Dependencies. All the crates now come from crates.io. No lockfile names a git source.
eql-bindingsnamesstack-encrypt0.3.0, which agrees with the tree. The exact pins onclww-oreandore-rsare correct, because a new release can change every stored order term. Dependabot ignores both crates in both cargo entries. - Block ORE length limit. In vitaminc-ore 0.8,
text()puts the marker byte (0x01) in front of the fold. ore-rs 0.10 counts that byte in its 1024-byte ceiling. Soencoded.len() > 1024is the same check as the oldfold.len() > 1023. Because the marker is0x01and not NUL, the empty string does not trip the trailing-NUL check.block_ore_refuses_text_it_has_no_term_for_before_any_termtests 1023 and 1024 characters through the key-free check and through derivation, so the pre-check and ore-rs cannot disagree without a test failure. BlockOre. It replacesore_block_is_bit6correctly. ore-rs picks the engine by the target's width, andcomparedispatches on the same two headers as before.- Typed nulls.
model_nullmaps every type in the value model and refuses a uuid. That agrees with how the model treats a present value. The frame path writes a plain null, and the tests prove this for the writer, a host's bytes, opening and the row split. The equality-term filter fordomain::NULLis on both paths (IndexSpec::takesandsem::normalized), so this PR keeps the old behaviour. - Release history. stack-encrypt 0.2.0 on crates.io uses
cllw-ore =0.5.0. So the order terms onnextwere already different from the published terms before this PR. This PR changes no term that has been published, which agrees with the PR description. The CHANGELOG entry is under[Unreleased]. - CI. All the checks pass, including Test EQL, Tests (Go), Mutants and Fuzz.
|
Replies to the two inline comments. GitHub refused threaded replies while a pending review is open on this PR.
The guest uses these and no longer depends on vitaminc-aead-frame, vitaminc-protected or (in its tests) vitaminc-aead itself, so it cannot resolve a second vitaminc. The auth guest is unchanged: its only vitaminc dependency is a test dependency on vitaminc-protected.
What blocked them has now shipped, so #1236 can follow this PR. The references stay as they are. Checks after the change: root nextest (1255 passed, 1 skipped, with one trybuild snapshot updated because it now names |
cipherstash-bot
left a comment
There was a problem hiding this comment.
Recommendation: 🟡 merge after changes
One item must change before merge: add a test for ValueSink::typed_null in stack-guest-abi. The suggested test needs the correct imports and signatures, which the comment tells you to check. The other four items can wait or are optional. I found no correctness, soundness or panic defect in the Rust changes.
How this review was made
| Agent | Model | Review type | Result |
|---|---|---|---|
| claude | claude-opus-5-5 | test-gap | 3 found, 3 posted |
| claude | claude-opus-5-5 | rust | 1 found, 1 posted |
| codex | gpt-5.6-terra | test-gap | 1 found, 1 posted |
| codex | gpt-5.6-terra | rust | 0 found, 0 posted |
Synthesis: claude-sonnet-5-5 merged the findings, removed duplicates and dropped findings it could not confirm in the code. 0 posted finding(s) were raised by two or more models.
Plain language: claude-sonnet-5-5 read every comment as a new reader would. 2 comment(s) had a problem that stopped the reader acting; it rewrote 2. It also rewrote the review body.
Stack: 1 earlier and 1 later pull request(s) (#1229, *️⃣ #1327, #1328). *️⃣ marks this pull request.
Context loaded: the description, 2 linked issue(s) and 13 discussion entries.
vitaminc-normalize 0.8 derives an equality term for a null: one term per context, shared by every null, typed or plain. #1327 dropped it to keep the old behaviour; this keeps it (#1298). - stack-encrypt: `EqualityTerm` of a `None`, and `IndexSpec::Equality` on a null, now derive that term. No other index has one. - Data plans: `"null": "sealed"` takes a field whose only index is equality, on the field or on the EQL type it names. The null is sealed as long as a value and stores the null term beside it, so the absent rows are one equality group, as any repeated value is, and the column holds no SQL NULL. An `eq` query whose input is a null derives that term (`query_term`, `record::query`); a null query of any other field or operator is the new `Reason::NullQuery`. - eql-bindings: a type whose only term is equality's (`DateEq`, `IntegerEq`, ...) and its query twin take an `Option` of the plaintext, as storage-only types do, and the by-name dispatch encrypts, opens and queries them through it. - Go: stashgen defaults to `null=sealed` for a fixed-width field whose only index is equality, and generates `EqNull`, the `eq` query of nil, through the new `gensupport.Field.NullTerm`. Verified in PostgreSQL: `=` with the query of a null finds exactly the rows of a DateEq column whose value was a sealed null. Refs #1298. Claude-Session: https://claude.ai/code/session_01XQN1QpaWVDGjHEg39KEJF3
Two review comments on #1327. The encrypt package doc said ORE and OPE apply to "the integers", but an 8-bit integer takes no order index, and it said "not yet" twice. It now says which kinds take an order index and why a bool and an 8-bit integer do not. `ValueFrame::from_bytes` explained a host's typed null through vitaminc's release history and an issue number. It now says only what a caller sees: the null reads as a plain null and is not padded when sealed. Refs #1298 Claude-Session: https://claude.ai/code/session_01XQN1QpaWVDGjHEg39KEJF3
vitaminc-normalize 0.8 derives an equality term for a null: one term per context, shared by every null, typed or plain. #1327 dropped it to keep the old behaviour; this keeps it (#1298). - stack-encrypt: `EqualityTerm` of a `None`, and `IndexSpec::Equality` on a null, now derive that term. No other index has one. - Data plans: `"null": "sealed"` takes a field whose only index is equality, on the field or on the EQL type it names. The null is sealed as long as a value and stores the null term beside it, so the absent rows are one equality group, as any repeated value is, and the column holds no SQL NULL. An `eq` query whose input is a null derives that term (`query_term`, `record::query`); a null query of any other field or operator is the new `Reason::NullQuery`. - eql-bindings: a type whose only term is equality's (`DateEq`, `IntegerEq`, ...) and its query twin take an `Option` of the plaintext, as storage-only types do, and the by-name dispatch encrypts, opens and queries them through it. - Go: stashgen defaults to `null=sealed` for a fixed-width field whose only index is equality, and generates `EqNull`, the `eq` query of nil, through the new `gensupport.Field.NullTerm`. Verified in PostgreSQL: `=` with the query of a null finds exactly the rows of a DateEq column whose value was a sealed null. Refs #1298. Claude-Session: https://claude.ai/code/session_01XQN1QpaWVDGjHEg39KEJF3
vitaminc-normalize 0.8 derives an equality term for a null: one term per context, shared by every null, typed or plain. #1327 dropped it to keep the old behaviour; this keeps it (#1298). - stack-encrypt: `EqualityTerm` of a `None`, and `IndexSpec::Equality` on a null, now derive that term. No other index has one. - Data plans: `"null": "sealed"` takes a field whose only index is equality, on the field or on the EQL type it names. The null is sealed as long as a value and stores the null term beside it, so the absent rows are one equality group, as any repeated value is, and the column holds no SQL NULL. An `eq` query whose input is a null derives that term (`query_term`, `record::query`); a null query of any other field or operator is the new `Reason::NullQuery`. - eql-bindings: a type whose only term is equality's (`DateEq`, `IntegerEq`, ...) and its query twin take an `Option` of the plaintext, as storage-only types do, and the by-name dispatch encrypts, opens and queries them through it. - Go: stashgen defaults to `null=sealed` for a fixed-width field whose only index is equality, and generates `EqNull`, the `eq` query of nil, through the new `gensupport.Field.NullTerm`. Verified in PostgreSQL: `=` with the query of a null finds exactly the rows of a DateEq column whose value was a sealed null. Refs #1298. Claude-Session: https://claude.ai/code/session_01XQN1QpaWVDGjHEg39KEJF3
…0.10 vitaminc 0.8 seals a fixed-width None as a typed null padded to the length of a Some, which #1298 needs before an optional field can be sealed without showing which rows are absent. Stack Encrypt could not take it on its own: cllw-ore and ore-rs pinned vitaminc-ore 0.6. Their move to vitaminc 0.8 (clww-ore, renamed, and ore-rs 0.10) is cipherstash/ore.rs#132, stacked on #131 and #130, and not yet released, so both come from git at that PR's head until they are on crates.io. What moves: - Order terms encrypt through clww-ore's ClwwOre/ClwwOpe and ore-rs's single BlockOre, opened as vitaminc-ore OrderSpaces. Every ORE, OPE and block ORE term changes (clww-ore's keystream fix and scheme names, BlockOre's key label). Wire formats, this crate's key domains, ciphertexts and equality terms do not; the fixtures and pins re-freeze their order terms only. - Every Sink implements typed_null. Sinks that forward (the frame writers) pass it on; the value model holds a typed null of its own types and refuses a uuid's, as it refuses a uuid. - A null keeps no equality term. vitaminc-normalize 0.8 derives one; it is dropped so absent values keep a SQL NULL until #1298 decides otherwise. - vitaminc-ore 0.7 puts the text marker byte in front itself, so block ORE's 1024-byte ceiling now counts it in the encoding stack-encrypt checks. Refs #1298. Claude-Session: https://claude.ai/code/session_01XQN1QpaWVDGjHEg39KEJF3
…mutant A typed null has no kind, as any null has none, so KindOf::typed_null's Ok(None) is Ok(Default::default()). The one survivor of cargo mutants --in-diff on the vitaminc 0.8 upgrade; folded into the passthrough exclusion, which is equivalent for the same reason. Claude-Session: https://claude.ai/code/session_01XQN1QpaWVDGjHEg39KEJF3
Both crates are released, so the root manifest pins them exactly instead of taking them from cipherstash/ore.rs at one git revision. A git dependency stopped stack-encrypt publishing; this removes it from every lockfile (root, EQL, the Go guest and the fuzz crate). The releases were cut from a later ore.rs history than the pinned revision, after more review. The frozen order-term fixtures pass unchanged against them, so stored terms derive exactly as before. Exact pins because a new release of either can change every stored order term. AGENTS.md and the Dependabot notes now say that rather than "until released". Refs #1298 Claude-Session: https://claude.ai/code/session_01XQN1QpaWVDGjHEg39KEJF3
ctgen still pinned ore-rs 0.9.0 while its manifest said it matched the root lockfile, which #1327 moved to 0.10.0. ore-rs 0.10 no longer exposes its engines directly: a value reaches them only through vitaminc-ore. So ctgen now opens `BlockOre` spaces for a u64 and for text and encrypts through them, as stack-encrypt does. vitaminc-ore puts a marker byte in front of text, so the prefix rows drop one padding NUL to keep the byte offsets the README describes. The rows keep their block counts (11 fixed, 43 chained, 22 against 43 for prefix), and ctgen still asserts ore-rs's comparator agrees with the plaintext order on every row before writing it. The README's recorded results were measured on 0.9.0; it now says so. Refs #1298 Claude-Session: https://claude.ai/code/session_01XQN1QpaWVDGjHEg39KEJF3
Review of #1327 found docs and one message that the move to clww-ore 0.6 and ore-rs 0.10 left wrong: - Block ORE docs named `Bit6BlockOre` and `ChainedBlockOre` and said `BlockOre` was the unused legacy scheme. `BlockOre` is now the one scheme, routing to its `OreAes128Bit6` and `OreAes128Bit6Chained` engines. The marker byte is vitaminc-ore's, not the scheme's. - The `order_term_pending` refusal, and the docs beside it in Rust and Go, said decimal, date and timestamp order indexes wait for new clww-ore and ore-rs releases. Those releases are here; what remains is #1236. The message now says the index is not supported yet (#1236). - Two comments named a clww-ore 0.5 that never existed. It also documents that `ValueFrame::from_bytes` reads a host's typed null as a plain null: a frame writer keeps no padding, and padding an absent value is for the field's plan to declare (#1298), not the host. Refs #1298 Claude-Session: https://claude.ai/code/session_01XQN1QpaWVDGjHEg39KEJF3
The Go guest named vitaminc-aead-frame and vitaminc-protected itself (and vitaminc-aead for its tests), with the version kept equal to stack-encrypt's by hand. A second version would not fail loudly: a `ValueFrame` would just stop meeting the other version's `Decrypt` bound. stack-encrypt now re-exports what a binding speaks through: `dynamic::frame` (vitaminc-aead-frame, with `serde`), `dynamic::plaintext` (the value model, for `Encrypt` on a binding's own values, as `dynamic::read` takes them), and `DecodeError`, `Protected` and `Controlled` at the root, since `ValueFrame::into_inner` returns a `Protected`. The guest uses those and drops its own vitaminc dependencies. One trybuild snapshot now names `Protected` by its shorter re-exported path. Refs #1298 Claude-Session: https://claude.ai/code/session_01XQN1QpaWVDGjHEg39KEJF3
Two review comments on #1327. The encrypt package doc said ORE and OPE apply to "the integers", but an 8-bit integer takes no order index, and it said "not yet" twice. It now says which kinds take an order index and why a bool and an 8-bit integer do not. `ValueFrame::from_bytes` explained a host's typed null through vitaminc's release history and an issue number. It now says only what a caller sees: the null reads as a plain null and is not padded when sealed. Refs #1298 Claude-Session: https://claude.ai/code/session_01XQN1QpaWVDGjHEg39KEJF3
Hold a block ORE term as an ore_rs::Term, so comparing two terms borrows them instead of copying both into new buffers, and decoding copies its input once. Name the 1023-byte block ORE limit by the fold, as the docs elsewhere do, and say the order terms of decimals, dates and timestamps are not supported yet (#1236) rather than waiting on a release. Add the tests the review found missing: a typed null in a guest frame decodes as a host null; a plain null opens as a fixed-width None; a typed null inside a passthrough is held to the value model; and bytes take a block ORE term up to 1023 bytes and no further. Claude-Session: https://claude.ai/code/session_01XQN1QpaWVDGjHEg39KEJF3
f3b1faf to
eb73756
Compare
vitaminc-normalize 0.8 derives an equality term for a null: one term per context, shared by every null, typed or plain. #1327 dropped it to keep the old behaviour; this keeps it (#1298). - stack-encrypt: `EqualityTerm` of a `None`, and `IndexSpec::Equality` on a null, now derive that term. No other index has one. - Data plans: `"null": "sealed"` takes a field whose only index is equality, on the field or on the EQL type it names. The null is sealed as long as a value and stores the null term beside it, so the absent rows are one equality group, as any repeated value is, and the column holds no SQL NULL. An `eq` query whose input is a null derives that term (`query_term`, `record::query`); a null query of any other field or operator is the new `Reason::NullQuery`. - eql-bindings: a type whose only term is equality's (`DateEq`, `IntegerEq`, ...) and its query twin take an `Option` of the plaintext, as storage-only types do, and the by-name dispatch encrypts, opens and queries them through it. - Go: stashgen defaults to `null=sealed` for a fixed-width field whose only index is equality, and generates `EqNull`, the `eq` query of nil, through the new `gensupport.Field.NullTerm`. Verified in PostgreSQL: `=` with the query of a null finds exactly the rows of a DateEq column whose value was a sealed null. Refs #1298. Claude-Session: https://claude.ai/code/session_01XQN1QpaWVDGjHEg39KEJF3
Summary
Stack Encrypt is the Rust engine that encrypts values for the Go SDK and the EQL types. It now builds on vitaminc 0.8 and on the new releases of the two order-preserving encryption crates:
clww-ore0.6 (the oldcllw-ore, renamed) andore-rs0.10. vitaminc is the library that seals each value. Its 0.8 release pads a missing value of a fixed-width type (an integer, a float, a date, a timestamp, a decimal) to the same length as a present one. That lets #1298 store an optional field without showing which rows are empty. This PR only does the upgrade. #1298 builds on it.The ORE crates are released.
clww-ore0.6.0 andore-rs0.10.0 are on crates.io, and the rootCargo.tomlpins both exactly. They no longer come from git, so nothing here stopsstack-encryptpublishing.Every stored order term changes. An order term is the index value that lets the database compare or sort encrypted values. Terms of the
ore,opeandore_blockindexes are derived differently now:BlockOrescheme, which derives its keys under a new label.So a query no longer finds an order term written before this change. Sealed values (ciphertexts) and equality terms do not change. A stored row still opens, and its order terms have to be derived again. Nothing has been released with the old terms on
next.Changes
Dependencies
cllw-ore =0.5.2-rc.1becomesclww-ore =0.6.0, andore-rs 0.9.0becomesore-rs =0.10.0, both on vitaminc 0.8, with default features off. They are pinned exactly because a new release can change every stored order term.clww-oreandore-rsin both cargo entries, and its comments are updated.Order terms (
stack-encrypt/src/sem)ClwwOreandClwwOpe. Block ORE goes through ore-rs's singleBlockOre, which picks its 6-bit or chained engine by the target's width. That replaces our ownore_block_is_bit6.OrderSpacefor the type's target (the oldOrderColumnis deprecated) and callsencrypt_checked.sem::OreCllw8VariableV1andsem::OpeCllw8VariableV1are renamedOreClww8VariableV1andOpeClww8VariableV1, to match clww-ore.Typed nulls (vitaminc 0.8's new
Sink::typed_null)Sinkin stack-encrypt, stack-guest-abi, the guest tests and eql-bindings' tests implementstyped_null.Equality on a null
NULLin its equality column.Fixtures, pins and docs
stored_rows_v1.json(withSTACK_ENCRYPT_REFREEZE_ORDER_TERMS=1),query_operators.json,record_lowering.jsonandvalue_kinds.json; EQL'sv4_ordered_queries.jsonandv4_ordered_rows.json; and the byte pins interm_bytes.rs,frozen_bytes.rsandore_block.rs.stored_rowsitself checks that only order terms moved.AGENTS.md's note on where the ORE crates come from is updated, and so is one trybuild snapshot.Verification
cargo nextest run --workspace --all-features(root): 1255 passed, 1 skipped.cargo clippy --workspace --all-features --all-targets -D warnings,mise run test:docandRUSTDOCFLAGS=-D warnings cargo docare clean.cargo metadata --lockedpasses in every Cargo workspace touched.roundtrip::a_fixed_width_none_is_as_long_as_a_some: a sealedNone::<i64>,None::<u32>andNone::<f64>is as long as a sealedSome, and opens asNone.value_frametests drive a typed null through the frame writer, the host-frame check, opening and the record-row split. A uuid's typed null is refused.cargo mutants --in-difffor stack-auth and stack-encrypt, the CI gate: 35 mutants, 20 caught, 14 unviable, 1 missed. The survivor replacesKindOf::typed_null'sOk(None)withOk(Default::default()), which is the same value: a null has no kind. It is excluded in.cargo/mutants.tomlnext to the matching passthrough exclusion.cargo test -p eql-bindings --all-featuresand-p eql-encryption-testspass, and so doesmise run test:encryption:postgresagainst PostgreSQL 15. That means EQL v4's SQL comparators order the new block ORE and CLWW terms.mise run go:testpasses. The guest crate's owncargo testpasses.--locked: the root nextest run (1255 passed, 1 skipped), clippy, rustdoc,-p eql-bindings --all-features,-p eql-encryption-tests,-p eql-codegen, the Go guests andgo:test. The frozen order-term pins (stored_rows_v1.json,term_bytes.rs,frozen_bytes.rs,ore_block.rs, the EQL ordered fixtures) pass unchanged, so the releases derive the same terms as the revision.cargo metadata --lockedpasses in the root, EQL, Go guest and fuzz workspaces, and no lockfile names a git source any more.pnpm run test:scripts(78 files) passes.Related
Refs #1298. Unblocks it: the Rust and then Go work for optional sealed fields stacks on this branch.
Uses clww-ore 0.6.0 and ore-rs 0.10.0, released from cipherstash/ore.rs#130, cipherstash/ore.rs#131, cipherstash/ore.rs#132 and cipherstash/ore.rs#135 (tracking cipherstash/ore.rs#129). Uses cipherstash/vitaminc#497 and cipherstash/vitaminc#499, released in vitaminc 0.8.0.
Review notes
packages/stack-encrypt/src/sem/mod.rs(the order visitors andore_block_text) andpackages/stack-encrypt/src/dynamic/value_frame.rs(model_nulland the sink methods).enginetestmessage now sayclww-ore. Earlier changelog entries still saycllw-ore, as they were written. The error variantTermBytesError::MalformedCllwCiphertextand its diagnostic code keep their names, so this PR does not also break those APIs.eql-bindingsnow requiresvitaminc-aead0.8 for itsstack-encryptfeature. That feature cannot be used from crates.io until a stack-encrypt 0.3.0 that matches it is published. There is no@cipherstash/eqlchangeset because the npm package does not change."null": "sealed"), not the host's.ValueFrame::from_bytesdocuments this.BlockOresupport those targets, so Stack Encrypt cannot order floats, dates, timestamps or decimals, and its integer order keys predate ADR-0002: derive every order term over vitaminc-ore #1236 can follow this PR.https://claude.ai/code/session_01XQN1QpaWVDGjHEg39KEJF3
Summary by CodeRabbit