Repository navigation
Conversation
… chain and exact containment Stack Encrypt had no JSON index: `op::Contains`, `op::ContainedBy` and `op::AtPath` were declared (#1209) and answered by nothing, and the v4 JSON wire format was unconstrained by anything shipped. This is Phase 1 of the spike in docs/plans/2026-10-17-json-index-stack-encrypt-spike.md (#1249). `Json` is an `Index<serde_json::Value>` whose term, `JsonTerms`, is the document's one-way terms: a path selector and a value selector per node, a token per node below the chain's heads, the grouping by container array element, and a leaf ordering term on numbers and strings. Sealing is the separate operation `target::json_document`, which flattens the document, requests one data key under the column's descriptor and seals every node's own label, kind and scalar under it, so a document's ciphertext is linear in its size. The stored `JsonDocument` carries the design's chain fields: position-keyed parent tags, wrapped tokens, and group tags salted by the document's iv, so nothing links across rows until a query supplies a token. By default the root is sealed: the root entry is the whole document and the top-level fields are the chain heads, so no token exists for the root. Queries go through the engine's own operators. `Supports<Contains>` and `ContainedBy` send a `JsonQuery`, the element-nested projection of the needle's selectors, which native `jsonb @>` compares exactly across array elements (the v4 half of #1231). `AtPath<Eq>` sends the projection of the needle document that holds the value at the path; the orderings at a path send a `JsonLeafQuery`, the selector and the leaf's OPE term; `Between` a `RangeQuery` of two. `Input<Pathed<One>>` and `Input<Pathed<Pair>>` are implemented for `(path, value)` and `(path, (lo, hi))`, which #1209 left open until the index existed. `JsonDocument` is an `EncryptFrom<Value>` target and a `DecryptInto<Value>`, and an extracted node (`JsonNode`) or the rows of a subtree walk (`JsonSubtree`) open on their own. `IndexSpec::Json(JsonOptions)` is the data form, key `"json"`, with the object form for a non-default `sealed_root`; a data plan reads it and refuses to run it with `Reason::JsonIndexPending`, since the binding's half is not written. The data form, the dynamic arms and the Go guest's and fuzz crate's lockfiles move with it (stack-encrypt gains `sha2`, already in the graph through vitaminc-hmac). Two deviations from the design are recorded in the module docs and the plan: nonces are the AEAD's own random ones rather than derived, so the crate keeps one leaf cipher, and the ordinal field is `o`, since `i` is the EQL envelope's identifier. Refs #1249, #1231, #1209, #1060
…, EQL v4 only A new family beside `public.eql_v4_json`, not a port of the SteVec surface: `public.eql_v4_json_tree` (the document), `_json_tree_entry` (one node, as the extractors return it), `eql_v4.query_json_tree` (a containment needle) and `eql_v4.query_json_tree_leaf` (an ordering operand at a path). Phase 2 of the spike in docs/plans/2026-10-17-json-index-stack-encrypt-spike.md (#1249). Confined to v4 with `-- ONLY EQL VERSION: 4`, so the v3 bundle is unchanged; the build's ordering, completeness, symbol-order and isolation gates pass as they are. Everything the server does is core PostgreSQL. Containment projects a stored document from its group tags into the element-nested shape the needle has (`eql_v4.json_tree_query`, a recursive plpgsql group builder) and compares the two with native `jsonb @>`, so each needle array element must fit inside one stored element: PostgreSQL's own rule, which closes the cross-element false positives of selector-set containment. A GIN index on that expression serves `@>`; the function is deliberately not STRICT, because a STRICT SQL function whose body is an `OR` is not inlined and the index never engaged until it was changed. The token chain is walked by a recursive CTE from one client-derived token, with `sha256`, `set_byte` and a sixteen-byte XOR: `json_tree_subtree`, `json_tree_subtree_at` (gated on a selector for a wildcard below the anchor), `json_tree_children` and `json_tree_child_count`. `->` by selector grafts the header and the entry's ordinal, so the entry opens on its own; `ord_term` serves a functional btree for the orderings. Every other native jsonb operator on the column is blocked, as on the other domains. `eql-bindings` gains the hand-written `v4::json_tree` types, with Stack Encrypt's `EncryptFrom<Value>` / `DecryptInto<Value>` for the document, the entry and a subtree's rows, under an `Identifier`; the family is not in the catalog yet, so it is not in the target table or `v4::all()`. The oracle is `tests/encryption/tests/json_tree.rs`, run by `mise run test:encryption:postgres`: the design's corpus and random documents stored encrypted beside their plaintexts, with `@>` and `<@` over the encrypted column checked against PostgreSQL's own `jsonb` operators over the plaintext column for every needle, the GIN index shown in `EXPLAIN`, extraction, the walk, the array functions and the orderings. This is Phase 0's oracle folded into Phase 2, against the real SQL and real encryption under the fake key source, so it runs on a fork PR. Refs #1249, #1231
…its design The implementation notes for Phases 1 and 2: where the code is, which of the ten decisions it took, and the deviations a reviewer reading the design first should know before the code (random nonces, the `o` ordinal, the walk excluding its anchor, the non-STRICT containment, the `<@` gap for a stored root scalar, byte-for-byte string selectors, and what the data plan and the catalog still lack). The agent guides point at the family's four homes. Refs #1249
🦋 Changeset detectedLatest commit: 8598446 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 ⚙️ Run configuration
You can disable this status message by setting the Use the checkbox below for a quick retry:
Comment |
…tence test `v3_and_v4_install_side_by_side` asserted that eql_v3 and eql_v4 own the same number of public domains. That held while the two bundles were one SQL tree rendered under two names. The JSON tree family under `src/v3/json_tree/` is confined to v4 with `-- ONLY EQL VERSION: 4`, so v4 now owns `public.eql_v4_json_tree` and `public.eql_v4_json_tree_entry` with no v3 counterpart, and the count assertion failed in CI (52 vs 54). The test now compares the domain sets by name: every v3 public domain must exist in v4 under the v4 prefix, and the domains v4 owns that v3 does not must be exactly the JSON tree family. A future v4-only domain fails the test until it is named here, which is the point: EQL v3's SQL must not change, and a domain leaking from v4 into v3, or a v3 domain missing from v4, is a build regression rather than a design decision. Verified by installing both built bundles into a scratch PostgreSQL 17 and diffing the sets with the same query: 52 v3 domains, 54 v4, nothing missing from v4, and the two JSON tree domains as the only surplus. Refs #1288
…tence test `v3_and_v4_install_side_by_side` asserted that eql_v3 and eql_v4 own the same number of public domains. That held while the two bundles were one SQL tree rendered under two names. The JSON tree family under `src/v3/json_tree/` is confined to v4 with `-- ONLY EQL VERSION: 4`, so v4 now owns `public.eql_v4_json_tree` and `public.eql_v4_json_tree_entry` with no v3 counterpart, and the count assertion failed in CI (52 vs 54). The test now compares the domain sets by name: every v3 public domain must exist in v4 under the v4 prefix, and the domains v4 owns that v3 does not must be exactly the JSON tree family. A future v4-only domain fails the test until it is named here, which is the point: EQL v3's SQL must not change, and a domain leaking from v4 into v3, or a v3 domain missing from v4, is a build regression rather than a design decision. Verified by installing both built bundles into a scratch PostgreSQL 17 and diffing the sets with the same query: 52 v3 domains, 54 v4, nothing missing from v4, and the two JSON tree domains as the only surplus. Refs #1288
Mutation testing (cargo-mutants,
|
| caught | missed | unviable | timeout |
|---|---|---|---|
| 314 | 0 | 85 | 0 |
Every mutant in the changed lines was caught by a test.
This comment has been minimized.
This comment has been minimized.
The Mutants gate on this branch reported 53 surviving and 6 timed-out mutants across stack-encrypt's json module. Each is now caught, removed or excluded: - The path parser and the tree rebuild indexed their input by hand, so a `+= 1` mutated to `*= 1` never advanced and the test run hung. The parser now walks a shrinking &str and the rebuild pulls children from an iterator, so every step consumes input and no such mutant exists. - New tests pin what the survivors showed was unchecked: refusal offsets and reasons for malformed paths, JSON escapes in quoted names, the integer/float boundary in number encoding, every truncation of a header and of a node label, the key tag's u16 length limit, the header and selector Debug forms (a subtree key never prints its token), stored wrapped tokens differing from the token, opening each stored form as a record field, documents without a sealed root, and accessor queries refused before any keyset load. - Four mutants are equivalent and are excluded in .cargo/mutants.toml with the reason beside each, matched on the specific replacement. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
The hand-written impl printed the keyset id, the root flag and the epoch and left the ZeroKMS iv and tag out. vitaminc's OpaqueDebug derive does the same job declaratively: the three layout fields are marked `#[non_sensitive]`, and the iv and tag, which retrieve the data key, print as the mask. The masking is now part of the type's declaration rather than a convention a later edit to the impl could drop, and the header carries the OpaqueDebug marker like the crate's other secret-adjacent types. The printed text changes (the derive names the type by its full path and lists the masked fields), so the test that pins it is updated to the new text and now asserts the masking too. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
The design document moved to `docs/plans/2026-10-17-json-tree-index.md` in the merge from the document's branch. Every reference to the old path follows it: the root and EQL `AGENTS.md`, the module docs of the Stack Encrypt JSON index and the `eql-bindings` `v4::json_tree` types, the `json_tree` SQL header, and the changeset, which also stops calling this work a spike. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
6427424 to
b9ac367
Compare
b8d1c87 to
55bbd66
Compare
…t reads back The JSON index admitted 512 levels of nesting, but a sealed root's plaintext is the document as JSON and serde_json parses JSON back only to 128 levels. A document nested 128 to 512 levels deep sealed without error under the default option and then opened to OpenedType: the write succeeded and the value was lost. The walk now refuses a document deeper than 127 levels under either root option, so the failure happens when the caller still holds the value, and the option never changes which documents the index accepts. A test seals the deepest admitted document of arrays and of objects under both options and opens it, and checks that one level deeper is refused when sealed. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
A child's token was an unkeyed hash of its parent's token and its label,
H("t" || t(parent) || 0x01 || key), and an object member's rank another,
H("o" || t(parent) || key). The walk hands the server every descendant's
token, so a server that held one token could test guessed key names
against every child below it, in every row of the column, for as long as
the column lived. Object keys are data whenever an object is a map.
The design's decision 11 proposed keying the step, and it is free: only
the client derives a child token from a label (the server unwraps w), and
the client holds the index key. Both steps are now PRFs under the index
key with their own PAE domains, json/token-step/v1 and json/rank/v1. This
changes every stored token below a chain head and every object ordinal, a
format change; the types are unreleased, so no stored document carries
the old form. The SQL never derives either value, so it is unchanged. The
design's implementation notes record decision 11 as taken and count
eleven decisions, not ten.
Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
…ake a reader do A stored document comes back from the database, so it is untrusted input, and its chain fields are not authenticated. Three reads let it choose the work: - JsonDocument::children computed max(o) + 1 and derived a tag for every ordinal below it. An entry with o = u32::MAX overflowed (a panic in a debug build) and, in a release build, a large o allocated up to four billion tags. It now checks each entry against the parent tag at the entry's own ordinal, which is the same set the SQL probe finds. - JsonDocument::subtree recursed with no bound, so a cycle of tags, or a chain longer than any sealed document, which whoever holds a token can write, overflowed the stack and aborted the process. It now stops below MAX_DEPTH and after as many entries as the document holds. - The rebuild of a subtree's rows nested whatever containers the server lined up, and the server can repeat a row or return only the containers of a wide document. A repeated ordinal is now refused, and a nesting deeper than MAX_DEPTH is OpenedType. The SQL walks get the same depth bound, so a cyclic document cannot keep a recursive query running. Tests build each malformed document; the rebuild test fails without the bound. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
JsonTerms said it held no plaintext, but each node keeps its positional path, whose key names are the document's plaintext keys, and its chain token, a capability over that subtree in every row. The derived Debug printed both, so a JsonTerms or an AtPathTerms in a log wrote out the document's key names and the tokens JsonSubtreeKey is careful never to print. Debug now prints the node count alone, and the doc comment says what the terms do hold. A test formats terms over a document keyed by an email address and checks that neither the key nor a token appears. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
…ontainment
op::AtPath is documented as an exact operator over one value at a path,
and the JSON index answered AtPath<Eq> with the containment of the needle
that holds the value there. For a scalar below the root that is
equality. For an object or an array it is containment: ("$.a", {"x": 1})
matched {"a": {"x": 1, "y": 2}} and ("$.a", []) matched every array at
$.a. At the root, the needle's alt made ("$", 1) match a stored [1].
A container value is now refused when the query is awaited, with a new
TermError::JsonValueNotScalar whose help points at op::Contains, and an
accessor's needle carries no alt. Tests cover the refusals and the root
case against op::Contains on the same documents.
Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
The firewall's header said every native jsonb operator other than the
supported ones was blocked, but the entry domain blocked only = and <>
against another entry. (doc -> sel) < '{...}'::jsonb and = '{...}'::jsonb
resolved to the native jsonb operators, compared the entry's ciphertext
object and returned rows with no error, which is exactly what a query
gets when its ordering operand loses the query_json_tree_leaf cast.
The six comparisons between an entry and jsonb are now blocked in both
directions. ORDER BY col -> 'sel' cannot be blocked without an operator
class, which needs a superuser, so the firewall's header and ord_term say
to order by eql_v4.ord_term(col -> 'sel'). The PostgreSQL oracle checks
that all twelve forms raise.
Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
The pull request adds the JSON index, six TermError variants and a sixth IndexSpec variant, but the crate's changelog did not mention any of them and still said the JSON operators had no index to answer them. IndexSpec is exhaustive on purpose, so the new variant is a breaking change and is listed as one. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
|
One more, found while fixing the review: |
…-and-projection Brings up the #1288 review fixes: the 127-level depth bound, the keyed token step and member rank, the bounds on reading a stored document, the JsonTerms Debug, AtPath<Eq> as equality, the entry-versus-jsonb blockers and the changelog. One conflict, in functions.sql: this branch rewrote json_tree_subtree and json_tree_subtree_at around the parent map and the budget, and the fix added a depth bound to the old walks. This branch's walks are kept, with the bound re-applied to their recursive terms (WHERE c.entry IS NOT NULL AND w.depth < 127), since they still unwrap w and a cyclic document could otherwise keep an uncapped walk recursing. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
…-ordinal-chain Brings up the #1288 review fixes. This branch replaced several of the places they touched, and its behaviour is kept wherever both sides edited the same code: - derive.rs: the ordinal chain is kept. Its child token is H(t || o), which the server derives as it walks and which carries no label, so the keyed label step from #1288 (json/token-step/v1) is dropped with it. The keyed member rank (json/rank/v1) stays: the server sees which selector sits at each ordinal under a token, so an unkeyed rank would let it rank guessed key names against them. Its doc and test say so. - mod.rs: this branch's bounded probe in children() is kept, and the walk takes both bounds, the entry count from here and MAX_DEPTH from #1288. The malformed-document test is rewritten for the ordinal chain (a 1000-link chain any token holder can write); the cycle and u32::MAX ordinal tests do not apply to a chain derived by hash with no stored ordinal. - functions.sql: this branch's walks are kept with no depth bound. A chain derived by hash cannot cycle and is no longer than the document, and a recursive CTE iterates rather than recursing on the stack. - codes.rs, sem/mod.rs: both new TermError variants, JsonPathNotOrdered and JsonValueNotScalar. - json_index.rs: both sides' tests, adapted to this branch's options API. - The design notes count eleven decisions on top of this branch's amendments, and record that only the keyed rank survives the ordinal chain. The changelog entry describes this branch's form of the index. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
Brings up the #1288 review fixes as #1329's branch carries them: the 127-level depth bound, the keyed member rank, the walk and rebuild bounds, the JsonTerms Debug, AtPath<Eq> as equality, the entry-versus-jsonb blockers and the changelog. No conflicts. With the rank keyed, the leakage reference's statement that nothing it describes reveals a key name holds for a token's holder too: the member order a token exposes is an order under the index key. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
…build bound The mutants gate found that the rebuild's depth bound was exercised only through objects: replacing `depth + 1` with `depth * 1` in the array arm survived, since the test lined up one-member objects alone. The test now also lines up one-element arrays, each taken as the element of the one before, and fails with that mutation. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
…-and-projection Brings up the array case of the rebuild-bound test from #1288, which the mutants gate asked for. No conflicts. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
…-ordinal-chain Brings up the array case of the rebuild-bound test from #1288, which the mutants gate asked for. No conflicts. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
Brings up the array case of the rebuild-bound test from #1288, which the mutants gate asked for. No conflicts. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
The json_tree stack targets next, but this branch forked from main and never took next, so every PR above it showed next's own changes (block ORE, text search terms, typed queries) as its diff. Bringing next in here shrinks #1288's view from 187 files to the 46 it changes. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
…ex-stack-encrypt Carries the base's merge of next up the stack, so the pull request diff reads against one merge base again. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
…-ordinal-chain Carries origin/next up the stack from #1250's branch through #1288's and #1325's, so this PR reads against one merge base. Content-empty: this branch already had next. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
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. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 85984466ee
ℹ️ 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".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| -- No sealed document nests deeper than 127 levels. A stored one | ||
| -- whose tags form a cycle or a longer chain, which whoever holds a | ||
| -- token can write, stops here instead of recursing without end. | ||
| WHERE w.depth < 127 |
There was a problem hiding this comment.
Cap recursive subtree walks by entry count
When a structurally valid stored payload has two children whose parent tags match the supplied token and whose wrapped tokens point back to that token, every recursive level doubles the working set. The depth check still permits up to 127 levels, so json_tree_subtree and json_tree_subtree_at can attempt an effectively unbounded number of rows and exhaust database resources; the Rust walker already limits output to the document's entry count. Add an equivalent row/visited-entry bound to both recursive CTEs rather than relying only on depth.
Useful? React with 👍 / 👎.
| SELECT COALESCE(jsonb_agg({{prefix}}_internal.json_tree_group(sv, elem ->> 'n') ORDER BY ord), '[]'::jsonb) | ||
| INTO groups | ||
| FROM jsonb_array_elements(sv) WITH ORDINALITY AS t(elem, ord) | ||
| WHERE elem ? 'n' AND (elem ->> 'g') IS NOT DISTINCT FROM parent; |
There was a problem hiding this comment.
Stop cycles while constructing containment groups
A domain-valid payload can make this recursion call itself with the same parent indefinitely—for example, a top-level entry with n = A makes the initial group descend into A, and another entry with n = A, g = A causes json_tree_group(sv, A) to recurse into json_tree_group(sv, A) again. Since the domain only checks that these fields are strings, projecting such a row (including while maintaining the documented GIN expression index) eventually hits PostgreSQL's stack-depth limit and aborts the operation. Track visited group tags or impose a document-sized recursion bound.
Useful? React with 👍 / 👎.
Summary
The JSON index for Stack Encrypt (our Rust encryption engine), and the EQL v4 domain family that stores and queries what it writes. EQL is the SQL library we install into a customer's PostgreSQL so it can search encrypted values. Before this, Stack Encrypt could not encrypt a JSON document searchably at all: the JSON operators existed but nothing answered them. After it, a JSON document encrypts into one small ciphertext per node (so the ciphertext grows with the document, not with the document times its depth), the server can walk the subtree under a node from one token the client derives from a path, and containment (
@>) agrees with PostgreSQL's ownjsonb @>across array elements, which the v3 design gets wrong (#1231).This is Phases 1 and 2 of the phased plan in #1249, built against the design in #1250 (this PR is stacked on it: read the design first, then this). Phase 0's oracle is folded into Phase 2. Phases 3 (measure) and 4 (decide the wire) are not here.
Changes
Engine (
packages/stack-encrypt)src/json/: theJsonindex (Index<serde_json::Value>), the sealing operationtarget::json_document, the storedJsonDocument(anEncryptFrom<Value>/DecryptInto<Value>target), the query terms (JsonQueryfor containment andAtPath<Eq>,JsonLeafQueryfor the orderings at a path), the extraction keys (JsonSelector,JsonSubtreeKey, derived on the keyset cipher), and what an extraction opens (JsonNode,JsonSubtree). The module docs are the reference for the wire form and the derivations.op:Supportsimpls forContains,ContainedBy,AtPath<Eq | Lt | Lte | Gt | Gte | Between>, and theInput<Pathed<One>>/Input<Pathed<Pair>>impls for(path, value)and(path, (lo, hi))that feat(stack-encrypt): JSON operators Contains, ContainedBy and AtPath #1209 left open.JsonDocumentanswers them, and they default toJson.IndexSpec::Json(JsonOptions), key"json", with the wire form{"json": {"sealed_root": bool}}. The data plan reads it and refuses to run it with a new reason,json_index_pending.TermErrorvariants for a bad path, a document too deep or wide, the sealed root's missing token, and an ordering on a non-orderable leaf.sha2, already compiled in the graph throughvitaminc-hmac; the four lockfiles that record the edge move with it.EQL (
packages/eql)src/v3/json_tree/{types,functions,operators,blockers}.sql, confined to v4 with-- ONLY EQL VERSION: 4: the four domains, the containment projection and operators,->, the subtree walk, the array functions, the ordering extractors, and the native-jsonb firewall. The v3 bundle is byte-for-byte unchanged.crates/eql-bindings/src/v4/json_tree.rs: the hand-written types with Stack Encrypt's targets. Not in the catalog yet, so not in the target table orv4::all().tests/encryption/tests/json_tree.rs, added tomise run test:encryption:postgres..changeset/eql-json-tree-domain.md(@cipherstash/eqlminor).Docs
Verification
cargo nextest run -p stack-encrypt --all-features: 716 tests pass, including the 14 new ones intests/json_index.rs: round trips under both root options, the chain unwrapping every child from its parent's token and nothing from a wrong one, tamper evidence (reordered, regrafted, truncated, a flipped byte, another column, a lifted node), and containment over the projection agreeing with a Rust model ofjsonb @>on the design's corpus and 300 random pairs. The model is pinned to the 23 answers injsonb-semantics.sql. Clippy clean with all features and with none; rustdoc clean with warnings as errors; the doc examples pass; the trybuild snapshots were regenerated and differ only by the new implementors they list.mise run test:encryption:postgresagainst PostgreSQL 17, with the v4 bundle built from the tree: all six suites pass. The new one stores 72 documents encrypted beside their plaintexts and, for 89 needles, checks that@>and<@over the encrypted column select exactly the rows PostgreSQL's ownjsonboperators select over the plaintext column, thatEXPLAINshows aBitmap Index Scanon the GIN index, that->opens to the leaf, that the walk from a client-derived token returns the six entries under$.nestedand reassembles them, that a wrong token unlocks nothing in any row, that a wildcard path walks each element's subtree, that the array functions count and list elements, that the orderings at a path select and sort as the plaintexts do, and that a native operator on the column raises.mise run test:encryption(both feature sets, clippy with warnings as errors),check:encryption:wasi,test:crates,build,codegen:parity,types:check,docs:validate:coverage,docs:validate:required-tags, anddocs:validate:documented-sql(the whole v4 install order in one transaction): all pass. The Go guest checks and lints with itseqlfeature under--locked. The three EQL CI-derivation tests underscripts/__tests__pass.STRICTcontainment function with anORbody is not inlined by PostgreSQL, so the GIN index never engaged until it was made non-STRICT (recorded in the SQL and the design notes); and an untyped literal on the firewall test resolved to the native operator, as the domain docs warn.stashgen, and the TypeScript export, which the design keeps out of the phased plan.Related
Refs #1249 (Phases 1 and 2)
Refs #1231 (the v4 half: exact containment across array elements)
Refs #1209, #1060
Stacked on #1250
Review notes
Read the design's new "Implementation notes" section first; it lists the deviations in one place. Then
packages/stack-encrypt/src/json/mod.rsfor the wire form,derive.rsfor the walk and the sealing,query.rsfor the projection, andpackages/eql/src/v3/json_tree/functions.sqlfor the server side of the same three things. The oracle intests/encryption/tests/json_tree.rsis what holds the two sides together.Deliberately deferred, with the reason in the design notes: a self tag per node (so a walk could return its anchor), the data-plan form, the catalog entry, and wiping the plaintext copies the walk makes.