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 generator fills "type" from the field's Go type, so every value a Go program sends is tagged by the generated declaration. The blocker is the generator, CIP-4163, not the chain.
Background
stack-encrypt is our Rust library that encrypts each value under its own data key from ZeroKMS (our key service), and can also derive search terms beside the ciphertext: an equality term, a full-text match term, and order terms (ORE, order-revealing encryption, and OPE, order-preserving encryption). Bindings for other languages (the Go binding today, through a WebAssembly guest) describe what to do with each field of a record in a plan: a small JSON-like object that says, per field, which encryption context to use and which outputs to produce ("c" for the ciphertext, "eq", "match", "ore", "ope" for terms).
cipherstash/stack#1069 adds an optional "type" key to each field of a plan, for example "type": "uint64". The names are vitaminc's ValueKind names (vitaminc is the cryptography library stack-encrypt is built on). When a field declares a type, the engine refuses an index that the type is not defined for (match on an integer, equality on a float), refuses to seal a value of any other type, and refuses a stored value that opens as another type.
Problem
"type" is optional, so that the plans the Go binding sends today, which carry no "type", stay valid. A field with an index output and no "type" is still handled the old way: the engine looks at each value's own type tag, as the binding set it, and derives the term for that type.
Search terms depend on the type. The ORE term of the integer 34 and the ORE term of the float 34.0 are different bytes. So for an untyped indexed field the engine trusts that the binding tags every value the same way, every time.
A concrete failure: a JavaScript host sends 34 as a Float64 on one write. Later, after a BigInt or an integer-typed ORM column arrives, it sends 34 as an Int64 under the same "ore" field. Both writes are accepted. They store different ORE terms. A range query then finds only the rows written one way, and no error says why. Rows silently go missing from query results.
Nothing catches this today. The plan parser accepts an indexed field without "type", and each value is internally consistent, so every check passes. The record::plan rustdoc says this is transitional, but nothing enforces the end of it.
Proposal
- Once the Go binding fills
"type" on every field from the Go struct's field types (#1046), make "type" required on every field that has a term output ("eq", "match", "ore" or "ope"). Building a plan with an indexed field that has no "type" fails with Error::Plan, so the mistake shows up when the plan is built, not as missing rows later.
- Keep
"type" optional on a field that has only a ciphertext ("c"). Sealing and opening do not depend on the type, so there is nothing to get wrong there.
- Update the plan grammar docs in
packages/stack-encrypt/src/dynamic/record.rs (plan) and src/dynamic/mod.rs, and remove the "transitional" note they carry today.
- Interim step, if useful before step 1 lands: a way for a binding author to see the gap, for example a method on
Plan that lists indexed fields with no declared type.
Relationship to other work
- cipherstash/stack#1069 adds the optional
"type" key and the transitional note.
- #1046 (Go plan builder) is what fills
"type" from Go struct types. Step 1 waits on it.
- #1057 tracks the query side: reading a query value as its field's type before deriving a term.
Background
stack-encrypt is our Rust library that encrypts each value under its own data key from ZeroKMS (our key service), and can also derive search terms beside the ciphertext: an equality term, a full-text match term, and order terms (ORE, order-revealing encryption, and OPE, order-preserving encryption). Bindings for other languages (the Go binding today, through a WebAssembly guest) describe what to do with each field of a record in a plan: a small JSON-like object that says, per field, which encryption context to use and which outputs to produce (
"c"for the ciphertext,"eq","match","ore","ope"for terms).cipherstash/stack#1069 adds an optional
"type"key to each field of a plan, for example"type": "uint64". The names are vitaminc'sValueKindnames (vitaminc is the cryptography library stack-encrypt is built on). When a field declares a type, the engine refuses an index that the type is not defined for (match on an integer, equality on a float), refuses to seal a value of any other type, and refuses a stored value that opens as another type.Problem
"type"is optional, so that the plans the Go binding sends today, which carry no"type", stay valid. A field with an index output and no"type"is still handled the old way: the engine looks at each value's own type tag, as the binding set it, and derives the term for that type.Search terms depend on the type. The ORE term of the integer
34and the ORE term of the float34.0are different bytes. So for an untyped indexed field the engine trusts that the binding tags every value the same way, every time.A concrete failure: a JavaScript host sends
34as aFloat64on one write. Later, after aBigIntor an integer-typed ORM column arrives, it sends34as anInt64under the same"ore"field. Both writes are accepted. They store different ORE terms. A range query then finds only the rows written one way, and no error says why. Rows silently go missing from query results.Nothing catches this today. The plan parser accepts an indexed field without
"type", and each value is internally consistent, so every check passes. Therecord::planrustdoc says this is transitional, but nothing enforces the end of it.Proposal
"type"on every field from the Go struct's field types (#1046), make"type"required on every field that has a term output ("eq","match","ore"or"ope"). Building a plan with an indexed field that has no"type"fails withError::Plan, so the mistake shows up when the plan is built, not as missing rows later."type"optional on a field that has only a ciphertext ("c"). Sealing and opening do not depend on the type, so there is nothing to get wrong there.packages/stack-encrypt/src/dynamic/record.rs(plan) andsrc/dynamic/mod.rs, and remove the "transitional" note they carry today.Planthat lists indexed fields with no declared type.Relationship to other work
"type"key and the transitional note."type"from Go struct types. Step 1 waits on it.