Skip to content
Draft
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 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.8 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 244 entries return in about 50 ms; `children` and `child_count` in under 3 ms.
- **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 (`JsonSubtree::at(path)` in `stack-encrypt`, `JsonTreeSubtree::new(rows).at(path)` in `eql-bindings`). 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 (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.
14 changes: 13 additions & 1 deletion docs/plans/2026-10-17-json-index-stack-encrypt-spike.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,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 @@ -350,6 +351,17 @@ The index, the SQL and the live oracle landed in the pull request stacked on thi
- *The data-plan form and the catalog are not done.* The Go guest, `stashgen`, the target table and `v4::all()` do not know the family; `IndexSpec::Json` crosses the wire and is refused with a reason. The family is hand-written in `eql-bindings` as the SteVec structs are. Both are the next bindings issue.
- *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`) 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.
- **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 244 entries return in about 50 ms (3.7 ms for a six-entry subtree); `children` and `child_count` in under 3 ms, from 7.
- **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: `JsonSubtree::at(path)` in the engine, `JsonTreeSubtree::new(rows).at(path)` in the bindings, over the new `JsonPath::select`. 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. The 244-entry subtree returns the root in about 8 ms. 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: 17 ms against 0.12 ms with the index.

Still open from the measurements, filed as a follow-up: `->` scans `sv` on every extraction (0.8 ms on a nested document; 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.92 ms against v3's 0.25 ms on an index scan both ways, unexplained; and the nested document is 24 times its plaintext (hex tags, a random nonce per entry, the sealed-root copy), which decisions 1 and 6 above and the opaque-subtree policy are the levers on.

## 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
82 changes: 74 additions & 8 deletions packages/eql/crates/eql-bindings/src/v4/json_tree.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@
use schemars::{schema_for, JsonSchema, Schema};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use stack_encrypt::json::{JsonDocument, JsonLeafQuery, JsonNode, JsonQuery, JsonSubtree};
use stack_encrypt::json::{
JsonDocument, JsonLeafQuery, JsonNode, JsonPath, JsonQuery, JsonSubtree,
};
use stack_encrypt::target::{AeadContext, ExpectedContext, IndexSpec};
use stack_encrypt::{
DecryptField, DecryptInto, Decryptable, Decryption, EncryptFrom, Encryption, Error, NonEmpty,
Expand All @@ -40,16 +42,20 @@ use crate::Identifier;
pub struct JsonTreeTag(pub String);

/// `public.eql_v4_json_tree`: a JSON tree document, `{v, i, h, sv:
/// [entry]}`. Strict. `h` is the document's key header, stored once (every
/// entry is sealed under the document's one data key); `sv` holds one
/// entry per node in depth-first order, the root first.
/// [entry], q: group}`. Strict. `h` is the document's key header, stored
/// once (every entry is sealed under the document's one data key); `sv`
/// holds one entry per node in depth-first order, the root first; `q` is
/// the document's containment projection, its `{s, vs}` pairs nested by
/// array element, which the client writes beside the entries so the
/// server's `eql_v4.json_tree_query` reads it rather than rebuilding it.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, TS, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct JsonTreeDocument {
pub v: SchemaVersion,
pub i: Identifier,
pub h: KeyHeader,
pub sv: Vec<JsonTreeEntry>,
pub q: JsonTreeQueryGroup,
}

/// `public.eql_v4_json_tree_entry`: one node. `s` and `vs` are the path
Expand Down Expand Up @@ -144,9 +150,34 @@ pub struct JsonTreeLeafQuery {
/// The rows a subtree walk returned (`eql_v4.json_tree_subtree` or
/// `json_tree_subtree_at`), collected: opens to the value under the
/// walked node, or to the list of complete subtrees the rows hold.
///
/// Past its budget a walk returns the sealed root alone, at depth `0`;
/// those rows open to the whole document and the path the walk was keyed
/// from, attached with [`at`](Self::at), selects the value under it. The
/// path is not part of the wire.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct JsonTreeSubtree(pub Vec<JsonTreeEntry>);
pub struct JsonTreeSubtree {
pub rows: Vec<JsonTreeEntry>,
#[serde(skip)]
pub path: Option<JsonPath>,
}

impl JsonTreeSubtree {
/// The rows, as returned.
pub fn new(rows: Vec<JsonTreeEntry>) -> Self {
Self { rows, path: None }
}

/// The rows with the walked path, for the sealed root a walk may fall
/// back to.
pub fn at(self, path: JsonPath) -> Self {
Self {
path: Some(path),
..self
}
}
}

macro_rules! json_tree_domain_type {
($ty:ident, $sql:literal) => {
Expand Down Expand Up @@ -335,12 +366,16 @@ impl DecryptInto<Vec<Value>> for JsonTreeSubtree {

impl JsonTreeSubtree {
fn subtree(&self) -> Result<JsonSubtree, Error> {
Ok(JsonSubtree::new(
self.0
let rows = JsonSubtree::new(
self.rows
.iter()
.map(JsonTreeEntry::node)
.collect::<Result<_, _>>()?,
))
);
Ok(match &self.path {
Some(path) => rows.at(path.clone()),
None => rows,
})
}
}

Expand Down Expand Up @@ -382,6 +417,37 @@ mod tests {
.is_err());
}

#[test]
fn a_document_carries_its_projection() {
let document = serde_json::json!({
"v": 4, "i": {"t": "users", "c": "profile"}, "h": "AA==",
"sv": [{"s": "aa", "vs": "bb", "c": "AA=="}],
"q": {"sv": [{"s": "aa", "vs": "bb"}], "el": []},
});
let stored: JsonTreeDocument = serde_json::from_value(document.clone()).unwrap();
assert_eq!(stored.q.sv.len(), 1);
assert_eq!(serde_json::to_value(&stored).unwrap(), document);
let mut without = document.clone();
let _ = without.as_object_mut().unwrap().remove("q");
assert!(
serde_json::from_value::<JsonTreeDocument>(without).is_err(),
"a document without `q` is not a stored document"
);
}

#[test]
fn a_subtree_carries_its_path_beside_the_wire() {
let rows: JsonTreeSubtree = serde_json::from_value(serde_json::json!([])).unwrap();
assert!(rows.path.is_none());
let at = rows.at(JsonPath::parse("$.a").unwrap());
assert_eq!(
at.path.as_ref().map(ToString::to_string).as_deref(),
Some("$.a")
);
assert_eq!(serde_json::to_value(&at).unwrap(), serde_json::json!([]));
assert!(at.subtree().unwrap().path().is_some());
}

#[test]
fn an_entry_without_the_grafted_fields_does_not_open() {
let entry: JsonTreeEntry = serde_json::from_value(serde_json::json!({
Expand Down
Loading
Loading