Repository navigation
feat(stack-encrypt): cascade the JSON tree's AEAD tags and store its entries in a random order - #1379
Conversation
…n-list merge The test module #1366 added to `tests/json_index.rs` was written against the gated walk: it called `subtree_nodes(doc, token)`, which the token-list read (#1363) replaced with `read_rows(doc, key)`, and opened an extracted element with `decrypt_as::<Value, JsonNode>`, which has no implementation (an extracted entry opens through `JsonExtracted`, which checks its selector first). Merged on top of #1363 the file no longer compiled, and the `tests-crates` workflow is red on `next` at ebdb5de. Both calls now use the helpers the rest of the file uses. The test's assertions are unchanged. Refs #1366, #1363. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
…its AAD A json_tree read used to be checked by position: the rows under the node a token named had to sit at consecutive slots in depth-first order, and each entry's AAD bound its slot. That rule is sound only while the stored order is the depth-first order, and the stored order is what reveals each row's tree (#1357). To store the entries in any other order, the client needs an integrity check that does not read the slot as a position. Each entry's AAD now binds the digest of its children's AEAD tags, SHA-256("k" ‖ tag(child_0) ‖ tag(child_1) ‖ …) in ordinal order, under the new domain json/entry/v2. A node's tag covers its AAD, so it covers its children's tags, which cover theirs: a node commits to its whole subtree. Sealing runs from the leaves up. A leaf, an empty container and the sealed root (whose members chain from nothing) open under the empty digest. Nothing stored changes: the AAD is not stored and the tag is already the last sixteen bytes of each ciphertext. Opening a read places its rows by the chain, as the server finds them: the node a token names by its self tag, a node's children by its parent tags at ordinals 0, 1, 2, … Each node then opens under the digest of the children the tags gave it, so a row swapped, dropped, added, reordered or moved under another parent is refused by the cipher (Error::Aead). The client still refuses, as JsonWrongEntry, a row placed twice, a row no read reaches, a reported depth the chain does not give, a selector or label that is not the path's, a child under the wrong rule, the sealed root anywhere but at `$`, and a container whose child count disagrees. The consecutive-position rule is gone. Opening a whole document whose root is open walks the chain the same way, so it derives the root's token; for now it takes the keyset from the cipher's cache. `->` of a container with children no longer opens alone (its AAD covers tags `->` does not return) and is refused by the cipher where it was refused by kind. The new code lives in json/cascade.rs (the digest and the seal order) and derive.rs (`place` and `Placing`). The hostile-row tests that asserted the position check's errors are rewritten in a later commit. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
A json_tree row stored its entries depth-first, root first, members in key byte order. Every entry carries its path selector, so the stored order alone gave anyone holding the table each row's tree: the parent of every node, the size of every subtree, and which of two sibling key names sorts first (#1357). The containment projection q restated it. With the cascade (the previous commit) nothing reads a slot as a position any more: the server finds entries by tag and reads q as sets, and the client places a read's rows by the chain and verifies them by the tags. So the entries are now stored in a uniformly random order per document, with the root kept at slot 0 where the sealed-root fallback looks for it, and q's pair lists and element groups are shuffled too. The AAD still binds each entry's slot. This is the format, not an option: no header flag, no data-plan option, no switch. The permutation is drawn at sealing time and thrown away; nothing derives it, keyed or not, because nothing needs it and anything it were derived from would have to be kept from the database. Production draws from vitaminc's SafeRand::from_entropy, one generator per document, Fisher–Yates over slots 1..n with next_below (Lemire's reduction, bias at most n / 2^64). The generator is not part of the format: nothing stored depends on it, so it can be replaced without a version change. For fixtures, `StackCipherBuilder::json_order_seed` (behind the `test-support` feature) draws every document's order from one generator started at a fixed seed (SafeRand::from_controlled_seed), and the test helper that builds a cipher over the deterministic key source sets it, so a pinned vector repeats. The one SQL meaning that moves: `->`, `->>` and ordering by `eql_v4.ord_term(col -> sel)` at a wildcard path return the first entry in the stored order carrying the selector, now an unspecified element of the row (the same one every time) rather than element 0. Every element is reached through all its matches; the rustdoc of the extraction says so. Tests that pick an entry by its depth-first slot are rewritten in a later commit. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
…h its root open Under the cascade, opening a whole JSON document whose root is open (`sealed_root: false`) walks the document's chain from the root's token, as the server would for a read of `$`, so the client needs the index key of the keyset the document was sealed under. The cascade commit took it from the cipher's keyset cache and refused when the keyset was not loaded, so a document sealed under a tenant keyset failed to open through a cipher that had not selected that tenant first. The open now asks for the keyset beside the data key, as a new kind of request a Pending carries (`Request::load_keyset`). Settling answers it the way `StackCipher::keyset` selects a keyset by id: from the cache, or loaded from ZeroKMS and cached on a miss. The request is held to a pending's keyset scope like a retrieve, so a keyset cipher refuses a document of another keyset before any I/O. Settling a pending asks only for a DataKeySource, and adding IndexKeySource there would tighten public bounds across the crate and its bindings. So the cipher captures the backend's `load_index_key` at `init`, where `K: IndexKeySource` is known, as a plain function pointer returning a boxed future (Send on native, as every pending's future is), and the keyset request goes through it. No public bound changes. The sealed root (the default) opens from its root alone and loads nothing. Tested with a reader cipher over the deterministic key source that has loaded only its default keyset: it opens an open-root document of another keyset, loading that keyset once and looking it up after. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
…fusal comes from With the entries stored in a random order, a test can no longer name a node by its depth-first slot. Every test that did now finds the row by its path, through the self tag of the path's token as the server does: the hostile-row tests (tests/json_read_checks.rs), tests/json_index.rs, tests/json_properties.rs, and the PostgreSQL oracle (packages/eql/tests/encryption/tests/json_tree.rs, whose two slot assertions now compare as sets, since the SQL returns a token's rows by slot). `q` and the selector lists compare as sets. Each hostile-row test asserts the error the new code produces, not either of two. Beneath the node a read names, a swapped entry, a dropped child, an added child, reordered children, a child moved under another parent, a truncated subtree and another column are refused by the cipher (Aead); a repeated row, a row no read reaches, a wrong depth, a self tag no rule derives (or not the one the label names), the wrong selector or label at the top of a read, the sealed root out of place and a wrong child count are JsonWrongEntry. Tests that forge rows compute the chain's public hashes the way a database holding the read's tokens can. New properties: a read's rows open to the same value in any order; any one row of a whole-document read changed, dropped, repeated or misreported is refused with the error that names how; the stored order is a permutation with the root at slot 0, and every seal draws its own. The pinned vectors (tests/json_tree_vectors.rs, behind `test-support`) now hold, per root option, the entries in their stored order, the path stored at each slot (the permutation the seeded generator draws), the selector multiset, `q` as sets and the tokens. The tags, the iv and the tokens are unchanged from the previous pin; only the order moved. The leakage profile (tests/json_leakage) models the random order: a new `StoredOrder` clause, a simulator that draws the same permutation, and a canonical order (canonical.rs) that every distinguisher reads the world in, built only from what an observer sees without the stored order. Distinguishers that read the depth-first order are retired; a mutant claiming that order is caught by `walked_subtrees_are_contiguous`. It found that a containment needle is still sent in key byte order, which the leakage page now states. Rewriting the tests also tightened the placement in derive.rs: rows of two documents are refused before repeated slots (two documents' entries can share a slot); a re-entry check that no key's disjoint paths can reach is gone; and unit tests cover the whole-document chain walk, a forged child count and another column. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
The design record gains a dated note on the change (#1357): the cascade as the integrity rule and which refusal comes from where, the random stored order as the format and why it is derived from nothing, the wildcard first-match semantics the owner chose (an unspecified element, with the element-0 fix-up and the sealed root's heads digest considered and dropped), the keyset load on a root-open decrypt, and the two SQL doc comments that still say "document order". The leakage page drops its stored-order row, says what a random order leaves visible (the selector multiset, q's element grouping, the head count, ciphertext lengths and the ordering terms) and what it no longer shows, rewrites "What is authenticated" around the subtree guarantee with the three allowances kept, says what `->` returns at a wildcard path, and notes that a containment needle is still sent in key order. ADR 0010 says what a json_tree document authenticates and keeps the three allowances: another row's entry, another element's node at the top of a read, and withheld rows. The json module's rustdoc and eql-bindings' JsonTreeDocument doc stop describing a depth-first order. A changeset records the change for @cipherstash/eql. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
🦋 Changeset detectedLatest commit: f783f8e 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 Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the
|
Mutation testing (cargo-mutants,
|
| caught | missed | unviable | timeout |
|---|---|---|---|
| 169 | 0 | 110 | 0 |
Every mutant in the changed lines was caught by a test.
…s sets From the review of #1379: - A keyset request is now answered once per keyset in a batch, however many documents ask: a column of root-open documents under one keyset costs one cache lookup, or one ZeroKMS load on a miss, not one each. - A load by id that ZeroKMS (or a custom source) answers for another id is refused as KeysetMismatch. It would otherwise walk the document under another keyset's PRF and cache the state under an id no later lookup asks for. - The three places that built a KeysetState from an index key share one constructor. - JsonProjection's equality is now as sets, the way `jsonb @>` reads a projection: two seals of one document carry projections that differ only in their shuffled order, and compared unequal under the derived equality. - Opening a whole root-open document moves its entries into the rows instead of cloning every ciphertext. - `no_children`'s rustdoc said the sealed root commits to its heads in its plaintext, which this change does not do. It commits to nothing beneath it; the heads are each checked as the top of a read. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
|
Review (
|
Three doc comments in functions.sql said rows come back in document order. Under the random stored order the path query returns entries in stored order, which says nothing about the document; the children walk returns them by ordinal, which is position for an array and keyed rank for an object; and the subtree walk promises no order at all, since the client places every row by its tags. Comments only; no function changes. Refs #1357. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
With the stored order random, the client's containment and equality needles were the last thing in key order: built in the walk's order, an object's members in their keys' byte order, so a query with two sibling keys told the database which sorts first, at selectors it also sees in the stored rows. The leakage model found it. `JsonQuery::of_terms` and `at_path` now shuffle the needle's projection as a stored `q` is shuffled: every pair list and element-group list in a uniformly random order, recursively, with a fresh permutation per query. The seed is drawn from the cipher's OrderSource when the query's terms are derived (`terms_pending`), so it is the operating system's entropy outside tests and the seeded stream under `test-support`, and it travels in `JsonTerms` until the needle is built. `jsonb @>` is set containment over arrays, so no answer changes; `JsonQuery` equality is already as sets, and two queries for one value now serialize differently and compare equal. Nothing in SQL, the header or the data plan changes. Tests: two needles for one value under the entropy source differ in order and are equal as sets; a proptest over the walk's projections shows a shuffled needle matches exactly the stored projections (in either order) the walk-order needle matched; the generated containment property derives every needle twice and holds both to `jsonb @>`. The PostgreSQL json_tree oracle passes unchanged. The leakage model gains a `SentOrder` clause; a mutant claiming needles go in the walk's order is caught by `repeated_needles_sent_alike`, and the key-order reading test now asserts that nothing the database sees tells the two readings apart. The leakage page, the design note and the changeset say so. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
|
Follow-up on finding 4 of the review (needles not shuffled): this is no longer declined. At the owner's request, 810852c shuffles every containment and equality needle with a fresh permutation per query, from the same |
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
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
left a comment
There was a problem hiding this comment.
Review attestation: Claude Code /code-review medium (two parallel reviewers: cascade/order/derive; cipher/pending/query/docs) at head 810852c, cross-checked with company-skills:cross-check-review-findings against the 9 existing threads and 5 PR comments. Findings were verified against head code.
Critical / blocking: none. The cascade digest encoding, Fisher–Yates shuffle, panic/allocation paths, needle seed entropy and keyset dispatch checked clean.
New (all minor, non-blocking):
derive.rs:1256— root-token diagnostic now always yieldsError::Aeadfor a container root (inline).cipher.rs:652— by-id keyset load can overwrite a named cache entry with a nameless state (inline).sem/mod.rs:350andjson/query.rs:762(unchanged files/lines, so not inlined) —JsonOrderingAtPositionrustdoc and miettehelpstill say->compares "the first element at the path". After this PR it is an unspecified element at[*]paths. Matches the wording already used in the changeset and leakage page.docs/plans/2026-10-17-json-tree-index.md:504— "Open for the owner: the stored entry order" is not marked superseded by the 2026-10-26 notes.
Already covered by existing threads (dropped): -> of a container → Aead; keyset load sequencing and batch-wide failure; keyset id mismatch; JsonProjection equality; open_whole_chain clone; KeysetState duplication. Open thread on the SQL "document order" doc comments is unchanged (the author declined it as out of scope).
Not run: build and tests (the PR reports them green; the mutation report shows 0 missed).
Generated by Claude Code
…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
The EQL workspace, the Go encrypt guest and the stack-encrypt fuzz crate resolve stack-encrypt by path, so each lockfile gains the one `rand` line; `cargo metadata --locked` passes in all three again. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
…k hold `entry_aad` took a `tag` that was a byte slice (the data key's ZeroKMS tag), an `s` of type `Tag` that is a selector, and a `j`; it now takes `key_tag`, a `JsonSelector` and `slot`, and its rustdoc says where each comes from. The entry AAD domain is `v1` again: nothing of this format has shipped, so there is no earlier derivation to tell apart. `retrieval` and `retrieval_of` are one `key_retrieval` whose rustdoc says what it builds from the header and the context. On the read side a `Row` is a `ReturnedRow` with a `slot` and a `depth`, `Under` is the `ExpectedSelector` a node must carry, `Placing` is the `ChainWalk` the client runs over a read's rows, and `JsonNode::ordinal` is `slot`, which is what it returns. Sealing asserts the pre-order it relies on. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
…he cipher's keyset path A keyset request was answered through a function pointer the cipher captured at `init`, with its own boxed-future aliases per target, so that settling a pending could keep asking only for a `DataKeySource`. Every `StackCipher` is built over a source that is also an `IndexKeySource`, so the settling path now says so and `keyset_state` calls `load_keyset`, the function `StackCipher::keyset` loads a keyset with; the pointer, the aliases and the second constructor go. The `LoadKeyset` request kind stays, and its definition says why a request rather than a call: the open that needs the keyset runs inside a pending's fulfilment, after dispatch, which is the one place a pending reaches ZeroKMS. The load's future is boxed on its own, so the pending's boxed future does not grow by it: a generator is copied into its box with the bytes it does not initialise, and the heap-hygiene spy reads every freed block whole. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
…nly the documents that need one
A batch that opened root-open JSON documents under an uncached keyset
waited for `retrieve_keys`, then loaded the keyset in a second round
trip, and a load that failed failed every fulfilment in the batch,
scalar decrypts included. `dispatch` now runs the retrieves and the
keyset loads together (`futures::future::join`, one `keyset_state` per
distinct keyset through `join_all`), and a keyset request settles to a
`KeysetAnswer`: the loaded state, or the load's error, shared by every
request that named that keyset. `Responses::next_keyset` returns it as
`Error::KeysetLoad { keyset_id, source }`, so the documents under that
keyset fail and the fulfilments beside them draw their keys. The Go
guest maps the new error as its cause.
The joined sides, the per-keyset futures and `keyset_state`'s load are
built before any data key has been through the stack, and boxed: a
future is built on the stack and moved with the bytes it does not
initialise, and a join replaces a finished side's slot from a stack
temporary as wide as the side. `tests/heap_hygiene.rs` is what sees the
difference; it now zeroes what it allocates and scrubs the stack before
each watch, so what it reports is what the watched operation itself
wrote to the heap.
Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
…n alone without claiming tampering `->` on an object or array with members was `Error::Aead`, which the Go guest maps to `STATUS_AUTH`: an honest query for a container read as tampering. The client cannot know the entry's kind before it opens (the kind is inside the ciphertext, and the entry is sealed over children `->` does not return), so the open under the empty children digest stays and its refusal is a new `Error::JsonNotALeaf`, which names both causes, a container with children and an altered entry, and claims neither. The guest maps it to `STATUS_ENCODING`, as it mapped a container before the cascade. ADR 0010 records the exception under its restored Consequences heading; the changeset, the stack-encrypt changelog, the leakage page and the design note say the same. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
A member's ordinal, the `o` in its parent tag that the server probes from zero to enumerate a node's children, was its keyed rank among its siblings. Nothing reads a rank back: placement probes the parent tags and reads what is there, and a member's token does not depend on its ordinal. So each object's members now get a uniformly random permutation of `0..n` as their ordinals (`member_ordinals`, through `shuffle::ordinals`), drawn from the document's generator beside its stored order; `rank` and `RANK_DOMAIN` go. A token's holder learns nothing from the ordinals it probes, and cannot link a key to an ordinal from row to row. The pinned vectors move in `order` and in each entry's `p` alone; the self tags, selectors, tokens and selector set are unchanged. The leakage profile's simulator draws member orders per row, and its distinguisher for a consistent member order across rows is now chance-bounded, as the one across parents is. The design note, the leakage page and the changeset say the same. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
`json_tree_children` ordered its rows by stored slot, which was the ordinal's order while entries were stored depth-first and is a random order now, so an array's elements came back shuffled where the function is meant to be `jsonb_array_elements` for an encrypted array. It orders by the probe's ordinal, which the server already computes, so an array's elements come back in position order and an object's members in the random order their ordinals were drawn in; the comment says so and no longer promises that every child opens on its own. The PostgreSQL test compares the elements' order unsorted. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
…e does The cascade module's rustdoc carries a diagram: a small document, what each node's AAD binds, sealing leaves up and opening root down. The leakage page opens with what the database needs to answer queries and that the values stay secret, and that `json_tree` reveals strictly less than `ste_vec`, before listing what each capability costs; its closing line and the selector set's rustdoc no longer narrate what an earlier order showed. The crate's front-page example and changelog say that awaiting a pending needs an `IndexKeySource` beside the `DataKeySource`, which every cipher already has. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
The guest is a detached Cargo workspace, which the root `cargo fmt --all` does not reach; CI's `wasm:guest:test` runs rustfmt there and refused the line wrap in `a_failed_keyset_load_maps_as_its_cause`. 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. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
Brings in #1379 (the json_tree cascade and random stored order). The one conflict was tests/json_index.rs, where this branch and #1379 each carried a fix for the float round-trip test; next's version is taken, since it also finds the node by path rather than by its stored slot. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
…profile Brings in next with #1379 (the json_tree cascade and random stored order). 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
Brings in next's JSON tree cascade and random storage order (#1379) through the base, with the base's review fixes. The guest's status map keeps both sides' arms: JsonWrongEntry and JsonWildcardTruncated from this branch, JsonNotALeaf from #1379, all STATUS_ENCODING. The operators' derivation follows #1379's renames (a path builds the document `holding` a value; JsonQuery::of_terms and at_path take the terms by value), and the guest's json_open bounds IndexKeySource, which awaiting a pending now needs. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
…in the JSON operators #1379 stores a document's entries in a random order, shuffles every containment query's selector set, and makes an entry's ciphertext commit to its children's. Three things here assumed otherwise: - the data plan's operand parity test compared a containment or equality operand with the typed chain's byte for byte; it now reads both back as JsonQuery, which compares as sets; - `->` on a container with members now fails as JsonNotALeaf (Go: ErrEncoding, code stack_encrypt::json_not_a_leaf), so a selector read of `$.visits` is pinned to that refusal in the data plan test and through the Go guest, and json_open's and Cipher.JSONOpen's docs say it; - the docs said an ordering or `->` through a wildcard path compares the first element; it sees an unspecified one, and an ordering over every element is written over all the matches (json_tree_path_query). The same wording is corrected in JsonOptions::ordered, JsonOrderingAtPosition and the typed orderings' position check. The data plan's identifiers follow #1379's naming: a containment operand is a document's selector set, not a needle (DocumentInput, Input::Document, Binds::Query). Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
Brings in next's JSON tree cascade and random storage order (#1379) through the base, with the base's review fixes. The per-type PostgreSQL test's comment takes the base's wording, which names this branch's TestJSONDocumentsQueryAndReadInPostgres. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
A JSON document's entries are now stored in a random order (#1379), so `col -> sel` at a wildcard path, and an ordering through it, sees an unspecified element of each row rather than the first. The At accessors stay: their operands are what a comparison over every match takes, and the engine stores ordering terms at a wildcard path when asked to, so refusing ordered= there would leave those terms unreachable from Go. What changes is the SQL they are documented and tested with. - The generated XPath comment gives both forms: col -> Selector() at a path with no array step, and at a wildcard path EXISTS over eql_v4.json_tree_path_query for a comparison and the least or greatest match's ordering term for ORDER BY. Lt, Lte, Gt, Gte and Between get doc comments of their own, which they lacked, and Selector says which form it binds into. The READMEs and the CHANGELOG say the same. - TestJSONDocumentsQueryAndReadInPostgres compares at $.visits[*].at with EXISTS (some visit > 2, and some visit < 2, which only Alice's second visit meets), orders rows by their least visit ascending and greatest descending against the plaintexts' order, and reads a wildcard past its bound from PostgreSQL's own rows, which Open refuses as ErrEncoding. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
Brings in next's JSON tree cascade and random storage order (#1379) through the base, with the base's review fixes and its orderings at a wildcard path written over every match. Resolved: the generated ordering accessors keep this branch's `any` operands and take the base's doc comments, the path type's comment keeps this branch's sentence on Go operands, planJSON keeps the base's refusal of ordered paths the engine answers no ordering on without the base's encoding/json import (a document of a Go type does not need it), the encrypt README's JSON paragraph keeps both sides' sentences, and json.go imports what both sides use. The generated test models are regenerated. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
A containment query now sends its selector set in a random order of its own (#1379), so two derivations of one document, typed or as text, serialize differently. The test that holds a typed needle to the query its JSON text derives compared bytes; it now sorts every list of both queries, recursively, and compares those. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
Summary
EQL v4's
json_treestores an encrypted JSON document in PostgreSQL as one small AES-GCM ciphertext per node, plus search terms the database can match without the key. This change makes the stored form reveal less and makes the integrity check independent of storage order:The format is unreleased, so there is no migration: documents sealed by the earlier build do not open. Nothing stored changes shape; one SQL function (
json_tree_children) returns a node's children in ordinal order, where it returned them by stored slot.Changes
Stack Encrypt (
packages/stack-encrypt)src/json/cascade.rs,placeinderive.rs). Each entry's AAD binds the SHA-256 digest of its children's AEAD tags, in ordinal order. Sealing runs leaves up; a read places the rows by the chain, as the server finds them, and opens each node over the children placed under it. The module's rustdoc carries a diagram.src/json/shuffle.rs). OneRandomnesssource per cipher draws, per document or query, aSafeRandseeded from the operating system; the shuffles arerand'sSliceRandom::shuffle(therandvitaminc-random wraps). The stored slots (root at 0), each object's member ordinals (member_ordinals), the stored selector setqand a query's selector set are all drawn from it. The deterministic stream for fixtures (StackCipherBuilder::json_seed) exists only under thetest-supportfeature.JsonProjectionisJsonSelectorSet(the selector pairs of a document's nodes, grouped by array element: what a row stores asqand what a containment query sends), withOpaqueDebugand constant-time set equality through vitaminc'sTimingSafeEq;JsonDocument::selector_setandJsonQuery::selector_set.JsonNode::slotreplacesordinal. On the read side aReturnedRow, anExpectedSelectorand aChainWalksay what they hold, andentry_aadtakeskey_tag, aJsonSelectorandslot.Protected<PlainJson>, the crate's existing zeroizing newtype, wherever the index owns one (sealing, the query checks, the owned-source release).Request::load_keyset, answered through the cipher's ownload_keysetpath). The loads run beside the data-key retrieval, and a load that fails isError::KeysetLoadon the documents under that keyset alone. Awaiting aPendingnow boundsK: DataKeySource + IndexKeySource, which everyStackCipheralready has.->on a container with members isError::JsonNotALeaf, notError::Aead: the entry does not open alone (its ciphertext commits to children->does not return), and the client cannot tell that from an altered entry before the open, so the error names both causes and claims neither. The Go guest maps it toSTATUS_ENCODING.tests/heap_hygiene.rsholds it, and now zeroes what it allocates and scrubs the stack before each watch, so it reports what the watched operation itself wrote to the heap.tests/fixtures/json_tree_vectors.json) are regenerated and the tags and tokens are unchanged; the leakage profile's simulator draws per-row member orders.EQL (
packages/eql)json_tree_childrenorders by the probe's ordinal (ORDER BY p.o), so an array's elements come back in position order; three doc comments stop promising document order; the PostgreSQL oracle compares the elements' order unsorted and assertsJsonNotALeaffor a moved entry read through->.docs/reference/json-tree-leakage.mdopens with what the database needs to answer queries and that the values stay secret, saysjson_treereveals strictly less thanste_vec, and records what a read proves and what a token's holder learns from random ordinals.Go guest (
languages/golang/encrypt/guest): the generic operations boundIndexKeySourcetoo; the status map coversKeysetLoad(as its cause) andJsonNotALeaf.Docs:
docs/plans/2026-10-17-json-tree-index.md(the 2026-10-26 notes), ADR 0010 (the->exception under a restored Consequences heading), the stack-encrypt changelog and a@cipherstash/eqlchangeset.Verification
Run locally on this branch (b540fa0):
cargo fmt --all --check: clean.RUSTFLAGS=-D warnings cargo clippy --workspace --all-features --testsand--workspace --tests(default features): clean.mise x --env test -- cargo nextest run --workspace --all-features: 1496 run, 1496 passed, 1 skipped.tests/heap_hygiene.rspasses repeatedly (six consecutive runs of the binary).mise run test:doc: 50 doctests in stack-encrypt pass;mise run doc: clean.packages/eql:mise run test:encryption: 526 passed, 0 failed.mise run test:encryption:postgresagainst a fresh PostgreSQL 17.10 database with the v4 bundle built from the tree: every suite passes, thejson_treeoracle 14 of 14 (42 PostgreSQL tests in all, 0 failed).mise run wasm:guest:build,wasm:guest:build:eql, both deterministic variants andwasm:auth-guest:buildbuild;mise run go:test: every package passes (11ok, none failed).cargo metadata --lockedpasses in the root workspace, both Go guest workspaces, the EQL workspace and the stack-encrypt fuzz crate (each gainedrand, and the rootfutures).cargo mutants --no-shuffle -vV -p stack-encrypt --in-diff <this pass's diff> --jobs 4: 124 mutants, 62 caught, 62 unviable, 0 missed, 0 timed out.Related
Review notes
cascade.rs(the diagram), thensealandplace/ChainWalk::nodeinderive.rs, thenshuffle.rs.PermutationKey<N>is defined for N of 8, 16, 32, 64 and 128 only, and a document has any number of nodes, so the shuffle isrand's (the one vitaminc-random wraps). A slice shuffle in vitaminc-random would letshuffle.rscall that instead.->at a wildcard path (any path with an array step) returns an unspecified element of the row, the same one every time that row is read; every element is reached throughjson_tree_path_queryor a subtree read.ORDER BYover rows already computed.https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu