Skip to content

perf(eql, stack-encrypt): store the json_tree projection, walk by parent map, budget the subtree - #1325

Open
coderdan wants to merge 13 commits into
feat/json-index-stack-encryptfrom
perf/json-tree-walk-and-projection
Open

coderdan wants to merge 13 commits into
feat/json-index-stack-encryptfrom
perf/json-tree-walk-and-projection

Conversation

@coderdan

@coderdan coderdan commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Stacked on #1288. The json_tree family (EQL v4's encrypted JSON document type, written by Stack Encrypt's JSON index) was benchmarked at 10,000 rows against the v3 SteVec type and native jsonb. On a flat five-field document it is on par with v3; on a nested document of about 220 nodes the shipped SQL was one to four orders of magnitude behind native jsonb. Three mechanisms accounted for all of it, and this PR fixes them. One is a wire change, which is why it must land before the family is released: a stored document now carries its own containment projection.

In user terms: a containment query (@>) on a nested document goes from about 70 ms to the low milliseconds and the GIN index builds in seconds rather than minutes per ten thousand rows; fetching a subtree by token goes from ten seconds to tens of milliseconds, or single digits when the server hands back the sealed root instead; and a range comparison keeps its index under a prepared statement.

Changes

EQL SQL (packages/eql/src/v3/json_tree/)

  • types.sql: the stored document requires q, its containment projection, validated as a group by the same validator the needle uses (moved ahead of the document validator so it is defined first).
  • functions.sql: json_tree_query(doc) reads doc -> 'q' and returns jsonb (a domain cast would run the needle's recursive CHECK per recheck row); it is plpgsql at COST 1000 rather than an inlinable SQL function, because inlined to value -> 'q' the planner costed a sequential scan over the TOASTed nested table below the GIN bitmap scan and chose it (1.5 s for a one-row match at 10k rows, 17 s at 100k; 0.7 ms through the index). json_tree_group is removed. The walk (json_tree_children, json_tree_child_count, json_tree_subtree, json_tree_subtree_at) builds one map of entries keyed by parent tag (json_tree_by_parent) and looks computed tags up in it, so a level costs its fan-out in hashes rather than fan-out times document size. json_tree_subtree and json_tree_subtree_at take a budget (default 64): past it, when the root is sealed, they return the root entry alone with d = 0. Every walk is bounded by the document's size as well as its depth: the fan-out it probes is capped at the entry count, and it returns at most as many rows as the document has entries, so a stored document whose unauthenticated o or w is hostile cannot keep a query running. json_tree_path_query_first is COST 1000.

stack-encrypt (packages/stack-encrypt/src/json/)

  • JsonDocument writes q on the wire (its projection()) and ignores it on the way in.
  • JsonPath::select evaluates a path over a value (keys, positions, wildcards).
  • JsonSubtree::below(path) (for json_tree_subtree rows) and at(path) (for json_tree_subtree_at rows), path(), is_sealed_root(); opening a sealed-root fallback takes the path over the whole document and opens to what the walked rows open to (for below, the node as a Value and its children as a Vec<Value>; for at, one value per node reached), and is refused without a path.

eql-bindings (packages/eql/crates/eql-bindings/src/v4/json_tree.rs)

  • JsonTreeDocument gains q; JsonTreeSubtree is now { rows, path, below } with new, below and at.

Tests and docs

  • packages/eql/tests/encryption/tests/json_tree.rs: the budget at, under and off; the fallback opens at the path for both walks, and as a list to the same elements as the walk; the budget is gated on the header's sealed-root flag; a wrong token still yields nothing; hostile documents (a looping chain, a huge ordinal) end under a statement_timeout.
  • packages/stack-encrypt/tests/json_index.rs: q on the wire equals the needle of the whole document; the fallback's openings and refusals; each walk shape opened walked and fallen back gives the same value.
  • Changeset for @cipherstash/eql; the design document's "Implementation notes (2026-10-21, Phase 3)", with the report's numbers and commits; stack-encrypt and eql-bindings CHANGELOG entries.

Verification

  • mise x --env test -- cargo nextest run -p stack-encrypt --all-features: 756 passed, 1 skipped (after the feat(stack-encrypt, eql): the JSON index and the json_tree EQL v4 domain family #1288 merges and the review fixes). cargo mutants --in-diff over the review fixes: 9 caught, 4 unviable, none missed.
  • cargo test -p eql-bindings --features stack-encrypt,targets in packages/eql: all passed.
  • EQL_TEST_DATABASE_URL=… mise run test:encryption:postgres in packages/eql against a fresh PostgreSQL 17 database: every suite passed, including the three json_tree tests (containment oracle against jsonb @> with the GIN index shown in EXPLAIN, the walks, the budget and the fallback, and the hostile documents).
  • mise run test:lint, test:schema, test:public_identifiers, test:symbol_order_v3, test:v4_isolation, test:installer_complete in packages/eql: passed. docs:validate passed its coverage and tags steps; its documented-sql step needs the Docker database on port 7432, which was not running locally, so CI is what runs it here.
  • Clippy with -D warnings on stack-encrypt, eql-bindings (stack-encrypt feature) and the encryption test crate: clean. cargo fmt --check: clean.
  • Benchmarked in cipherstash/benches (branch feat/json-tree-benches, commit 57abcf9, report/JSON_TREE_COMPARISON.md) at 10,000 and 100,000 rows against the v3 SteVec type and native jsonb, every containment scenario checked against jsonb @> over a plaintext twin before timing. Nested corpus (about 215 nodes per document), 10k rows, first run → this branch → native: @> deep object LIMIT 10, 68 ms → 3.2 ms → 0.36 ms; @> one array element, 12.7 ms → 0.70 ms → 0.10 ms; equality at a nested path, 12.9 ms → 0.50 ms → 0.10 ms; subtree of 6 entries, 292 ms → 2.8 ms; subtree of 244 entries, 10.2 s → 9.5 ms with the sealed root returned, 23 ms walked out; children, 7.1 ms → 1.6 ms; GIN build per 10k rows, 44 s → 5 s. Flat corpus against v3: equality at a path 33% faster at 10k and 82% at 100k, range at a path 87% faster at both tiers (the generic-plan fix), containment index a tenth the size; ORDER BY at a path and bare containment of a whole small document remain behind v3 (json_tree: the smaller performance findings from the first benchmarks #1324). The 100k tier holds the same shape throughout.

Review

The review of this PR found nine things, posted above. The fixes are on this branch: 923364d (the fallback opens to the walk's shape), c1059d7 (the walks are bounded by the document's size), 014d25d (the documentation matches the cited report), and 31d5da4 (a test). Two need your decision: whether the budget should be on by default, and whether to verify q against sv at write time. One question is open on the report: its table counts 122 rows for the walked $.orders subtree, while its prose says 244.

Related

Refs #1288 (the family), #1324 (the smaller findings left open).

Review notes

Start with functions.sql: the projection section and the walk section are each small and self-contained. The wire change is one field; q is the selectors already in sv regrouped, so it reveals nothing the entries do not. The fallback's client side is derive.rs (open_fallback) and the two DecryptInto impls above it.

https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu

…ent map, budget the subtree

The first measurements of the json_tree family (cipherstash/benches,
branch feat/json-tree-benches) put it on par with the v3 SteVec on a flat
corpus and one to four orders of magnitude behind native jsonb on a nested
one: containment 70-80 ms a query, a 244-entry subtree 10 s, the GIN index
44 s to build on 10,000 rows. Three mechanisms accounted for all of it.

The containment projection was rebuilt from the group tags by a recursive
plpgsql function on every GIN recheck and again in the Filter, about 3 ms
per 220-entry document. The client already derives that shape for the
needle, so the document now writes it to the wire as `q` beside `sv`, the
domain CHECK validates it as a group, and `eql_v4.json_tree_query(doc)`
reads `doc -> 'q'`. It returns jsonb rather than the needle domain, since
a domain cast runs the needle's recursive CHECK and the recheck would pay
it per row. `q` is the selectors already in `sv`, regrouped; nothing new
is revealed, and the field is required (the family is unreleased).

The walk evaluated the parent tag inside a LATERAL scan of `sv`, once per
(node, ordinal, entry): ~670k SHA-256 calls for the 244-entry subtree. It
now builds one jsonb map of the chained entries keyed by `p` and looks
each computed tag up, so a level costs its fan-out in hashes, which is the
design's W x F as written. 10 s becomes ~50 ms; children and child_count
7 ms become under 3.

And with the root sealed, the root's ciphertext is the whole document at
about 1.3 times the plaintext, where the walk returns ~440 characters per
entry and pays the fan-out per node. So `json_tree_subtree` and
`json_tree_subtree_at` take a `budget` (default 64) and, past it, return
the root entry alone with `d = 0`; the client opens it whole and takes the
walked path over it (`JsonSubtree::at`, `JsonTreeSubtree::new(rows).at`,
over the new `JsonPath::select`). Budget 0 never falls back, a token that
names nothing still yields nothing, an unsealed root is always walked out.

Also `json_tree_path_query_first` is costed at 1000: under a prepared
statement's generic plan the default cost chose a sequential scan with the
extraction as a Filter over the functional btree (17 ms against 0.12 ms).

Measured in the benches repository; the Postgres oracle
(`mise run test:encryption:postgres`) covers the budget, the fallback's
opening at the path, and containment against `jsonb @>` with the stored
projection. The design document's implementation notes record Phase 3.

Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
@coderabbitai

coderabbitai Bot commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Organization UI; Enforced overrides: Organization
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: e8b9ca2b-c509-4103-b579-199caa29da79

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Mutation testing (cargo-mutants, --in-diff, stack-auth + stack-encrypt)

caught missed unviable timeout
18 0 6 0

Every mutant in the changed lines was caught by a test.

…IN index wins over a TOASTed seq scan

Re-measured after the projection moved to the wire, the nested corpus's
containment queries ran as sequential scans: 1.5 s for a one-row match on
10,000 rows, 17 s on 100,000, with the GIN index present and usable
(`enable_seqscan = off` took it at 0.8 ms). Inlined to `value -> 'q'`, the
filter is costed as one operator per row, and a column of 97 KB documents
is TOASTed out of the heap, so the main relation is a few hundred pages
and the sequential scan is costed below the bitmap scan. The planner does
not cost the decompression that reading `q` from each row actually pays.

So `eql_v4.json_tree_query` is now plpgsql, which the planner cannot
inline, with `COST 1000`. The index expression still matches the call, the
call keeps its cost, and the planner takes the index on every nested
containment scenario at both tiers. The per-row call is a few
microseconds. The oracle suite passes against a fresh install.

Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
@changeset-bot

changeset-bot Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 53a63fa

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 12 packages
Name Type
@cipherstash/eql Minor
stash Patch
@cipherstash/stack-prisma Patch
@cipherstash/basic-example Patch
@cipherstash/e2e Patch
@cipherstash/prisma-example Patch
@cipherstash/stack-drizzle Patch
@cipherstash/stack-supabase Patch
@cipherstash/stack Patch
@cipherstash/wizard Patch
@cipherstash/bench Patch
@cipherstash/test-kit Patch

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

coderdan added a commit to cipherstash/benches that referenced this pull request Oct 21, 2026
…st the walk-and-projection branch

The second measurement, against cipherstash/stack#1325. Every nested
operation is now index-served and within an order of magnitude of native
jsonb except the subtree walks, which are per-node hashing on the server
and sit at 3 to 13 ms per row (23 ms walked out in full). The report
records the four mechanisms the two runs found and what each measures
now, with the one this run found itself: a TOASTed column makes a
sequential scan look free to the planner, so the projection reader has to
stay out of its inlining to keep the GIN index.

Also the v3 SteVec and plaintext baselines at 100k rows, re-measured
today on the same cluster.

Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
…e fallback's refusal messages

CI found two gaps. The DB-free round-trip test still asserted the stored
document's keys as `h, i, sv, v`; `q` is one of them now. And the in-diff
mutants gate deleted the fallback opening's `0 =>` arm and survived: both
refusals are `Error::OpenedType`, and the test checked only the variant.
It now asserts each refusal's `expected` text, so a path that reaches
nothing and a wildcard that reaches two nodes are told apart.

Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
…-and-projection

Brings up the review fixes to the json_tree design record and its SQL
prototypes from docs/json-index-stack-encrypt-spike.

Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
…-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
…-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

@coderdan coderdan left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review of the walk-and-projection pass

Scope: what this PR adds over #1288 (git diff origin/feat/json-index-stack-encrypt...origin/perf/json-tree-walk-and-projection). Background for a reader new to EQL: a json_tree column stores one encrypted entry per JSON node. The database answers containment (@>) from selectors it cannot read, and walks a subtree from a "token" the client derives from a path. This PR makes three changes. The client now stores the containment projection q beside the entries. The walk looks children up in a map instead of rescanning the document. And past a budget, a walk returns the sealed root (the whole document in one ciphertext) for the client to open.

Findings by severity

# Severity Finding
1 High When the server falls back to the sealed root, opening json_tree_subtree rows as Vec<Value> gives a different shape. A caller reading an encrypted array's elements gets back the whole array as a single element once the subtree passes 64 entries. No error is raised.
2 Medium A document whose wrapped tokens loop back makes json_tree_subtree / json_tree_subtree_at produce 2^127 rows. The depth bound does not stop a loop that branches. Demonstrated with a three-entry document: the query runs until statement_timeout.
3 Medium The walk probes as many ordinals as the document's largest o asks for. One entry with o = 2147483646 makes json_tree_children / json_tree_child_count and every walk level hash two billion times. Demonstrated: the query runs until the timeout.
4 Medium (owner decision) The budget is on by default (DEFAULT 64). A caller that never attaches a path works in tests with small fixtures and fails with OpenedType in production, on the first document whose subtree passes 64 entries.
5 Low (owner decision; docs fixed) The server now trusts q, and nothing ties it to sv. A row written by anything but Stack Encrypt can answer @> differently from ->, the walks and decryption.
6 Low The Postgres suite does not cover three of the new shapes: an unsealed root under a budget (the SQL's flag bit against the Rust header), Vec<Value> over a fallen-back json_tree_subtree, and hostile documents.
7 Low The numbers in the design doc and the changeset do not match the report this PR cites (cipherstash/benches 57abcf9), and neither document cites it.
8 Low The fallback has the client decrypt the whole document to answer a subtree read. That costs the client something the benchmarks do not measure (no decrypt). It also widens the "plaintext copies are not zeroized" note from one subtree to the whole document.
9 Nit stack-encrypt's CHANGELOG does not mention JsonSubtree::at / JsonPath::select / q. eql-bindings' CHANGELOG has no json_tree entry at all. operators.sql:66 still says json_tree_query(a)::jsonb. The PR body's test counts predate the #1288 merges.

Each finding has an inline comment that says what breaks for a user.

What I checked and found sound

  • No security property is loosened by the projection. q holds the same {s, vs} pairs as sv, grouped exactly as sv's own n/g tags already group them. Neither this projection nor the old server-built one was authenticated, so storing it reveals nothing new and skips no AAD. JsonDocument ignores a stored q and derives it again from the entries, so the client never trusts it.
  • The fallback opens under the same AAD as any root read (j = 0, the document's header). A server that falls back when it need not still produces the right answer. The fallback takes the path on the client, so it is not exposed to the open issue that JsonNode / JsonSubtree open under whichever selector the server's row carries (that issue is still the owner's). The walked rows still are.
  • The map-based walk matches the Rust derivations. json_tree_by_parent keys on p. The tag and unwrap formulas did not change. doc.meta || entry || {d} is what json_tree_graft built, because the map adds j. The sealed-root bit (byte 1, bit 0) matches JsonHeader::to_bytes.
  • #1288's bounds survived the merge. Both walks still carry AND w.depth < 127. The extra comparison costs nothing measurable, so the merge did not undo the performance work.
  • cargo nextest run -p stack-encrypt --all-features passes (755 passed, 1 skipped). Clippy with -D warnings passes with all features and with none. cargo fmt --check is clean.
  • The report at cipherstash/benches 57abcf9 reproduces the PR body's table: 68 → 3.2 ms, 0.70, 0.50, 2.8 ms, 9.5 / 23 ms, 1.6 ms, 44 → 5 s. One exception: the report's table counts 122 rows for the walked $.orders subtree, where the PR body and the report's prose say "the same 244 entries". Please check which is right; finding 7 has the details.

I'm pushing fixes for 1, 2, 3, 6, 7, 8 and 9 to this branch, plus the documentation half of 5. Findings 4 and 5 need a decision from you.

type Context = AeadContext;
fn decryption<K: 'static>(self, context: Self::Context) -> Decryption<Vec<Value>, K> {
Decryption::from_open(move |cipher| {
if self.is_sealed_root() {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[High] When the server falls back to the sealed root, Vec<Value> over json_tree_subtree rows changes shape

What breaks for a user: they keep a list in an encrypted JSON column (say $.orders) and read its elements with json_tree_subtree(doc, token_of("$.orders")), opening the rows as Vec<Value>. Walked, the rows open to the elements: [o1, o2, …]. Once the list grows past 64 entries, the server returns the sealed root instead. The caller attaches the path, as the docs say (.at("$.orders")), and the same call now returns [[o1, o2, …]]: one element, the whole array. No error is raised. The code takes the other branch only on large documents, so tests with small fixtures never see it.

Cause: on the walked branch, Vec<Value> opens json_tree_subtree rows to the node's children (the forest is rooted at d = 1). On the fallback branch, path.select(&whole) returns the node itself. For json_tree_subtree_at rows the two branches agree, because there the forest roots are the matched nodes. The rows cannot tell the fallback which walk produced them. A positional path does not tell it either: #1329 walks positional paths through gates, so the shape of the path does not decide the walk.

Value has the same problem in the other direction: walked json_tree_subtree_at rows assemble into {"b": v}, while the fallback gives v. #1329 already fixes the walked side of that.

Fix: say which walk the rows came from. JsonSubtree::below(path) marks json_tree_subtree rows: Vec<Value> opens to the children of the one node at path, and Value to that node. at(path) stays as it is, for json_tree_subtree_at. The same two methods go on JsonTreeSubtree. Tests open the same subtree walked and fallen back and require the two results to be equal.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 923364d: JsonSubtree::below(path) (and JsonTreeSubtree::below) marks json_tree_subtree rows, so a fallen-back list opens to the node's children, as the walked rows do; at keeps the json_tree_subtree_at meaning. the_fallback_opens_to_what_the_walk_opens_to opens an array, an object and a scalar both walked and fallen back; 31d5da4 pins that the last attachment decides. Carried to #1329 in fee616f, where the test walks chain heads, since only a head's token is derived there.

-- 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
WHERE c.entry IS NOT NULL AND w.depth < 127

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Medium] A looping chain makes the walk exponential; the depth bound does not stop it

What breaks for a user: one row stops a query. Nothing authenticates w, and the unwrap is public (w XOR H("w" ‖ iv ‖ t ‖ c)). So whoever holds a token can write a child whose unwrapped token is its parent's own token. Tokens are sent to the database in queries, so anyone who can read query logs and write a row holds one. Two such children per node double the rows at every level, and the depth bound allows 127 levels: 2^127 rows. Demonstrated on this branch against PostgreSQL 17 with a three-entry document (a root and two looping children): json_tree_subtree(doc, token, 0) runs until statement_timeout.

It applies to json_tree_subtree with budget = 0 or an unsealed root, and to json_tree_subtree_at under any budget when no looping entry carries the selector. That is because the budget counts only emitted rows, and the LIMIT sits after the hit filter. The comment above this line ("stops here instead of recursing without end") holds only for a loop that does not branch.

The walk was like this in #1288's version too. This PR rewrote it and kept the bound, so this is the place to close it. The Rust walk already stops at the document's entry count (design doc, Phase 1-2 notes).

Fix: an honest walk visits each entry at most once. So read the walk through LIMIT jsonb_array_length(sv) (the seed row plus at most entries - 1 non-root entries) before the hit filter and the budget. PostgreSQL evaluates a recursive CTE only as far as its reader pulls rows, so the LIMIT stops the recursion as well as the output. The Postgres suite gets the looping document, under a statement_timeout so a regression fails instead of hanging.

#1329 derives a child's token by hashing (H(t ‖ o)), so a loop there would need a SHA-256 cycle. That walk needs neither bound.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c1059d7: both walks read the recursion through LIMIT the entry count, which stops the recursion and not just the output. The three-entry looping document now returns 2 rows in about 1 ms. postgres_walks_end_on_hostile_documents builds it sealed and unsealed, under budgets 0 and 64, for both walks. In #1329 (fee616f) the ordinal chain cannot loop, because a child's token is a hash of its parent's, so that branch keeps its own walk. Its version of the test is a 300-deep stored chain, which still ends after one row per entry.

)
SELECT (doc.meta || c.entry || '{"d": 1}'::jsonb)::public.{{prefix}}_json_tree_entry
FROM doc
CROSS JOIN generate_series(0, doc.fanout - 1) AS o

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Medium] The document chooses how many ordinals every walk probes

What breaks for a user: the same as finding 2, with one entry. json_tree_fanout is max(o) + 1 over the document. A stored entry with "o": 2147483646 makes generate_series(0, doc.fanout - 1) produce two billion ordinals, and each costs a SHA-256. That cost lands on every node of a walk, and on json_tree_children and json_tree_child_count. Demonstrated: a two-entry document makes json_tree_child_count run until statement_timeout.

The #1288 review closed this on the Rust side (children() checks each entry at its own ordinal instead of looping over a fan-out the document chooses), but not in SQL.

Fix: an honest document's ordinals under any parent are below its entry count, so json_tree_fanout takes LEAST(max(o) + 1, entries). An honest walk then costs at most entries × fanout hashes. #1329 probes ordinals until the first miss and has no fan-out, so it needs no bound.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c1059d7: json_tree_fanout is LEAST(max(o) + 1, entries). The two-entry document with o = 2147483646 now answers json_tree_child_count in about 1 ms, and the hostile-document test covers it. #1329 has no fan-out (it probes ordinals until the first miss), so this does not carry there.

CREATE FUNCTION {{prefix}}.json_tree_subtree(e public.{{prefix}}_json_tree, token text)
--! depth `d` below the node, in depth-first order; or the root
--! entry alone, with `d = 0`, past the budget.
CREATE FUNCTION {{prefix}}.json_tree_subtree(e public.{{prefix}}_json_tree, token text, budget integer DEFAULT 64)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Medium, needs your decision] The budget is on by default, and a caller that does not know about it fails only on large documents

What breaks for a user: they call json_tree_subtree(doc, token) and open the rows with JsonTreeSubtree::new(rows), as #1288 taught them. That works on every fixture and in staging. In production, the first document whose subtree passes 64 entries comes back as the sealed root, and the open fails with OpenedType ("the walked path, attached with JsonSubtree::at, …"). The error message is clear. The problem is when it appears: only on large documents, and at runtime.

Options:

  • (a) Keep DEFAULT 64 and accept this. The family is unreleased, so no existing caller exists yet, and the docs and changeset say it.
  • (b) Default to 0. The fallback becomes opt-in for the caller who attaches a path, and the performance win becomes opt-in with it.
  • (c) Keep 64, and make the client API make a path-less open impossible. For example, JsonTreeSubtree::new takes the path, or the key returns the subtree wrapper.

I have not changed this. (a) and (c) keep the PR's headline numbers. (b) is the conservative choice. I'd lean to (c) in Phase 4 with (a) until then, but it is your call.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not changed; this needs your decision. I recorded it as open in the design doc's Phase 3 notes (014d25d).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The owner chose option (c): a path-less open is now impossible. This is f1f27f2 on #1329's branch (feat/json-tree-ordinal-chain). JsonSubtreeKey now carries the JsonPath it was derived from and builds the wrapper itself: key.open(rows) in stack-encrypt, JsonTreeSubtree::from_key(&key, rows) in eql-bindings. The key also decides between below and at from its gates: no gates means the rows came from json_tree_subtree, gates mean json_tree_subtree_at. The path-less constructors and the below and at attachments are gone, so a caller can no longer forget the path. A new test, the_key_built_fallback_opens_to_what_the_walk_opens_to, checks ten paths, gated and ungated. For each, the wrapper the key builds opens past the budget to the same value as the walked rows.

AS $$
SELECT {{prefix}}_internal.json_tree_group(e::jsonb -> 'sv', NULL)::{{prefix}}.query_json_tree
BEGIN
RETURN e::jsonb -> 'q';

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Low, needs your decision; docs fixed] The server trusts q, and nothing ties it to sv

What breaks for a user: before this PR, the server derived the projection from sv, so @> and the entries could not disagree. Now @> reads q as written. A row written by anything other than Stack Encrypt (a hand migration, another client, a future client with a bug) can answer containment from q while ->, the walks and decryption answer from sv, and nothing notices. This is not a confidentiality loss: the old projection was not authenticated either, and a database that wants to lie can always lie. It is a consistency guarantee that the old design had and this one drops.

Options: (a) accept and document it; (b) have the domain CHECK rebuild q from sv at write time and compare. (b) moves the old recursive projection from every recheck to every write, roughly the 44 s per 10k rows the index build used to cost, now paid at insert.

I've documented (a) in types.sql and the design doc, so the trust is stated rather than implied. Whether to pay for (b) is your decision.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Documented in 014d25d. types.sql and the design doc now say the CHECK validates q's shape but not that it matches sv, and what that means. Write-time verification is left for your decision. Carried to #1329, where q is the only place the value selectors live, so the note matters more there.

};
assert_eq!(walked(&mut db, 6).len(), 6, "at the budget, the subtree");
assert_eq!(walked(&mut db, 0).len(), 6, "budget 0 never falls back");
let root = walked(&mut db, 5);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Low] The Postgres suite misses three of the new shapes

This suite is what holds the SQL and the Rust together, and it does not exercise:

  • An unsealed root under a budget. Nothing checks that json_tree_sealed_root reads the same bit JsonHeader::to_bytes writes. A wrong bit would turn the fallback on for unsealed documents, whose root has no document to return, so every large walk would come back unreadable, or it would turn the fallback off everywhere. Tests below the budget pass either way.
  • Vec<Value> over a fallen-back json_tree_subtree: finding 1.
  • Hostile documents: findings 2 and 3.

Fix: add all three, the hostile ones under a statement_timeout so a regression fails instead of hanging the suite.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c1059d7. The suite now pins the budget to the header's sealed-root flag (with the flags byte cleared, the walk returns all 6 rows), opens the list walked and fallen back to the same elements, and builds both hostile documents under a statement_timeout. #1336 runs this suite in CI on every supported PostgreSQL, so the hostile test runs there too after cd8c060.


The family was measured at 10,000 rows in the `cipherstash/benches` repository (branch `feat/json-tree-benches`, report `report/JSON_TREE_COMPARISON.md`) against the v3 SteVec and native `jsonb`, on a flat five-node corpus and a nested corpus of about 220 nodes per document. The flat corpus was on par with v3 (containment 0.47 ms against 0.23 ms, equality at a path 0.70 against 0.81 ms, the containment index eleven times smaller). The nested corpus was not: containment 70 to 80 ms per query, a 244-entry subtree 10 s, the GIN index 44 s to build. Three mechanisms accounted for it, and the fixes landed in the pull request stacked on the family's:

- **The projection is stored, not rebuilt.** Decision 9 above chose to build the element-nested projection server-side from the tags (`eql_v4_internal.json_tree_group`, recursive plpgsql). That function ran once per candidate row in the lossy GIN recheck, and again in the Filter, at about 3 ms per 220-entry document: the whole cost of containment on the nested corpus, and of the index build. The client already derives the shape for the needle, so the document now writes it to the wire as `q` beside `sv`, the domain CHECK validates it as a group, and `eql_v4.json_tree_query(doc)` is `doc -> 'q'`. It returns `jsonb` rather than `eql_v4.query_json_tree`, because a domain cast runs the needle's recursive CHECK, and the recheck would pay it per row. And it is plpgsql with `COST 1000`, not an inlinable SQL function, for a reason the first re-measurement found: inlined to `value -> 'q'` the filter is costed as one operator, and the nested table's rows are TOASTed, so its main heap is a few hundred pages and a sequential scan over it was costed below the GIN bitmap scan and chosen, at 1.5 s for a one-row match on 10,000 rows (17 s on 100,000), because every row is decompressed to read `q`. Un-inlined, the call keeps its cost, the index expression `eql_v4.json_tree_query(col)` still matches it, and the planner takes the index: 0.8 ms. Nothing new is revealed: `q` is the selectors already in `sv`, regrouped, at about seven per cent of the document. The deviation this replaces, "9, the projection is built from the tags server-side", is withdrawn; the `groups_ok` fallback remains unneeded.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Low] The design doc and the changeset quote numbers the cited report does not contain

The PR body cites cipherstash/benches 57abcf9 (report/JSON_TREE_COMPARISON.md), and its table matches that report. The design doc and the changeset carry numbers from an earlier interim run, and neither cites the commit:

claim design doc / changeset report at 57abcf9
244-entry subtree walked about 50 ms 23 ms
six-entry subtree 3.7 ms 2.8 ms
sealed root returned about 8 ms 9.5 ms
one-row @> through the index 0.8 ms 0.5–0.7 ms
q's share of the document about 7% 19,100 of 114,309 characters, about 17%
nested document vs plaintext 24× 29× in characters, 34× on disk

The "7%" matters most: it is the storage cost of this design choice, and the measured figure is more than twice that.

Separately, the report's own table counts 122 rows for subtree/orders_walked at 10k (130 at 100k). Its prose, and the PR body, call it "the same 244 entries" as the first run's 10.2 s row. If the two runs sampled different rows, the 10.2 s → 23 ms comparison is not like for like. Please check which count is right.

Fix: align the doc and the changeset to the report, cite the commit, and leave the 244/122 question to you.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 014d25d. The design doc and the changeset now quote the report at 57abcf9 and name both bench commits. The 244 vs 122 count is flagged in the doc as still to be confirmed, because the report itself gives both numbers.

/// Open the sealed root a walk fell back to: the whole document, with the
/// path the rows were keyed from, which the caller selects over. Refused
/// without the path, since nothing in the rows records it.
fn open_fallback<'a, K>(

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Low] The fallback has the client decrypt the whole document for a subtree read

What changes for a user: past the budget, a request for $.orders makes the client decrypt the entire document (every field, including ones the caller never asked for), parse it into a serde_json::Value, and select the path from it. Access does not widen: it is one data key, and the caller could read the root anyway. Two things are not said, though:

  • Cost. "Cheaper on both sides" is measured only on the server side. The report states "no decrypt", so the client's AES-GCM pass and JSON parse over a document of about 1.3× the plaintext size are not counted.
  • Plaintext in memory. The Phase 1-2 notes record that the walk's plaintext copies are not zeroized. Under the fallback that now covers the whole document on every large subtree read, not one subtree.

Fix: say both in the design doc's Phase 3 notes. No code change.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 014d25d. The Phase 3 notes now state the client-side cost that the benchmarks leave out, and that past the budget the whole document's plaintext is in client memory (not zeroized).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Related, on #1329's branch in 63bec8f: the fallback's whole document is now held under vitaminc's Protected (Protected<PlainJson>, a Zeroize newtype over serde_json::Value). The walked path is selected inside the wrapper, so only the selected nodes reach the caller, and the document is wiped when the selection is done. Every other plaintext copy in the json module is held the same way, on both the seal side and the open side.


Also: `eql_v4.json_tree_path_query_first`, the function `->` and the ordering comparisons extract through, is costed at 1000 so a prepared statement's generic plan keeps the functional btree on `eql_v4.ord_term(col -> sel)` instead of filtering a sequential scan (17 ms down to 0.1 ms after the fifth execution). `eql_v4_internal.json_tree_group`, the server-side projection builder, is removed.

The `eql-bindings` crate's `JsonTreeDocument` gains the `q` field and `JsonTreeSubtree` becomes a struct with `rows` and an optional `path`; `stack-encrypt`'s `JsonDocument` writes `q` on the wire and ignores it on the way in, `JsonPath::select` evaluates a path over a value, and `JsonSubtree::at`, `path` and `is_sealed_root` carry the fallback. EQL v4 only; the v3 bundle is unchanged.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Nit] Release notes and stale text

  • packages/stack-encrypt/CHANGELOG.md's unreleased JSON index entry does not mention JsonSubtree::at / below, JsonPath::select, or the q field on the wire. The crate's public API grew in this PR.
  • packages/eql/crates/eql-bindings/CHANGELOG.md has no json_tree entry at all, so this PR's change to JsonTreeSubtree (from a tuple struct to {rows, path}) has nowhere to be recorded.
  • packages/eql/src/v3/json_tree/operators.sql:66 still says the operator inlines over json_tree_query(a)::jsonb.
  • The PR body's "734 passed" predates the feat(stack-encrypt, eql): the JSON index and the json_tree EQL v4 domain family #1288 merges (755 now), and the Changes list will need below.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. The stack-encrypt and eql-bindings CHANGELOG entries are in 923364d. The operators.sql comment is in 014d25d and carried to #1329, which had kept the stale text. The PR body is updated: test counts, the below API, and the bounds.

… walked rows open to

Past its budget a walk returns the sealed root, and the client selects
the attached path over the whole document. For json_tree_subtree rows
that gave the wrong shape as a list: the walked rows open to the
children of the token's node, the fallback opened to the node itself.
So a caller reading an encrypted array's elements got the whole array
as one element once the subtree passed 64 entries, silently.

The rows cannot say which walk produced them, and neither can the
path's shape (the ordinal chain walks positional paths through gates),
so the caller says it: JsonSubtree::below(path) for json_tree_subtree
rows, where a list opens to the node's children, and at(path), as
before, for json_tree_subtree_at rows, where it opens to the nodes the
path reaches. The same pair is on JsonTreeSubtree. A test opens each
shape both walked and fallen back and requires the two to agree.

Also records the fallback and the stored projection in the
stack-encrypt CHANGELOG, and gives eql-bindings' CHANGELOG the
json_tree entry it never had.

Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
…its depth

A stored document's chain fields are not authenticated, and two of them
let a row stop a query. `w` wraps a child's token under its parent's
with a public unwrap, so whoever holds a token can write children whose
tokens are their parent's: two per node double the walk's rows at each
of the 127 levels the depth bound allows. And the fan-out every walk
level probes was the document's largest `o` plus one, so one entry
claiming o = 2147483646 made json_tree_children hash two billion tags.
Both ran until statement_timeout on a three-entry document.

