Skip to content

feat(go): generate encrypted JSON document fields - #1374

Draft
coderdan wants to merge 5 commits into
feat/json-tree-queries-readsfrom
feat/go-json-tree-fields
Draft

coderdan wants to merge 5 commits into
feat/json-tree-queries-readsfrom
feat/go-json-tree-fields

Conversation

@coderdan

@coderdan coderdan commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

Summary

This pull request gives Go applications encrypted JSON documents (EncryptedJson, EQL's json_tree type; EQL is the Encrypt Query Language, the SQL that stores and queries encrypted values) through stashgen, the Go SDK's code generator. A json.RawMessage field tagged encrypt_into=EncryptedJson is sealed by Stack Encrypt (the Rust engine, run in Go as a WebAssembly guest) from the field's data plan (the declaration the generator emits and the engine resolves), and gets generated methods for every query and read the previous pull request added. It is slices D and E of the plan in #1353.

type Customer struct {
	_       struct{}         `stash:"context=customers"`
	ID      int64            `stash:"id,passthrough"`
	Profile json.RawMessage  `stash:"profile,encrypt_into=EncryptedJson(ordered=$.age;$.visits[*].at)"`
	Prefs   *json.RawMessage `stash:"prefs,encrypt_into=EncryptedJson,null=sql"`
}

q, _ := CustomerFields.Profile.Contains(ctx, cipher, json.RawMessage(`{"tier":"gold"}`))
older, _ := CustomerFields.Profile.At.Age.Gt(ctx, cipher, json.RawMessage(`40`))
sel, _ := CustomerFields.Profile.Selector(ctx, cipher, "$.name")
// SELECT profile -> $1 FROM customers ... ; then
values, _ := sel.Open(ctx, rows)

Changes

Runtime (languages/golang/encrypt)

  • json.go: JSONSelector, JSONToken, JSONTokens (a text[] value), JSONValue (Found, JSON), JSONReadOptions (WildcardBound), JSONRange, and the two read handles: JSONSelection (a -> read; Selector() to bind, Open to open each row's entry) and JSONRead (a subtree read; Tokens() to bind, Open / OpenOne for each row's entries). A handle holds the request that prepared it; the engine re-derives from that request, never from the rows.
  • gensupport: the json kind, a declaration's TargetOptions, and the helpers generated code calls (JSONQuery, JSONQueryAt, JSONBetweenAt, JSONSelect, JSONSubtree, JSONNodeToken).
  • term.go: JSON() / JSONTerm (the old SteVec placeholder, never producible) marked Deprecated.

Generator (languages/golang/stashgen)

  • Tag options: encrypt_into=EncryptedJson(ordered=$.a;$.b[*].c,sealed_root=false). ; separates ordered paths because , separates tag options. A path in a tag is a plain JSONPath (dotted keys, [*]); a quoted key is refused. The generator hands the options to the engine, which validates them as it resolves the plan, so a declaration the engine refuses (a position in an ordered path, options on a type that takes none) fails at generation.
  • json.RawMessage / *json.RawMessage map to the json kind. A pointer must say null=sql (a nil document is SQL NULL; null=sealed is refused); JSON null is a document. Other Go types for EncryptedJson, and the Go spelling EncryptedJSON in a tag, are refused with a hint.
  • Generated per JSON field: Encrypt, Contains, ContainedBy, EqAt, Selector, Subtree, Children, ChildCount, and, for a field that orders paths, an At struct with one accessor per ordered path (Lt, Lte, Gt, Gte, Between, and the path's own Selector). A json.RawMessage sealed any other way than encrypt_into stays bytes. The accessors are generated from the options the engine accepted, so an unordered path has none.

Tests

  • encrypt/internal/testusers/customers.go (+ generated customer_stash.go): a model with an ordered document and an optional one.
  • encrypt/json_fields_test.go: round trip (including null=sql and a JSON null), and every generated method derives the operand the data plan does.
  • encrypt/json_postgres_test.go (TestJSONDocumentsQueryAndReadInPostgres): rows stored by generated code in PostgreSQL with the EQL v4 bundle from the tree; stored values read back; containment both ways (including an array element), eq_at at a wildcard path, containment on the null=sql column; lt and between at $.age and gt at the wildcard path $.visits[*].at; -> selector reads opened per row (absence included); subtree reads of a wildcard and of an array; and children / child count by node token. Added to mise run go:test:postgres, which runs each test by name.
  • cmd/stashgen/main_test.go (TestRunGeneratesEncryptedJSONFields): the generated surface, and nine refusals (sealed null, pointer without null=sql, a position in an ordered path, a quoted key, an unknown option, the Go spelling of the type, two paths naming one accessor, a string field, options on a scalar type).

Docs: cmd/stashgen/README.md and encrypt/README.md gain an "Encrypted JSON documents" section; Go CHANGELOG.

Assumptions

The five owner decisions in #1347 are open; this stack takes the plan's recommended answer to each:

  1. Go field types: json.RawMessage and *json.RawMessage. A struct-typed document is not supported yet.
  2. JSON crosses the guest as text: yes. Inputs and opened values are JSON text (json.RawMessage).
  3. Containment needle: an explicit JSON value, not the field's struct type.
  4. Ordered-path accessors: generated from the validated options, as a nested At struct named after each path ($.visits[*].at → VisitsAt).
  5. First release scope: the full inventory (containment, eq_at, orderings, selector and subtree reads, children tokens).

Also assumed:

  • The tag syntax above (ordered= with ;), and sealed_root=false as the only other option.
  • Children / ChildCount return the node token to bind; the child rows are read in SQL (the opener for child rows is not in this stack).

Not done here

  • Typed struct needles and struct-typed documents (decision 1 / 3 extensions).
  • A request-bound opener for json_tree_children rows.
  • ORDER BY on an ordered path is not exercised in PostgreSQL here; the orderings are tested as comparisons.

Verification

After the review fixes (3ae7166) and merging the updated base (534d32f), with the guests rebuilt from it: mise run go:test, go:lint (0 issues), go generate ./... (no drift) and go:test:postgres against PostgreSQL 17 with the tree's EQL v4 bundle (all four tests by name, including TestJSONDocumentsQueryAndReadInPostgres), all passed. The original verification, macOS arm64, Go 1.26.10, Rust 1.94.1:

  • mise run go:test (gofmt check, go vet, and the module's tests against the deterministic guests): passed, including TestACustomersDocumentsRoundTrip, TestACustomersFieldsDeriveEachQuery, TestRunGeneratesEncryptedJSONFields, and the stub-contract tests TestStubAgreesWithGensupport / TestStubAgreesWithEncrypt (the generator's test stub gains the new symbols).
  • mise run go:test:postgres against PostgreSQL 17 in Docker, with the EQL v4 bundle built from the tree: passed, all four tests by name, including TestJSONDocumentsQueryAndReadInPostgres.
  • mise run go:lint (golangci-lint): 0 issues. The two internal uses of the now-deprecated encrypt.JSON / encrypt.JSONTerm carry a nolint:staticcheck with the reason.
  • go generate ./... leaves the tree unchanged (the CI drift check).
  • git diff --check: clean.
  • No Rust, SQL or catalog change here, so the Rust suites, guest builds and types:check from the previous pull request stand; the guests used are the ones built from it.
  • Not run: live ZeroKMS tests (no credentials here).

Related

Stack

Merge order is bottom to top; each pull request is based on the one below it.

  1. chore(deps): take vitaminc's Go bindings from cipherstash/vitaminc#526 by git reference #1369: vitaminc's Go bindings from the unreleased feat(vcffi): read and write the storage profile in Go vitaminc#526 by git reference (temporary)
  2. feat(go): seal an opaque struct as a vitaminc storage blob #1370: Go: an opaque struct sealed as a vitaminc storage blob
  3. feat(stack-encrypt,eql): store a JSON document through a data plan as EncryptedJson #1372: EncryptedJson stored through a data plan (engine, EQL catalog, guest, Go runtime)
  4. feat(stack-encrypt): answer a document field's JSON operators and open its reads from a data plan #1373: JSON operators and request-bound reads through a data plan
  5. feat(go): generate encrypted JSON document fields #1374: Go: generated encrypted JSON document fields (this pull request)

https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu

A json.RawMessage field tagged encrypt_into=EncryptedJson, with optional
ordered paths and sealed_root, is sealed by the engine from the field's
data plan and gets generated methods for every json_tree query and read:
Contains, ContainedBy, EqAt, Selector, Subtree, Children, ChildCount,
and an At accessor per ordered path with Lt, Lte, Gt, Gte and Between.

Selector and subtree reads return handles (encrypt.JSONSelection,
encrypt.JSONRead) that keep the request they were prepared from and open
the rows the database returns only as answers to it.

TestJSONDocumentsQueryAndReadInPostgres runs every method against the
EQL v4 bundle from the tree and joins go:test:postgres.

Refs #1347, #1351, #1352

Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
@changeset-bot

changeset-bot Bot commented Oct 25, 2026 •

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 3ae7166

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@coderabbitai

coderabbitai Bot commented Oct 25, 2026

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown
Contributor

Mutation testing (cargo-mutants, --in-diff, stack-auth + stack-encrypt)

No mutants were generated for the changed lines.

@coderdan

Copy link
Copy Markdown
Contributor Author

Automated code review of this pull request at eb47bad06 (the review skill did not post inline comments, so the findings are listed here):

  1. languages/golang/stashgen/read.go:963: json.RawMessage is classified as the json kind for every verb, so a model that seals one as bytes (encrypt, index=equality) stops generating, unlisted under Breaking.
  2. languages/golang/stashgen/emit.go:1145: the ordered-path accessors return only the leaf operand; the selector is derived separately from a retyped path, and EQL's < compares order terms only, so a mismatched path silently compares the wrong values.
  3. languages/golang/encrypt/json.go:287: OpenOne on a wildcard read succeeds or fails depending on the data (0–1 elements vs. 2+).
  4. languages/golang/stashgen/emit.go:1011: planJSON adds the encoding/json import after the import clash check has run.
  5. languages/golang/stashgen/emit.go:1055: isJSON() keys on the Go kind, not the EQL family, and builds the leaf and entry type names by concatenation.
  6. languages/golang/encrypt/gensupport/json.go:88: the subtree request is built twice (prepare and open), with nothing tying the two together.
  7. languages/golang/encrypt/gensupport/json.go:29: jsonTerm compares fidelity to a literal and never checks the term's domain against the Go type returned.
  8. languages/golang/stashgen/emit.go:1031: jsonPathName drops all-digit key steps and names an underscore-only key Root; duplicate paths report an accessor collision.
  9. languages/golang/stashgen/engine.go:268: the index=json refusal still says the index is coming, without pointing at encrypt_into=EncryptedJson.
  10. languages/golang/encrypt/json.go:194: JSONSelection.Open makes a ZeroKMS request when no row has an entry, and reports a group of more than one value as not found.
  11. languages/golang/encrypt/json.go:186: a zero JSONSelection's Selector() binds '' silently.
  12. languages/golang/encrypt/json_postgres_test.go:102: row loops never check rows.Err().
  13. languages/golang/encrypt/json_fields_test.go:84: unchecked v.(string) assertions.
  14. languages/golang/encrypt/gensupport/json.go:59: the term-count check is repeated four times; jsonText is a no-op wrapper.

https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu

- A json.RawMessage is a document only under encrypt_into; sealed or
  indexed any other way it stays bytes, so existing models generate as
  before.
- An ordered path's accessor has its own Selector, so an ordering and the
  value it compares come from one path; the README says why.
- JSONRead keeps the subtree input it was prepared from and sends it back
  when it opens; OpenOne refuses a wildcard path before opening anything.
- JSONSelection.Open makes no request for a page with no entries and
  refuses more than one value for a row; a zero JSONSelector or JSONToken
  is refused rather than bound as ''.
- gensupport's JSON helpers check the term's encoding, fidelity
  (record.Exact) and part count in one place.
- stashgen: imports are re-checked after planning adds encoding/json; a
  document field needs the engine to answer selector on it; an ordered
  path given twice, or with no name, is refused by name; a key of digits
  names its accessor; index=json points at encrypt_into=EncryptedJson.
- Tests check rows.Err() and no longer assert a driver value's type.

Refs #1347

Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
@coderdan

Copy link
Copy Markdown
Contributor Author

Review response to the findings listed above (fixed in 3ae7166 unless noted):

# Finding Outcome
1 read.go:963: json.RawMessage reclassified for every verb Fixed: it is the json kind only under encrypt_into; sealed or indexed any other way it stays bytes, as before. Tested: Data json.RawMessage with encrypt,index=equality generates EncryptIndex("data", gensupport.Bytes, …). Nothing to list under Breaking now.
2 emit.go:1145: ordering operand without its selector Fixed: each ordered-path accessor has its own Selector(ctx, c) from the same path; the PostgreSQL test and README use it, and the README says why (EQL's < compares ordering terms alone).
3 json.go:287: OpenOne depends on the data Fixed: a path with a wildcard is refused before anything is opened. Tested.
4 emit.go:1011: import clash checked too early Fixed: imports are checked again after planning.
5 emit.go:1055: isJSON keyed on the Go kind Fixed in part: a document field now also needs the engine to answer selector on its type, so a json.RawMessage into any other JSON-kind type is refused by name instead of emitting types that do not exist. The leaf and entry names are still built from the type's name: the catalog the generator reads lists stored types only.
6 gensupport/json.go:88: request built twice Fixed: JSONRead keeps the input its tokens were derived from and sends it back unchanged when it opens (NewJSONRead takes it).
7 gensupport/json.go:29: literal fidelity, domain unchecked Fixed in part: record.Exact, and the encoding and part count are checked together. The domain is not compared with the Go type: the Go type is generated from the same catalog the engine's domain comes from, and parseQueryTerm now refuses a term whose encoding and domain disagree (#1373).
8 emit.go:1031: accessor names Fixed: a key of digits names its accessor ($.a.1 is At.A1); a path that gives no name, and one given twice, are refused by name. Tested.
9 engine.go:268: stale index=json message Fixed: it points at encrypt_into=EncryptedJson (the test engine's message too).
10 json.go:194: request with no entries; >1 values Fixed: a page with no entries opens to no values without a request; more than one value for a row is an error. Tested.
11 json.go:186: zero selector binds '' Fixed: a zero JSONSelector or JSONToken is refused by Value(). Tested.
12 json_postgres_test.go:102: rows.Err() Fixed in every loop.
13 json_fields_test.go:84: unchecked assertions Fixed.
14 gensupport/json.go:59: repeated count check Fixed: one check in jsonTerm; jsonText removed.

Verified on this branch: mise run go:test and go:lint (0 issues), go generate ./... (only the new accessor Selector in the test model), and mise run go:test:postgres against PostgreSQL 17 with the tree's EQL v4 bundle (all four tests by name).

https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu

@coderdan
coderdan added this pull request to stack #1377 October 25, 2026 22:47

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant