Repository navigation
Go SDK: stash struct tags and stashgen, in place of a chained plan builder #1046
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). The chain in this issue is superseded. A user putsstashtags on a struct and runsstashgen, which writes the encrypted type andEncrypt,DecryptandFieldsinto the user's package. There is noRun, noUsing, noPlanOf[T]and no plan in the user's hands. The blocker on CIP-4161 is gone: the generated file is the reviewable artifact. Items 1 to 4 below are history.
Background
stack-encrypt (packages/stack-encrypt) is our Rust library for field-level encryption. Each value is sealed under its own data key from ZeroKMS, our key management service. The Go binding (languages/golang/stackencrypt) runs that same Rust code inside a WebAssembly guest, so Go gets exactly the Rust behaviour. The guest (languages/golang/stackencrypt/guest) is a thin layer: each function it exports calls one stack-encrypt function. The Go binding has never been released, so its API can change freely. Nothing outside this repository depends on it.
Rewritten 2026-10-04. This issue previously proposed
EncryptAs/DecryptAswith a genericTarget[O], plus a section "EQL v3 outputs asEncryptAstargets". Both are superseded by the plan-builder design indocs/plans/2026-10-04-plan-builder.mdand ADR-0007 (packages/stack-encrypt/docs/adr/0007-bindings-enter-through-a-plan-never-a-second-executor.md), both on PR cipherstash/stack#1052. The EQL half moved to #1062.
Problem
The Go API is split by input shape: single values (Encrypt, EncryptElement, Decrypt, DecryptElement), records (EncryptRecord(s), DecryptRecord(s), with RecordOption and WithPlan), and query probes (Term). The split is the problem, not the names:
- A record is not a different kind of thing. The cipher already encrypts any value, including maps and structs, as one tree. What a database row needs that a tree does not give is exactly two things: a context per field, and indexes (search term derivations) per field. A plan is those two facts and nothing else: the tail of an ordinary encrypt call with the value left out, saved so the write, the query and the read all take the same declaration and cannot drift. (A query written against a respelled context matches nothing, silently, as in #1051.)
- Per-call features have no single place to attach. Lock context and audit context need to reach every call that touches ZeroKMS.
- Tags are applied silently. A zero
Plancurrently means "use the struct's tags", so a caller cannot see from the call site which declaration ran.
Proposal
Mirror the Rust plan builder (#1057). Go has no await, so the finalizer is an explicit Run(ctx). Exact signatures belong in the PR.
-
One verb,
Encrypt, for a tree and for a plan alike:// One value, one tree. ct, err := cipher.Encrypt(doc).Context("documents/v2/body").Run(ctx) // Field by field: the full chain, plan built and run in one call. row, err := cipher.Encrypt(user).Context("users").Fields(). EncryptIndex("email", stackencrypt.Equality, stackencrypt.Match). EncryptIndex("age", stackencrypt.Equality, stackencrypt.Ore). Index("attrs", stackencrypt.Json()). Encrypt("notes"). Passthrough("id"). Run(ctx) // Build()'s validation happens here, with the same errors
Field verbs:
Encrypt(name)seals with no index;EncryptIndex(name, kinds...)seals with a non-empty index set;Index(name, kinds...)derives indexes alone, no ciphertext;Passthrough(name)carries the field unsealed and unauthenticated. -
A saved plan is the same chain without the value:
usersPlan, err := stackencrypt.PlanContext("users").Fields(). EncryptIndex("email", stackencrypt.Equality, stackencrypt.Match). Encrypt("notes"). Passthrough("id"). Build() row, err := cipher.Encrypt(user).Using(usersPlan).Run(ctx) rows, err := cipher.Encrypt(users).Using(usersPlan).Run(ctx) err = cipher.Decrypt(row).Using(usersPlan).Into(&user).Run(ctx)
Build()fails closed: names once, labels plain, no two fields sharing one identity, passthrough not indexed, every value field named by the plan and every plan field present, field type resolvable. Indexes are the existingTermKindvalues (data);Matchon an integer is aBuild()error, since Go cannot make it a compile error. -
Struct tags through
PlanOf[T](), the Go spelling of the Rust derive'sEncryptedUser::plan(). It reads and caches the tags. Tags are never applied silently: a chain with noUsingis the tree, as it reads. -
Queries take the plan the data was written with:
emailPlan, err := usersPlan.Field("email") q, err := cipher.Query("bob@example.com").Using(emailPlan).Equality().Run(ctx)
Asking for an index the field never declared is an error.
-
The options interface from PR cipherstash/stack#1019 becomes chain methods (
ExtendContextbecomes.Extend(parts...), keyset selection becomes.Keyset(..)), so lock context and audit context (#1067) attach once, to every call. -
Encrypttakes aContextwhere it tookaad []byte; raw bytes stay possible asNewContext(bytes). -
Mixed batches:
Prepareon each chain and onestackencrypt.Run(ctx, p1, p2), mirroring Rust'sall(..). Sugar, not the entry point. -
Removed (never released, so removed rather than deprecated):
Encrypt(…, aad []byte),EncryptElement,DecryptElement,EncryptRecord(s),DecryptRecord(s),Term,RecordOption,WithPlan,PlanFromTags(replaced byPlanOf[T]), and the "zero Plan means the struct's tags" rule. -
The guest stays thin: it already receives the plan as data and calls
dynamic::record, which becomes a lowering into the builder (#1059). The exports are renamed once (se_encrypt,se_decrypt,se_query, with the plan as an argument) and the element exports fold intose_decrypt. -
Update docs and examples (
doc.go,README.md,example/), and live tests. Theplantest.Goldensnapshots regenerate once.
Relationship to other work
- Blocked by #1059 (
dynamic::recordas a lowering into the builder) and cipherstash/stack#1025 (plantest.Golden, merged first so snapshots regenerate once). - Blocks #1061 (Go
Json()index), #1062 (EQL domains as field targets), #1066 (typedPlan[T]), #1067 (lock and audit context), and the Go README rewrite. - #1045 (the stack-auth per-user token fix) is independent of this, but blocks the identity-claim half of #1067.
- Design:
docs/plans/2026-10-04-plan-builder.md("The Go mirror") and ADR-0007, on PR cipherstash/stack#1052.
Activity
feat(go): a plan context's "table/column" crosses as the pair
coderdan commentedon Oct 4, 2026
Plan document for this issue: #1052 (
docs/plans/2026-10-04-go-encrypt-as.md). Decisions settled 2026-10-04:Encrypttakes aContextand never raw bytes; a target is a value carrying its output type, soEncryptAsinfers its return type and misuse does not compile;Runcarries mixed batches asPending::alldoes; per-target and per-run options are distinct interfaces; the EQL domain types are generated into astackencrypt/eqlv3sub-package holding every domain, with one guest module carrying all ofeql-bindings. Two phases: the reshape (can start now) and EQL v3 targets (after #971).docs(plans): replace option B targets with the plan builder design
docs(plans): the Go binding's Encrypt / EncryptAs reshape and EQL v3 …
docs(plans): replace option B targets with the plan builder design
docs(stack-encrypt): glossary follows the plan builder design
docs(stack-encrypt): glossary places the two directions and defines Q…
docs(stack-encrypt): ADR-0007, bindings enter through a plan, never a…
docs(plans): JSON is an index field; Go names its tag plan
docs(plans): correct the index-key claim; a plan yields a Pending syn…
docs(plans): a ZeroKMS-side term is a Request kind, not an await
docs(plans): other languages: one guest, many hosts
63 remaining items
docs(plans): replace option B targets with the plan builder design
docs(stack-encrypt): glossary follows the plan builder design
docs(stack-encrypt): glossary places the two directions and defines Q…
docs(stack-encrypt): ADR-0007, bindings enter through a plan, never a…
docs(plans): JSON is an index field; Go names its tag plan
docs(plans): correct the index-key claim; a plan yields a Pending syn…
docs(plans): a ZeroKMS-side term is a Request kind, not an await
docs(plans): other languages: one guest, many hosts
docs(plans): where the design strains for other hosts
docs(plans): other languages through a native-first lens
docs(plans): the per-field type serves encrypt, query and decrypt
[-]Go binding: one chained Encrypt with a plan, mirroring stack-encrypt's plan builder[/-][+]Go SDK: stash struct tags and stashgen, in place of a chained plan builder[/+]coderdan commentedon Oct 8, 2026
Related: #1144 changes how the Go SDK maps
int8/int16/unsigned widths, dates, timestamps, decimals and 128-bit integers. It must land before the Go SDK ships, because it changes the bytes stored for those fields (ADR-0002, #1139).