A sealed document's walk visits each entry at most once, and a node's
ordinals sit below the entry count, so both caps cost an honest walk
nothing: the walks read their recursion through LIMIT entries (which
also stops the recursion, since PostgreSQL computes a recursive query
only as far as its reader pulls), and json_tree_fanout takes the least
of max(o) + 1 and the entry count.

The Postgres suite now builds both hostile documents, sealed and not,
under a statement_timeout so a regression fails instead of hanging; and
pins the two fallback shapes the suite did not cover: a list opened
walked and fallen back, and the budget gated on the header's
sealed-root flag.

Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
…e, and state what q is trusted for

The design doc and the changeset quoted an interim run (50 ms for the
walked subtree, 0.8 ms for a one-row match, q at seven per cent of the
document, the document at 24 times its plaintext) where the report the
PR cites, cipherstash/benches 57abcf9, says 23 ms, 0.5 to 0.7 ms, about
17 per cent and 29 times. They now quote the report and name its
commits, and flag the one count the report disagrees with itself on.

They also say what the numbers leave out: the fallback's cost on the
client, which the benchmarks do not decrypt, and that it puts every
field of the document in the client's memory for a subtree read; and
that the server trusts q as written, so a row not written by the index
can answer @> differently from its entries. Whether to verify q at
write time, and whether the budget should be on by default, are left
open for the owner.

operators.sql still described the index expression with the ::jsonb
cast the projection no longer needs.

Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
…k's walk

`at` and `below` each set both the path and which walk the rows came
from; a test that only compared paths would let either keep the other's
walk.

Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
coderdan added a commit that referenced this pull request Oct 23, 2026
…-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
coderdan added a commit that referenced this pull request Oct 23, 2026
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
coderdan added a commit that referenced this pull request Oct 23, 2026
…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
…-and-projection

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
coderdan added a commit that referenced this pull request Oct 23, 2026
…-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
@coderdan
coderdan marked this pull request as ready for review October 23, 2026 13:28
@coderdan
coderdan requested a review from a team as a code owner October 23, 2026 13:28
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-23T13:35:18.254388Z 53a63fa Draft marked ready
🔒 Security Review ✅ Completed 2026-10-23T13:33:52.827490Z 53a63fa Draft marked ready
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant