Repository navigation
stack-encrypt: dynamic::record is a second executor that already seals differently from the derive — make it a lowering into the plan builder #1059
Description
Updated 2026-10-05 (PDT). The Go SDK design is PR cipherstash/stack#1070:
docs/plans/2026-10-04-plan-builder.md, "The Go SDK", and the principles indocs/sdk-design-principles.md(ADR-0008). Item 5: the golden-snapshot proof is replaced.plantest.Goldenis removed with the plan package. The proof is the record fixture: the Rust chain and the lowering each open the records that the other encrypted, and both derive the same bytes for each term (ADR-0007, amended 2026-10-06).
Background
The Go binding (languages/golang/stackencrypt/) runs stack-encrypt, our Rust field-level encryption library, inside a WebAssembly (WASI) guest (languages/golang/stackencrypt/guest/). When Go encrypts a record, the guest calls dynamic::record (packages/stack-encrypt/src/dynamic/record.rs), which takes a plan as data (which fields to encrypt, under which context, with which search indexes) and produces the encrypted record.
Problem
dynamic::record is a second executor. It walks the data plan itself, calls the term functions and the seal path per field, collects the pendings and merges them with Pending::all. It never touches the engine's Encryption descriptions that the derive uses. So every rule (one context per field, caller extension, batching) exists twice, and they have already diverged: its own docs record that a derive over a bare u32 seals four untagged bytes while a plan seals the tagged encoding, so the same field written from Rust and from Go does not interchange.
It is also async for no structural reason: it settles each term's ready Pending eagerly instead of zipping it into the batch. Term derivation is local (KeysetCipher::equality_term and its siblings are plain functions over the index key loaded when the keyset was resolved).
Proposal
Per ADR-0007 (packages/stack-encrypt/docs/adr/0007-bindings-enter-through-a-plan-never-a-second-executor.md, on PR cipherstash/stack#1052):
dynamic::recordparses the data plan and drives the builder from #1057. Its per-field loop, batching and output shaping are deleted.dynamic::term's dispatch from a runtime scalar to a typed index stays, as the one dynamic step in the lowering.- A plan yields a
Pendingsynchronously; the eager per-term settling goes away. (If term derivation ever becomes a ZeroKMS call, it arrives as a new request kind batched with data keys, and this stays synchronous.) - The tagged-versus-untagged divergence closes by construction, because both paths seal leaves through the same code. Decide and document which encoding wins, and pin it.
- Rebuild the Go guest. The Go
plantest.Goldensnapshots from PR cipherstash/stack#1025 must not change; that is the proof the lowering preserved behaviour. - No module may call the term functions or the seal path directly to produce a record after this (the ADR's rule).
From the plan doc's "Consolidation" section; not yet verified by implementation.
Relationship to other work
- Blocked by #1057 (the plan builder) and by the fix for the plaintext-clone bound that excludes
FfiValuefrom the target layer (the guest's value type is zeroized and notClone, so it cannot reach the target layer until that bound changes). - Blocks #1046 (the Go chained
Encrypt), #1064 (sans-I/O guest ABI) and #1065 (TypeScript on stack-encrypt). - Design:
docs/plans/2026-10-04-plan-builder.mdand ADR-0007 on PR cipherstash/stack#1052.
Activity
coderdan commentedon Oct 5, 2026
Tracking a design point from review of #1069: the plan grammar's
"type"is optional today, so an indexed field with no type is dispatched on the value's own tag rather than a declared type. Once the Go side (#1046) sends"type"on every field, this lowering should require it on every field that carries terms. Until then, therecord::planrustdoc should call the untyped form transitional.coderdan commentedon Oct 5, 2026
Tracking a second design point from review of #1069:
FieldType::readhas no caller in the engine yet, so a dynamic query is not read as the field's declared type before its term is derived. Whendynamic::recordbecomes a lowering,query(v).using(&plan)must go throughFieldType::read, so a query value is checked and converted the same way the stored value was.docs(stack-encrypt): the leaf-encoding decision is tracked in #1118, …
auxesis commentedon Oct 7, 2026
Item 4 (the tagged-versus-untagged divergence) is only partly closed by #1093: data-plan fields now all seal the same tagged leaf, but a Rust
u32/Stringfield and a data-plan field still seal different leaf bytes under one label. That remaining part is tracked in #1118.