Skip to content

feat(eql, stack-encrypt): chain json_tree tokens by ordinal and probe the walk to the first miss - #1329

Open
coderdan wants to merge 27 commits into
perf/json-tree-walk-and-projectionfrom
feat/json-tree-ordinal-chain
Open

coderdan wants to merge 27 commits into
perf/json-tree-walk-and-projectionfrom
feat/json-tree-ordinal-chain

Conversation

@coderdan

@coderdan coderdan commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

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. Its json_tree type (unreleased) stores an encrypted JSON document as one entry per JSON node: a small ciphertext c holding 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 selector s is 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 tag p, 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.

  • Three fields had no reader once the containment projection q was stored (perf(eql, stack-encrypt): store the json_tree projection, walk by parent map, budget the subtree #1325). q is the document's selector pairs grouped by array element, so PostgreSQL's own jsonb @> over it decides containment. Those three fields were the per-entry value selector vs and the two group tags n and g. The engine now reads q back as the projection instead of rebuilding it from the entries.
  • Two more fields existed only so the server could continue the token chain: the wrapped token w and the ordinal o. A child's token is now H("t" ‖ t(parent) ‖ s ‖ LE32(o)), a hash of its parent's token, its own selector s and 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.
  • The client can no longer derive an object member's token: its position is a rank among sibling keys the client does not know. So a query sends the deepest token it can derive (a top-level field's, then any array positions directly after it) plus one gate per further path step: that step's selector, or #n:<selector> for an array position, with the array elements' selector. The server descends through the gates and returns the node they reach at depth d = 0, with its descendants beneath it.
  • The leaf ordering term op is opt-in per path (commit ba62e3fc5). op is an order-preserving ciphertext of a number or string leaf, about 80 bytes each, which ORDER BY and range queries at that path use through eql_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

  • SQL (packages/eql/src/v3/json_tree/, rendered into EQL v4 only):
    • The entry validator accepts s, c, p and op only, 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 at d = 0 with its descendants. json_tree_subtree(doc, token, budget) is now that function with no gates, and still returns the token's node's descendants from d = 1.
    • json_tree_children and json_tree_child_count take the same optional gates.
    • The budget is unchanged: when the document's root is sealed (its ciphertext holds the whole document) and a walk would return more than budget entries, the walk returns the root entry alone at j = 0, d = 0, and the client opens it and selects the path itself.
    • eql_v4_internal.json_tree_child_token is added; json_tree_unwrap, json_tree_xor16 and json_tree_fanout are removed.
  • stack-encrypt (src/json/):
    • JsonEntry is {s, c, p, op}, and JsonDocument carries its projection, which the wire form requires as q.
    • JsonSubtreeKey holds a token and gates(); a position gate is #n:<selector>.
    • JsonDocument::children() probes positions in turn.
    • Rows from a gated walk open to the reached node's value.
    • JsonOptions gains ordered: BTreeSet<JsonPath> (so it is Clone, no longer Copy), JsonOptions::ordering([..]) parses paths into it, and JsonPath::covers is 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 and json_subtree_key takes &JsonOptions.
    • TermError::JsonPathNotOrdered (code stack_encrypt::json_path_not_ordered) refuses Lt, Lte, Gt, Gte and Between at an unordered path when the query is awaited.
  • stack-encrypt (src/plan/, src/target/): a typed plan had no place for index options, since encrypt_into::<JsonDocument>() reads the type's static EncryptFrom. 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. Error gains From<JsonPathError>.
  • eql-bindings (src/v4/json_tree.rs): JsonTreeEntry drops vs, o, w, n and g, and the typed document hands q to the engine. JsonTree::new(options) is the json_tree layout under options (a Layout<Value> whose target is JsonTreeDocument); the type's own EncryptFrom is that layout under the defaults.
  • Tests in all three places are updated for the new fields and the gated walk.
  • Design doc (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.
  • Doc comments that still described the old format are corrected (the header's SQL reader, the projection's source, the walk's cost).
  • Changeset: @cipherstash/eql minor.
  • Byte encodings (commit 37e02ab42): every field the SQL compares, derives or decodes is padded standard base64 (s, the s/vs pairs of q, p, op, tokens and gates), which PostgreSQL's encode/decode handle natively and which decode refuses unpadded; c is RFC 1924 base85 through the base85-simd 0.1.0 crate (no SQL function decodes c); h stays base64 because the SQL reads the iv and the sealed-root flag from it. p is the first eight bytes of its hash: a lookup key within one document, and the AAD binds s and j, not p. eql_v4_internal.json_tree_ope_cllw is added because the shared ope_cllw(jsonb) of the scalar types reads hex. eql-bindings gets JsonTreeSelector, JsonTreeCiphertext and JsonTreeOrderingTerm newtypes 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:postgres against 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 with jsonb @> 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 warnings and cargo fmt --check clean for stack-encrypt and eql-bindings, mise run test:schema passed, and mise run docs:validate's coverage and required-tags checks passed. Its documented-sql step 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-features 737 passed (new: an_ordering_term_sits_only_at_an_ordered_path, a_laid_out_plan_opens_what_it_wrote, the refusal cases in an_accessor_asks_at_a_path and an_accessor_is_refused_before_its_keyset_loads, the ordered wire round trip); the 46 doctests, including the new encrypt_as example; rustdoc with -D warnings; clippy and fmt clean; the trybuild snapshot op_matches_on_integer.stderr regenerated, its only change being the new Laid impl 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 $.hello and $.number ordered; the SQL did not change in that commit. Docker was not running locally, so the full test:encryption:postgres task was not re-run. cargo-mutants over the commit's diff found five survivors, all on the new Laid plan shape, pinned by 2e405c462 (ValuePlan::specs added 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):

    scenario 10,000 rows 100,000 rows
    subtree/orders, default budget 9.48 → 5.71 8.77 → 5.46
    subtree_at/items ($.orders[*].items), default budget 12.95 → 8.21 10.92 → 7.18
    subtree/orders, walked out (budget 0) 22.97 → 10.92 24.57 → 11.35
    subtree_at/items, walked out (budget 0) 23.12 → 10.93 24.83 → 11.27
    subtree/user 3.90 → 2.38 3.99 → 2.45
    path_query/wildcard 2.24 → 1.64 1.80 → 1.41
    field_order/nested 4.81 → 3.81 5.23 → 4.12
    children/orders 1.63 → 1.54 1.64 → 1.49
    child_count/orders 1.62 → 1.53 1.66 → 1.52

    children and child_count moved 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 jsonb controls over unchanged plaintext drifted +5% to +19% in the same run, so this reads as machine noise rather than the format.

  • Sizes:

    • Nested document: 113,458 → 85,157 characters (−25%); sv 94,324 → 66,023 (−30%).
    • Nested pg_column_size: 81,264 → 62,143 bytes.
    • Nested heap: 810 MB → 621 MB at 10,000 rows, 8,093 MB → 6,201 MB at 100,000.
    • GIN index unchanged (40 MB / 358 MB). GIN build 5 s at 10,000 rows; 58 → 65 s at 100,000, one sample.
    • Flat document: 2,346 → 2,136 characters; pg_column_size 2,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 ($.age flat, $.company.salary nested), correctness 462 of 462 at both tiers:

    • Nested 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.
    • Flat 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 native jsonb for 10 needles per predicate. 462 passed, 0 failed, at both tiers.

  • For the encodings commit: cargo nextest run -p stack-encrypt --all-features 751 passed; doctests 46 passed; clippy and rustdoc (-D warnings) clean; mise run wasm:wasi-check passed (base85-simd compiles for wasm32-wasip1 on its scalar path); mise run test:encryption:postgres passed 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_size averaged after VACUUM 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

Review notes

  • Start with the walk comment block in functions.sql and json_tree_subtree_at, then subtree_key_for in stack-encrypt/src/json/derive.rs, which decides what the client derives and what it gates.
  • The one semantic subtlety is in DecryptInto<Value> for JsonSubtree: a d = 0 row 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 at d = 0, at j = 0, and is told apart by that.
  • Trust is unchanged: only c is authenticated, through its AAD (the associated data the AEAD binds), and q was already what containment ran over. The w mask's binding to c is gone. A swapped ciphertext no longer breaks the walk on the server, but it still fails when the client opens the entry, because c is bound to s and its position j in the document.
  • At rest the format leaks less: there are no stored positions to count children from.
  • feat(stack-encrypt, eql): the JSON index and the json_tree EQL v4 domain family #1288's changeset still describes the format this PR replaces; it will sit beside this one in the same release's changelog, and this one says what superseded it. perf(eql, stack-encrypt): store the json_tree projection, walk by parent map, budget the subtree #1325's changeset is edited here, to carry the opt-in ordering paragraph.

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 profile of type public.eql_v4_json_tree:

-- containment, served by the GIN index; $1 is the encrypted needle
SELECT id FROM users WHERE profile @> $1::eql_v4.query_json_tree;

-- range at a path, served by a btree on the functional index; $sel is the path selector
SELECT id FROM users WHERE (profile -> $sel) >= $2::eql_v4.query_json_tree_leaf;
SELECT id FROM users ORDER BY eql_v4.ord_term(profile -> $sel);

-- one node, opens on the client
SELECT profile -> $sel FROM users WHERE id = 7;

-- a subtree: the token and the gates this PR introduces; rows open on the client into the value
SELECT e FROM users, eql_v4.json_tree_subtree_at(profile, $tok, $gates::text[]) e WHERE id = 7;

Rust, as stack-encrypt has it on this branch:

// The plan names the paths the index orders; nothing is ordered by default.
let options = JsonOptions::default().ordering(["$.age", "$.orders[*].total"])?;
let plan = Plan::value::<Value>().context("users/profile").encrypt_as(Json::new(options)).build()?;
let q = cipher.query(op::Contains, &json!({"orders": [{"id": 2}]})).on(&plan).await?;
let b = cipher.query(op::AtPath(op::Gte), &("$.age", 18)).on(&plan).await?;   // ordered: derives
// cipher.query(op::AtPath(op::Gte), &("$.name", "m")).on(&plan): refused, `$.name` is not ordered
let k = keyset.json_subtree_key(&"$.orders[*].items".parse()?, &options, column).await?;
// k.token() and k.gates() are the arguments of json_tree_subtree_at
let items: Vec<Value> = cipher.decrypt_as(JsonTreeSubtree::from_key(&k, rows), column).await?;

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:

db.select().from(users).where(await ops.contains(users.profile, { orders: [{ id: 2 }] }))
db.select().from(users).where(await ops.selector(users.profile, "$.age").gte(18))
db.select().from(users).orderBy(ops.asc(ops.selector(users.profile, "$.age")))

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_token now takes (token, s, o).
  • d72d9fe15: a path that names an array position is refused in JsonOptions::ordering, in the data plan's ordered, in an ordering query, and in a seal (TermError::JsonOrderingAtPosition).
  • d267735af: a json_wire_decode fuzz target, and the fuzz crate's refreshed Cargo.lock.
  • d3d7f764b, 151fd5d7c, 6d9ece2e7: the docs in types.sql, both CHANGELOGs, and the design doc's eight-byte p bound.

Two decisions from the owner followed the review, as separate commits:

  • f1f27f218: this is option (c) for the walk budget. JsonSubtreeKey carries 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's Protected. Node plaintexts are encoded straight into Protected<Vec<u8>>. Opened keys, scalars and documents stay in Protected<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-features 764 passed, and mise run test:encryption:postgres passed against PostgreSQL 17.10, including the new postgres_a_position_reaches_array_elements_only.

https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu

… 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
@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: 76d1085d-2a72-4d94-91f2-7360c11886d7

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
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-bot

changeset-bot Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 62f2132

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

…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
…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
…-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
coderdan added a commit that referenced this pull request Oct 23, 2026
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
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
… 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 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 #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 LIMIT on found stops the recursion early. The 300-entry chain test holds this.
  • The rank is keyed (json/rank/v1 under 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's iv sits at byte 22 (substr(…, 23, 16)) and the sealed-root flag at byte 1, which matches JsonHeader::to_bytes. base64ct decodes strictly. base85-simd 0.1.0 is CipherStash's own crate: its non-test code denies unwrap, panic and indexing, and it refuses overflowing blocks and invalid lengths.
  • The eight-byte p is a lookup key only. The AAD binds s and j, so truncating p weakens no authentication.
  • The newtypes. TryFrom and the node() and document() conversions are total: every failure maps to Error. The only v3 type v4 still uses is KeyHeader.
  • No skill mentions json_tree or v4 JSON behaviour.

Outside this diff, for the owner

  • At rest, the order of sv ranks key names. The walk visits object members in serde_json's map order. In this workspace that is sorted, since preserve_order is off. So in every row, sibling selectors appear in sv in 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) or at(path) is now fully determined by the key: no gates means json_tree_subtree, so below; any gates means at. A helper that takes the JsonSubtreeKey and 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) q is 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 {

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] 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.

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 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> {

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] 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. -> returns scores[0], which has no op, so ord_term is 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 (covers lets a wildcard cover a position). It compares scores[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.

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 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"

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 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.

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 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.

Comment thread packages/eql/src/v3/json_tree/types.sql Outdated
--! 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

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] 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.

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 d3d7f76.

Comment thread packages/stack-encrypt/CHANGELOG.md Outdated
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

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 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::Layout
  • Plan::value().encrypt_as and plan::Laid
  • ValuePlan::specs and ValuePlan::layout
  • From<JsonPathError> for Error
  • JsonSubtreeKey::gates(), which replaces selector()
  • JsonOptions::ordering and JsonPath::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.

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 151fd5d (both CHANGELOGs).


- **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.

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] 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.

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 6d9ece2.

…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
coderdan added a commit that referenced this pull request Oct 23, 2026
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
coderdan added a commit that referenced this pull request Oct 23, 2026
Brings in the JsonPath::covers test the #1329 mutants gate asked for. No
conflicts.

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

Copy link
Copy Markdown
Contributor Author

The CI mutants gate failed on 63bec8f. Two mutants survived in the position arm of JsonPath::covers (path.rs:191). Once d72d9fe made ordered paths and ordering queries refuse positions, no test reached that arm any more, although covers is public and still matches positions. 1a86672 adds a_position_covers_its_own_position_only. Run locally over covers, cargo-mutants reports 9 mutants, 9 caught. #1336 is merged up to the new head (0d441a2).

…-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 added a commit that referenced this pull request Oct 23, 2026
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
@coderdan
coderdan marked this pull request as ready for review October 23, 2026 13:29
@coderdan
coderdan requested a review from a team as a code owner October 23, 2026 13:29
@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:34:29.940907Z 62f2132 Draft marked ready
🔒 Security Review ✅ Completed 2026-10-23T13:35:20.894313Z 62f2132 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.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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".

Comment on lines +1351 to +1352
Segment::Index(index) => Gate::Slot(
u32::try_from(*index).map_err(|_| TermError::JsonTooWide)?,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

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