Repository navigation
feat(eql, stack-encrypt): chain json_tree tokens by ordinal and probe the walk to the first miss - #1329
feat(eql, stack-encrypt): chain json_tree tokens by ordinal and probe the walk to the first miss#1329coderdan wants to merge 27 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
|
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 |
Mutation testing (cargo-mutants,
|
| caught | missed | unviable | timeout |
|---|---|---|---|
| 135 | 0 | 81 | 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: 62f2132 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
…e index declares
The leaf ordering term `op` was derived for every number and string leaf
of a JSON document. After the ordinal-chain change it was the largest
field of a stored document (24,832 of 85,157 characters on the nested
benchmark document, about 80 bytes of hex per leaf), and the one field
that says something across rows at rest: the order of a leaf's value
among every row that holds one, on paths nobody asked to order.
A JSON index now names the paths it orders. `JsonOptions` gains
`ordered`, a set of `JsonPath`s beside `sealed_root`; the walk derives a
leaf's term only when some ordered path covers the node's positional
path (`JsonPath::covers`): a wildcard step covers every array position,
a position covers that element alone, so `$.orders[*].total` and
`$.scores[0]` are both allowed. No path is ordered by default. The set is
part of the index's identity and crosses the data-plan wire as
`{"json": {"sealed_root": true, "ordered": ["$.age", ...]}}`; it is not in
the header, since a stored leaf shows whether it carries a term.
An ordering query at a path the index does not order is refused when the
query is awaited (`TermError::JsonPathNotOrdered`), before any keyset
loads. Without the refusal `eql_v4.ord_term` is NULL for every stored
leaf at the path and the comparison silently matches no row.
The typed plan had no place for options: `encrypt_into::<JsonDocument>()`
reads the type's static `EncryptFrom`, so every typed plan sealed under
the defaults and a query under other options was refused as
`PlanError::IndexOptions`. `target::Layout<S>` is a target's layout
chosen by value, with the index it answers queries through;
`Json::new(options)` implements it, and `Plan::value().encrypt_as(layout)`
builds a `ValuePlan<_, Laid<X, T>>` that seals under the options and
resolves every query through the plan's own index, so a query does not
name the options again. `eql_bindings::v4::JsonTree` is the same layout
for the EQL type, under an identifier.
The SQL is unchanged: `ord_term` was already NULL for an entry without
`op`, and the entry validator already admitted its absence. The
PostgreSQL oracle seals its corpus with `$.hello` and `$.number` ordered,
the paths its ordering checks ask at.
Refs #1231
Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
…ntext check cargo-mutants over the opt-in commit left five mutants alive, all on the new `Laid` plan shape: its `specs` (and the `Layout::specs` default it calls) could return nothing, its `Debug` could print nothing, and the `Opens::check` of one document and of a batch could accept a call that names a context the plan already has. `ValuePlan::specs` is added so a test can read the indexes a plan declares, as `FieldPlan::indexes` does, and the laid-out plan test asserts the four. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
…rm opt-in The benches re-sealed both corpora with one ordered path each and measured the stored sizes at 10,000 and 100,000 rows; the Phase 3c note carries the table in place of the estimate it gave, and the two side effects the measurement found (the flat document leaving TOAST, the flat GIN growing with its row spread). Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
… before its keyset loads The two `Opens::check` impls of the `Laid` plan survived mutation: the opening itself resolves the context again, so a call that names a second context failed either way and the test could not tell the early refusal from the late one. The counting cipher can: the refusal in `check` runs before any keyset loads, and the test now asserts that count. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
…-ordinal-chain Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
…base85, and the parent tag in eight bytes The stored document's largest costs after the opt-in ordering term were its encodings: every sixteen-byte tag took 32 hex characters, and the ciphertext took base64's eight characters per six bytes on the field that is most of a document. The rule for what may change is the one the design set: a field the SQL compares, derives or decodes stays in an alphabet core PostgreSQL encodes and decodes natively, and only a field the SQL never opens may take a denser one. So every field the SQL reads is padded standard base64, as `encode(x, 'base64')` writes it: the selectors `s` and the `s`/`vs` pairs of `q`, the parent tag `p`, the ordering term `op`, and the tokens and gates a walk takes. Padded, because `decode(x, 'base64')` refuses the unpadded form, and not base64url, because PostgreSQL has no decoder for it. The ciphertext `c`, which no SQL function decodes, is RFC 1924 base85 through `base85-simd` 0.1.0 (SIMD on aarch64 and x86_64, scalar code on wasm32). The header `h` stays base64: the SQL reads the `iv` and the sealed-root flag out of it. The parent tag `p` is now the first eight bytes of its hash. It is a lookup key for the server's walk within one document and nothing else: an entry's AAD binds its selector and its ordinal, not its parent tag, so the truncation authenticates nothing less, and eight bytes keep a collision in a ten-thousand-node document below one in 10^11. `eql_v4.ord_term` decodes `op` through a new `eql_v4_internal.json_tree_ope_cllw`, because the `ope_cllw(jsonb)` the scalar types share reads hex and those wires are released. The bindings get `JsonTreeSelector`, `JsonTreeCiphertext` and `JsonTreeOrderingTerm` so the v4 wire is documented apart from the v3 hex types. Re-encoding 1,000 stored rows of each benchmark corpus and reloading them as jsonb: a nested document's `pg_column_size` goes from 40,808 to 31,894 bytes (22% smaller), a flat one's from 1,596 to 1,398 (12%). The corpora are not re-ingested and no query is re-timed here. The design doc's Phase 3d note records the decision and the measurement. Refs #1324 Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
Seven percent of a nested document is what a nonce derived from the header and the ordinal would save. Taking it needs vitaminc to accept a caller-chosen nonce, and that is the one thing its AEAD layer withholds on purpose: its misuse resistance rests on never letting a caller repeat a (key, nonce) pair. The safety argument here, a fresh data key per seal, is a stack-encrypt invariant vitaminc cannot check. The random nonce stays. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
…-ordinal-chain # Conflicts: # docs/plans/2026-10-17-json-tree-index.md
d80a33a to
cfd5dea
Compare
fdf1eb9 to
9657508
Compare
…-ordinal-chain Brings up the review fixes to the json_tree design record and its SQL prototypes from docs/json-index-stack-encrypt-spike. One conflict, in section 3 of the design record: this branch added a dated note after the native-projection paragraph and the review added a ContainedBy paragraph at the same place. Both are kept, the dated note first since it annotates the paragraph above it. 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
…-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
…-ordinal-chain Carries the #1325 review fixes up. The ordinal chain already replaces the code two of them fixed, so this branch keeps its own behaviour there: - functions.sql is this branch's. The fan-out cap and the walk's entry-count limit guard the wrapped-token chain and its stored fan-out, neither of which exists here: a child's token is a hash of its parent's, so a chain cannot loop, and the walk probes to the first miss. The hostile-document test is rewritten for this chain: a stored chain of 300, deeper than any sealed document, still ends after one row per entry, under every budget and root option. - JsonSubtree::below and the fallback's list shape apply unchanged. The engine test walks chain heads, since json_tree_subtree takes a token that names its node and only a head's is derived; the Postgres test opens `$.nested` through json_tree_subtree with `below` and `$.nested.list` through the gated walk with `at`, each walked and fallen back. - types.sql keeps this branch's description of q (the only place the value selectors live) and adds that its CHECK does not tie it to sv. - The CHANGELOG and changeset keep this branch's text, add the fallback attachment, and state the walk's bound without the mechanism the ordinal chain replaced. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
…oc comment back on it A test inserted above it took its doc comment with it, so the gated-walk test carried two descriptions and the record-field test none. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
Carries the #1325 review fixes up through #1329. One conflict: both branches appended a PostgreSQL test at the end of the json_tree suite (this branch's planner test, the hostile-document test from below), and both are kept. Since this branch runs the suite in CI on every supported PostgreSQL, the hostile-document test now runs there too. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
… chain step and position gates
The ordinal chain derived a child's token as H("t" ‖ t(parent) ‖ LE32(o))
and so dropped the kind byte the label-derived step carried. An ordinal does
not say which kind of child it numbers: an array element's is its position,
an object member's its rank among its siblings. Rows of one column need not
agree on a node's kind, so the token the client derived for $.a[0] was, in a
row whose $.a is an object, the token of the member ranked first, and a walk
from it returned that member's subtree as the element. A `#n` gate admitted
the same member. jsonb has no node there, and the sealed-root fallback, which
selects over the plaintext, agreed with jsonb, so one row answered
differently under and over the budget.
The step is now H("t" ‖ t(parent) ‖ s(child) ‖ LE32(o)). The server holds
every hit's s and derives the token as before; the client knows an element's
selector from the path and still derives positions after a head. An element's
selector is the wildcard path's and a member's names its key, so the two
never share a token. A position gate carries the element selector,
`#n:<selector>`, and the walk admits the hit at ordinal n only if it carries
it; a bare `#n` admits nothing.
postgres_a_position_reaches_array_elements_only holds both forms against
jsonb's strict path over the plaintexts, and a_position_reaches_no_object_member
holds the engine's walk. The hostile-chain fixture now carries valid base64
selectors, since the walk decodes each hit's s.
Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
…ordering queries An ordering at a JSON path compares the leaf `->` extracts by the path's selector, and a selector reads every position as the wildcard, so `->` returns the first element at the path whatever position was named. The opt-in ordering admitted positions anyway. An index ordering `$.scores[2]` put a term on the third score alone, and a query there compared the first score, which carries none, and matched no row: the silent empty result the JsonPathNotOrdered refusal exists to prevent. A query at `$.scores[2]` under an index ordering `$.scores[*]` was accepted and compared the first score, returning rows for the wrong element. JsonOptions::ordering, the data plan's `ordered` and an ordering query now refuse a path that names a position with the new TermError::JsonOrderingAtPosition (code stack_encrypt::json_ordering_at_position), and sealing under options whose `ordered` field holds one, which only direct construction produces, is refused the same way. `ordering` returns TermError, which carries the parse error as before. Ordering at an element by position would need an extractor that reaches a position; it is recorded in the design document and not scheduled. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
… crate's lock A stored json_tree document, and every entry `->` or a walk returns, comes back from the database through the JSON index's wire decoders before anything is authenticated: base64 for the selectors, parent tags, ordering terms and header, the header's frozen layout, and since the byte-encoding change base85 for the ciphertexts, decoded by base85-simd with NEON and AVX2 code. No fuzz target reached any of it. json_wire_decode runs JsonHeader's byte parser and the JsonDocument, JsonNode, JsonProjection and JsonLeafQuery wire forms over arbitrary input, and requires an accepted document or node to survive a round trip through its own wire form. Seeds are a sealed and an open-root document, an extracted node, a projection and a header. Two minutes locally found nothing. Wired as `fuzz:json-wire` into both fuzz.yml jobs and docs/fuzzing.md. The fuzz crate is a detached workspace with its own Cargo.lock, which the base85-simd dependency left stale (`cargo metadata --locked` exited 101); `cargo fuzz run` regenerated it in memory, so nothing went red. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
…s.sql The type file's field list still called the ciphertext `c` base64 and gave the parent tag as the whole hash, after the byte-encoding change made `c` RFC 1924 base85 and `p` the hash's first eight bytes in base64. It is the description a reader of the SQL meets first. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
…es and the json_tree wire types The stack-encrypt entry for the JSON index did not mention what this branch added beside it: target::Layout and Plan::value().encrypt_as with plan::Laid, ValuePlan::specs and ::layout, From<JsonPathError> for Error, the subtree key's gates, JsonOptions::ordering and JsonPath::covers, the base64 and base85 wire encodings, and the position refusal. The eql-bindings entry did not mention v4::JsonTree, the json_tree layout under options, or the newtypes that type the v4 wire apart from the v3 hex terms. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
…'s bound The Phase 3d note argued that a parent tag need only be distinct among one document's entries. A walk's misses matter as much: a probe for an ordinal that does not exist, matching a stored tag, returns an unrelated entry as a child. The chance is the same order, about N² / 2^64 a walk and row, so the conclusion stands; the note now says so, and that the row such a hit adds is authentic, so the bound is the probability rather than the rebuild's checks. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
coderdan
left a comment
There was a problem hiding this comment.
Review of #1329 (the ordinal chain, the opt-in ordering term and the byte encodings)
Scope: what this PR adds over #1325, git diff origin/perf/json-tree-walk-and-projection...7bf1d7533. Reviewed against the design document's Phase 3b, 3c and 3d notes and the "Known non-findings" in AGENTS.md. Fixes for every finding below follow on this branch; each inline comment gets a reply with its commit.
Some background for a reader new to EQL. A json_tree column stores one encrypted entry per JSON node. Each entry has a selector s (a keyed hash of the node's path, with every array position folded into [*]), a ciphertext c, and below the top-level fields a parent tag p. To read a subtree, the client hands PostgreSQL a token, a secret per path. The server finds the node's children by recomputing their parent tags from the token and each child's ordinal (its position among its siblings), and derives each child's own token the same way. Where the client cannot derive a token itself, it sends gates: a selector, or an array position, that each step down must match.
Findings
| # | Severity | Finding |
|---|---|---|
| 1 | High | A position ($.a[0]) can return an object member's data. The ordinal chain dropped the key/index kind byte, so in rows where $.a is an object, the token for $.a[0] is the token of whichever member ranks first. |
| 2 | Medium | Ordering with an array position in the path silently compares the wrong element or matches no row. -> reads a position as [*] and returns the first element, so $.scores[2] can never be reached. |
| 3 | Low | No fuzz target covers the JSON wire decoders. Since this PR, those decoders run SIMD base85 over stored, unauthenticated text. Separately, the fuzz crate's Cargo.lock was left stale by the new base85-simd dependency. |
| 4 | Low | types.sql still calls c base64 and gives p as the whole hash. |
| 5 | Low | The CHANGELOGs miss this PR's public API: target::Layout, encrypt_as, Laid, ValuePlan::specs, From<JsonPathError>, the gates, the wire encodings, v4::JsonTree and the eql-bindings newtypes. |
| 6 | Nit | The eight-byte p argument counts collisions among stored tags only, not the walk's misses. The conclusion holds. |
| 7 | Nit | The PR body is stale in places (below). |
Finding 1, in a user's terms. Say a column holds {"a": [{"x": 2}]} in one row and {"a": {"k": {"x": 1}}} in another. The application reads $.a[0] through json_tree_subtree. It gets {"x": 2} from the first row, which is right. From the second row it gets {"x": 1}, the value of $.a.k, presented as $.a[0]. PostgreSQL's jsonb_path_query_first(doc, 'strict $.a[0]') returns NULL there. A gated position ($.h.a[0], sent as the gate #0) has the same fault. The answer also changes with document size: past the walk budget the client opens the sealed root and selects the path over the plaintext, and that answer is correct, so the same row answers differently below and above 64 entries. This is a regression from #1325. That chain bound 0x01 ‖ key or 0x02 ‖ index into each step, which kept elements and members apart.
Finding 2, in a user's terms. Say the index orders $.scores[1]. A query "rows where $.scores[1] >= 5" is accepted. It then matches no row, because -> returns scores[0], which carries no ordering term. That is exactly the silent empty result the new JsonPathNotOrdered refusal exists to prevent. Now say the index orders $.scores[*]. The same query is accepted and compares scores[0], so it returns rows chosen by the wrong element.
Checked and found sound
- The walk ends on a hostile stored document (1325's question). Each recursion row yields at most two probes: down to ordinal 0 or a gated position, and across to the next ordinal. Each probe is one hash lookup. A hit is a distinct (parent token, ordinal) position, and a child's token is a SHA-256 of its parent's, so the chain cannot return to a token it has already visited. The walk therefore returns at most one row per stored tag, unless an attacker holds a token and finds eight-byte tag collisions; each extra row then costs about 2^32 hashes and adds one row, not a loop. The recursive CTE runs iteratively, so depth cannot exhaust a stack. With the budget on, the
LIMITonfoundstops the recursion early. The 300-entry chain test holds this. - The rank is keyed (
json/rank/v1under the index key) and only the client computes it. Someone holding a token learns which selector sits at each ordinal. The member order is a keyed pseudo-random permutation, the same in every row for one parent token, so it reveals nothing about key names that the selectors do not already. - Gates. A selector gate reveals nothing a selector does not already reveal. A gate cannot be forged into anything new or replayed between rows to effect, because the server only uses it to filter hits under a token it was given.
- Encodings. Every field the SQL decodes is padded base64, and the SQL never decodes
c. RFC 1924's alphabet contains neither"nor\, and a test pins that. The header'sivsits at byte 22 (substr(…, 23, 16)) and the sealed-root flag at byte 1, which matchesJsonHeader::to_bytes.base64ctdecodes strictly.base85-simd0.1.0 is CipherStash's own crate: its non-test code deniesunwrap,panicand indexing, and it refuses overflowing blocks and invalid lengths. - The eight-byte
pis a lookup key only. The AAD bindssandj, so truncatingpweakens no authentication. - The newtypes.
TryFromand thenode()anddocument()conversions are total: every failure maps toError. The only v3 type v4 still uses isKeyHeader. - No skill mentions
json_treeor v4 JSON behaviour.
Outside this diff, for the owner
- At rest, the order of
svranks key names. The walk visits object members inserde_json's map order. In this workspace that is sorted, sincepreserve_orderis off. So in every row, sibling selectors appear insvin lexicographic order of their key names, and across many rows the database can sort a column's key selectors alphabetically. The keyed rank hides member order from someone holding a token, but the stored order gives the same information away to anyone who can read the table. This dates from #1288's walk order, not this PR. A fix (seal members in rank order, and heads in a keyed order) changes the format, so I have left it for a decision. The leakage page in #1336 should say either way. - A convenience for attaching paths (raised in the #1325 review). Whether a walk's rows need
below(path)orat(path)is now fully determined by the key: no gates meansjson_tree_subtree, sobelow; any gates meansat. A helper that takes theJsonSubtreeKeyand the path would remove the misuse the default budget exposes. It is new API rather than a defect, so I have not added it here. - These stay open by decision, and I have not touched them: (a) rows open under the selector the server's row carries; (b) the budget is on by default; (c)
qis trusted as written.
PR body
Several references to "the last commit" and "the docs follow-up commit" no longer locate anything after the merges. The verification counts predate #1288's and #1325's fixes. The Rust example opens JsonTreeSubtree::new(rows) without .at(path), which fails once a document passes the budget. And "The changesets from #1288 and #1325 still describe the format" is no longer true: this PR edits #1325's changeset. I will update the body.
Checks run on the fixed branch
cargo nextest run -p stack-encrypt --all-features (759 passed), doctests (46 passed), mise run doc:stack-encrypt, clippy with -D warnings both with all features and with none, cargo fmt --check (root and EQL workspaces), mise run test:encryption, mise run test:encryption:postgres against PostgreSQL 17.10 (every suite passed, including the json_tree oracle and the new positional test), the eight EQL static checks, pnpm run test:scripts, the new fuzz target for two minutes, and cargo mutants --in-diff over the fix (17 mutants: 13 caught, 4 unviable, none missed).
| /// so the client derives an array element's token from the path alone, and | ||
| /// the server derives every hit's from its parent's as it walks; no token | ||
| /// is ever stored. | ||
| pub(super) fn child_token(parent: &Tag, ordinal: u32) -> Tag { |
There was a problem hiding this comment.
[High] A position can return an object member's data.
The step is H("t" ‖ t(parent) ‖ LE32(o)). The ordinal o is an array element's position, but for an object member it is the member's rank among its siblings, and nothing in the step says which kind of child it is. Rows of one column need not agree on a node's kind. So in a row whose $.a is an object, the token the client derives for $.a[0] here (in subtree_key_for, by child_token(&token, o)) is the token of the member that ranks first. json_tree_subtree then returns that member's subtree, and the client opens it as $.a[0].
For example, {"a": {"k": {"x": 1}}} opens to {"x": 1} at $.a[0], where jsonb_path_query_first(doc, 'strict $.a[0]') is NULL. Below a key step, the #n gate admits the same member: the SQL passes any hit at ordinal n (OR COALESCE(gates[probe.depth], '') LIKE '#%'). Past the 64-entry budget, the sealed-root fallback selects the path over the plaintext and answers correctly. So the same row answers differently depending on the document's size.
#1325's chain could not do this, because its step bound 0x01 ‖ key or 0x02 ‖ index. Fix: bind the child's own selector into the step, H("t" ‖ t(parent) ‖ s(child) ‖ LE32(o)). The server has every hit's s. The client knows an element's selector from the path: it is the wildcard's, and a member's selector names its key, so the two never collide. Also make a position gate carry the element selector, #n:<selector>, and admit the hit only if it carries that selector.
There was a problem hiding this comment.
Fixed in 67b1f54. The chain step now binds the child's selector: H("t" ‖ t(parent) ‖ s(child) ‖ LE32(o)), in Rust and in eql_v4_internal.json_tree_child_token(token, s, o). A position gate is #n:<selector> and admits the hit only if it carries that selector; a bare #n admits nothing. New tests: a_position_reaches_no_object_member (engine) and postgres_a_position_reaches_array_elements_only (both forms against jsonb_path_query_first(plain, 'strict …')). The design doc's Phase 3b note and the changeset record the change.
|
|
||
| /// Whether the index orders the leaves at `path`: some path in its | ||
| /// `ordered` option covers it. | ||
| fn orders(index: &Json, path: &JsonPath) -> Result<(), Error> { |
There was a problem hiding this comment.
[Medium] An ordering at a position compares the wrong element, or matches no row.
An ordering query compares the leaf that col -> sel extracts, and sel is the path's selector. A selector reads every position as [*] (json_tree_path_query_first returns the first entry that carries it). So -> reaches the first element at the path and never the one a position names. That leaves two failure modes:
- The index orders
$.scores[1], and the query at$.scores[1]is accepted.->returnsscores[0], which has noop, soord_termis NULL and the comparison matches no row. That is the silent empty result this function's refusal exists to prevent. - The index orders
$.scores[*], and a query at$.scores[2]is accepted (coverslets a wildcard cover a position). It comparesscores[0]and returns rows selected by the wrong element.
The test an_accessor_asks_at_a_path asserts that the first case is accepted. Fix: refuse a path that names a position, here and in JsonOptions::ordering and the data plan's ordered, with its own error that says why. Making positions work would need an extractor that can reach a position, which -> by selector cannot.
There was a problem hiding this comment.
Fixed in d72d9fe. The new TermError::JsonOrderingAtPosition (code stack_encrypt::json_ordering_at_position) refuses a path that names a position in four places: JsonOptions::ordering (which now returns TermError), the data plan's ordered, an ordering query (before any keyset loads), and a seal under options whose ordered field was given one directly. The design doc's Phase 3c note and #1325's changeset paragraph now say a position is refused.
| # The JSON index's ciphertext field on the wire, which nothing in SQL | ||
| # decodes: RFC 1924 base85, five characters per four bytes. SIMD on | ||
| # aarch64 and x86_64, portable scalar code elsewhere (wasm32 included). | ||
| base85-simd = "0.1.0" |
There was a problem hiding this comment.
[Low] The JSON wire decoders have no fuzz target, and the fuzz crate's lock is stale.
A stored document, and every entry that -> or a walk returns, comes back from the database through JsonDocument's and JsonNode's wire decoders before anything is authenticated. That path now runs base85_simd::decode, which is NEON and AVX2 unsafe code, over every c. It also runs base64ct over every tag and JsonHeader::from_bytes over h. docs/fuzzing.md says the repo fuzzes "bytes read back from storage", and none of these decoders is covered.
Separately, packages/stack-encrypt/fuzz is a detached workspace with its own Cargo.lock, and this dependency was not added to it: cargo metadata --locked there exits 101. cargo fuzz run regenerates the lock in memory, so CI stays green. That is the same silent drift AGENTS.md describes for --locked.
Fix: add a json_wire_decode target. It should require that an accepted document or node round-trips through its wire form, ship seeds, and be wired into both fuzz.yml jobs. Also commit the refreshed lock.
There was a problem hiding this comment.
Fixed in d267735. The new json_wire_decode fuzz target covers JsonHeader::from_bytes and the JSON wire forms of JsonDocument, JsonNode, JsonProjection and JsonLeafQuery, and requires an accepted document or node to round-trip. It has five seeds, a fuzz:json-wire task, entries in both fuzz.yml jobs and a row in docs/fuzzing.md. It ran two minutes locally (about 8.9M executions) with no crash. The commit also refreshes the fuzz crate's Cargo.lock, which cargo metadata --locked now accepts.
| --! parent's, so a path reads `$.a[*].x`. | ||
| --! `vs` value selector (hex): the node's kind and, for a scalar, its value, | ||
| --! bound to `s`. Containment is a set comparison over `{s, vs}` pairs. | ||
| --! `c` the node's own ciphertext (base64): its label, its kind and, for a |
There was a problem hiding this comment.
[Low] Stale field docs. Since the encodings commit, c is RFC 1924 base85, which no SQL function decodes, not base64. A few lines down, p is the first eight bytes of the hash in base64, not the whole hash. This comment block is the first description of the stored fields a reader of the SQL sees.
| on by default, makes the root entry the whole document so no token for | ||
| the root exists. A document is at most 127 levels deep. New `TermError` | ||
| variants: `JsonPath`, `JsonTooDeep`, `JsonTooWide`, `JsonSealedRoot`, | ||
| `JsonLeafNotOrdered`, `JsonPathNotOrdered` and `JsonValueNotScalar`. A |
There was a problem hiding this comment.
[Low] The CHANGELOGs miss this PR's public API. This entry describes the JSON index, but nothing in [Unreleased] covers what the PR adds beside it:
target::LayoutPlan::value().encrypt_asandplan::LaidValuePlan::specsandValuePlan::layoutFrom<JsonPathError> for ErrorJsonSubtreeKey::gates(), which replacesselector()JsonOptions::orderingandJsonPath::covers- the base64 and base85 wire encodings
eql-bindings' CHANGELOG likewise misses v4::JsonTree (the layout under options) and the JsonTreeSelector, JsonTreeCiphertext and JsonTreeOrderingTerm newtypes.
|
|
||
| - **Every field the SQL reads is padded standard base64**, as `encode(x, 'base64')` writes it and `decode(x, 'base64')` reads it: the selectors `s` and the `s`/`vs` pairs of `q` (sixteen bytes, 24 characters where hex took 32), the parent tag `p`, the ordering term `op` (54 characters where hex took 80), and the tokens and gates `json_tree_subtree_at`, `json_tree_children` and `->` take. `h` already was. The padding stays because `decode(x, 'base64')` refuses the unpadded form (`invalid base64 end sequence`, PostgreSQL 17.10): two characters on a sixteen-byte field and one on an eight-byte one, against a padding step written into every SQL site that would ever decode one. base64url was not taken for the same reason: PostgreSQL has no decoder for it. | ||
| - **The ciphertext `c` is RFC 1924 base85** (`base85-simd` 0.1.0: NEON on aarch64, AVX2 on x86_64, portable scalar code elsewhere, wasm32 included), five characters per four bytes against base64's eight per six, on the field that is most of a document. No SQL function decodes `c`. The alphabet has no `"` or `\`, so a JSON string carries it unescaped. `h` stays base64: the SQL reads the `iv` and the sealed-root flag out of it, and it is one field a document. | ||
| - **The parent tag `p` is the first eight bytes of its hash.** A parent tag is a lookup key for the server's walk and nothing else: an entry's AAD binds its selector and its ordinal, not its parent tag, so truncation authenticates nothing less. It has to be distinct among the entries of one document only, where eight bytes put a collision in a ten-thousand-node document below one in 10^11. `eql_v4_internal.json_tree_parent_tag` is `encode(substr(h16(…), 1, 8), 'base64')`, twelve characters where hex took 32. |
There was a problem hiding this comment.
[Nit] The bound leaves out the walk's misses. "Distinct among the entries of one document" is half the requirement. Every miss probe (one per visited node, at the end of its children) must also miss every stored tag. Otherwise an unrelated entry comes back as a child. That entry is authentic and opens, and the rebuild's checks of child counts and element positions catch it in most shapes but not all. The probability is the same order, about N² / 2^64 per walk and row, so the conclusion stands, but the note should say so.
…gh the key that keyed it Past its budget a walk returns the sealed root, and the rows open only at the walked path, below the path's node for json_tree_subtree and at the nodes it reaches for json_tree_subtree_at. The caller had to attach that path with `below` or `at`, and a caller who did not knew nothing of it until a document grew past the budget and the open failed with OpenedType. The owner chose option (c) from the #1325 review: make a path-less open impossible. The path is in the caller's hand when the key is derived, so the key now carries it (`JsonSubtreeKey::path`) and builds the wrapper: `key.open(rows) -> JsonSubtree` in stack-encrypt and `JsonTreeSubtree::from_key(&key, rows)` in eql-bindings. The key knows from its gates which walk the rows came from. `JsonSubtree::new`, `below` and `at` are gone, as are `JsonTreeSubtree::new`, `below`, `at` and its public fields; neither wrapper deserializes any more, since that was a path-less constructor too. The path is not part of the key's wire form. The test that the fallback refuses without a path is now a compile-time property. the_key_built_fallback_opens_to_what_the_walk_opens_to holds, for ten paths with and without gates, that the key-built fallback opens to what the walked rows open to, as a list and as one value; the gated rows are walked in the test as the SQL walks them. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
…nd wipe the fallback's document The JSON module was the one place in stack-encrypt that held plaintext outside vitaminc's Protected. Sealing cloned every scalar and key out of the caller's value into a NodePlain and serialised the sealed root with serde_json::to_vec, and the copies were freed unwiped. Opening unwrapped the cipher's Protected<Vec<u8>> into a plain Vec with risky_unwrap, parsed it into serde_json::Value, and the sealed-root fallback parsed the whole document, selected the walked path and freed the rest unwiped, on every subtree read past the budget. Following the vitaminc skills (wrap at the source, transform without unwrapping, unwrap only at the boundary): - PlainJson is a newtype over serde_json::Value whose Zeroize wipes every string, values and object keys alike (keys taken out by ownership), and overwrites numbers and booleans in place. It has no Debug, Clone, PartialEq, Default or Deref. A parsed value lives in Protected<PlainJson>. - The walk encodes each node's plaintext straight from the caller's value into a Protected<Vec<u8>>, sized before it is written so it never reallocates, and hands that to the cipher. A number's text is made wrapped. The sealed root's JSON is measured, then written once into its own Protected buffer. Nothing is cloned out of the caller's value. - An opened entry's bytes stay in the cipher's Protected while they are parsed; each key, scalar and sealed-root document is read out through zip_ref and wrapped as it is made. Subtrees are rebuilt inside a Protected, each member moved in from its own, so a rebuild that fails part-way wipes what it had. The fallback selects the walked path inside the document's Protected, so only the selected nodes come out. A value leaves its wrapper in one place, hand_over, where the caller receives it. Numbers and booleans have no volatile overwrite without unsafe code, which this crate denies, so theirs is best-effort. The query and term side still holds plaintext vitaminc does not reach from here (an accessor's needle document, the value selector's PAE input, the key names in term paths); the design document's Phases 1 and 2 note records both. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
Brings in the #1329 review fixes (the selector-bound chain step and position gates, the refusal of positions in ordered paths, the JSON wire fuzz target), the key-built subtree wrapper, and the json plaintext held under Protected. One conflict, in packages/eql/tests/encryption/tests/json_tree.rs: both sides added a PostgreSQL test after postgres_extracts_walks_and_orders_by_path (this branch's postgres_the_planner_picks_the_indexes_without_help with its flat_document and nested_document helpers, and #1329's postgres_a_position_reaches_array_elements_only). Both are kept, the planner test first, each with its own body; git had interleaved their shared opening lines. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
The CI mutants gate found the position arm of JsonPath::covers unpinned: since ordered paths and ordering queries refuse positions, no test reached it any more, though covers is public and still matches positions. A unit test now holds that a position covers that position alone, and that a wildcard covers it. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
Brings in the JsonPath::covers test the #1329 mutants gate asked for. No conflicts. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
|
The CI mutants gate failed on 63bec8f. Two mutants survived in the position arm of |
…-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
Carries origin/next up from #1250 through #1329, so this PR reads against one merge base. Content-empty. 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: 62f213233b
ℹ️ 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".
| Segment::Index(index) => Gate::Slot( | ||
| u32::try_from(*index).map_err(|_| TermError::JsonTooWide)?, |
There was a problem hiding this comment.
Reject gate positions outside PostgreSQL integer range
For a gated path such as $.items[2147483648], this conversion succeeds because the position fits u32, and the key serializes it as #2147483648:...; both json_tree_subtree_at and json_tree_children then cast that gate ordinal with ::integer in functions.sql, causing PostgreSQL error 22003 instead of returning no rows for the nonexistent position. Limit gated positions to i32::MAX or widen the SQL ordinal type consistently.
AGENTS.md reference: AGENTS.md:L684-L695
Useful? React with 👍 / 👎.
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) ‖ s ‖ LE32(o)), a hash of its parent's token, its own selectorsand 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.#n:<selector>for an array position, with the array elements' selector. The server descends through the gates and returns the node they reach at depthd = 0, with its descendants beneath it.opis opt-in per path (commitba62e3fc5).opis an order-preserving ciphertext of a number or string leaf, about 80 bytes each, whichORDER BYand range queries at that path use througheql_v4.ord_term. It was derived for every number and string leaf, which made it the largest field of a nested document (24.8 KB of 85 KB) and the one field that reveals something across rows at rest (the order of that leaf's value among every row that holds one), on paths nobody orders. A JSON index now names the paths it orders, with[*]covering every element of an array (a position such as[2]is refused, since->reads it as[*]); nothing is ordered by default; and an ordering query at a path the index does not order is refused before any key loads, instead of silently matching no row.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(); a position gate is#n:<selector>.JsonDocument::children()probes positions in turn.JsonOptionsgainsordered: BTreeSet<JsonPath>(so it isClone, no longerCopy),JsonOptions::ordering([..])parses paths into it, andJsonPath::coversis the match rule the walk and the query share. The data-plan wire is{"json": {"sealed_root": true, "ordered": ["$.age", "$.orders[*].total"]}}; the header does not record the set, since a stored leaf shows whether it has a term.JsonHeader::options()is removed andjson_subtree_keytakes&JsonOptions.TermError::JsonPathNotOrdered(codestack_encrypt::json_path_not_ordered) refusesLt,Lte,Gt,GteandBetweenat an unordered path when the query is awaited.src/plan/,src/target/): a typed plan had no place for index options, sinceencrypt_into::<JsonDocument>()reads the type's staticEncryptFrom.target::Layout<S>is a target's layout chosen by value, with the index it answers queries through;Json::new(options)implements it, andPlan::value().encrypt_as(layout)builds aValuePlan<_, Laid<X, T>>that seals under the options and resolves every query through the plan's own index, so a query does not name the options again.ErrorgainsFrom<JsonPathError>.src/v4/json_tree.rs):JsonTreeEntrydropsvs,o,w,nandg, and the typed document handsqto the engine.JsonTree::new(options)is thejson_treelayout under options (aLayout<Value>whose target isJsonTreeDocument); the type's ownEncryptFromis that layout under the defaults.docs/plans/2026-10-17-json-tree-index.md): the Phase 3b implementation note carries the measured outcome below, and a Phase 3c note records the opt-in and its measured sizes. 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.37e02ab42): every field the SQL compares, derives or decodes is padded standard base64 (s, thes/vspairs ofq,p,op, tokens and gates), which PostgreSQL'sencode/decodehandle natively and whichdecoderefuses unpadded;cis RFC 1924 base85 through thebase85-simd0.1.0 crate (no SQL function decodesc);hstays base64 because the SQL reads theivand the sealed-root flag from it.pis the first eight bytes of its hash: a lookup key within one document, and the AAD bindssandj, notp.eql_v4_internal.json_tree_ope_cllwis added because the sharedope_cllw(jsonb)of the scalar types reads hex.eql-bindingsgetsJsonTreeSelector,JsonTreeCiphertextandJsonTreeOrderingTermnewtypes so the v4 wire is documented apart from the v3 hex types. Design doc: a Phase 3d note.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 (
3860ecc3c):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.For the opt-in commit (
ba62e3fc5):cargo nextest run -p stack-encrypt --all-features737 passed (new:an_ordering_term_sits_only_at_an_ordered_path,a_laid_out_plan_opens_what_it_wrote, the refusal cases inan_accessor_asks_at_a_pathandan_accessor_is_refused_before_its_keyset_loads, theorderedwire round trip); the 46 doctests, including the newencrypt_asexample; rustdoc with-D warnings; clippy and fmt clean; the trybuild snapshotop_matches_on_integer.stderrregenerated, its only change being the newLaidimpl in rustc's help list.mise run test:encryption(eql-bindings and the encryption test crate, no database) passed. The json_tree PostgreSQL oracle (postgres_extracts_walks_and_orders_by_path,postgres_containment_over_the_encrypted_column_is_jsonb_containment) passed against a database carrying this branch's v4 bundle, with the corpus sealed under$.helloand$.numberordered; the SQL did not change in that commit. Docker was not running locally, so the fulltest:encryption:postgrestask was not re-run. cargo-mutants over the commit's diff found five survivors, all on the newLaidplan shape, pinned by2e405c462(ValuePlan::specsadded for the test).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.Sizes with the ordering term opt-in (cipherstash/benches@cd9643d, "Fourth measurement"), each corpus re-sealed with the one path its scenarios order (
$.ageflat,$.company.salarynested), correctness 462 of 462 at both tiers:pg_column_size: 62,143 → 40,578 bytes (−35%); heap 621 → 408 MiB at 10,000 rows, 6,201 → 4,069 MiB at 100,000; GIN unchanged.pg_column_size: 2,250 → 1,596 bytes (−29%); heap 27.1 → 17.0 MiB at 10,000 rows. A flat document now sits under the TOAST threshold, so the flat heap moves into the main relation and the flat GIN grows 12% at 100,000 rows from the wider row spread. Query timings were not re-run.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.For the encodings commit:
cargo nextest run -p stack-encrypt --all-features751 passed; doctests 46 passed; clippy and rustdoc (-D warnings) clean;mise run wasm:wasi-checkpassed (base85-simd compiles for wasm32-wasip1 on its scalar path);mise run test:encryption:postgrespassed against the v4 bundle built from the tree, including the json_tree oracle. Size (1,000 stored rows of each corpus from the Phase 3c tables, re-encoded field by field as the code now writes them, reloaded as jsonb, PostgreSQL 17.10,pg_column_sizeaveraged afterVACUUM FULL): nested 40,808 → 31,894 bytes (−22%), flat 1,596 → 1,398 bytes (−12%); wire characters nested 58,111 → 47,038, flat 1,419 → 1,224. The corpora were not re-ingested and no query re-timed for this commit.Related
37e02ab42(a further 22% off a nested document); the nonce lever is rejected (a caller-chosen nonce would weaken vitaminc's misuse resistance for a 7% saving; design doc Phase 3d), and->(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:
Review fixes (2026-10-23)
The review is the comment above. The fixes are on this branch:
67b1f543e: the chain step binds the child's selector,H("t" ‖ t(parent) ‖ s ‖ LE32(o)), and a position gate is#n:<selector>. Without the selector, a position could reach an object member in rows whose node at the path is an object.eql_v4_internal.json_tree_child_tokennow takes(token, s, o).d72d9fe15: a path that names an array position is refused inJsonOptions::ordering, in the data plan'sordered, in an ordering query, and in a seal (TermError::JsonOrderingAtPosition).d267735af: ajson_wire_decodefuzz target, and the fuzz crate's refreshedCargo.lock.d3d7f764b,151fd5d7c,6d9ece2e7: the docs intypes.sql, both CHANGELOGs, and the design doc's eight-bytepbound.Two decisions from the owner followed the review, as separate commits:
f1f27f218: this is option (c) for the walk budget.JsonSubtreeKeycarries its path and builds the rows' wrapper:key.open(rows)in stack-encrypt,JsonTreeSubtree::from_key(&key, rows)in eql-bindings. From its gates the key decides whether the rows open below the path's node or at it, so a path-less open no longer compiles.63bec8f71: the json module's plaintext is held under vitaminc'sProtected. Node plaintexts are encoded straight intoProtected<Vec<u8>>. Opened keys, scalars and documents stay inProtected<PlainJson>until they are handed to the caller. The sealed-root fallback's document is wiped once the path is selected from it.Verification counts above predate these fixes. With them:
cargo nextest run -p stack-encrypt --all-features764 passed, andmise run test:encryption:postgrespassed against PostgreSQL 17.10, including the newpostgres_a_position_reaches_array_elements_only.https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu