Repository navigation
feat(eql, stack-encrypt): chain json_tree tokens by ordinal and probe the walk to the first miss - #1329
Draft
coderdan wants to merge 4 commits into
Draft
feat(eql, stack-encrypt): chain json_tree tokens by ordinal and probe the walk to the first miss#1329coderdan wants to merge 4 commits into
coderdan wants to merge 4 commits into
Conversation
… the walk to the first miss
The stored entry shrinks from nine fields to four, `s c p op`, and the walk
stops depending on the document's width.
Three fields nothing read: once the projection `q` was stored, no SQL function
read an entry's value selector `vs` or its group tags `n` and `g`, and the
engine read them only to rebuild `q` on deserialize. The engine now reads `q`
back as the projection. That changes no trust boundary: only `c` is
authenticated, through its AAD, and `q` was always what containment ran over.
Two fields the walk did not need: a child's token is now
`H("t" ‖ t(parent) ‖ LE32(o))`, which the server derives as it walks, so the
wrapped token `w` is not stored; and the ordinals under a node are dense from
zero, so the walk probes `0, 1, 2, …` until the first miss and neither `o` nor
a fan-out bound is stored. A subtree of N nodes costs about 2N hashes where it
cost the widest node in the document per node visited.
What the client gives up is deriving an object member's token, since an
ordinal is a rank among sibling keys it does not know. `JsonSubtreeKey` now
holds the deepest token the client can derive (a chain head, then array
positions) and one gate per further step, a selector or `#n`, and
`json_tree_subtree_at(doc, token, gates text[], budget)` descends through
them, returning the reached node at `d = 0` and its descendants beneath. The
children functions take the same optional gates.
Verified: stack-encrypt nextest (733), eql-bindings tests, the Postgres oracle
suite against a fresh v4 install built from the tree, clippy and fmt, the EQL
schema gate and documentation coverage. The documented-sql validation step
needs the Docker database and was not run.
Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
Contributor
|
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 |
Contributor
Mutation testing (cargo-mutants,
|
| caught | missed | unviable | timeout |
|---|---|---|---|
| 18 | 0 | 12 | 0 |
Every mutant in the changed lines was caught by a test.
…nts, and drop the old format from the docs The benchmark run against this branch has come in, so the design doc's Phase 3b section now carries the measured outcome in place of "results to follow": the walks are 30 to 55% faster at 10,000 and 100,000 rows, the stored nested document is 25% smaller, containment and field queries are unchanged within the run's noise, and the benches correctness check passed all 462 of its comparisons at both tiers. The earlier sections of the design still describe the wrapped token, the group tags and the stored ordinal; they are kept as the record, with a dated note wherever one would now mislead. Several doc comments still described the old format: the header's SQL reader computing group tags, the projection being built from group tags, the test suite unwrapping tokens, `json_tree_subtree_at` matching a single selector, and the parent map costing a level its fan-out. They now say what the code does. The walk's cost was stated as about 2N hashes; a subtree of N nodes takes about 2N probes, each a hash and a lookup, plus a hash per hit for its token, so the SQL comment, the design doc and the changeset say that. The changeset also says that the gates argument replaces the earlier selector one and that the fallback root still comes back at d = 0. No SQL or Rust behaviour changes. Checked with clippy and fmt for stack-encrypt and eql-bindings, the EQL schema gate, and the EQL documentation coverage and required-tags checks; the documented-sql step needs the Docker database and could not connect. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
🦋 Changeset detectedLatest commit: 6adf37f 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 |
…s opening and the projection's wire The in-diff mutants gate on the ordinal-chain commit left four survivors. Two were timeouts: `JsonDocument::children` probed ordinals up to `u32::MAX` until the first miss, so a mutant that never misses never ends. A node has at most as many children as the document has entries, so the loop is now bounded by that count; the behaviour on real documents is unchanged. Two were misses with no test behind them. A gated walk's rows carry the node the gates reached at depth 0, and `DecryptInto<Value>` returns that node's value rather than assembling a container from the rows; no engine test exercised that branch, only the Postgres oracle did. And `JsonProjection` gained its own serde impls for the stored `q` without a round-trip test. Both are pinned now. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
…'s walk The count bound alone ends a walk over tags that do not form a tree: every hit adds an entry, and the walk stops once it has returned as many entries as the document holds. The depth bound added beside it could never fire in a test, so the mutants gate reported its three operators as unkilled. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
This was referenced Oct 21, 2026
Open
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
EQL (Encrypt Query Language) is the SQL bundle that stores and queries encrypted values in PostgreSQL; EQL v4 is the version for values written by Stack Encrypt, the Rust encryption engine in
packages/stack-encrypt. Itsjson_treetype (unreleased) stores an encrypted JSON document as one entry per JSON node: a small ciphertextcholding that node alone, plus keyed tags that let PostgreSQL search and walk the document without the decryption key. Two of those tags matter here. A selectorsis a keyed hash of a node's path. A token is a secret 16-byte value per node that the client hands the server in a query: from it the server can find that node's children, through each child's parent tagp, and so walk the subtree under the node.This PR shrinks what each entry stores from nine fields to four (
s,c,p,op) and changes how the server walks a subtree, so a walk no longer costs the document's widest node for every node it visits. Stacked on #1325. The stored format changes with it, so it has to land before the family ships.qwas stored (perf(eql, stack-encrypt): store the json_tree projection, walk by parent map, budget the subtree #1325).qis the document's selector pairs grouped by array element, so PostgreSQL's ownjsonb @>over it decides containment. Those three fields were the per-entry value selectorvsand the two group tagsnandg. The engine now readsqback as the projection instead of rebuilding it from the entries.wand the ordinalo. A child's token is nowH("t" ‖ t(parent) ‖ LE32(o)), a hash of its parent's token and its position among its siblings, which the server derives as it walks. The positions under a node run 0, 1, 2, … with no gaps, so the walk probes them in turn and stops at the first miss. A subtree of N nodes costs about 2N probes (a SHA-256 and a lookup each) plus N token hashes, where it cost every position up to the document's widest node at every node visited.#nfor an array position. The server descends through the gates and returns the node they reach at depthd = 0, with its descendants beneath it.Changes
packages/eql/src/v3/json_tree/, rendered into EQL v4 only):s,c,pandoponly, so a document written by the earlier build of this unreleased type is refused.eql_v4.json_tree_subtree_at(doc, token, gates text[], budget DEFAULT 64)replaces the earlier(doc, token, selector, budget)form. It walks the gates and returns the reached node atd = 0with its descendants.json_tree_subtree(doc, token, budget)is now that function with no gates, and still returns the token's node's descendants fromd = 1.json_tree_childrenandjson_tree_child_counttake the same optionalgates.budgetentries, the walk returns the root entry alone atj = 0,d = 0, and the client opens it and selects the path itself.eql_v4_internal.json_tree_child_tokenis added;json_tree_unwrap,json_tree_xor16andjson_tree_fanoutare removed.src/json/):JsonEntryis{s, c, p, op}, andJsonDocumentcarries its projection, which the wire form requires asq.JsonSubtreeKeyholds a token andgates().JsonDocument::children()probes positions in turn.src/v4/json_tree.rs):JsonTreeEntrydropsvs,o,w,nandg, and the typed document handsqto the engine.docs/plans/2026-10-17-json-index-stack-encrypt-spike.md): the Phase 3b implementation note carries the measured outcome below. The earlier sections, which still describe the wrapped token, group tags and stored ordinal, are kept as the record, with dated notes where they would otherwise mislead.@cipherstash/eqlminor.Verification
cargo nextest run -p stack-encrypt --all-features: 733 passed.cargo test -p eql-bindings --all-features json_tree: passed.mise run test:encryption:postgresagainst a fresh database with the v4 bundle built from this tree: all suites passed, including the json_tree oracle, which compares containment over the encrypted column withjsonb @>over the plaintexts (postgres_containment_over_the_encrypted_column_is_jsonb_containment,postgres_extracts_walks_and_orders_by_path).After the docs follow-up commit:
cargo clippy --all-features --all-targets -D warningsandcargo fmt --checkclean for stack-encrypt and eql-bindings,mise run test:schemapassed, andmise run docs:validate's coverage and required-tags checks passed. Itsdocumented-sqlstep needs the Docker database on port 7432 and could not connect locally; CI runs it.Benchmarks, cipherstash/benches@2d9d5f2 (branch
feat/json-tree-benches,report/JSON_TREE_COMPARISON.md, "Third measurement"). They ran at 10,000 and 100,000 rows on one machine (Apple M1 Max, PostgreSQL 17.10), over the same documents before and after, so every scenario returned the same rows. Query-only criterion medians in ms, before → after, on the nested corpus (about 213 entries a document):subtree/orders, default budgetsubtree_at/items($.orders[*].items), default budgetsubtree/orders, walked out (budget 0)subtree_at/items, walked out (budget 0)subtree/userpath_query/wildcardfield_order/nestedchildren/orderschild_count/orderschildrenandchild_countmoved only 6 to 9%. The report's explanation, not yet tested, is that a per-call fixed cost (detoasting the document and building the parent-tag map) dominates them, and this change does not touch it.Containment and field queries are unchanged within noise. The flat corpus read +7% to +13% on byte-identical operands, plans and indexes, and the native
jsonbcontrols over unchanged plaintext drifted +5% to +19% in the same run, so this reads as machine noise rather than the format.Sizes:
sv94,324 → 66,023 (−30%).pg_column_size: 81,264 → 62,143 bytes.pg_column_size2,452 → 2,250 bytes.Correctness: a new benches task,
check:v4:json_tree <N>, ran at both tiers. It decrypted every walk form for 25 random rows and compared each with the plaintext, and it compared the encrypted id sets with nativejsonbfor 10 needles per predicate. 462 passed, 0 failed, at both tiers.Related
->(item 1) is 15 to 19% faster only becausesvis shorter.Review notes
functions.sqlandjson_tree_subtree_at, thensubtree_key_forinstack-encrypt/src/json/derive.rs, which decides what the client derives and what it gates.DecryptInto<Value> for JsonSubtree: ad = 0row means "the node the gates reached", so its value is returned, rather than a container assembled from the rows. A sealed-root fallback also comes back atd = 0, atj = 0, and is told apart by that.cis authenticated, through its AAD (the associated data the AEAD binds), andqwas already what containment ran over. Thewmask's binding tocis gone. A swapped ciphertext no longer breaks the walk on the server, but it still fails when the client opens the entry, becausecis bound tosand its positionjin the document.What a query looks like to a user
Users never see selectors, tokens or gates. The SDK derives them from a path and a plaintext needle, and the only encrypted thing in the SQL text is one typed operand.
SQL, as the v4 bundle ships it, for a column
profileof typepublic.eql_v4_json_tree:Rust, as stack-encrypt has it on this branch:
TypeScript through Drizzle, as the EQL v3 surface reads today; a v4 column keeps it and gains the walk, which has no adapter shape yet:
https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu