Skip to content

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

Merged
coderdan merged 26 commits into
nextfrom
feat/json-tree-data-plan
Oct 26, 2026
Merged

coderdan merged 26 commits into
nextfrom
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 JsonSearch (stored in public.eql_v4_json_search), 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 JsonSearch (plaintext json, index json, needle JsonSearchQuery); v4::all() / v4::all_query() list the document and its two query operands; the Go types JSONSearch, JSONSearchQuery, JSONSearchLeafQuery, JSONEntry. The Go-name rule spells the word Json as JSON wherever it is a word.
  • eql-bindings: encryption::targets::encrypt_with(name, options, …) seals JsonSearch 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": "JsonSearch", "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 JsonSearch (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 name follows EQL v3: Json stays the storage-only type, and JsonSearch (Go eql.JSONSearch) is the searchable document at public.eql_v4_json_search, queried with eql_v4.query_json and eql_v4.query_json_leaf, with entries public.eql_v4_json_entry. The SQL domains were renamed from json_tree to match (the eql_v4.json_tree_* functions keep their names), and the v4 bundle no longer renders the SteVec json surface: those SQL files are confined to EQL v3.
  • 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

After merging #1370, which brings in next's JSON tree cascade and random stored order (#1379), and the review fixes, at c22b688, 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: clean.
  • mise x --env test -- cargo nextest run --workspace --all-features: 1,526 passed, 1 skipped (including tests/heap_hygiene.rs and the trybuild ui binary).
  • packages/eql: cargo fmt --check, clippy (-D warnings) and cargo nextest run --workspace --exclude eql_tests (625 passed); mise run types:generate then types:check (no drift); mise run test:encryption (534 passed, including the new a_document_does_not_open_under_another_column and a_sql_null_stores_no_document); mise run test:encryption:postgres with the v4 bundle built from the tree: 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), including TestJSONDocumentsDoNotOpenUnderAnotherColumn.
  • cargo metadata --locked in the root workspace, both guest workspaces and packages/eql: clean.
  • b944419: the data-plan/typed-layout parity test compares the stored entries as a multiset and the selector sets as sets, since each seal draws its own storage order; it failed byte for byte after the merge (checked).
  • 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) (this pull request)
  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
  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
    • EQL v4 supports encrypting and decrypting JSON documents through data plans, with searchable JSON targets and JSON query operators.
    • JSON target options configure document handling, and invalid JSON is reported before key requests.
    • Go bindings include types for searchable JSON documents and queries.
  • Breaking Changes
    • EQL v4 renames its JSON storage and query types. The previous SteVec document and query types are no longer available in v4.
    • Target encryption interfaces accept optional target options; JSON fields must use a target.
  • Documentation
    • Updated JSON search, type, and fuzzing guidance.

… 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: db6dca0

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 →

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

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: 7a9b72e2-732a-47c1-bbf4-b5a79cb959e4

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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: 92a4c8d0-e4cf-472a-98ae-43e5d5339029
Commits

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

Files selected for processing: 10
  • languages/golang/encrypt/eql_postgres_test.go
  • languages/golang/encrypt/json_tree_test.go
  • languages/golang/internal/record/record.go
  • languages/golang/internal/record/record_test.go
  • packages/eql/tasks/build.sh
  • packages/eql/tests/encryption/tests/json_tree_data_plan.rs
  • packages/stack-encrypt/CHANGELOG.md
  • packages/stack-encrypt/src/dynamic/json.rs
  • packages/stack-encrypt/src/dynamic/record.rs
  • packages/stack-encrypt/src/json/text.rs
Files skipped from review as they are similar to previous changes: 4
  • languages/golang/encrypt/eql_postgres_test.go
  • packages/stack-encrypt/CHANGELOG.md
  • packages/stack-encrypt/src/dynamic/record.rs
  • packages/stack-encrypt/src/json/text.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

The pull request adds JSON text parsing and JSON document support in EQL v4 data plans. It adds document-family metadata, updates SQL domains and target dispatch, adds Go types and record wiring, confines selected SQL to EQL v3, and adds parser fuzz coverage.

Changes: Searchable JSON

