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 in docs/sdk-design-principles.md (ADR-0008). Item 5: the golden-snapshot proof is replaced. plantest.Golden is 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::record parses 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
Pending synchronously; 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.Golden snapshots 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
FfiValue from the target layer (the guest's value type is zeroized and not Clone, 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.md and ADR-0007 on PR cipherstash/stack#1052.
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 callsdynamic::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::recordis 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 withPending::all. It never touches the engine'sEncryptiondescriptions 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 bareu32seals 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
asyncfor no structural reason: it settles each term's readyPendingeagerly instead of zipping it into the batch. Term derivation is local (KeysetCipher::equality_termand 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.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.)plantest.Goldensnapshots from PR cipherstash/stack#1025 must not change; that is the proof the lowering preserved behaviour.From the plan doc's "Consolidation" section; not yet verified by implementation.
Relationship to other work
FfiValuefrom the target layer (the guest's value type is zeroized and notClone, so it cannot reach the target layer until that bound changes).Encrypt), #1064 (sans-I/O guest ABI) and #1065 (TypeScript on stack-encrypt).docs/plans/2026-10-04-plan-builder.mdand ADR-0007 on PR cipherstash/stack#1052.