Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/eql-json-tree-walk-and-projection.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"@cipherstash/eql": minor
---

**The `json_tree` family's read path no longer pays for the document's size per query.** Three changes to the EQL v4 `json_tree` SQL, each measured (cipherstash/benches, `report/JSON_TREE_COMPARISON.md` at 57abcf9) on a 10,000-row corpus of nested documents (about 220 nodes each) where the shipped surface was one to four orders of magnitude behind native `jsonb`:

- **A stored document carries its own containment projection, `q`.** The client writes the document's `{s, vs}` pairs nested by array element beside `sv`, and `eql_v4.json_tree_query(doc)` now reads that field instead of rebuilding the projection from the group tags on every call. The GIN index recheck, which runs the projection once per candidate row, was the whole cost of `@>` on a nested document: 75 ms for ten rows, 16 s for a two-thousand-row result set, 44 s to build the index on 10,000 rows. With the projection stored the same queries measure in the low milliseconds and the index builds in seconds. `json_tree_query` returns `jsonb` rather than the needle domain, since the domain's CHECK would run per recheck; the document's own CHECK validates `q` when the row is written. It is a plpgsql function costed at 1000 rather than an inlinable SQL one: inlined to `value -> 'q'` the planner costs the filter as one operator, and a column of large documents is TOASTed out of the heap, so a sequential scan over it looked nearly free and was chosen over the GIN index while in fact decompressing every row (1.5 s for a one-row match on 10,000 rows; 0.5 to 0.7 ms through the index). The index expression is `(eql_v4.json_tree_query(col)) jsonb_path_ops`; the `::jsonb` cast earlier guidance wrote is harmless and no longer needed. **Wire change:** `q` is required in `public.eql_v4_json_tree`, and a document written by the earlier build of this unreleased family is refused by the domain CHECK.
- **The token-chain walk reads the document once.** `json_tree_subtree`, `json_tree_subtree_at`, `json_tree_children` and `json_tree_child_count` computed each parent tag inside a scan of `sv`, once per (node, ordinal, entry); a 244-entry subtree took ten seconds. The walk now builds one map of the document's entries keyed by parent tag and looks each computed tag up in it, so a level costs its fan-out in SHA-256 evaluations and the document is never scanned per node. The same subtree walks out in 23 ms; `children` and `child_count` in 1.6 ms. The walk is also bounded by the document's size: it probes at most as many ordinals as the document has entries and returns at most as many rows, so a stored document whose unauthenticated chain fields claim a huge ordinal, or wrap a child's token back to its parent's, can no longer keep a query running until `statement_timeout`.
- **A walk has a budget, and past it returns the sealed root.** `json_tree_subtree(doc, token, budget DEFAULT 64)` and `json_tree_subtree_at(doc, token, selector, budget DEFAULT 64)` stop once the subtree exceeds `budget` entries and return the root entry alone, with `d = 0`, when the document's root is sealed: the root's ciphertext is the whole document at about the plaintext's size, where the walk returns some four hundred characters per entry and pays the fan-out in hashes per node, so past a dozen or so entries the whole document is cheaper on both sides. The client opens the root and takes the walked path over it, and gets what the walked rows open to: attach the path with `below(path)` for `json_tree_subtree` rows and `at(path)` for `json_tree_subtree_at` rows (`JsonSubtree` in `stack-encrypt`, `JsonTreeSubtree::new(rows)` in `eql-bindings`). Rows that fell back and carry no path refuse to open. A budget of `0` never falls back; a token that names no node still yields no row; a document whose root is not sealed is always walked out.

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 (16.9 ms down to 0.11 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`, an optional `path` and `below`; `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::below`, `at`, `path` and `is_sealed_root` carry the fallback. EQL v4 only; the v3 bundle is unchanged.
14 changes: 13 additions & 1 deletion docs/plans/2026-10-17-json-tree-index.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,8 @@ A document in the new domain, `public.eql_v4_json_tree` (placeholder name; a sib
{ "s": "…", "vs": "…", "c": "…" }, // $.list (array) a chain head
{ "s": "…", "vs": "…", "p": "…", "w": "…", "n": "…", "c": "…" }, // $.list[*] element 0 (object)
{ "s": "…", "vs": "…", "p": "…", "w": "…", "g": "…", "c": "…", "op": "…" } // $.list[*].x = 1, inside element 0
]
],
"q": { "sv": [ {"s": "…", "vs": "…"}, … ], "el": [ { "sv": [ … ], "el": [ … ] } ] } // the containment projection, written by the client (Phase 3)
}
```

Expand Down Expand Up @@ -354,6 +355,17 @@ The index, the SQL and the live oracle landed in the pull request stacked on thi
- *A stored document is read as untrusted input.* Its chain fields are not authenticated, and whoever holds a token can write tags that form a cycle or a chain deeper than any sealed document. `JsonDocument::children` checks each entry at its own ordinal instead of looping over a fan-out the document chooses; `JsonDocument::subtree` and the SQL walks stop below 127 levels, and the Rust walk also stops once it has returned as many entries as the document holds; the rebuild of a subtree's rows refuses a repeated row and a nesting deeper than 127 levels as `OpenedType`. Each would otherwise have overflowed the stack, looped, or allocated what the document asked for.
- *Nothing wipes the plaintext copies the walk makes* (the per-node scalars it clones before sealing), where the scalar paths hold theirs in `Zeroizing`. A `serde_json::Value` does not zeroize; a follow-up should walk the caller's value without copying scalars out of it.

## Implementation notes (2026-10-21, Phase 3: first measurements and the fixes they forced)

The family was measured at 10,000 rows in the `cipherstash/benches` repository (branch `feat/json-tree-benches`, report `report/JSON_TREE_COMPARISON.md`; the first run at commit 076ebfc, the re-measurement of this phase's fixes at 57abcf9, both at 10,000 and 100,000 rows) against the v3 SteVec and native `jsonb`, on a flat five-node corpus and a nested corpus of about 220 nodes per document. The figures below are that report's at 10,000 rows unless they say otherwise. 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.5 to 0.7 ms for a one-row match, 3.2 ms for ten deep-object rows (0.36 ms native), and 5 s to build the index. Nothing new is revealed: `q` is the selectors already in `sv`, grouped as `sv`'s own `n` and `g` tags already group them. It is not small: 19,100 of a nested document's 114,309 characters, about 17 per cent, though nothing measurable on disk, since its hex pairs compress against `sv`. And it is trusted as written: the CHECK validates its shape, not that it is `sv`'s, so a row written by anything but this index can answer `@>` differently from `->`, the walks and decryption, which answer from `sv`. The server-built projection could not disagree with the entries; verifying `q` against `sv` at write time would restore that at roughly the old per-row cost, paid once per write instead of once per recheck, and is left open. The deviation this replaces, "9, the projection is built from the tags server-side", is withdrawn; the `groups_ok` fallback remains unneeded.
- **The walk reads the document once, keyed by parent tag.** The shipped walk put `json_tree_parent_tag(iv, t, o)` inside a LATERAL scan of `sv`, so it was evaluated once per (node, ordinal, entry): about 670,000 SHA-256 evaluations for the 244-entry subtree. The walk now builds one jsonb object of the chained entries keyed by `p` (`eql_v4_internal.json_tree_by_parent`) and looks each computed tag up in it, so a level costs its fan-out in hashes plus one lookup each, which is the design's `W × F` as written. The `$.orders` subtree walks out in 23 ms (the six-entry `$.company` subtree in 2.8 ms); `children` and `child_count` in 1.6 ms, from 7.1. The report's prose calls `$.orders` the same 244 entries as the first run's 10.2 s, while its table counts 122 rows for the walked scenario; which count the 23 ms is for is still to be confirmed. The walk is bounded by the document as well as by depth: the fan-out it probes is at most the document's entry count, where a stored `o` could otherwise ask for two billion, and it returns at most as many rows as the document has entries, where a stored `w` that wraps a child's token to its parent's could otherwise double the rows at every level. Neither changes a sealed document's walk; the Postgres suite holds both against hostile documents (`postgres_walks_end_on_hostile_documents`).
- **A walk has a budget and falls back to the sealed root.** With the root sealed, the root's ciphertext is the whole document at about 1.3 times the plaintext, where a walk returns about 440 characters per entry and pays the fan-out in hashes per node visited. Past a dozen or so entries the document is cheaper on both sides, so `json_tree_subtree` and `json_tree_subtree_at` take a `budget` (default 64) and, once the walk passes it, return the root entry alone with `d = 0`. The client opens the root to the whole document and takes the walked path over it, over the new `JsonPath::select`, and the result is what the walked rows open to: the path is attached with `JsonSubtree::below(path)` for `json_tree_subtree`, whose rows are the descendants of the path's node, and `JsonSubtree::at(path)` for `json_tree_subtree_at`, whose rows are the nodes the path reaches (the same two on `JsonTreeSubtree` in the bindings). The two differ when the rows are opened as a list, to the node's children or to the node, which is why the rows cannot be opened without saying which walk they came from. A budget of 0 never falls back, a token that names no node yields no row under any budget, and an unsealed root is always walked out. `$.orders` returns the root in 9.5 ms, against 23 ms walked out; `json_tree_subtree_at` over its items in 13 ms against 23. Two costs are not in those numbers: the client's, since the benchmarks do not decrypt, which for the fallback is one AES-GCM open and a JSON parse of a document about 1.3 times the plaintext's size; and the plaintext the client then holds, which is every field of the document and not the subtree asked for. The data key is the same, so nothing is readable that was not, but the note above that a `serde_json::Value` is never zeroized now covers the whole document on every subtree read past the budget. This is the sealed root paying for itself a second time: it is also what makes the fallback possible.
- **`json_tree_path_query_first` is costed at 1000.** `(col -> sel) > $1` extracts through it, and under a prepared statement's generic plan (what every driver reaches after five executions) the default cost chose a sequential scan with the extraction as a Filter over the functional btree: 16.9 ms against 0.11 ms with the index, at both tiers.

Still open from the measurements, filed as a follow-up: `->` scans `sv` on every extraction (0.6 ms on a nested document, 0.2 on a flat one, against 0.1 native; an index of `sv` by `s` would make it a lookup); `json_tree_contains` plans a second GIN probe for `alt` even when the needle carries none; `ORDER BY ord_term(col -> sel)` measured 0.96 ms against v3's 0.25 ms on an index scan both ways, unexplained; and the nested document is 29 times its plaintext in characters and 34 times on disk (hex tags, a random nonce per entry, the sealed-root copy, and now `q`), which decisions 1 and 6 above and the opaque-subtree policy are the levers on. Also open, for the owner: whether the budget should be on by default (a caller who opens a walk's rows without attaching a path works until a document passes the budget, then fails), and whether to verify `q` at write time.

## Decisions to take before Phase 1

1. **Token encoding on the wire:** hex, matching v3's `s`, or base64url, saving about 40 characters per entry. Proposal: measure in Phase 3, default to base64url.
Expand Down
14 changes: 14 additions & 0 deletions packages/eql/crates/eql-bindings/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **The EQL v4 `json_tree` family's types, `v4::json_tree`** (`stack-encrypt`
feature). `JsonTreeDocument` is `public.eql_v4_json_tree`, `{v, i, h, sv,
q}`: the document's entries, one per JSON node, and `q`, its containment
projection, which the server compares with a needle by native `jsonb @>`.
`JsonTreeEntry` is one entry as `->` and the walks return it,
`JsonTreeQuery` the containment needle and `JsonTreeLeafQuery` the
ordering operand at a path. `JsonTreeSubtree` collects a walk's rows; past
the walk's budget the server returns the sealed root instead, and
`JsonTreeSubtree::below(path)` (for `json_tree_subtree`) or `at(path)`
(for `json_tree_subtree_at`) attaches the path the rows then open at.
The document is an `EncryptFrom<Value>` and `DecryptInto<Value>` target,
the entry and the subtree open through `DecryptInto`, and the needle and
the ordering operand convert from Stack Encrypt's `JsonQuery` and
`JsonLeafQuery`.
- **Range query operands for `eql_v4.between`** (`stack-encrypt` feature).
Every ordered v4 family has two: `IntegerRangeOreQuery` and
`IntegerRangeOpeQuery`, `TextRangeOreQuery` and `TextRangeOpeQuery`, and so
Expand Down
Loading
Loading