Repository navigation
stack-encrypt: one chained plan builder as the single front door, so writes, queries and reads cannot drift #1057
Description
Background
stack-encrypt (packages/stack-encrypt) is our Rust library for field-level encryption, with keys from ZeroKMS, our key management service. Today two separate front ends drive its engine by hand: the #[derive(EncryptFrom)] code generator, and dynamic::record (src/dynamic/record.rs), which the Go binding's WebAssembly guest runs. The design in docs/plans/2026-10-04-plan-builder.md (PR #1052, not yet merged) replaces both with one chained plan builder. A plan is the saved tail of an encrypt call with the value left out: a context, and per field its indexes. The write, the query and the read all take the same plan, so none of them can respell the context. ADR-0007 on the same PR makes the builder the only way a binding reaches the engine.
Problem
Without a builder:
- The same rules live in two places and have already drifted. The derive and
dynamic::recordboth encode "one context per field", caller extension and batching. Nothing but tests keeps them in step, anddynamic::record's own docs record a divergence: a derive over a bareu32seals untagged bytes while a plan seals the tagged encoding, so the same field written by each does not interchange. - Combinators are not a usable front door. A four-field record in combinator spelling is roughly thirty lines of
accepting,zip, nested-pairmapandproject. AnEncryptionis single-use and not data, so it cannot be saved in a variable or handed to Go. - Queries can drift from writes. A query re-states the field's context by hand; if it differs, the query matches nothing, silently (the failure behind Rust
TextEqQueryprobes against a column written by cipherstash-client match nothing, silently #1051).
Proposal
From the plan doc's "The Rust API" and "How the builder lowers to the combinators" sections. Names are settled; exact signatures belong in the PR.
- The chain.
cipher.encrypt(&v)returns a builder holding the value and a plan under construction..context(c)sets the context;.with(indexes)indexes a single value;.fields()switches to field by field (each top-level field sealed under<context>/<field>). Field verbs:encrypt(name)(ciphertext alone),encrypt_index(name, indexes)(ciphertext plus a non-empty index set),index(name, indexes)(indexes alone, no ciphertext; the field comes back from decrypt only if an index's output is reversible, as JSON's is),passthrough(name)(carried unsealed and unauthenticated). Then.keyset(..),.extend(parts)(the caller's parts extend every field's context, asDeclaredContextdoes today), and.awaitthroughIntoFuture. Before the await nothing has touched a key. - Saved plans.
Plan::context(..)starts the same chain without a value;.build()validates the whole plan and returns an error on: a field named twice, a label that is not plain, two fields sharing one identity (the label segment a field is keyed under), passthrough on an indexed field, a value field the plan does not name (or a plan field missing from the value), or a field type that cannot be resolved..using(&plan)runs it:cipher.encrypt(&user).using(&users_plan), and over a slice with one key request. - Queries.
cipher.query(v).using(&field_plan)wherefield_plan = users_plan.field("email")?. The form is chosen by the source type;.equality()is sugar on a scalar field. A query against an index the field never declared is an error. - Decrypt.
cipher.decrypt(row).using(&plan)returns the fields that can come back (not index-only fields with non-reversible output). - Batching.
stack_encrypt::all((a, b))settles several operations in one ZeroKMS request, overPending::zip/Pending::all. - Lowering, not executing. Every builder call lowers to an existing combinator, per the plan doc's table:
with/encrypt_indextoindexed();encrypttociphertext();passthroughtopassthrough();fields()toproject(select)plus.under(label)pluszipper field;usingto execute-by-value;queryto the selected index alone under the field's label;decrypttoopenper field run bydecrypt_as. - Native Rust keeps its two entry points. Cipher-directed encryption (the value's shape drives it, the caller supplies the context) and target-directed encryption (the output type declares what it is made from) remain the preferred entries for a Rust caller. The chain is their fluent spelling:
cipher.encrypt(&v).context(c)is cipher-directed,.using(T::plan())is target-directed through the derive. A hand-builtPlanat run time is what bindings lower data into and what the derive emits.
Open question carried from the plan doc: whether a hand-built one-value plan carries its plaintext type for decrypt or the caller names it.
Relationship to other work
- Blocked by stack-encrypt: add the engine pieces the plan builder needs (passthrough, run-by-value, Index trait, per-field types) — 0.3.0 #1056 (engine additions).
- Blocks stack-encrypt-derive:
#[derive(EncryptFrom)]hand-writes combinators — emit the plan builder instead #1058 (derive emits the builder), stack-encrypt:dynamic::recordis a second executor that already seals differently from the derive — make it a lowering into the plan builder #1059 (dynamic::recordas a lowering), Go and Rust plans cannot target EQL domains, so Go writes rows EQL cannot query — make domains plan field targets #1062 (EQL domains as field targets) and@cipherstash/stackruns a second encryption engine (protect-ffi) — new major built on stack-encrypt (umbrella) #1065 (TypeScript on stack-encrypt). - 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.
Activity
coderdan commentedon Oct 5, 2026
Noting a gap for the trail:
projectandproject_byonly borrow, so a fields plan clones each field once when the record is consumed. Before the guest can seal anFfiValuerecord without those clones, the builder needs a field-splitting combinator that takes the record by value. #1059 owns that work.docs(stack-encrypt): say what is transitional and what the engine doe…
docs(stack-encrypt): say what is transitional and what the engine doe…