Skip to content

feat(stack-encrypt, golang): JSON operators and data-plan reads, with generated Go JSON document fields (#1374) - #1373

Merged
coderdan merged 49 commits into
nextfrom
feat/json-tree-queries-reads
Oct 26, 2026
Merged

coderdan merged 49 commits into
nextfrom
feat/json-tree-queries-reads

Conversation

@coderdan

@coderdan coderdan commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

This pull request now also carries #1374. #1374 (Go: generated encrypted JSON document fields) was merged into this branch, feat/json-tree-queries-reads, rather than into next, because it had not been retargeted when #1372 merged. The branch head is that merge (28510260f), whose tree is exactly #1374's final head (01a2fc621), so merging this pull request lands both on next. #1374's description, review threads and checks remain the record for its half.

Summary

This pull request lets a language SDK query and read an encrypted JSON document (JsonSearch, EQL's json_tree type) through a data plan, the declaration a non-Rust SDK sends Stack Encrypt (the Rust engine) as data. It is slice C of the plan in #1353: the JSON operators, selector and token preparation, and reads opened against the request that prepared them, in the engine and in the WebAssembly guest (the engine compiled for Go). The Go runtime learns to carry the new answers; generated Go methods follow in the next pull request.

Every operand is the typed Rust chain's: a containment or equality operand is the same selector set, which each derivation sends in a random order of its own, and every other operand is the same bytes. A read's rows open only against the trusted request (operator, path, bound, field and keyset), which the engine re-derives from, so the database never says which path a row is for.

Changes

Operators (packages/stack-encrypt/src/dynamic)

  • OpSpec gains the document operators. Each takes an object of named inputs, with JSON values as JSON text:

    operator input operand, bound as
    contains, contained_by {"json"} needle, eql_v4.query_json
    eq_at {"path", "json"} needle holding the scalar there
    lt_at … gte_at {"path", "json"} at an ordered path leaf operand, eql_v4.query_json_leaf
    between_at {"path", "low", "high"} two leaf operands
    selector {"path"} text
    subtree {"path", "wildcard_bound"?} text[]
    children, child_count {"path"}, no wildcard text (the node's token)
  • field_operators lists them from the field's resolved options (the orderings only when the field orders a path), and only those whose SQL type the target names, so it lists exactly what query_term derives. between_at's arity is one: its input is one object.

  • The query term's wire form gains encoding (bytes, eql, text, text_array), and Operand gains Text and TextList, so a selector or a token list is a named value, not anonymous bytes. An operator entry gains input (its input's shape), so a generator can tell inputs apart without a copy of the rules.

  • Refusals before anything is derived: a position in a path where the operand is a selector (json_path_position; a positional read is a subtree read), a wildcard where the operator names one node (json_path_wildcard), malformed inputs (json_input), and the engine's existing ones (container at a path, unordered path, non-ordered leaf).

  • json_open(cipher, plan, field, request, rows, resolver) / check_json_open: open the rows of a selector or subtree read, one group per database row, in one key request. The engine derives the selector or the subtree key again from the request and the field's options under the caller's keyset, runs the index's own row checks, and opens through that keyset alone.

EQL (packages/eql)

  • Target::leaf_query_sql_domain, so the engine knows what an ordering at a path binds as.
  • tests/json_tree_data_plan.rs: every operand equals the typed chain's; the operator list; the refusals; reads of -> and of a wildcard subtree across two documents, absence as no value, rows refused against another path's request, and a truncated wildcard refused.

Guest (languages/golang/encrypt/guest)

  • New export se_json_open; JSON read refusals (JsonWrongEntry, JsonWildcardTruncated) are STATUS_ENCODING with their own diagnostic codes, not internal errors.

Go runtime (languages/golang)

  • QueryTerm.Encoding and QueryTerm.Text; record.Operator.Input; record.JSONIndex; Cipher.JSONOpen.
  • json_tree_test.go: every operator through the guest with its encoding and domain, a refused positional selector, and a selector read opened against its request (and refused against another's).

Docs: CHANGELOGs (stack-encrypt, eql-bindings, Go) and a changeset for @cipherstash/eql.

Assumptions

The five owner decisions in #1347 are open; this stack takes the plan's recommended answer to each (the Go-facing ones land in the next pull request):

  1. Go field types: json.RawMessage / *json.RawMessage.
  2. JSON crosses the guest as text: yes; every JSON input here is JSON text.
  3. Containment needle: an explicit JSON value ({"json": …}), not the field's struct type.
  4. Ordered-path accessors: generated from the validated options; the engine lists orderings only for a field that orders a path.
  5. First release scope: the full inventory.

Also assumed:

  • Operator names contains, contained_by, eq_at, lt_at, lte_at, gt_at, gte_at, between_at, selector, subtree, children, child_count; input objects follow the plan's examples ({"json"}, {"path", "json"}, {"path", "wildcard_bound"}).
  • Read descriptor, not a guest handle (plan section 4): the host keeps the request and hands it back with the rows; the guest re-derives. The read is opened through the keyset that prepared it, never the client-wide scope, so a document from another keyset is refused before any key request.
  • Children: the guest prepares the node token for json_tree_children / json_tree_child_count. The plaintext child-opening helper the plan lists (parent-bound child validation) is not in this pull request; children are exposed as encrypted entries for SQL. See "Not done" below.
  • Text-valued operands bind as text / text[], named in the term's domain.

Not done here

  • A request-bound opener for json_tree_children rows (child scalars without a subtree read). A subtree read of the node returns the same values today.
  • A typed struct adapter for a needle (plan section 2): the needle is JSON text.

Verification

After merging #1372 (which carries #1379, the cascade and the random stored order) and the review fixes, at 83bee57, on macOS arm64, Rust 1.94.1 (the root mise.toml pin), Go 1.26.10, PostgreSQL 17.10 (a fresh database per run):

  • cargo fmt --all --check and RUSTFLAGS=-D warnings cargo clippy --workspace --all-features --tests (and without --all-features): clean.
  • mise x --env test -- cargo nextest run --workspace --all-features: 1,534 passed, 1 skipped, at 9b01531's tree; 83bee57 then gated a re-export on the dynamic feature and dropped a needless borrow, after which clippy was clean and json_operators passed again (6 tests). be25106 pins the depth cap's boundary (a path exactly as deep as a document is accepted), which the CI mutants gate found unpinned; the test fails under that mutant (checked). The new heap test (a_document_fields_operators_and_reads_leave_no_plaintext_or_key_on_the_heap) fails when the JSON terms operation drops its owned document unwiped (checked).
  • packages/eql: fmt, clippy and cargo nextest run --workspace --exclude eql_tests (629 passed); types:generate then types:check (no drift); mise run test:encryption (538 passed); mise run test:encryption:postgres: every suite passed, the json_tree oracle 14 of 14.
  • 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).
  • cargo metadata --locked in the root, both guest workspaces and packages/eql: clean.
  • 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 untagged vitaminc 06568ba93 (docs: refresh root README around the three Stack pillars #526 on main) by git reference (temporary) (merged)
  2. feat(go): seal an opaque struct as a vitaminc storage blob #1370: Go: an opaque struct sealed as a vitaminc storage blob (merged)
  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) (merged)
  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 (this pull request)
  5. feat(go): generate encrypted JSON document fields #1374: Go: generated encrypted JSON document fields (merged into this pull request's branch; lands with it)
  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 encrypted JSON document queries, including containment, equality, path-based comparisons, and subtree operations.
    • Added selector and subtree reads that return matching JSON values and validate results against the original request.
    • Expanded Go query terms to support text and JSON operands.
  • Breaking Changes
    • Operator declarations now require an input shape, and query terms include an encoding value. Update integrations that consume these formats.
  • Documentation
    • Updated Go and Rust changelogs with JSON query and read support details.

…n its reads from a data plan

A data plan could store an EncryptedJson document but not query or read
one: the operators were reachable only from typed Rust, and a read's rows
opened only against a JsonSubtreeKey or JsonSelector a binding cannot
hold.

OpSpec gains the document operators (contains, contained_by, eq_at, the
orderings at a path, between_at, selector, subtree, children,
child_count), each taking an object of named inputs with JSON values as
JSON text. query_term derives the typed chain's operands bit for bit and
tags them: the term's wire form gains `encoding`, and Operand gains Text
and TextList, so a selector or a token list is named, not anonymous
bytes; an operator entry gains `input`. A position is refused where the
operand is a selector, which reads it as the wildcard, and a wildcard
where the operator names one node.

json_open opens a selector or subtree read's rows against the request
that prepared it: the engine derives the selector or the key again from
the request, the field's resolved options and the keyset, runs the
index's own row checks on each database row's group, and confines
opening to that keyset. Nothing in the rows says which path they are
for. The guest exports it as se_json_open, and maps a wrong entry and a
truncated wildcard to STATUS_ENCODING. The Go runtime reads the new keys
(QueryTerm.Encoding/Text, record.Operator.Input) and adds
Cipher.JSONOpen.

tests/json_tree_data_plan.rs holds every operand to the typed chain's and
opens reads of `->` and of a wildcard subtree across documents.

Refs #1347, #1350, #1352

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

changeset-bot Bot commented Oct 25, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 2851026

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 12 packages
Name Type
@cipherstash/eql Minor
stash Patch
@cipherstash/stack-prisma Patch
@cipherstash/basic-example Patch
@cipherstash/e2e Patch
@cipherstash/prisma-example Patch
@cipherstash/stack-drizzle Patch
@cipherstash/stack-supabase Patch
@cipherstash/stack Patch
@cipherstash/wizard Patch
@cipherstash/bench Patch
@cipherstash/test-kit Patch

Not sure what this means? Click here to learn what changesets are.

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

@coderabbitai

coderabbitai Bot commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review.

Recent review info
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: b71ad92a-75a0-49c2-a498-8651b153d667
Commits

Reviewing files that changed from the base of the PR and between c22b688 and be25106.

Files selected for processing: 35
  • .cargo/mutants.toml
  • .changeset/eql-encrypted-json-operations.md
  • languages/golang/CHANGELOG.md
  • languages/golang/encrypt/checker.go
  • languages/golang/encrypt/guest.go
  • languages/golang/encrypt/guest/src/abi.rs
  • languages/golang/encrypt/guest/src/ops.rs
  • languages/golang/encrypt/guest/src/status.rs
  • languages/golang/encrypt/guest/src/targets.rs
  • languages/golang/encrypt/guest/tests/native_ops.rs
  • languages/golang/encrypt/json.go
  • languages/golang/encrypt/json_tree_test.go
  • languages/golang/encrypt/query.go
  • languages/golang/encrypt/query_test.go
  • languages/golang/encrypt/targets_test.go
  • languages/golang/internal/record/record.go
  • packages/eql/crates/eql-bindings/CHANGELOG.md
  • packages/eql/crates/eql-bindings/src/encryption/targets.rs
  • packages/eql/tests/encryption/tests/json_tree_data_plan.rs
  • packages/eql/tests/encryption/tests/typed_queries.rs
  • packages/stack-encrypt/CHANGELOG.md
  • packages/stack-encrypt/src/dynamic/json_query.rs
  • packages/stack-encrypt/src/dynamic/mod.rs
  • packages/stack-encrypt/src/dynamic/query.rs
  • packages/stack-encrypt/src/dynamic/target.rs
  • packages/stack-encrypt/src/json/derive.rs
  • packages/stack-encrypt/src/json/mod.rs
  • packages/stack-encrypt/src/json/query.rs
  • packages/stack-encrypt/src/sem/mod.rs
  • packages/stack-encrypt/tests/heap_hygiene.rs
  • packages/stack-encrypt/tests/json_operators.rs
  • packages/stack-encrypt/tests/query_operators.rs
  • packages/stack-encrypt/tests/ui/op_at_path_not_a_leaf.stderr
  • packages/stack-encrypt/tests/ui/op_json_not_answered.stderr
  • packages/stack-encrypt/tests/value_kinds.rs

Included review availability: This review used your included allowance. Your plan provides up to 2 included reviews per hour; 0 remain after this review.

Walkthrough

This change adds JSON document operators and query-term encodings to the dynamic query API. It adds selector and subtree read opening tied to the request that prepared the rows, with Rust engine, EQL, guest ABI, and Go API support.

Changes: Encrypted JSON operations

Layer / File(s) Summary
Operator and query-term contracts
packages/stack-encrypt/src/dynamic/query.rs, packages/stack-encrypt/src/dynamic/target.rs, packages/eql/crates/eql-bindings/src/encryption/targets.rs, languages/golang/internal/record/record.go, languages/golang/encrypt/checker.go, languages/golang/encrypt/query.go, packages/stack-encrypt/tests/*, packages/eql/tests/encryption/tests/json_tree_data_plan.rs, changelogs and changeset
Operator entries now include input shapes. Query terms add encoding and support text and text-array operands. JSON targets and path-ordering operands carry JSON index and SQL-domain metadata.
JSON query validation and derivation
packages/stack-encrypt/src/dynamic/json_query.rs, packages/stack-encrypt/src/dynamic/query.rs, packages/stack-encrypt/src/dynamic/mod.rs, packages/stack-encrypt/src/json/*, packages/stack-encrypt/tests/json_operators.rs, packages/stack-encrypt/tests/heap_hygiene.rs, packages/eql/tests/encryption/tests/json_tree_data_plan.rs
The JSON binder validates operator inputs and derives query, leaf, selector, node, and subtree operands. Operator availability follows JSON-index and ordering options. Tests cover operand forms, refusals, and heap handling.
Request-bound reads and Go integration
packages/stack-encrypt/src/dynamic/json_query.rs, packages/stack-encrypt/src/dynamic/query.rs, languages/golang/encrypt/guest/src/{abi.rs,ops.rs,status.rs}, languages/golang/encrypt/{guest.go,json.go}, languages/golang/encrypt/json_tree_test.go, packages/stack-encrypt/tests/{json_operators.rs,heap_hygiene.rs}, packages/eql/tests/encryption/tests/json_tree_data_plan.rs
The read path validates requests and row groups, derives selector or subtree keys, and opens values. The guest ABI and Cipher.JSONOpen expose the operation. Tests cover matching and absent results, mismatched rows, and read bounds.

Priority: Normal

Estimated code review effort: 4 (Complex) | ~60 minutes

Change: Feature

Sequence Diagram(s)

sequenceDiagram
  participant GoCipher
  participant KMSGuest
  participant GuestABI
  participant GuestOps
  participant DynamicQuery
  GoCipher->>KMSGuest: Send rows, plan, field, request, and options
  KMSGuest->>GuestABI: Call se_json_open
  GuestABI->>GuestOps: Validate and open the JSON read
  GuestOps->>DynamicQuery: Call json_open with plan, request, and rows
  DynamicQuery-->>GuestOps: Return opened JSON groups
  GuestOps-->>GuestABI: Encode the result
  GuestABI-->>KMSGuest: Return encoded plaintext
  KMSGuest-->>GoCipher: Return opened values by row
Loading

Possibly related issues

  • cipherstash/stack#1351: Adds runtime query and read support that generated json_tree Go methods can use.
  • cipherstash/stack#1347: Implements the Go SDK foundation for JSON data-plan encryption, query terms, and request-bound reads.
  • cipherstash/stack#1348: Adds JSON target and data-plan integration for the Go and guest surfaces.
  • cipherstash/stack#1349: Adds JSON data-plan querying, typed operands, query terms, and request-bound reads.
  • cipherstash/stack#1350: Adds JSON containment, path equality, ordering, range, and selector operator handling.
  • cipherstash/stack#1352: Adds request-bound opening for selector and subtree reads; the json_tree_children opener remains outside this change.

Possibly related PRs

Suggested reviewers: freshtonic, auxesis

Merge Risk: Minimal · up to be251

This change adds query and read support for encrypted JSON documents across the Rust engine, the guest, and the Go runtime. The supplied evidence shows no outstanding concrete defect, and the earlier review findings were reported as fixed with tests. No blocking merge risk remains beyond normal checks.

Pre-merge checks: Passed 4 · Failed 1

Failed Failed checks: 1 warning

Check name Status Explanation Resolution
Docstring Coverage Warning Docstring coverage is 71.81% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 149 functions across 28 files. (7 skipped… Write docstrings for the functions missing them to satisfy the coverage threshold.
Passed Passed checks: 4 passed
Check name Status Explanation
Description Check Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Title check Passed The title uses a conventional commit prefix and names the main change: JSON operators and data-plan reads for stack-encrypt and the Go runtime. The changeset adds JSON document operators, request-boun…
Full details: Title check

Explanation

The title uses a conventional commit prefix and names the main change: JSON operators and data-plan reads for stack-encrypt and the Go runtime. The changeset adds JSON document operators, request-bound JSON reads, and Go JSONOpen and query-term support, so the title matches the primary change. The title is long at 111 characters, but it remains specific and accurate.

Full details: Docstring Coverage

Explanation

Docstring coverage is 71.81% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 149 functions across 28 files. (7 skipped: 7 unsupported.)

Resolution

Write docstrings for the functions missing them to satisfy the coverage threshold.

  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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

@blacksmith-sh

This comment has been minimized.

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
@github-actions

github-actions Bot commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

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

caught missed unviable timeout
63 0 32 0

Every mutant in the changed lines was caught by a test.

CI's rustdoc, CRAP and coverage gates failed on this branch: two public
docs linked the private json_query module, and the JSON operators were
exercised only from the EQL workspace, so stack-encrypt's own coverage of
read_input, derive, open and read_open was zero.

- OpSpec's docs link OpSpec::input, query_term and json_open instead.
- tests/json_operators.rs runs every operator, the input refusals, the
  read refusals and reads through a resolver backed by the engine's own
  JSON index, so the crate measures its own code.
- read_input is split per operator; its complexity alone (31) passed the
  CRAP threshold at full coverage.

Refs #1347

Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
…nst a selector

The mutants gate's last survivors in derive: eq_at's needle and an
ordering's leaf operand were only checked to be objects, and children's
token only against child_count's. The test now holds eq_at to
contains' shape and an ordering to another, and a node's token apart
from its selector. Parsed's Drop and the JsonText visitor's expecting
are excluded with their reasons, as for the PR below.

Refs #1347

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

Copy link
Copy Markdown
Contributor Author

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

  1. packages/stack-encrypt/src/dynamic/query.rs:619: answers() lists a document field's operators from its options alone, without checking that the target names the domain each binds as, so field_operators can list an operator query_term then refuses.
  2. packages/stack-encrypt/src/dynamic/json_query.rs:516: json_open's scope has no effect: Pending::all(keyset, …) scopes the batch to the deriving keyset whatever scope is passed.
  3. packages/stack-encrypt/src/dynamic/query.rs:268: takes_pair() is true for between_at, so the catalog says pair while the input is one object {path, low, high}.
  4. packages/stack-encrypt/src/dynamic/json_query.rs:336: derive() clones the parsed plaintext into an owned index operation that never wipes it.
  5. packages/stack-encrypt/src/dynamic/json_query.rs:418: open()'s docs say a non-read operator is operator_not_answered; the code says json_input.
  6. languages/golang/encrypt/query.go:210: parseQueryTerm accepts term shapes the engine never writes (encoding vs. text/list/domain mismatches); its doc lists five keys.
  7. languages/golang/encrypt/guest/src/ops.rs:376: parse_field took parse_query's doc comment, and parse_query repeats parse_field's parsing.
  8. packages/stack-encrypt/src/dynamic/json_query.rs:133: json_reason matches error codes as strings with a catch-all, not the enum.
  9. packages/stack-encrypt/src/dynamic/json_query.rs:85: binds() and answered_by end in catch-all arms, so a new operator is silently mis-bound or unanswered.
  10. languages/golang/encrypt/query.go:114: public docs not updated for the document operators (Cipher.QueryTerm, parseOperatorName, the guest's query_term, Rust query_term, QueryTerm::domain).
  11. .cargo/mutants.toml:134: excluding Parsed's Drop exempts a reachable, behaviour-changing mutant, against AGENTS.md's rule.
  12. packages/stack-encrypt/src/dynamic/query.rs:620: every answer() for a document field clones the options twice to look up one operator.

https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu

- A document field lists an operator only when its target names the SQL
  type the operand binds as, so field_operators lists exactly what
  query_term derives.
- json_open drops its scope parameter, which had no effect: a read opens
  through the keyset that derived it, and that keyset alone.
- between_at's arity is one (its input is one object); takes_pair is
  between alone. Pinned wire bytes re-pinned.
- json_query::binds is exhaustive over OpSpec and defines is_json;
  answered_by lists the document operators in their own arm; json_reason
  matches JsonTextError's variants.
- Go's parseQueryTerm refuses a term whose encoding, shape and domain do
  not agree as the engine writes them.
- Parsed's Drop is no longer excluded from mutation: heap_hygiene's
  a_json_operators_input_leaves_no_plaintext_on_the_heap fails without it.
- Docs: the guest's parse helpers and query_term (with encoding), Rust
  query_term's inputs and refusals, QueryTerm::domain, Cipher.QueryTerm,
  parseOperatorName, open()'s refusal reasons.

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 abdc767 unless noted; merged with the base in accb055 and 978f0cd):

# Finding Outcome
1 query.rs:619: operators listed without their domain Fixed: a document field lists an operator only when the target names the SQL type its operand binds as (json_query::domain, now shared with query_term), so field_operators lists exactly what query_term derives.
2 json_query.rs:516: json_open's scope has no effect Fixed: the parameter is gone (json_open(cipher, plan, field, request, rows, resolver)); a read opens through the keyset that derived it and that keyset alone, as documented. Guest and tests updated.
3 query.rs:268: between_at reported as a pair Fixed: takes_pair is between alone; between_at's arity is one and its input path_pair. Pinned wire bytes re-pinned for the seven between_at entries.
4 json_query.rs:336: cloned plaintext handed to the index Not changed: the JSON index takes its input by value and its derivation does not wipe it yet. That is the same gap reported on #1372 for the seal path (json::derive frees key and leaf plaintext unwiped); moving instead of cloning would not change what the index frees.
5 json_query.rs:418: wrong reason in open()'s docs Fixed: the docs list json_input for every request refusal, as the code does.
6 query.go:210: parseQueryTerm too lenient Fixed: encoding, term shape and domain must agree as the engine writes them (bytes: bytes, no domain; eql: bytes with a domain; text: one string; text_array: a list of strings). New TestParseQueryTermRefusesAMixedShape (seven accepted shapes, seven refused). Doc updated.
7 ops.rs:376: misplaced doc, duplicated parsing Fixed: each function has its own doc, and parse_query calls parse_field.
8 json_query.rs:133: reason by string code Fixed: json_reason matches JsonTextError's variants.
9 json_query.rs:85: catch-all arms Fixed: binds is exhaustive over OpSpec and returns None for a scalar operator (is_json is defined by it); answered_by lists the document operators in its own arm.
10 query.go:114: stale docs Fixed: Cipher.QueryTerm, parseOperatorName, the guest's query_term (with encoding), Rust query_term (inputs and refusals) and QueryTerm::domain.
11 mutants.toml:134: Parsed's Drop excluded Fixed: the exclusion is removed. heap_hygiene's new a_json_operators_input_leaves_no_plaintext_on_the_heap checks a document operator's input and fails when Parsed's Drop is a no-op (checked by making it one). The same was done for JsonPlaintext on #1372.
12 query.rs:620: options cloned per lookup Not changed: field_operators returns an owned IndexSpec per operator, so a copy per listed operator is part of its contract; the cost is a small path set per call.

CI failures on the previous push of this branch (rustdoc's private links, the CRAP gate, which needed in-crate coverage of the JSON operators, and the mutants gate's survivors) were fixed in e1b21ce and b2b5e1d.

Verified on this branch after the fixes: root cargo clippy and cargo nextest run --workspace --all-features (1,493 passed), rustdoc with warnings as errors, packages/eql fmt, clippy and nextest (627 passed), types:check, the four guest builds and wasm:guest:test, go:test, go:lint, go:test:postgres against PostgreSQL 17 with the tree's EQL v4 bundle, and the CRAP gate (crap:stack-encrypt: none over 30).

https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu

…est writer

CI's Linux run flagged a freed hash table in vitaminc's frame writer that
held the marker in bytes left by the test's own input building
(serde_json and a growing format! string), not by the engine. The inputs
are now joined into strings sized before they are written and framed by
the suite's object writer. The test still fails when Parsed's Drop is a
no-op.

Refs #1347

Claude-Session: 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
coderdan added this pull request to stack #1377 October 25, 2026 22:47
Brings in 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`) and the SteVec SQL confined to EQL v3.

Claude-Session: https://claude.ai/code/session_01HLuZxys1NDRmAkv3uhnuoJ
… v3 names

The JSON operators and reads this branch adds named the document target
`EncryptedJson` and bound their operands as `eql_v4.query_json_tree` and
`eql_v4.query_json_tree_leaf`. With the base's rename those are
`JsonSearch`, `eql_v4.query_json` and `eql_v4.query_json_leaf`, and a read
opens `public.eql_v4_json_entry` rows. The resolvers the tests build, the
pinned operand domains, the guest's and Go's operator tests, the
operations changeset and the CHANGELOGs now say so.

Refs #1350, #1352

Claude-Session: https://claude.ai/code/session_01HLuZxys1NDRmAkv3uhnuoJ
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
Brings in the 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
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 guest's status map
keeps both sides' arms: JsonWrongEntry and JsonWildcardTruncated from
this branch, JsonNotALeaf from #1379, all STATUS_ENCODING. The operators'
derivation follows #1379's renames (a path builds the document `holding`
a value; JsonQuery::of_terms and at_path take the terms by value), and
the guest's json_open bounds IndexKeySource, which awaiting a pending
now needs.

Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
…in the JSON operators

#1379 stores a document's entries in a random order, shuffles every
containment query's selector set, and makes an entry's ciphertext commit
to its children's. Three things here assumed otherwise:

- the data plan's operand parity test compared a containment or equality
  operand with the typed chain's byte for byte; it now reads both back as
  JsonQuery, which compares as sets;
- `->` on a container with members now fails as JsonNotALeaf (Go:
  ErrEncoding, code stack_encrypt::json_not_a_leaf), so a selector read of
  `$.visits` is pinned to that refusal in the data plan test and through
  the Go guest, and json_open's and Cipher.JSONOpen's docs say it;
- the docs said an ordering or `->` through a wildcard path compares the
  first element; it sees an unspecified one, and an ordering over every
  element is written over all the matches (json_tree_path_query). The
  same wording is corrected in JsonOptions::ordered, JsonOrderingAtPosition
  and the typed orderings' position check.

The data plan's identifiers follow #1379's naming: a containment operand
is a document's selector set, not a needle (DocumentInput,
Input::Document, Binds::Query).

Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
eq_at and the orderings build the document holding a value at the path
they are given, one level per step, before anything checks its depth,
and JsonPath::parse caps no length. A path of fifty thousand steps from
a binding built a value that deep, whose recursive drop can overflow the
stack and abort the host. A path with more steps than a document has
levels (MAX_DEPTH) names no node, so every operator's path is now
refused as JsonTooDeep when it is read, before anything is built or any
keyset resolved.

The rest of the operator module's docs: check_query and check_json_open
say they refuse what the input alone shows (the limits a read's key hits
are derive's and open's, after the keyset), json_open documents its
query_context_field refusal (also in the changelog), OpSpec::fidelity
says what exact means for the reads and tokens, and comments say why an
ordering's position refusal is the index's own error while eq_at's and
selector's is the data plan's, and what is_positional means at
node_path's call site.

Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
… read's, and their heap

The malformed-input test mapped every refusal without a data plan reason
to "engine", so a refusal of another kind passed; each row now names the
index's term error (JsonOrderingAtPosition, JsonPathNotOrdered, ...), and
gains the over-deep path rows. The orderings test's doc promised a check
it does not make; it now says where options on a target that takes none
are refused.

A new test refuses rows that are not the read's: a document sealed under
another keyset (JsonWrongEntry for a selector, ForeignKeyset for a
subtree, both before any key is retrieved), an entry of another field of
the plan, and a subtree read's rows handed over as a selector read's; a
leaf's selector row read as a subtree of the same path opens to the same
value, which is the node that read names.

heap_hygiene gains a watched run of query_term (Contains, EqAt, GtAt,
BetweenAt) and of json_open (selector and subtree) through the data
plan, with the marker in the operands and the stored values. It fails
when the JSON terms operation drops its owned document unwiped (checked
by making that release a plain drop).

Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
A subtree read's request carries its wildcard bound as Go writes an
integer; the guest test now opens one (a row with no node opens to no
values) and refuses a bound that is not an integer as ErrEncoding.

Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
A merge left the JsonSearch entry's first line twice.

Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
The re-export the operator path cap reads is unused without the dynamic
feature, which warnings-as-errors refuses; it is now gated on it. The
operator test passes its rows by value, as clippy asks.

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 findings, at 83bee57 (every inline thread is answered on its line):

Finding Outcome
cipherstash-agent (major): a long eq_at path builds a value as deep as the path Fixed in 36898b2: every operator's path is refused as JsonTooDeep past a document's depth, before anything is built.
freshtonic: plaintext the query path leaves in freed memory Pinned by the new heap test in 8afa98f; the operation now wipes the document it owns (from #1379, merged here).
freshtonic nit: fidelity() says exact for the reads and tokens; eq_at and orderings at a wildcard compare "any" or "first" OpSpec::fidelity now says what exact means for the reads and tokens, and that an ordering through col -> sel at a wildcard path sees an unspecified element (36898b2); the module docs say the same (0d38691).
freshtonic nit: the PR body says "rename to EQL v3's names" while the code says eql_v4 The body now says the names are the ones EQL v3 gives its searchable JSON document, under the eql_v4 schema.

This branch now carries #1379 (the cascade and the random stored order). 0d38691 follows it in the operators: containment and equality operands are compared with the typed chain's as sets, a selector read of a container is JsonNotALeaf (Go: ErrEncoding), and the docs no longer promise the first element at a wildcard path.

https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu

The mutants gate found the depth cap's comparison unpinned: refusing a
path of MAX_DEPTH steps instead of more than MAX_DEPTH passed every
test. A selector path of 127 steps, the deepest that can name a node,
is now among the inputs the check accepts.

Claude-Session: 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
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
Base automatically changed from feat/json-tree-data-plan to next October 26, 2026 13:19
feat(go): generate encrypted JSON document fields
@coderdan coderdan changed the title feat(stack-encrypt): answer a document field's JSON operators and open its reads from a data plan feat(stack-encrypt, golang): JSON operators and data-plan reads, with generated Go JSON document fields (#1374) Oct 26, 2026
@coderdan
coderdan merged commit f9b93ac into next Oct 26, 2026
55 checks passed
@coderdan
coderdan deleted the feat/json-tree-queries-reads branch October 26, 2026 13:52
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