Layer / File(s) Summary
JSON text and dynamic value handling
packages/stack-encrypt/src/json/*, packages/stack-encrypt/src/dynamic/*, packages/stack-encrypt/fuzz/*
Adds JSON parsing, serialization, wiping, JSON value-kind handling, target options, and JSON-specific errors.
Document-family catalog and SQL contracts
packages/eql/crates/eql-domains/*, packages/eql/crates/eql-codegen/*, packages/eql/crates/eql-bindings/src/v4/*, packages/eql/src/v3/*
Adds document-family metadata and inventories, renames JSON domains and query types, and confines selected SQL surfaces to EQL v3.
JSON data-plan and target encryption
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
Routes JSON document targets through option-aware encryption and decryption. Data-plan validation checks JSON input, target options, context, and document-query refusals.
Go record and generated bindings
languages/golang/encrypt/eql/*, languages/golang/internal/record/*, languages/golang/stashgen/*, languages/golang/encrypt/json_tree_test.go
Adds JSON record and target-option wiring, generated searchable-JSON types, updated Go name conversion, and explicit stashgen rejection for unsupported JSON fields.

Priority: Normal

Estimated code review effort: 5 (Critical) | ~100 minutes

Change: Feature

Sequence Diagram(s)

sequenceDiagram
  participant DataPlan
  participant read_json
  participant TargetResolver
  participant encrypt_with
  participant KeysetCipher
  DataPlan->>read_json: validate JSON source
  DataPlan->>TargetResolver: resolve target and options
  TargetResolver->>encrypt_with: encrypt document with options
  encrypt_with->>KeysetCipher: seal JSON-tree document
Loading

Possibly related issues

  • cipherstash/stack#1349: The PR admits JSON indexes in data plans and adds JSON source handling, directly addressing the issue’s stated refusal path.
  • cipherstash/stack#1348: The PR adds document-family catalog entries and generated searchable-JSON types.
  • cipherstash/stack#1351: The PR updates the shared refusal path for unsupported generated JSON fields, while field generation remains unsupported.
  • cipherstash/stack#1347: The PR implements the Rust data-plan and EQL catalog portion of encrypted JSON support; Go field generation and reads remain outside this change.

Suggested reviewers: auxesis

Merge Risk: Minimal · up to c22b6

No concrete regression in JSON document storage is established here; the change is mergeable after 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 57.48% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 214 functions across 46 files. (1 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.
Title check Passed The title clearly and concisely describes storing a JSON document through a data plan with the JsonSearch target.
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.
Full details: Docstring Coverage

Explanation

Docstring coverage is 57.48% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 214 functions across 46 files. (1 skipped: 1 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.

@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
EQL v3 stores a searchable JSON document as `public.eql_v3_json_search`,
queries it with `eql_v3.query_json` and returns `public.eql_v3_json_entry`
from `->`. In v4 the JSON tree scheme implements that document, so its
domains take the same names: `public.eql_v4_json_tree` is now
`public.eql_v4_json_search`, `public.eql_v4_json_tree_entry` is
`public.eql_v4_json_entry`, and the two query operands are
`eql_v4.query_json` and `eql_v4.query_json_leaf`. `json_tree` stays the
implementation's name: the SQL directory, the Rust structs and the SQL
functions keep it.

Those names were already taken in the v4 bundle. The SteVec SQL under
src/v3/json/ carried no version line, so it rendered into both bundles and
gave v4 a SteVec `json_search`, `json_entry` and `query_json` that nothing
writes. The five files, and the generated json_entry_<T> cross files that
bind the SteVec entry to each scalar family's query operands, now carry
`-- ONLY EQL VERSION: 3`; eql-codegen writes that line into the cross
files. The v3 bundle changes only by those 23 comment lines. With the
SteVec SQL gone from v4, the v4 target table drops its SteVecDocument row,
which was never producible, and the generated Go package drops the
SteVecDocument and SteVecQuery types with it.

Both bundles still install side by side: every public domain each owns,
the other owns under its own prefix, and both lints report no error.

Refs #1347, #1348

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

The previous commit gave the JSON tree domains v3's names; this gives the
contract names that point at them the same treatment. A data plan, a Go
`encrypt_into` tag and the target table named the stored document
`EncryptedJson`, a role name chosen before the domain had one. It is now
`JsonSearch`, over `public.eql_v4_json_search`, with `JsonSearchQuery`
(`eql_v4.query_json`), `JsonSearchLeafQuery` (`eql_v4.query_json_leaf`)
and `JsonEntry` (`public.eql_v4_json_entry`). `Json` stays the
storage-only type over `public.eql_v4_json`, so the two v3 words mean the
same thing in v4.

The catalog's document family (`eql_domains::JSON_TREE`) is the only place
the names are written; the target table, the stack-encrypt descriptors the
tests pin, the guest's producible list and the generated Go package
(`eql.JSONSearch`, `JSONSearchQuery`, `JSONSearchLeafQuery`, `JSONEntry`)
follow from it, regenerated with `mise run types:generate`. The Rust
structs keep the implementation's name (`JsonTreeDocument` and its
companions), as v3's `SteVecDocument` does.

Refs #1348, #1349

Claude-Session: https://claude.ai/code/session_01HLuZxys1NDRmAkv3uhnuoJ
The changesets and CHANGELOGs for the unreleased JSON tree family named
the target `EncryptedJson` over `public.eql_v4_json_tree`, and the target
changeset said no SQL changed. They now give the names the family ships
under, say that the v4 bundle no longer carries the SteVec domains and
that the v3 bundle gains only the version-directive comment lines, and the
Go CHANGELOG lists the removal of `eql.SteVecDocument` and
`eql.SteVecQuery` under Breaking. AGENTS.md and packages/eql/AGENTS.md
record which surfaces are confined to which version; the design plan
records the decision and keeps its earlier sections' placeholder names as
history.

Refs #1348, #1349

Claude-Session: https://claude.ai/code/session_01HLuZxys1NDRmAkv3uhnuoJ
The list of the four domains in src/v3/json_tree/types.sql was aligned
for the `json_tree` names; with the shorter and longer v3 names the
dashes and continuation lines no longer lined up. Comment only.

Claude-Session: https://claude.ai/code/session_01HLuZxys1NDRmAkv3uhnuoJ
The target descriptor's table said a type's indexes include `json` for a
SteVec document. The v4 target table no longer has a SteVec row; the one
type carrying the JSON index is the document family's stored type,
`JsonSearch`, as eql-bindings' own table says.

Claude-Session: https://claude.ai/code/session_01HLuZxys1NDRmAkv3uhnuoJ
@coderdan coderdan changed the title feat(stack-encrypt,eql): store a JSON document through a data plan as EncryptedJson feat(stack-encrypt,eql): store a JSON document through a data plan as JsonSearch Oct 26, 2026
@coderdan
coderdan marked this pull request as ready for review October 26, 2026 04:54
@coderdan
coderdan requested a review from a team as a code owner October 26, 2026 04:54
@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-26T04:56:57.578382Z 5fef296 Draft marked ready
🔒 Security Review ✅ Completed 2026-10-26T04:58:59.648831Z 5fef296 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 medium plus cross-check-review-findings, run against head 5fef296. No critical or blocking issues found.

Seven findings were raised, checked against head, then cross-checked against the existing PR feedback. 4 dropped (1 refuted: float_roundtrip is enabled in stack-encrypt/Cargo.toml:82; 3 unconfirmed or style-only). 2 are new and minor:

  • minor packages/stack-encrypt/src/dynamic/record.rs:2391: check_field calls read_json and discards the result, then encrypt_document (packages/eql/crates/eql-bindings/src/encryption/targets.rs:427) parses and wipes the same document again, so each row pays two full parses. Keep the parsed JsonPlaintext from the check, or accept the cost knowingly for large documents.
  • minor languages/golang/internal/record/record.go:332: Plan.Validate rejects TargetOptions without a target, but not a JSON-kind field with no target. That fails late in the engine (json_needs_target) without the field context the Go validator adds.

Not verified here: the live-credential and PostgreSQL suites, and the failing TestEQLTypesRoundTripAndQueryInPostgres/EncryptedJson reported by Blacksmith on an earlier commit. Re-check CI on the current head.


Generated by Claude Code

@coderdan
coderdan force-pushed the feat/json-tree-data-plan branch from 5fef296 to 43faafc Compare October 26, 2026 05:03
@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 added a commit that referenced this pull request Oct 26, 2026
…keys before freeing them

The data-plan work in #1372 found that sealing a JSON document freed
key names, scalar values and the data key unwiped, and left it for the
index work. A heap spy over this branch's seal, open, read, extraction
and needle paths, under the deterministic key source, found 42 blocks
for one sealed-root seal (43 with the root open), 4 for each whole open
and each `->`, 7 for each subtree read, 40 for a containment needle and
20 for an equality needle. Each holds a marker the document carries or
the data key the seal used. After this change every one of those paths
frees none.

Where they came from, and what holds them now:

- JsonPath carries key names, and the walk builds one per node: a path
  now wipes its key names when it drops, and `selector_bytes` and
  `token_bytes` are sized before they are written (a growing buffer
  freed a copy) and come back in `Zeroizing`.
- The value selector framed the scalar, and the rank framed the key
  name, through `Context::pae`, whose buffer is not wiped: both now
  frame into a sized `Zeroizing` buffer with the same bytes (a unit test
  holds the equality), and the canonical value is `Zeroizing` too. The
  rank map no longer clones key names.
- The document `seal_pending` owns (a clone, in borrowed mode) is now
  wiped when sealing returns (`wipe_value`); a query's needle, owned by
  the terms operation, is wiped through a new `SourceMode::release`; the
  copies `AtPath` checks make are wiped after the check.
- The data key was moved out of the response buffers (`pop_front`,
  `drain`, `into_iter`), leaving its bytes in memory freed later. Each
  response is now handed out as a clone and the original drops in place,
  where `DataKey` wipes itself; `dispatch` clones retrieved keys the same
  way. This is the shared settling path, so every decryption benefits.

`tests/heap_hygiene.rs` gains the JSON index's tests, with a key source
that records each data key so the spy looks for it as well as the
marker, and a control showing a freed key copy fails the watch. Each
fix was checked by removing it and watching a test fail. One case is
left out and written down: the path an `AtPath` caller passes as an
owned `String` is cloned by the query builder and dropped by the plan
chain, which knows only `P: AsRef<str>`.

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

@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.

This slice is well built, and I found no blocking defect.

  • Single source. The DOCUMENT_FAMILIES catalogue is the single source for the target row, v4::all() / all_query() and the Go types. the_table and v4_inventory hold the hand-written json_tree_domain_type! SQL names to the catalogue.
  • Agreement. The guest targets.rs, record.go and eql_gen.go agree on JsonSearch / json_tree / json / public.eql_v4_json_search / eql_v4.query_json.
  • Context binding. encrypt_document seals under Identifier::from_label(context), and decrypt_document opens with ExpectedContext from the same column. This matches the typed path. Error payloads carry only a code and a position, never text.
  • Validation. Options are resolved once, when the plan is built. A document that is not valid JSON is refused in check_source before any key request.
  • The SQL rename is safe for installed databases. The json_tree SQL exists only on next and the stack under it, and the published @cipherstash/eql@3.0.6 has no eql_v4 object. The rename is mechanical, and each new name is unique in its bundle, so the v3 bundle gains comment lines only, as the changeset says.
  • Changesets. There is one @cipherstash/eql minor change in the root .changeset/, and the two pending json_tree changesets are reworded. eql-bindings is in lockstep and has a CHANGELOG entry. The breaking changes in stack-encrypt and Go are under Breaking. No skill needs a change, because no skills/* file names eql_v4. fuzz:json-text is in both fuzz.yml matrices, in docs/fuzzing.md and in the corpus.

Checks that ran: cargo test -p eql-codegen -p eql-domains and mise run types:check (pass, no drift in generated files); the stack-encrypt json, kind and record-target tests (102 pass); and the encryption tests heap_hygiene, json_tree_data_plan and targets (pass). The Go tests and PostgreSQL were not run here.

Two small points that have no line in the diff:

  • Nit: no SQL NULL test. No test in languages/golang/encrypt/json_tree_test.go or json_tree_data_plan.rs seals an absent value (nil) on the null: "sql" field. A row with no value is the common case for *json.RawMessage. Please add one row.
  • Nit: packages/eql/tasks/build.sh:93-95. This comment still says that only the range surface is limited to one EQL version. The json_tree family (v4) and now the SteVec SQL (v3) are limited too. packages/eql/AGENTS.md now describes all three surfaces correctly.

Comment thread packages/stack-encrypt/src/json/text.rs Outdated
Comment thread packages/stack-encrypt/CHANGELOG.md Outdated
Comment thread packages/eql/crates/eql-bindings/src/encryption/targets.rs
Comment thread languages/golang/encrypt/eql_postgres_test.go
Comment thread packages/stack-encrypt/src/dynamic/record.rs
Comment thread packages/stack-encrypt/src/dynamic/record.rs
Comment thread packages/stack-encrypt/src/dynamic/json.rs 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
Brings in next's JSON tree cascade and random storage order (#1379).
Conflicts resolved to #1379's names and wording, keeping this branch's
data plan target, its JSON text parser and the JsonSearch rename:
JsonProjection is JsonSelectorSet in the fuzz table and the JsonQuery
docs, the leakage page takes #1379's opening under the eql_v4_json_search
name, json_tree_path_query's comment says stored order under the
json_entry domain, and the oracle's hostile-row updates find each entry's
slot (the order is random) and cast to eql_v4_json_search. The changelog
keeps both sides' entries.

Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
… as sets

A sealed document now stores its entries in a random order (root first)
and its containment selector set shuffled, so two seals of one document
differ in order. The parity test compared entries by position and q by
bytes, which only held while both seals walked the document the same way.
It now compares the root entry, the entries as a sorted multiset of
(selector, ordering term, parent tag's place), and the two selector sets
through JsonSelectorSet's set equality.

Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
The task's description still listed JsonProjection, the selector set's
former name, among the decoders the target fuzzes.

Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
The parser's rustdoc and the changelog said no heap block it frees holds
plaintext. That holds for strings and array buffers, but an object's
members go into serde_json's BTreeMap, which splits a full node by
copying half its slots and does not clear the ones it leaves: a number
or boolean member of an object with twelve or more members can stay in
a freed node. Strings move by pointer, so their bytes never sit there.
Both now say so, and that numbers and booleans are wiped best-effort.

Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
json_frame copied the text into the Protected value and wiped the
original. Taking the buffer moves it, so the plaintext exists once.

Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
…t options are refused

check_field parses a json field's document and drops it, and the
resolver parses it again when it seals: the check runs before any key
and needs no resolver, and a resolver takes the field's value as a
frame. Options are read in the JSON index's grammar whatever the
target, so options outside it are an invalid target while well-formed
options on a target that takes none are target_options; the rustdoc on
TargetWire now says both.

Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
…ta plan

Nothing opened a JsonSearch value under another column. The data plan
test now seals under users/profile and opens under users/other, which is
refused as a context mismatch before any key is retrieved; the Go guest
test does the same and gets ErrContextMismatch. A row whose document is
a frame null, or absent, under null=sql stores no value and opens as a
null, beside a JSON null that round-trips as a document: the doc comment
that promised this had no test.

Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
The engine refuses a json field that names no target (json_needs_target)
after the plan crosses the guest. Validate already refused target options
without a target; it now refuses a JSON document with no target too, and
names the field before the guest is asked.

Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
…ores documents

build.sh's comment named only the range surface as confined to one EQL
version; the JSON tree family (v4) and the SteVec SQL (v3) are confined
too. The per-type PostgreSQL test skips JsonSearch, and its comment now
names TestJSONDocumentsQueryAndReadInPostgres, which stores documents
through the generated fields into an eql_v4_json_search column.

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

Copy link
Copy Markdown
Contributor Author

Review-body findings, at c22b688:

Finding Outcome
cipherstash-agent: check_field parses each document and encrypt_document parses it again Kept, with a comment saying why (05d19b3), as answered on the record.rs:2397 thread.
cipherstash-agent: Plan.Validate does not refuse a JSON-kind field with no target Fixed in 02acc73: Validate refuses it naming the field (languages/golang/internal/record/record.go), tested in record_test.go.
freshtonic nit: no SQL NULL test on the null: "sql" field Added in 20f099e: a_sql_null_stores_no_document seals a document, a frame null, an absent field and a JSON null; the two nulls store no value and open as null, the JSON null round-trips as a document.
freshtonic nit: packages/eql/tasks/build.sh:93-95 names only the range surface Fixed in c22b688: it names the three confined surfaces, as packages/eql/AGENTS.md does.

This branch also merged #1379 (the cascade and the random stored order) through #1370; b944419 compares the data plan's stored document with the typed one as sets, since each seal draws its own order.

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/go-opaque-storage-profile to next October 26, 2026 13:15
@coderdan
coderdan merged commit 067ac28 into next Oct 26, 2026
60 of 62 checks passed
@coderdan
coderdan deleted the feat/json-tree-data-plan branch October 26, 2026 13:19
coderdan added a commit that referenced this pull request Oct 26, 2026
Brings in the Go json_tree stack's first pull requests (#1369, #1370,
#1372). 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