Repository navigation
stack-encrypt: add the engine pieces the plan builder needs (passthrough, run-by-value, Index trait, per-field types) — 0.3.0 #1056
Description
Background
stack-encrypt (packages/stack-encrypt) is our Rust library for field-level encryption: every value is sealed under its own data key from ZeroKMS, our key management service, and can carry search terms beside the ciphertext so an encrypted column can still be queried. Its execution engine is the Encryption / Decryption descriptions and the batched Pending in src/target/ (operations.rs, pending.rs). A Pending is an output whose local work is done and whose ZeroKMS key requests are queued but not yet sent, so many values settle in one network round trip.
The design in docs/plans/2026-10-04-plan-builder.md (on PR #1052, not yet merged) puts one front door on that engine: a chained plan builder. A plan is a saved, validated declaration of how a value is encrypted: its context (the parts it is sealed under) and its indexes (equality, match, ORE, OPE, JSON: the operations that derive search terms). ADR-0007 on the same PR (packages/stack-encrypt/docs/adr/0007-bindings-enter-through-a-plan-never-a-second-executor.md) records the rule that every caller, including language bindings, goes through that builder. This issue is the first step: the engine pieces the builder needs and the engine does not have yet. It is planned as stack-encrypt 0.3.0.
Problem
The builder is meant to be a thin lowering onto the existing combinators. Five things it needs are missing, so today it could not be written without reaching into private code or re-implementing composition per caller:
- There is no "carry this field unsealed" operation. A plan can name a passthrough field (present in the output, not encrypted, not authenticated). The engine has
ciphertext()and the term operations, but nothing that just returns the source. - A description held in a variable cannot be run. The only way to execute an
Encryptionis through a type'sEncryptFromimpl viaencrypt_as; the closure that runs it is private. A plan is a value built at run time, so it needs a public execute-by-value path. AnEncryptionis also single-use (its body is aFnOnce), so a reusable plan must produce a fresh description per call. - Indexes are not types the engine can compose. Every caller (today the derive and
dynamic::record) spells outciphertext().accepting().zip(equality()).zip(..).map(..)itself, with nested-pair bookkeeping. Nothing lets "this field has equality and ORE" be one value. - There is no output type for "one value plus its terms" read without declaring a struct.
- A plan does not say what type a field is. That is fine in Rust, where the type is known, but a dynamically typed host (JavaScript, PHP, Ruby) cannot say whether
34is an integer or a float, and index semantics depend on it: ORE on34and34.0differ, and match only applies to text.
There is also a hard prerequisite outside this issue: the target layer bounds its plaintext as S: Encrypt + Clone (for example ciphertext in src/target/operations.rs). The dynamic value type the WebAssembly (WASI) guest uses, FfiValue, is zeroized on drop and deliberately not Clone, so the guest cannot use the target layer at all. That bound must be fixed (by a separate change) in or before this work, or nothing here is reachable from a binding.
Proposal
All items are from the plan doc's "Additions" and "Where the design strains" sections. Signatures are proposals; the PR settles them.
passthrough::<S>(), a constructor besideciphertext()and the term operations. Its build isPending::ready(cipher, Ok(source.clone())); it ignores the context. The rustdoc says plainly that a passthrough value is not authenticated, and that a tamper-evident but readable field should be a sealed field with an equality index instead.- Execute-by-value: a public way to run an
Encryptionand aDecryptionheld in a variable (proposed asKeysetCipher::run(&plan, &source, ctx)once the builder exists). Index<S>andIndexes<S>.Index<S>is generic over the plaintext, so an index that does not apply to a type (match on an integer) does not compile. It has an associatedtype Termand aspec()method that lowers the index to data (IndexSpec, today'sTermKind) for the FFI and saved plans. Parameters (for exampleMatchOptions) live on the struct and lower throughspec().Indexes<S>is implemented for one index and for tuples of two or more. It is not implemented for(), so an empty index set is a compile error rather than a quiet ciphertext-only field.indexed::<S>(idx)does theciphertext().accepting().zip(..).map(..)composition once, in the engine.Indexes::select::<I>()picks one index for the query side.
Encrypted<Terms>, the output of a one-value indexed encryption, with the terms as a tuple read by destructuring:let (eq, ore) = out.terms;. No named accessors (they would need type-level tuple search for a case that barely exists; a struct with named fields is the derive's job).- A type per field in the plan grammar. The vocabulary is vitaminc's frozen tag table plus the composite kinds, not new names. It is used three times: by encrypt, to admit indexes and derive the right term bytes; by query, to read the query value (a
34against au64field is au64term); and by decrypt, to know what to hand back in a host with no types (an integer, not a float; bytes, not a string). A build fails on an index whose field type cannot be resolved, and the engine verifies each tagged value against the declaration rather than trusting the binding. This is a wire-format addition, so it belongs in this first engine PR. - Rename
MatchTerm(src/sem/mod.rs). A match index produces a set of terms, so the singular name misleads; the new name is settled in the PR.
The combinators' public surface does not shrink; they remain the extension point for custom targets, EQL domain types and transcode.
Verified against the code: the S: Encrypt + Clone bound on ciphertext and the term operations (src/target/operations.rs); MatchTerm in src/sem/mod.rs. From the plan doc, not yet verified by building: everything in the Proposal.
Relationship to other work
- Blocked by the fix for the plaintext-clone bound that excludes
FfiValuefrom the target layer (must land in or before this work). - Blocks stack-encrypt: one chained plan builder as the single front door, so writes, queries and reads cannot drift #1057 (the plan builder), stack-encrypt has no searchable JSON — add a
Jsonindex that seals a document's entries under one data key #1060 (the JSON index) and stack-encrypt: every index is compiled into the core crate — moveEquality,Match,Ore,Opeinto their own crates #1063 (index implementations in their own crates). - Design:
docs/plans/2026-10-04-plan-builder.mdand ADR-0007 on PR docs(plans): the plan builder, one front door for stack-encrypt, the derive, the FFI and Go #1052. Go-side consumer: Go SDK: stash struct tags and stashgen, in place of a chained plan builder #1046.
Activity
fix(stack-encrypt): let a non-Clone plaintext run a target operation
fix(stack-encrypt)!: let a non-Clone plaintext run a target operation
fix(stack-encrypt)!: let a non-Clone plaintext run a target operation