Skip to content

Go SDK: stash struct tags and stashgen, in place of a chained plan builder #1046

Description

@coderdan

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). The chain in this issue is superseded. A user puts stash tags on a struct and runs stashgen, which writes the encrypted type and Encrypt, Decrypt and Fields into the user's package. There is no Run, no Using, no PlanOf[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 / DecryptAs with a generic Target[O], plus a section "EQL v3 outputs as EncryptAs targets". Both are superseded by the plan-builder design in docs/plans/2026-10-04-plan-builder.md and 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 Plan currently 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.

  1. 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.

  2. 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 existing TermKind values (data); Match on an integer is a Build() error, since Go cannot make it a compile error.

  3. Struct tags through PlanOf[T](), the Go spelling of the Rust derive's EncryptedUser::plan(). It reads and caches the tags. Tags are never applied silently: a chain with no Using is the tree, as it reads.

  4. 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.

  5. The options interface from PR cipherstash/stack#1019 becomes chain methods (ExtendContext becomes .Extend(parts...), keyset selection becomes .Keyset(..)), so lock context and audit context (#1067) attach once, to every call.

  6. Encrypt takes a Context where it took aad []byte; raw bytes stay possible as NewContext(bytes).

  7. Mixed batches: Prepare on each chain and one stackencrypt.Run(ctx, p1, p2), mirroring Rust's all(..). Sugar, not the entry point.

  8. Removed (never released, so removed rather than deprecated): Encrypt(…, aad []byte), EncryptElement, DecryptElement, EncryptRecord(s), DecryptRecord(s), Term, RecordOption, WithPlan, PlanFromTags (replaced by PlanOf[T]), and the "zero Plan means the struct's tags" rule.

  9. 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 into se_decrypt.

  10. Update docs and examples (doc.go, README.md, example/), and live tests. The plantest.Golden snapshots regenerate once.

Relationship to other work

  • Blocked by #1059 (dynamic::record as 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 (typed Plan[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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions