Skip to content

feat(stack-encrypt,eql): store a JSON document through a data plan as EncryptedJson - #1372

Draft
coderdan wants to merge 9 commits into
feat/go-opaque-storage-profilefrom
feat/json-tree-data-plan
Draft

coderdan wants to merge 9 commits into
feat/go-opaque-storage-profilefrom
feat/json-tree-data-plan

Conversation

@coderdan

@coderdan coderdan commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

Summary

This lets a language SDK store an encrypted JSON document (EQL's json_tree type) through a data plan. A data plan is the declaration a non-Rust SDK sends the Rust engine (Stack Encrypt) as data, field by field; until now the engine refused the JSON index there, and the json_tree family was missing from the EQL catalog, the one list every generated type comes from. EQL is the SQL library installed in the customer's PostgreSQL.

A field now names the target EncryptedJson (stored in public.eql_v4_json_tree), declares the new json type, gives the JSON index's options, and hands each document over as UTF-8 JSON text. The engine parses the text by one set of rules and seals it through exactly the layout typed Rust uses. This is slice A plus the full-document half of slice B of the plan in #1353; queries and reads follow in the next pull request.

Changes

Catalog and generated code (packages/eql)

  • eql-domains: a DocumentFamily list, DOCUMENT_FAMILIES = [JSON_TREE], naming the four domains and their roles (stored, query, leaf query, entry). Kept apart from CATALOG so the scalar SQL and struct generators never see it: this family's SQL and Rust bodies are hand-written and EQL v4 only.
  • eql-codegen: a target-table row for EncryptedJson (plaintext json, index json, needle EncryptedJsonQuery); v4::all() / v4::all_query() list the document and its two query operands; the Go types EncryptedJSON, EncryptedJSONQuery, EncryptedJSONLeafQuery, EncryptedJSONEntry. The Go-name rule spells the word Json as JSON wherever it is a word.
  • eql-bindings: encryption::targets::encrypt_with(name, options, …) seals EncryptedJson under the field's options; decrypt opens it to its text. New errors TargetError::Json, TargetError::Options and TargetError::DocumentQuery (a one-plaintext query of a document).

Engine (packages/stack-encrypt)

  • A json value kind (ValueKind::Json): a document crosses as text or bytes and opens back to its text as bytes (dynamic::read_json, dynamic::json_frame).
  • json::parse_text, the one JSON parser at the boundary, and the engine's own (not serde_json's deserializer), so no heap block it frees holds the document: refuses an object that names a member twice, an integer literal outside i64::MIN ..= u64::MAX, and text that is not JSON, each with a stable code and a position, never a byte of the text. Held to serde_json by a differential property test and a new fuzz target, fuzz:json-text. json::to_text sizes its buffer before it writes. check_source reads a document by these rules too, so a malformed one is refused before any key.
  • Plan grammar: "target": {"name": "EncryptedJson", "options": {"sealed_root": …, "ordered": […]}} (or the bare name). Options are resolved once when the plan is built (TargetOptions::Json), so one definition serves writes, queries and reads. Refused: options on a target that takes none, a json field with no target, a sealed null on a document.
  • TargetResolver::encrypt takes the field's options (breaking for an outside resolver; CHANGELOG).

Go (languages/golang)

  • internal/record: the json kind and Field.TargetOptions.
  • encrypt/json_tree_test.go: a document round trip (numbers compared as text) and refusals through the real WebAssembly guest (the Rust engine compiled for Go) with the EQL types.
  • stashgen names the missing support for a field of EncryptedJson (the next pull requests add it) instead of a kind mismatch; the per-type PostgreSQL test skips the document type rather than counting it.

Docs: changeset for @cipherstash/eql, CHANGELOG entries for stack-encrypt, eql-bindings and the Go module, and a line in docs/plans/2026-10-17-json-tree-index.md.

Assumptions

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

  1. Go types a field may have: json.RawMessage (required) and *json.RawMessage (optional, null=sql). Here only the wire is fixed: a document crosses as text or bytes.
  2. Crossing the guest boundary as JSON text: yes. The engine parses it (json::parse_text); Go never builds a tree.
  3. Containment needle type: an explicit JSON value, not the field's struct type (next pull requests).
  4. Ordered-path accessors: generated from the validated options (next pull requests).
  5. First release scope: the full operation inventory; this pull request is the first slice of it.

Also assumed:

  • The target is named EncryptedJson (Go eql.EncryptedJSON), the plan's role-based name, over the existing SQL domain public.eql_v4_json_tree; issue json_tree is missing from the EQL catalog, so no Go types are generated for it #1348's JSONTree… names were not used. The name lives in one catalog constant, so a rename is one line plus regeneration.
  • The JSON index stays refused as an output ({"json": …} beside "c"); a document is reached only through the target, as the plan says.
  • Duplicate keys and out-of-range integers are refused, not resolved (plan section 2's recommendation). A float literal follows binary64, and key order and number spelling are not promised to round-trip byte for byte.
  • A document's text opens back in a bytes leaf (so Go receives []byte it can wipe), and is accepted in a text or bytes leaf.
  • Context is the existing column-only profile: the target's label must be table/column, and extended or row-bound contexts stay refused (plan section 5).

Verification

macOS arm64, Rust 1.94.1, Go 1.26.10, on this branch. After the review fixes (ffd4212, d36fb16): the root cargo clippy and cargo nextest run --workspace --all-features (1,474 passed before the last parser change; -p stack-encrypt 922 passed after it), packages/eql clippy and nextest, types:check, the four guest builds, wasm:guest:test, go:test, go:lint, go:test:postgres against PostgreSQL 17 in Docker with the EQL v4 bundle from the tree (passed), pnpm run test:scripts (81 files passed), fuzz:json-text for 2 minutes (7.9M inputs, no failure), the new heap-spy tests (heap_hygiene in stack-encrypt and EQL; each fails when its wipe is removed, checked), and a local cargo mutants --in-diff pre-screen of the parser. The original verification:

  • cargo fmt --all --check (root, packages/eql, guest): clean.
  • cargo clippy --locked --no-deps --workspace --all-targets --all-features -- -D warnings (root) and cargo clippy --workspace --all-targets --all-features --exclude eql_tests -- -D warnings (packages/eql): clean.
  • mise x --env test -- cargo nextest run -p stack-encrypt --all-features: 905 passed (new: json::text, dynamic::json, the json kind, the target-options plan tests).
  • packages/eql: cargo nextest run --workspace --exclude eql_tests: 620 passed, including the new tests/json_tree_data_plan.rs (batch round trip in one key request; data plan and typed layout write the same selectors, ordering terms and projection, and each opens the other's document; refusals before any key) and cargo test -p eql-codegen. The SQLx crate (eql_tests) was not built: it needs generated fixtures and a database.
  • mise run types:generate, then mise run types:check: clean after commit.
  • Guests: wasm:guest:build, :deterministic, :eql, :eql:deterministic built; mise run wasm:guest:test passed (75 tests with eql).
  • mise run go:test: passed, including TestJSONDocumentsRoundTripThroughTheGuest and TestJSONTextThatIsNotADocumentIsErrEncoding. go generate ./...: no diff.
  • Not run: live ZeroKMS tests (no credentials on this machine). PostgreSQL suites: nothing here changes SQL; the next pull requests run them.

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) (this pull request)
  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

https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu

… EncryptedJson

The JSON index could only be reached from typed Rust: a data plan parsed
`{"json": ...}` and refused it (json_index_pending), and the json_tree
family was missing from the EQL catalog, so no binding could name it.

The catalog now lists the family once, as a document family
(eql_domains::DOCUMENT_FAMILIES): its stored type is the target
`EncryptedJson` (public.eql_v4_json_tree), produced from the new `json`
value kind and carrying the `json` index, with EncryptedJsonQuery,
EncryptedJsonLeafQuery and EncryptedJsonEntry beside it. eql-codegen
renders the target table row, v4::all()/all_query() and the Go types from
it; the scalar SQL and struct materializers never see it, since its SQL
and Rust bodies are hand-written and v4 only.

A plan field names the target with the JSON index's options,
`"target": {"name": "EncryptedJson", "options": {...}}` (the bare name
for the defaults). The plan resolves the options once, when it is built,
so the field's writes, queries and reads run under one definition, and
refuses options on a target that takes none, a json field with no target,
and a sealed null on a document. TargetResolver::encrypt takes the
options; eql-bindings' encrypt_with seals the document through the same
JsonTree layout typed Rust uses.

The document crosses the binding as UTF-8 JSON text and is parsed once,
by json::parse_text: an object that names a member twice and an integer
literal outside the 64-bit range are refused rather than resolved or
rounded, and every refusal carries a code and a position, never the text
(TargetError::Json). It opens back to its text in a bytes leaf.

tests/json_tree_data_plan.rs holds the data plan to the typed layout: the
same selectors, ordering terms and containment projection for the same
document under the same column, each path opening what the other sealed,
and a batch of documents in one key request.

Refs #1347, #1348, #1349

Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
encrypt/eql gains the generated EncryptedJSON, EncryptedJSONQuery,
EncryptedJSONLeafQuery and EncryptedJSONEntry. The Go-name rule, here and
in eql-codegen, spells the word Json as JSON wherever it is a word, not
only as a prefix (EncryptedJson is EncryptedJSON).

internal/record carries the `json` kind and a target's options
(Field.TargetOptions), written as the `{"name", "options"}` target the
engine reads. A hand-built plan can store and open a document through
the guest with the EQL types: json_tree_test.go round-trips scalar roots,
arrays, null and a u64, and shows a refused document is ErrEncoding with
the engine's code and none of the text. stashgen support follows.

Refs #1347, #1348, #1349

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: ba65680

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

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.

@blacksmith-sh

This comment has been minimized.

@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
152 0 20 0

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

…son in the per-type PostgreSQL test

The fuzz harness's TargetResolver still had encrypt's old signature, so
fuzz regression (check-record) did not compile. The per-type PostgreSQL
test failed on EncryptedJson, the first producible type with no
plaintexts of its own: a document has operators of its own and is run in
PostgreSQL with the generated fields.

Refs #1347

Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
…ions::json

The mutants gate found the integer pre-scan's index arithmetic unpinned
(a scan that left a string early passed while the number it reached
fitted, and several replacements hung). The scan now walks slices with
no arithmetic to mutate, and tests pin a long number inside a string, a
number after an escaped quote, an unterminated string, a lone sign and
a malformed token. The visitor drops visit_string, visit_none and
visit_some, which serde_json never calls.

wipe_value and TargetOptions::json get unit tests. Two survivors are
excluded with their reason: the strict visitor's expecting and
visit_unit (equivalent), and JsonPlaintext's Drop (no safe code can read
a value after it is dropped; what it runs is wipe_value, now pinned).

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 8d608bd9f (the review skill did not post inline comments, so the findings are listed here):

  1. packages/stack-encrypt/src/dynamic/record.rs:2391: check_field only checks that a json field's value is text or bytes; the JSON rules run inside the resolver at encrypt time, so check_source (and the guest's validate::record) no longer agree with encrypt, and a malformed document can cost a keyset load before it is refused.
  2. packages/stack-encrypt/src/json/text.rs:101: parse_text frees plaintext without wiping it: serde_json's scratch buffer (strings with escapes, long numbers), and the partly built document on every refusal.
  3. packages/stack-encrypt/src/dynamic/json.rs:98: json_frame serializes with serde_json::to_vec, whose growth frees unwiped prefixes of the opened document's text.
  4. packages/eql/crates/eql-bindings/src/encryption/targets.rs:494: the public query() has no document branch, so EncryptedJson falls through to an "unproducible: no arm" error.
  5. packages/stack-encrypt/src/dynamic/record.rs:774: three predicates decide "takes JSON options" (the engine by the json index, eql-bindings by DOCUMENT_FAMILIES, tests by plaintext kind); they disagree on SteVecDocument.
  6. packages/stack-encrypt/src/dynamic/record.rs:1425: "options": null is read as no options, against the plan grammar's rule that a present key must hold its kind of value.
  7. packages/stack-encrypt/src/dynamic/kind.rs:351: ValueKind is exhaustive and ALL changes length; neither is under Breaking.
  8. languages/golang/encrypt/eql_postgres_test.go:151: case "json": return counts EncryptedJson as covered, citing a test that only lands in feat(go): generate encrypted JSON document fields #1374.
  9. languages/golang/encrypt/json_tree_test.go:79: comparing through any turns numbers into float64, so the u64::MAX document cannot show precision loss.
  10. languages/golang/stashgen/read.go:170: EQLGoName reads one byte as a rune; eql-codegen decodes a char. They disagree for a non-ASCII letter after "Json".
  11. packages/stack-encrypt/src/json/text.rs:129: the new boundary parser has no fuzz target (AGENTS.md: untrusted-input parsers are fuzzed).
  12. languages/golang/CHANGELOG.md:19: points users at internal/record, which they cannot import.
  13. packages/eql/crates/eql-codegen/src/go_eql.rs:194: the doc template prints "the json_tree family's the ordering operand…".
  14. packages/eql/crates/eql-bindings/src/encryption/targets.rs:425: encrypt_document clones the options and builds a JsonTree per value.
  15. languages/golang/stashgen/engine.go:236: EncryptedJson is producible but no Go type seals as json yet, so tagging a field fails with a misleading kind mismatch (until feat(go): generate encrypted JSON document fields #1374).

https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu

- json::parse_text is the engine's own strict parser, held to serde_json
  by a differential property test and a new fuzz:json-text target, so no
  heap block it frees holds the document (serde_json's scratch buffer and
  partial documents on refusal did); to_text sizes its buffer first. A
  heap-spy test watches the boundary.
- check_source reads a json field's document by the engine's rules, so a
  malformed one is refused before any key, as encrypt refuses it.
- A present "options": null is refused rather than read as defaults.
- eql-bindings' query() refuses a document by name (DocumentQuery); a
  test pins the engine's and eql-bindings' rules for which targets take
  JSON options to agree.
- Go: the per-type PostgreSQL test no longer counts EncryptedJson;
  opened JSON numbers compare as text; EQLGoName decodes a rune; stashgen
  names the missing document support instead of a kind mismatch.
- Docs: ValueKind's break under Breaking, the Go CHANGELOG no longer
  points at internal/record, the generated eql doc sentence reads.

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 ffd4212 unless noted):

# Finding Outcome
1 record.rs:2391: the check does not read the document Fixed: check_field runs read_json for a json field, so check_source and the guest's validation refuse a malformed document before any key or keyset load, with the same target_json error and code encrypt gives. Test: the_check_reads_a_document_by_the_engines_rules.
2 text.rs:101: parse_text frees plaintext Fixed: parse_text is now the engine's own strict parser (no serde_json deserializer, so no scratch buffer): a string is decoded into a buffer sized before it is written, a container's buffer is wiped before it is replaced, numbers are read in place, and a refusal wipes what was built. A differential property test holds it to serde_json (same values, same number bits; it refuses beyond serde_json only repeated members and out-of-range integers). New heap_hygiene test a_document_leaves_no_plaintext_on_the_heap_at_the_boundary (fails on the old parser, checked).
3 json.rs:98: json_frame growth Fixed: to_text counts the text first and writes into a buffer of exactly that size (tested: length equals capacity). Covered by the same heap test.
4 targets.rs:494: query() falls through for a document Fixed: TargetError::DocumentQuery (the operands are the engine's, by JSON operator). Test: a_document_has_no_one_plaintext_query.
5 record.rs:774: three predicates for "takes JSON options" Pinned, not unified: the_engine_and_the_table_agree_on_which_targets_take_json_options fails if the engine's rule (carries the json index) and eql-bindings' (a document family) ever name different producible targets, e.g. the day SteVecDocument becomes producible.
6 record.rs:1425: "options": null accepted Fixed: refused (invalid_target), tested for TextEq and EncryptedJson.
7 kind.rs:351: ValueKind break unlisted Fixed: listed under Breaking in the stack-encrypt CHANGELOG.
8 eql_postgres_test.go:151: json counted as covered Fixed: a json type is skipped before it is counted; it is run in PostgreSQL with the generated fields (#1374).
9 json_tree_test.go:79: comparison through float64 Fixed: both sides decode with UseNumber, so numbers compare as text.
10 stashgen/read.go:170: byte vs rune Fixed: utf8.DecodeRuneInString; both name rules gain Jsonécrit / JsonÉcrit cases.
11 text.rs:129: no fuzz target Fixed: fuzz:json-text (json_text), differential against serde_json, with a seed corpus, in fuzz.yml's regression and campaign matrices and docs/fuzzing.md. Ran 2 minutes locally: 7.9M inputs, no failure.
12 CHANGELOG.md:19: points at internal/record Fixed: reworded.
13 go_eql.rs:194: broken doc sentence Fixed; eql_gen.go regenerated (types:generate).
14 targets.rs:425: options cloned per value Not changed: one clone of a small path set per sealed value is small beside the sealing itself, and caching it would need a layout carried through TargetOptions.
15 engine.go:236: misleading refusal Fixed: "EncryptedJson seals a json document, and stashgen does not generate a field of it yet", tested; #1374 adds the fields.

Found while fixing 2 and 3, and not fixed here: the JSON index's own seal path (stack_encrypt::json::derive, already on next) frees key and leaf plaintext unwiped (22 blocks for one document in a heap-spy run). The new heap test watches the boundary only and says why; that belongs with the JSON index's own work.

CI failures on the previous push (the check_record fuzz harness signature, the per-type PostgreSQL test, and the mutants gate's survivors in the JSON text scan) were fixed in e2077b5 and deb4578.

https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu

@blacksmith-sh

This comment has been minimized.

…its wipes

A local mutants run timed out on the parser's index arithmetic: every
`at += 1` replaced by `at *= 1` loops forever, which the gate counts as
a survivor. The parser now holds the unread text as a slice and moves by
re-slicing, so a mutation changes what it reads rather than hanging.
Tests pin a string's buffer to its raw length, an array's growth from
four by doubling, and a container closed by the other bracket.

JsonPlaintext's Drop is no longer excluded from mutation: heap_hygiene's
new a_json_document_read_from_a_frame_leaves_no_plaintext_on_the_heap
fails when it is a no-op. The strict-visitor exclusion goes with the
visitor.

Refs #1347

Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
The local mutants run timed out on `len == capacity` replaced by `!=`:
every push then doubled the buffer and wiped its ever larger spare
capacity. Asking the buffer for its spare capacity leaves nothing there
to mutate; the growth test still pins the sizes.

Refs #1347

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

Copy link
Copy Markdown
Contributor Author

Follow-up to the response above. A local cargo mutants --in-diff run over this pull request's stack-encrypt changes (172 mutants: 150 caught, 20 unviable) found the parser's index arithmetic produced timeouts (an at += 1 mutated to at *= 1 loops forever), which the gate counts as survivors. Fixed:

  • d36fb16: the parser holds the unread text as a slice and moves by re-slicing; tests pin a string's buffer to its raw length, array growth, and a container closed by the other bracket. JsonPlaintext's Drop is no longer excluded from mutation: heap_hygiene's a_json_document_read_from_a_frame_leaves_no_plaintext_on_the_heap fails when it is a no-op (checked).
  • ddf9ea1: push_wiping asks the buffer for spare capacity instead of comparing lengths.

https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu

…arbage

CI's Linux run of EQL's heap_hygiene flagged a freed array buffer that
held the marker in its unused slots: bytes another allocation freed
before the watch began (the test's own format! growth, or the text-target
test running beside it in the same process). The inputs are now built
into strings sized before they are written, and EQL's boundary test,
whose binary runs its tests as threads of one process, parses strings
only; stack-encrypt's, run one test to a process, keeps containers.

Refs #1347

Claude-Session: 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