Repository navigation
feat(stack-encrypt): answer a document field's JSON operators and open its reads from a data plan - #1373
feat(stack-encrypt): answer a document field's JSON operators and open its reads from a data plan#1373coderdan wants to merge 10 commits into
Conversation
…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 detectedLatest commit: 8a2488e The changes in this PR will be included in the next version bump. This PR includes changesets to release 12 packages
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 |
|
Important Draft PR not reviewedDraft PRs are not automatically reviewed by default.
To automatically review draft PRs, update your CodeRabbit configuration: reviews:
auto_review:
drafts: true
Comment |
This comment has been minimized.
This comment has been minimized.
Mutation testing (cargo-mutants,
|
| caught | missed | unviable | timeout |
|---|---|---|---|
| 60 | 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
|
Automated code review of this pull request at
|
- 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
|
Review response to the findings listed above (fixed in abdc767 unless noted; merged with the base in accb055 and 978f0cd):
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 |
…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
Summary
This pull request lets a language SDK query and read an encrypted JSON document (
EncryptedJson, EQL'sjson_treetype) 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, bit for bit. 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)OpSpecgains the document operators. Each takes an object of named inputs, with JSON values as JSON text:contains,contained_by{"json"}eql_v4.query_json_treeeq_at{"path", "json"}lt_at…gte_at{"path", "json"}at an ordered patheql_v4.query_json_tree_leafbetween_at{"path", "low", "high"}selector{"path"}textsubtree{"path", "wildcard_bound"?}text[]children,child_count{"path"}, no wildcardtext(the node's token)field_operatorslists 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 whatquery_termderives.between_at's arity isone: its input is one object.The query term's wire form gains
encoding(bytes,eql,text,text_array), andOperandgainsTextandTextList, so a selector or a token list is a named value, not anonymous bytes. An operator entry gainsinput(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 asubtreeread), 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 aselectororsubtreeread, 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)se_json_open; JSON read refusals (JsonWrongEntry,JsonWildcardTruncated) areSTATUS_ENCODINGwith their own diagnostic codes, not internal errors.Go runtime (
languages/golang)QueryTerm.EncodingandQueryTerm.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):
json.RawMessage/*json.RawMessage.{"json": …}), not the field's struct type.Also assumed:
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"}).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/text[], named in the term'sdomain.Not done here
json_tree_childrenrows (child scalars without a subtree read). A subtree read of the node returns the same values today.Verification
macOS arm64, Rust 1.94.1, Go 1.26.10, on this branch. After the review fixes (abdc767, merged with the base in 978f0cd): root clippy and
cargo nextest run --workspace --all-features(1,493 passed, including the new in-cratetests/json_operators.rsand the heap-spy test for an operator's input), rustdoc with warnings as errors,packages/eqlfmt, clippy and nextest (627 passed),types:check, the four guest builds andwasm:guest:test,go:test(includingTestParseQueryTermRefusesAMixedShape),go:lint,go:test:postgresagainst PostgreSQL 17 with the tree's EQL v4 bundle,crap:stack-encrypt(none over 30), and a localcargo mutants --in-diffrun (96 mutants; the six survivors are fixed or, for the visitor'sexpecting, excluded as equivalent). The original verification:cargo fmt --all --check(root,packages/eql, guest): clean.cargo clippy --locked --no-deps --workspace --all-targets --all-features -- -D warnings: clean. EQLcargo clippy --workspace --all-targets --all-features --exclude eql_tests -- -D warnings: clean.mise x --env test -- cargo nextest run --workspace --all-features --locked: 1,470 passed.packages/eqlcargo nextest run --workspace --exclude eql_tests: 624 passed, including the 7 tests oftests/json_tree_data_plan.rs. The SQLx crate needs generated fixtures and a database and was not built.wasm:guest:build,:deterministic,:eql,:eql:deterministic);mise run go:testpassed, including the newTestJSONOperatorsDeriveTaggedTermsandTestJSONSelectorReadsOpenAgainstTheirRequest.mise run wasm:guest:test(clippy in every feature set, docs, nextest with and withouteql): passed, with the catalog test now pinningEncryptedJson's operators.dynamic::query::wire_bytes) is re-pinned for the new keys and gains the JSON index's entries and atextandtext_arrayterm; the two trybuild snapshots that now printstack_encrypt::op::Containsin full (becauseOpSpec::Containsexists) are re-recorded.Related
Stack
Merge order is bottom to top; each pull request is based on the one below it.
EncryptedJsonstored through a data plan (engine, EQL catalog, guest, Go runtime)https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu