Skip to content

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

Merged
coderdan merged 24 commits into
feat/json-tree-queries-readsfrom
feat/go-json-tree-fields
Oct 26, 2026
Merged

coderdan merged 24 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 (JsonSearch, 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=JsonSearch 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=JsonSearch(ordered=$.age;$.visits[*].at)"`
	Prefs   *json.RawMessage `stash:"prefs,encrypt_into=JsonSearch,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=JsonSearch(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 JsonSearch, and the Go spelling JSONSearch 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). At a wildcard path the generated comments give the comparison and the ordering over every match, since -> there returns an unspecified element of the row. 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 and lt at the wildcard path $.visits[*].at over every match (EXISTS over eql_v4.json_tree_path_query); -> selector reads opened per row (absence included); subtree reads of a wildcard and of an array, and a wildcard read past its bound refused; 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 (TestRunGeneratesJSONSearchFields): 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.
  • No longer a gap: the PostgreSQL test sorts rows by both ordered paths against the order computed from the plaintexts, by ORDER BY eql_v4.ord_term(profile -> $1::text) at $.age, and at the wildcard path $.visits[*].at by each row's least (ascending) or greatest (descending) match over eql_v4.json_tree_path_query, since -> there returns an unspecified element of the row.

Verification

After merging #1373 (which carries #1379) and the review fixes, at 955b9a1 (e6ae4a8 then merged #1373's one-test follow-up), on macOS arm64, Rust 1.94.1 (the root mise.toml pin), Go 1.26.10, PostgreSQL 17.10 (a fresh database per run). Nothing outside languages/golang differs from #1373's head but mise.toml (this branch's go:test:postgres), so #1373's Rust, EQL and PostgreSQL oracle results stand for it.

  • Go: the five guest builds (wasm:guest:build, :eql, both deterministic builds, wasm:auth-guest:build), mise run wasm:guest:test, mise run go:test (every package ok), mise run go:lint (0 issues), gofmt -l (clean) and go generate ./... (no drift).
  • mise run go:test:postgres with the EQL v4 bundle built from the tree: all four tests passed by name, including TestJSONDocumentsQueryAndReadInPostgres with the wildcard comparisons written over every match (EXISTS over json_tree_path_query), the wildcard ORDER BY by each row's least and greatest match against the plaintexts' order, and a wildcard read past its bound refused as ErrEncoding.
  • 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 vitaminc d7de535c4 (the docs: refresh root README around the three Stack pillars #526 merge) 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 JsonSearch #1372: JsonSearch stored through a data plan (engine, EQL catalog, guest, Go runtime)
  4. feat(stack-encrypt, golang): JSON operators and data-plan reads, with generated Go JSON document fields (#1374) #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)
  6. feat(go): encrypted JSON documents of Go types, with typed needles #1378: Go: encrypted JSON documents of Go types, with typed needles

https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu

Summary by CodeRabbit

  • New Features
    • Added support for encrypting JSON documents while querying their contents, selecting values, and reading subtrees.
    • Added containment and ordered comparisons on configured JSON paths, including wildcard paths.
    • Added support for optional JSON fields stored as SQL NULL.
    • Sensitive query and read details are redacted in logs and formatted output.
  • Documentation
    • Added guidance on JSON document storage, supported field types, query options, and path restrictions.
    • Clarified that JSON indexes require JSON document storage with JsonSearch.

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: 01a2fc6

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

Review in Change Stack →

Note

Currently processing new changes in this PR. This may take a few minutes, please wait...

Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Organization UI; Enforced overrides: Organization
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 057d3f6b-00f6-4fbf-8794-dc3c4ebf9f9d
Commits

Reviewing files that changed from the base of the PR and between 32dc305 and 01a2fc6.

Files selected for processing: 25
  • languages/golang/CHANGELOG.md
  • languages/golang/cmd/stashgen/README.md
  • languages/golang/cmd/stashgen/main_test.go
  • languages/golang/encrypt/README.md
  • languages/golang/encrypt/gensupport/codec.go
  • languages/golang/encrypt/gensupport/declaration.go
  • languages/golang/encrypt/gensupport/gensupport_internal_test.go
  • languages/golang/encrypt/gensupport/json.go
  • languages/golang/encrypt/gensupport/widen.go
  • languages/golang/encrypt/internal/testusers/customer_stash.go
  • languages/golang/encrypt/internal/testusers/customers.go
  • languages/golang/encrypt/json.go
  • languages/golang/encrypt/json_fields_test.go
  • languages/golang/encrypt/json_postgres_test.go
  • languages/golang/encrypt/term.go
  • languages/golang/stashgen/declaration.go
  • languages/golang/stashgen/emit.go
  • languages/golang/stashgen/engine.go
  • languages/golang/stashgen/enginetest/enginetest.go
  • languages/golang/stashgen/read.go
  • languages/golang/stashgen/refusal_test.go
  • languages/golang/stashgen/tag.go
  • languages/golang/stashgen/testdata/stubsdk/encrypt/encrypt.go
  • languages/golang/stashgen/testdata/stubsdk/encrypt/gensupport/gensupport.go
  • mise.toml
+----------------------------------------------+
| Easter bunny by day, code reviewer by night. |
+----------------------------------------------+
  \
   \     /)/)
        ( '')
        /  |
      o(___)
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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

@github-actions

github-actions Bot commented Oct 25, 2026 •

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
Brings in, by way of the base, the v4 JSON document's v3 names
(`JsonSearch` over `public.eql_v4_json_search`, `eql_v4.query_json`,
`eql_v4.query_json_leaf`, `public.eql_v4_json_entry`), the SteVec SQL
confined to EQL v3, and the base's JSON operators renamed to match. The
one conflict, in cmd/stashgen/main_test.go, keeps this branch's refusal
text; the rename of this branch's own code follows in the next commit.

Claude-Session: https://claude.ai/code/session_01HLuZxys1NDRmAkv3uhnuoJ
The PostgreSQL test compared the ordered paths $.age and
$.visits[*].at only through <, <=, >= and >, so nothing showed that
rows sort by them. Ordering by the entry `->` returns sorts by its
ciphertext, which the blockers cannot stop, so the form EQL documents
is ORDER BY eql_v4.ord_term(col -> sel), and that form had no Go test.

The test now inserts two more rows, giving each path a tie broken by
id and a document whose first visit (2) is neither its least nor its
greatest element, then sorts by both paths ascending and descending
and compares the ids with the order computed from the plaintexts. A
row with no value at the path (no age, or no visits) has a NULL term
and sorts last ascending and first descending, as PostgreSQL sorts
NULL by default. At the wildcard path the term is the first element's,
which is what `->` returns. Sorting by the entry itself instead fails
all four orderings.

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

Copy link
Copy Markdown
Contributor Author

c502848 adds the ORDER BY coverage the body listed as missing. TestJSONDocumentsQueryAndReadInPostgres now sorts the stored rows by both ordered paths, $.age and $.visits[*].at, ascending and descending, with the form EQL documents: ORDER BY eql_v4.ord_term(profile -> $1::text), followed by id. It compares the returned ids with the order it computes from the plaintexts it inserted. It adds two rows for this, so that each path has a tie (broken by id) and one document's first visit (2) is neither its smallest nor its largest (9).

What it showed: all four orderings match the plaintext order. A row with no value at the path (Dan has no age; Carol's visits are empty and Dan has none) gets a NULL term, so it sorts last ascending and first descending, which is PostgreSQL's default for NULL. At the wildcard path the sort uses the first element's value, which is the value -> returns. As a check, I swapped in ORDER BY profile -> $1::text, which sorts the entry by its ciphertext, and all four orderings failed.

Run locally: mise run go:test:postgres (all four tests passed by name, against PostgreSQL 17.10 with the tree's EQL v4 bundle), mise run go:lint (0 issues), gofmt -l (clean), and go generate ./... against freshly built guests (no drift).

https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu

With the base's rename the EQL type a document field names is
`JsonSearch` (Go `eql.JSONSearch`, over `public.eql_v4_json_search`).
The tag is now `encrypt_into=JsonSearch(ordered=$.a;$.b[*].c,sealed_root=false)`;
the Go spelling `JSONSearch` in a tag is refused with the same hint as
before, naming the engine's spelling.

The generator named a document's companion types by appending to the
stored type's Go name, `<Stored>LeafQuery` and `<Stored>Entry`. Under the
v3 names the entry is not `JSONSearchEntry` but `JSONEntry`, named for the
plaintext as `public.eql_v4_json_entry` is, so the leaf operand is now
derived from the needle (`JSONSearchQuery` to `JSONSearchLeafQuery`) and
the entry is `eql.JSONEntry`. The test model, its regenerated
customer_stash.go, the READMEs, the CHANGELOG and the PostgreSQL test's
SQL (`eql_v4.query_json`, `eql_v4.query_json_leaf`) follow.

Refs #1351

Claude-Session: https://claude.ai/code/session_01HLuZxys1NDRmAkv3uhnuoJ
Brings in the base's realigned JSON tree domain list and the corrected
json index note in the target descriptor's docs.

Claude-Session: https://claude.ai/code/session_01HLuZxys1NDRmAkv3uhnuoJ
Takes in the ordered-path sort test pushed while the v3-names rename was
being merged here.

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

Copy link
Copy Markdown
Contributor Author

History rewritten on this branch, authorship only. Twelve commits pushed here this morning by a cloud session were unsigned and authored Claude <noreply@anthropic.com> with a Co-Authored-By line, against this repository's rule that commits are signed and human-authored. Each was recreated with the same tree, parents, message and dates, authored and signed by the human owner, with the co-author line removed; the commits above them were re-signed because their parents moved. git diff between the old and new tips is empty on every branch. No code changed.

https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu

@coderdan
coderdan marked this pull request as ready for review October 26, 2026 05:11
@coderdan
coderdan requested a review from a team as a code owner October 26, 2026 05:11
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 26, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-26T05:13:36.623770Z b418feb Draft marked ready
🔒 Security Review ✅ Completed 2026-10-26T05:16:35.168654Z b418feb Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@cipherstash-agent cipherstash-agent left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review attestation — automated code review (/code-review medium) cross-checked against existing PR feedback (cross-check-review-findings), at b418feb.

Critical / blocking: none.

New findings: none. Read encrypt/json.go, encrypt/gensupport/json.go, stashgen/tag.go, stashgen/read.go and the planJSON / jsonPathName / jsonEntry paths in stashgen/emit.go. The defects I found were already raised and fixed in 3ae7166, per the earlier review and its response comment on this PR. Nothing in them is still present at head.

Not covered by this review:

  • The generated-code snapshot (customer_stash.go), the test files, and the READMEs were not read line by line.
  • Several CI jobs were still running at review time, including WASI check, guests and Go (Linux), so the Go test suite and the Postgres test had no result. Go lint and ci-required passed.
  • The Codex code and security reviews were also still running.

Non-blocking: changeset-bot reports no changeset. That looks fine for a Go-module-only change recorded in languages/golang/CHANGELOG.md.


Generated by Claude Code

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b418feb519

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread languages/golang/encrypt/json.go Outdated
Comment thread languages/golang/encrypt/json.go Outdated

@freshtonic freshtonic left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found no blocking problem. One gap goes against fail-closed behaviour: an empty JSONTokens binds as {} instead of being refused (see the comment on json.go:135).

Summary. This PR adds json.RawMessage / *json.RawMessage fields with encrypt_into=JsonSearch(...) to stashgen. For each field, it generates containment, EqAt, the orderings under At, the selector and subtree reads and the node-token methods. The design is good. The tag words (ordered, sealed_root) are the engine's own. The engine checks the options and gives the refusal messages, and At accessors come only from options the engine accepted. null=sealed is refused, and a nil *json.RawMessage is SQL NULL, while JSON null is a document. The generated Encrypted* types redact their sealed fields, and the negative tests show that errors do not contain plaintext. The CHANGELOG and READMEs match the generated surface and the SQL names, except for the sentence in the json.go:135 comment.

What was run:

  • All five guests were built, and mise run go:test passed.
  • The JSON field tests ran without skipping, and all 11 refusal subtests passed.
  • go generate ./... left the tree unchanged, and mise run go:lint reported 0 issues.
  • mise run go:test:postgres passed against Docker postgres:17 with the tree's EQL v4 bundle, including TestJSONDocumentsQueryAndReadInPostgres.
  • A probe test (not committed) against the embedded engine found no problems in opaque structs or passthrough fields, and stashgen refused the wrong declarations it tried.

Nit (not on a single line): the README does not say that a defined type over json.RawMessage is refused, although defined types are accepted for other kinds.

Comment thread languages/golang/encrypt/json.go
Comment thread languages/golang/encrypt/json.go Outdated
Comment thread languages/golang/encrypt/json.go
Comment thread languages/golang/cmd/stashgen/main_test.go Outdated
Comment thread languages/golang/stashgen/emit.go
Comment thread languages/golang/stashgen/emit.go Outdated
Comment thread languages/golang/encrypt/json.go Outdated
Comment thread languages/golang/encrypt/json.go Outdated
Brings in the vitaminc d7de535c4 pin for vcffi/vcvalue and the go-release
guard that refuses a final version while go.mod names a pseudo-version,
with the opaque storage profile's two fixes beneath them.

Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
Brings in the mutants gate fix from #1381, which reads the pull request's real base from the API and checks out the head, so the gate no longer diffs all of next and times out on this stack.

Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
JSONRead.OpenOne refused a wildcard read by looking for "[*]" in the
path text. That copies a rule of the engine's path parser into Go, and
gets it wrong for a quoted key: $["a[*]"] is one member key, and OpenOne
refused it.

The engine already answers the question. It derives exactly one token
for a path that names one node, and for a wildcard one token per element
below the bound plus a probe at the bound, which is two or more at any
bound of 1 or more. Generated code never sends a bound of 0. OpenOne now
refuses a read with more or fewer than one token. A read prepared by hand
with a bound of 0 passes the check, but it reaches no value: a row with an
element there is refused as truncated, and any other opens to none.

The refusal no longer prints the path, which can hold a customer's key.
The parameter is named entries, for what it holds: one database row's
json_tree_subtree entries. JSONRead no longer keeps the path, which
nothing reads now, so NewJSONRead stops taking it.

Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
A zero JSONSelector or JSONToken is refused with ErrEncoding when bound,
because the empty value matches no row without an error. JSONTokens did
not follow the rule: a nil or empty list bound as the text[] literal {},
so a zero JSONRead's tokens read nothing from every row and said nothing.
Every read the engine prepares has at least one token, so an empty list
is always a read that was never prepared. It is now refused the same way,
and the changelog's sentence covers the token list too.

Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
JSONToken and JSONTokens are documented as capabilities: a token opens
the structure of its node's subtree in every row of the column. They
still printed in the clear with %v and through slog. JSONSelection and
JSONRead printed their selector, tokens and path with %+v, and a path's
keys can be a customer's data (Go principle 12).

Each now has String, GoString, Format and LogValue, as the query types
in query.go do, so every fmt verb and slog record shows the type, the
field and a token count, with [redacted] for the tokens, the selector
and the path. The values still bind through driver.Valuer, which pgx
and database/sql consult before a Stringer. JSONValue's doc now says it
holds plaintext JSON that prints in the clear, as the generated plaintext
types' docs do.

Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
NewJSONSelection and NewJSONRead are exported, so their parameter names
are part of what godoc shows. The parameter was named plan, a word for
the engine's internal form that a user never sees (Go principle 7). It
is now declaration, for what it is: the declaration of the record type
the field belongs to, and the docs say so.

Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
stashgen wrote a document field's At accessors only when the engine
answered lt_at on it. A tag that names ordered paths on a field the engine
answers no ordering on means the generator and the engine it embeds
disagree, and the generator then wrote the field with no At and said
nothing: the paths the user asked to order had no methods, and the build
still passed.

planJSON now refuses that field by name, so the drift fails generation
instead of producing a quieter API. jsonEntry writes At whenever the
field has ordered paths, since planJSON has already checked the engine
answers on them.

The test runs the embedded engine with lt_at removed from its answer,
which is the one way to reach the refusal: the real engine always answers
the orderings on an ordered field.

Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
The "a sealed null" case asserted only that stderr named Customer.Prefs,
so any refusal of that field passed it, including one for the wrong
reason. It now asserts the field and the reason together: a document
field carries an index other than equality, a nil has only an equality
term, so a sealed nil would show which documents are absent, and the
field must say null=sql.

Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
stashgen accepts a scalar field declared with any type defined over the
scalar (type Email string), so a reader would expect type Doc
json.RawMessage to work for JsonSearch too. It does not: stashgen knows a
document by the json.RawMessage type itself, so a defined type is bytes,
and JsonSearch refuses it with "declare the field json.RawMessage". An
alias (type Doc = json.RawMessage) is the type itself and is accepted.

Both READMEs now say so, and the stashgen test pins both: the alias
generates an eql.JSONSearch field, and the defined type is refused by
name with that reason.

Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
The encrypt README's JSON section said how the selector, token and token
list bind, but not that a zero one is refused, nor that the read types
print and log redacted while JSONValue prints the plaintext it holds. A
reader deciding what is safe to log needs both, and the README is where
they look before the godoc.

Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
Brings in next's JSON tree cascade and random storage order (#1379)
through the base, with the base's review fixes. The per-type PostgreSQL
test's comment takes the base's wording, which names this branch's
TestJSONDocumentsQueryAndReadInPostgres.

Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
A JSON document's entries are now stored in a random order (#1379), so
`col -> sel` at a wildcard path, and an ordering through it, sees an
unspecified element of each row rather than the first. The At accessors
stay: their operands are what a comparison over every match takes, and
the engine stores ordering terms at a wildcard path when asked to, so
refusing ordered= there would leave those terms unreachable from Go. What
changes is the SQL they are documented and tested with.

- The generated XPath comment gives both forms: col -> Selector() at a
  path with no array step, and at a wildcard path EXISTS over
  eql_v4.json_tree_path_query for a comparison and the least or greatest
  match's ordering term for ORDER BY. Lt, Lte, Gt, Gte and Between get
  doc comments of their own, which they lacked, and Selector says which
  form it binds into. The READMEs and the CHANGELOG say the same.
- TestJSONDocumentsQueryAndReadInPostgres compares at $.visits[*].at with
  EXISTS (some visit > 2, and some visit < 2, which only Alice's second
  visit meets), orders rows by their least visit ascending and greatest
  descending against the plaintexts' order, and reads a wildcard past
  its bound from PostgreSQL's own rows, which Open refuses as
  ErrEncoding.

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

Copy link
Copy Markdown
Contributor Author

Review-body finding, at 955b9a1: freshtonic's nit that the README did not say a type defined over json.RawMessage is refused is fixed in 66a81a1 (both READMEs; cmd/stashgen/main_test.go pins the refusal and that an alias is accepted). Every inline thread is answered on its line.

This branch now carries #1379, under which -> at a wildcard path returns an unspecified element. 955b9a1 keeps the At accessors for wildcard paths and documents and tests the SQL they serve there: a comparison is EXISTS over eql_v4.json_tree_path_query(col, $sel), and an ordering is by the least or greatest match's ordering term. TestJSONDocumentsQueryAndReadInPostgres checks both against the plaintexts, and reads a wildcard past its bound, which Open refuses as ErrEncoding.

https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu

Brings in the base's test that pins the JSON path depth cap at a
document's depth. No conflicts.

Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
Carries the vitaminc Go bindings pin to 06568ba93 on vitaminc main.

Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
@coderdan
coderdan removed this pull request from stack #1377 October 26, 2026 12:41
@coderdan
coderdan merged commit 2851026 into feat/json-tree-queries-reads Oct 26, 2026
57 of 60 checks passed
@coderdan
coderdan deleted the feat/go-json-tree-fields branch October 26, 2026 13:20
@coderdan

Copy link
Copy Markdown
Contributor Author

This was merged into feat/json-tree-queries-reads (#1373's branch) rather than next, because the base had not been retargeted when #1372 merged. Nothing is lost: #1373's head is now that merge commit, 28510260f, whose tree is exactly this pull request's final head 01a2fc621. #1374 lands on next when #1373 merges, and #1373's description says so.

https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu

coderdan added a commit that referenced this pull request Oct 26, 2026
feat(stack-encrypt, golang): JSON operators and data-plan reads, with generated Go JSON document fields (#1374)
coderdan added a commit that referenced this pull request Oct 26, 2026
Brings in the rest of the Go json_tree stack (#1373, #1374, #1378). The
only conflicts were adjacent CHANGELOG entries in the Go module and
stack-encrypt, resolved by keeping both.

Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
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.

3 participants