Skip to content

feat(stack-encrypt, eql): json_tree tokens derivable from the path, a self tag, client-side wildcards - #1363

Merged
coderdan merged 24 commits into
nextfrom
feat/json-tree-derivable-tokens
Oct 25, 2026
Merged

coderdan merged 24 commits into
nextfrom
feat/json-tree-derivable-tokens

Conversation

@coderdan

@coderdan coderdan commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Summary

EQL v4's json_tree is the encrypted JSON column Stack Encrypt writes: one encrypted entry per JSON node, so PostgreSQL can answer containment, path extraction and "give me the subtree at this path" without the key. A subtree read sends the database a token, a keyed hash that lets it find a node's entries and the entries beneath it in every row.

Until now the client could only derive tokens for top-level fields, so reading $.profile.address.city sent $.profile's token, which exposes the structure of all of $.profile in every row, for as long as the column lives, and walked down through "gates". It also could not return the node itself, so {}, [], a scalar and a missing field all came back as "no rows" (#1356).

This PR implements the agreed design change (the design-doc section "Design change (2026-10-24)" on this PR's base, #1361): every token is now derivable from the path alone, every stored entry carries a self tag x by which the database finds a node, and wildcards are expanded on the client into concrete paths up to a bound. A read now sends exactly the token of the node it reads, returns that node, and answers the four cases the way jsonb does. The owner's decisions D1–D10 are folded into the design doc as "Decisions (2026-10-24)".

Changes

Stack Encrypt (packages/stack-encrypt)

  • Derivation: member token H16("t" ‖ t(parent) ‖ s) (no ordinal); element token keeps its index; x = H8("x" ‖ iv ‖ t) on every entry, the root and heads included. The parent tag p is unchanged and used for enumeration only. A wire entry is {s, c, x, p?, op?}. JsonHeader::FORMAT_VERSION and v are unchanged (D5).
  • JsonSubtreeKey is a list of tokens: one per concrete path, plus a probe per wildcard level at the bound N (default 64, JsonSubtreeKey::DEFAULT_BOUND, set per read with KeysetCipher::json_subtree_key_with_bound). tokens(), token_bytes(), probes(), bound(). Gates are gone.
  • Reads open as Value, Option<Value> (None means no node) and Vec<Value> (one value per concrete path). A probe hit fails with the new Error::JsonWildcardTruncated { bound }, walked or via the sealed-root fallback (D3, D4).
  • Row check (D8): the depth-0 row's selector and decrypted label against its token's path; deeper rows at consecutive j with the parent's path's selector plus their label; every row's x against the recomputed token (a consistency check only, and documented as such). The p check is dropped.
  • $ is a read under the sealed root (D6); TermError::JsonSealedRoot is removed (it had no other use); the new TermError::JsonWildcardTooWide refuses a read past 8,192 tokens (the owner lowered it from 65,536 in f3400a2).
  • JsonDocument::find / children / subtree(&[tokens]), the in-crate model of the SQL, follow the new rules.
  • CHANGELOG (unreleased entry rewritten), fuzz seeds regenerated with x, the stale mutants exclusion for the removed (0, true) arm dropped, proptest added as a dev-dependency (already in the lockfile).

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

  • eql_v4.json_tree_subtree(e, tokens text[], budget integer DEFAULT 64); json_tree_subtree_at removed (D9). The budget counts rows across all tokens; the recursion's LIMIT is entries × tokens, and the comment "The bound" is rewritten.
  • json_tree_children(e, token) and json_tree_child_count(e, token) lose gates; the count is NULL for an absent node and for the sealed root (D7).
  • New internals: json_tree_self_tag, json_tree_member_token, json_tree_element_token, json_tree_hit_token (the member-or-element choice by x, D2), and json_tree_index, which maps every entry's x and every chained entry's p to its position in one pass; json_tree_child_token and json_tree_by_parent removed. The chain primitives and json_tree_index carry the eql-inline-critical marker, and each read decompresses the document once (f94a6eb). The entry CHECK requires x.

eql-bindings: JsonTreeEntry gains x; JsonTreeSubtree opens to Option<Value>; docs and CHANGELOG.

Docs: the design doc section gets "Decisions (2026-10-24)"; the leakage page's read bullets and "What is authenticated" are updated, including the two allowances (another row; the same selector under another element) and that a database can withhold rows.

Changeset: @cipherstash/eql minor.

Verification

From the worktree root:

  • mise x --env test -- cargo nextest run -p stack-encrypt --all-features → 858 tests run: 858 passed, 1 skipped (includes the trybuild ui binary; re-run by the review on 2026-10-25)
  • mise x --env test -- cargo test -p stack-encrypt --doc --all-features (what test:doc:stack-encrypt runs) → 50 passed; 0 failed
  • cargo clippy -p stack-encrypt --all-features --all-targets -- -D warnings → exit 0
  • cargo fmt --check → exit 0 (root and packages/eql)
  • RUSTDOCFLAGS="-D warnings" cargo doc -p stack-encrypt --no-deps --all-features (what doc:stack-encrypt runs) → no warnings
  • pnpm run test:scripts (.cargo/mutants.toml changed) → Test Files 81 passed (81), Tests 1432 passed | 29 skipped. A first run alongside the mutants job timed out two unrelated tests at 5 s; the rerun on an idle machine passed.
  • mise run fuzz:json-wire -- -runs=0 (seed replay, as CI runs it) → Done 6 runs, no crash, with the regenerated seeds.

From packages/eql:

  • cargo test --locked -p eql-bindings --all-features → every suite ok
  • mise run test:encryption → exit 0 (both feature sets, the encryption tests, both clippy runs)
  • mise run test:encryption:postgres against a disposable PostgreSQL 17 container on port 5455 → exit 0; the json_tree suite 11 passed
  • mise run types:check → exit 0 (run after committing: before that it diffs the hand-written json_tree.rs)
  • mise run docs:validate:source, test:docs_v3_grep, test:self_contained_v3, test:installer_complete, test:symbol_order_v3, test:v4_isolation, test:build_ordering_helpers, test:doc-anchors → all exit 0
  • tasks/docs/validate/documented-sql.sh against the same container → Validated: 290 Errors: 0

Mutants: mise x --env test -- cargo mutants --in-diff <diff vs docs/json-tree-derivable-tokens> -p stack-encrypt -j 4 (settings from .cargo/mutants.toml; a cold build in a fresh worktree) → 132 mutants tested in 40m: 1 missed, 91 caught, 40 unviable. The one miss (next = at + 1 → at * 1 in place, letting two rows match one token) is killed by one_token_answered_twice_is_refused; applying the mutant by hand fails that test. Re-run by the review on 2026-10-25 at 4ce771c's code: 134 mutants tested in 39m: 94 caught, 40 unviable, none missed.

Not run: benchmarks (step 3 of the plan, for a separate agent; check:v4:json_tree in cipherstash/benches calls the gated walk and has to move to the token list). No new fuzz target: the existing json_wire_decode already fuzzes the JsonDocument and JsonNode decoders the client runs on every row the database returns, and is wired into fuzz:json-wire and the CI replay. A target for the row check itself would need a key source inside the fuzz crate.

New tests, by what they hold:

  • Pinned vectors (tests/json_tree_vectors.rs, fixture tests/fixtures/json_tree_vectors.json): s, x and p per entry, and the tokens for the root, a head, a member, an array, an element and a member under an element, sealed and open, under the deterministic key source. Regenerate only with STACK_ENCRYPT_UPDATE_JSON_TREE_VECTORS=1 (exactly 1).
  • Property tests (tests/json_properties.rs, proptest, and unit proptests in derive.rs): a member's token is independent of its siblings; element tokens are distinct by index and from every member (keys include "", "0", ., [*], ], $, quotes, a backslash and unicode); every path's read equals select, and the reads reassemble the document; a read then a step equals the longer read, and $ (walked, or sealed and opened whole) plus any path equals that path's read; -> equals the read for every scalar; children equals the child count, and is empty under a scalar; selectors, layout and tokens are independent of values and iv, with every x, p and c distinct; x on every entry and p absent only on the root and heads; a key sends exactly the tokens of the concrete paths (checked against a witness document through the stored self tags), the probes, and no ancestor's token; the serialized key is exactly those tokens.
  • Row check (tests/json_read_checks.rs): every refusal of D8 (wrong depth-0 selector, wrong label, non-consecutive j, wrong x at depth 0 and deeper, a splice, a gap, an orphan, a wrong depth, truncation, the root among rows, one token answered twice, one entry returned twice, rows of two documents), the two allowed cases, a 127-deep document, and the four answers as Value, Option<Value> and Vec<Value>.
  • Wildcards: arrays at N − 1, N and N + 1 for N = 1, 3 and 64, walked and fallen back; nested wildcards send N² + N + 1; the expansion limit.
  • Size regression: x adds +9.8% characters on the nested benchmark shape and +9.4% on the flat one; the bounds are 8–12% and 7–12%.
  • PostgreSQL oracle (packages/eql/tests/encryption/tests/json_tree.rs): postgres_every_subtree_read_answers_as_jsonb_does (ported from fix(eql): return the node itself from every json_tree subtree read #1359 and extended with nested empties, degenerate roots, the stress keys, numbers, null against absent, a 300-key object and 40-level nesting; sealed and open; budgets 0, 1 and 64; Value, Option and Vec; children and child_count against jsonb_object_keys and jsonb_array_length, NULL included; -> against the read); postgres_wildcard_reads_answer_as_jsonb_path_query; postgres_reads_never_answer_wrongly_under_tampering (120 random rows, nine kinds of tampering; observed 512 refusals, 1,402 right answers, 98 withheld); the hostile tests rewritten for tokens and x (a forged x, another position, a moved node, swapped entries, truncated and reordered rows, the two allowances); and postgres_walks_do_not_multiply_on_forged_member_tags (new) beside the duplicated-entries and deep-chain tests.

Rewritten rather than kept: every test that used gates, json_tree_subtree_at, JsonSubtreeKey::token() or gates(), or expected a read to return only a node's descendants; the_sealed_root_has_no_token_and_its_fields_are_heads (the sealed root has a token now) and a_position_past_postgres_integer_is_refused_not_sent (no position reaches the server as an integer any more; the refusal stays, since no stored element sits there).

Related

Closes #1360
Closes #1356
Refs #1358
Refs #1359
Refs #1357

Review notes

https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu

A member's token depended on its ordinal, a keyed rank among its
siblings' keys, so the client could not derive the token of any node
below a top-level field. Reads therefore sent the head's token and walked
the rest through gates: a read of `$.profile.address.city` handed the
database a capability over all of `$.profile`, in every row, for the life
of the column, and a read could not return the node it named, so `{}`,
`[]`, a scalar and an absent field were one answer (#1356).

A member's token is now H16("t" ‖ t(parent) ‖ s), an element's keeps its
index, and every entry carries a self tag x = H8("x" ‖ iv ‖ t) by which
the server finds the node a token names, the root and the heads
included. Ordinals stay, in the parent tag, for enumeration only. The
subtree key is the list of tokens of the concrete paths a path names: one
for a path with no wildcard, `$` included under the sealed root, and for
a wildcard the positions below a bound (64 by default,
`json_subtree_key_with_bound`) and a probe at it. A probe that finds an
element fails the read with `Error::JsonWildcardTruncated` rather than
return part of the list, walked or fallen back to the sealed root, so a
row's answer does not depend on the budget.

The client checks each row: the depth-0 row's selector and decrypted
label against its token's path, each deeper row at the next position
with its parent's path's selector extended by its label, and every
row's self tag against the token it recomputes. The self tag is
unauthenticated, so that last check is for consistency; the selector,
position and label are the binding. The rows open as `Value`,
`Option<Value>` (None for no node) and `Vec<Value>`.

`TermError::JsonSealedRoot` had no use left and is removed;
`TermError::JsonWildcardTooWide` refuses a read past 65,536 tokens. The
derivations are pinned under the deterministic key source, and property
tests cover the read's metamorphic relations, the independence of
tokens from values and iv, and exactly which tokens a key sends.
JsonHeader::FORMAT_VERSION is unchanged: the family is unreleased.

Refs #1360

Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
With every token derivable from its node's path, the server no longer
walks from a head through gates to reach a node: it is handed the
token of exactly the node read, finds it by its self tag, and returns it
at d = 0 with everything beneath it. `json_tree_subtree(doc, tokens
text[], budget DEFAULT 64)` replaces `json_tree_subtree(doc, token,
budget)` and `json_tree_subtree_at`, and answers `{}`, `[]`, a scalar
and an absent node as four answers, as `jsonb` does (#1356). Under the
member rule a hit's token no longer says whether it is a member or an
element, so the walk keeps whichever rule's self tag matches the hit's
`x`. The budget counts rows across the tokens; the recursion's LIMIT is
the entry count times the token count, since forged member tags can
make one subtree be visited twice. `json_tree_child_count` is NULL for
a token that names no node, and for the sealed root, whose members
chain from nothing.

The entry validator requires `x`; `JsonTreeEntry` gains it and
`JsonTreeSubtree` opens to `Option<Value>`. The PostgreSQL oracle holds
every read with no wildcard to strict `jsonb_path_query_first`, wildcard
reads to `jsonb_path_query` with truncation past the bound, the child
functions to `jsonb_object_keys` and `jsonb_array_length`, and reads of
randomly tampered rows to the right answer, a refusal or a documented
allowance.

Closes #1356

Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
The design change section left eight questions open; the owner settled
them before the implementation, and the section now records them as
"Decisions (2026-10-24)": keep the consecutive-position check beside
the self-tag check and drop the parent-tag one, choose the member or
element rule by the self tag with no stored kind bit, send probes as
ordinary tokens, apply the bound in the sealed-root fallback, edit the
derivation in place without a version bump, allow `$` under the sealed
root, return NULL for an absent node's child count, and drop the gates.

The leakage page's read and authentication sections now say what the
check binds and what it leaves to the database by design: the row an
entry comes from, the element a node at a selector sits under, and
whether rows were withheld.

Refs #1360

Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
cargo-mutants found that nothing failed when a read's rows could match
the same token more than once: `next = at + 1` survived as `at * 1`. A
server that rewrites a second node's self tag to the first token's
would then have two genuine nodes at one selector answer one token. The
test forges exactly that and expects the refusal.

Refs #1360

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

changeset-bot Bot commented Oct 24, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 8c843ee

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

@coderabbitai

coderabbitai Bot commented Oct 24, 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: 639788dc-3826-43a3-b6ad-7761578993fa

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 24, 2026 •

Copy link
Copy Markdown
Contributor

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

caught missed unviable timeout
100 0 40 0

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

coderdan added a commit to cipherstash/benches that referenced this pull request Oct 24, 2026
cipherstash/stack#1363 changes the json_tree stored form (a self tag on
every entry, ordinal-free member tokens, reads by token list, wildcards
expanded on the client). Deciding whether to ship it needs the new form
measured against its base on the same documents and the same machine,
with the noise the comparison carries stated.

The crate now builds either format from one source, by feature:
`derivable` (default, stack 683802bcc) or `gates` (its base, edabee625),
each a git dependency pinned by rev and renamed so both resolve in one
Cargo.lock. What differs between them is confined to `ReadKey` in
lib.rs. The plan builder's rename (`encrypt_with_layout`) is picked up.

- dt_bench: `plan` derives each format's scenarios against its own
  database; `time` runs both plans from one process, alternating which
  format goes first, per call and per document in-server.
- check_json_tree_v4 runs in both formats and opens every read through
  the key that derived it; it now also covers deep member paths,
  positions, wildcards at a bound, `$`, and the open-root and wide
  tables.
- Two corpora for what the others cannot show: the nested corpus with
  the root open, and a wide one (300 members, 300 elements).
- The ingest reports seal and INSERT time separately.
- mise tasks bench:dt:{build,ingest,check,plan,time} and report:dt.

Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
coderdan added a commit to cipherstash/benches that referenced this pull request Oct 24, 2026
The measurement cipherstash/stack#1363 asked for, so the owner can decide
whether to ship the format change: stack 683802bcc against its base
edabee625, the same documents in two databases on one cluster, timed
interleaved, 10,000 rows in full and 100,000 rows for sizes and the
headline reads.

Wildcard-free deep paths, single-level wildcards and wide enumeration
got faster. Whole-subtree reads, the budget fallback, a full walk of a
wide array, a two-level wildcard at N = 64 and json_tree_child_count on
a small node got slower: the per-hit self-tag test, and a second
whole-document map built on every read. Stored documents are 11% larger
on disk (nested) and 7% (flat). Both formats pass every correctness
check at both tiers.

report/JSON_TREE_DERIVABLE_TOKENS.md holds the measurement. The
JSON_TREE_COMPARISON.md Results tables are regenerated on the new format
(the SteVec columns are the second measurement's, not re-run), and a
fifth-measurement section there summarises it.

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

Copy link
Copy Markdown
Contributor Author

Benchmarks: this PR (683802b) against its base (edabee6)

I measured the derivable-token format against the gated walk on the same documents, on the same machine. Each format had its own database on one PostgreSQL 17.10 cluster, and the two were timed interleaved. Full report, with methodology, the noise floor and the EXPLAIN plans: report/JSON_TREE_DERIVABLE_TOKENS.md (benches commit 3c837be). Both formats pass every correctness check at 10,000 and 100,000 rows: every timed read was decrypted and compared with the plaintext.

Summary

Better

Worse

  • Whole-subtree reads: +38% to +46% per call, +45% to +59% in-server.
  • Full walk of a 300-element array: +78%.
  • The budget fallback: +51%.
  • Two-level wildcard at N = 64 (4,161 tokens): +48%.
  • json_tree_child_count on a small node: +59%.
  • Stored nested documents: +11% on disk.
  • Ingest: +3% to +9%.
  • ->, ordering and recheck-heavy containment on the nested corpus: +2% to +10%, in step with the larger document.

Unchanged within noise

  • Member paths 1 step below a field.
  • Flat and selective containment.
  • -> on the flat corpus.
  • json_tree_children on a small node.
  • GIN size and build time.
  • Every outer query plan.

Where the cost comes from

  • The self-tag test on every hit. In isolation, json_tree_hit_token costs 15.5 µs when the member test hits and 38.2 µs when it falls back to the element rule. The base's json_tree_child_token cost 7.8 µs. Inside a walk the wide corpus puts the extra at about 36 µs per member and 80 µs per element.
  • A second whole-document map on every read. json_tree_by_self costs 0.41 ms per nested document.
  • json_tree_child_count builds that map twice: once to test that the node exists, then again inside json_tree_children. Checking existence against the map json_tree_children already builds would remove about 1 ms per call.

Stored size

corpus entries / doc chars / doc pg_column_size / doc heap incl. TOAST containment GIN
nested, 10k 217 49,042 → 53,603 (+9%) 31,714 → 35,256 B (+11%) 320 → 355 MiB (+11%) 39.9 MiB, unchanged
nested, 100k 217 49,003 → 53,561 (+9%) 31,691 → 35,228 B (+11%) 3,193 → 3,543 MiB (+11%) 358.1 MiB, unchanged
flat, 10k 5 1,279 → 1,384 (+8%) 1,398 → 1,502 B (+7%) 16 → 16 MiB (+0%) 1.0 MiB, unchanged
wide (300 + 300), 1k 603 125,967 → 138,630 (+10%) 91,195 → 99,998 B (+10%) 91 → 100 MiB (+10%) —

Each entry gains 21 characters of ::text and about 16 bytes on disk. The flat heap does not move, because a page still holds 5 rows.

Ingest (seconds; 10k rows is the median of 3 interleaved runs, 100k one run)

corpus seal: gates → derivable INSERT: gates → derivable
nested 10k 8.37 → 8.75 (+5%) 23.68 → 25.62 (+8%)
nested 100k 80.00 → 82.73 (+3%) 224.96 → 245.13 (+9%)
flat 10k 0.29 → 0.31 (+5%) 1.29 → 1.35 (+5%)
flat 100k 2.83 → 2.95 (+4%) 12.28 → 12.95 (+5%)

Reads with no wildcard (nested, 10,000 rows)

These are per-call client times by id over 600 calls (200 rows × 3 passes), which include about 0.1 ms of round trip. The in-server column is one query over 500 documents.

read sent (gates → derivable) gates, median (p95) ms derivable, median (p95) ms per call in-server per doc
$.user.contact.emergency_contact.name (4 steps) 1 tok + 3 gates → 1 tok 2.48 (2.76) 1.93 (2.37) -22% -27%
$.company.name 1 tok + 1 gate → 1 tok 1.97 (2.27) 1.98 (2.42) +0% +3%
$.orders[2].status 1 tok + 1 gate → 1 tok 1.88 (2.13) 1.95 (2.41) +4% +7%
$.orders[2] (object, 24 entries) 1 tok → 1 tok 3.57 (4.92) 5.08 (7.13) +42% +52%
$.company (whole object) 1 tok → 1 tok 2.00 (2.29) 2.81 (3.34) +41% +59%
$.user (whole object) 1 tok → 1 tok 2.54 (2.84) 3.52 (4.01) +38% +49%
$.orders, budget 0 (176 entries) 1 tok → 1 tok 18.1 (26.2) 25.9 (37.8) +43% +46%
$.orders, default budget (falls back to the sealed root) 1 tok → 1 tok 6.36 (6.70) 9.62 (10.1) +51% +56%
$, root sealed refused → 1 tok — 1.97 (2.42) new new
$, root open (212 entries) 1 tok → 1 tok 21.1 (29.2) 30.7 (42.2) +46% +45%
$.first_name, flat (gates returns no row, #1356) 1 tok → 1 tok 0.62 (0.75) 0.65 (0.78) +5% +80%

Wildcard reads (nested, 10,000 rows)

The base sends the same head token and gates at every N. 33% of rows hold more than 8 orders, so at N = 8 the derivable read refuses on those rows (JsonWildcardTruncated), and that is the p95.

read sent (gates → derivable) gates, median (p95) ms derivable, median (p95) ms per call in-server per doc
$.orders[*].total, N = 8 1 tok + 2 gates → 9 tok 5.39 (6.96) 2.27 (7.14) -58% -46%
$.orders[*].total, N = 64 1 tok + 2 gates → 65 tok 5.51 (7.08) 2.54 (3.06) -54% -59%
$.orders[*].total, N = 256 1 tok + 2 gates → 257 tok 5.47 (7.06) 3.62 (4.16) -34% -38%
$.orders[*].items[*].price, N = 8 1 tok + 4 gates → 73 tok 17.8 (25.8) 3.50 (8.52) -80% -78%
$.orders[*].items[*].price, N = 64 1 tok + 4 gates → 4161 tok 17.4 (25.2) 25.8 (27.0) +48% +52%

Enumeration and the member/element test

The first five rows are the wide corpus (1,000 rows, 300 members and 300 elements per document). The last two are nested $.orders, from the focused run.

read sent (gates → derivable) gates, median (p95) ms derivable, median (p95) ms per call in-server per doc
json_tree_children, 300-element array 1 tok → 1 tok 25.1 (26.1) 12.8 (13.4) -49% -49%
json_tree_children, 300-member object 1 tok → 1 tok 25.1 (26.2) 12.9 (13.6) -49% -49%
json_tree_child_count, 300-element array 1 tok → 1 tok 24.8 (25.9) 15.1 (15.9) -39% -40%
full read, 300-member object 1 tok → 1 tok 31.1 (32.3) 42.0 (43.8) +35% +37%
full read, 300-element array 1 tok → 1 tok 31.1 (32.4) 55.4 (57.6) +78% +81%
json_tree_children, $.orders (5–10 elements) 1 tok → 1 tok 1.74 (2.16) 1.81 (2.30) +4% +12%
json_tree_child_count, $.orders 1 tok → 1 tok 1.75 (2.17) 2.79 (3.59) +59% +71%

Containment, -> and ordering (10,000 rows, focused run: 10 passes, 100 reps)

read sent (gates → derivable) gates, median (p95) ms derivable, median (p95) ms per call in-server per doc
@> one array element (nested, 1 row) – → – 0.64 (0.79) 0.64 (0.75) +1% —
@> deep object (nested, LIMIT 10) – → – 1.84 (2.03) 1.92 (2.13) +4% —
@> flat – → – 0.44 (0.62) 0.43 (0.58) -1% —
-> nested – → – 0.46 (0.57) 0.49 (0.68) +7% +10%
-> flat – → – 0.18 (0.31) 0.18 (0.32) +0% +5%
ORDER BY ord_term(… -> sel) LIMIT 10, nested – → – 2.17 (2.45) 2.33 (2.59) +7% —
the self-tag map alone (built by every derivable read) – n/a (no such map) 0.55 (0.72) n/a n/a
detoast and parse the document (floor) – → – 0.14 (0.19) 0.15 (0.20) +4% +9%

100,000 rows, headline reads

read sent (gates → derivable) gates, median (p95) ms derivable, median (p95) ms per call in-server per doc
$.user.contact.emergency_contact.name 1 tok + 3 gates → 1 tok 2.37 (2.65) 1.84 (2.32) -22% -27%
$.company 1 tok → 1 tok 1.90 (2.15) 2.68 (3.14) +41% +61%
$.orders, budget 0 1 tok → 1 tok 16.6 (24.8) 24.1 (36.1) +45% +47%
$.orders, default budget 1 tok → 1 tok 6.03 (6.35) 9.19 (9.75) +53% +63%
$.orders[*].total, N = 64 1 tok + 2 gates → 65 tok 4.95 (6.63) 2.47 (3.12) -50% -60%
$.orders[*].items[*].price, N = 64 1 tok + 4 gates → 4161 tok 16.4 (24.3) 24.8 (26.1) +51% +50%
json_tree_child_count, $.orders 1 tok → 1 tok 1.74 (2.13) 2.75 (3.58) +58% +69%
@> deep object, LIMIT 10 – → – 6.12 (6.29) 6.20 (6.25) +1% —
-> nested – → – 0.46 (0.54) 0.48 (0.54) +4% +9%

The noise floor is run 1 against run 2 of the whole 10k matrix in the same format. The median change was under 1%. Above 1 ms, every gates-to-derivable change reproduced within 3.5 points. Below 1 ms, round-trip noise reaches ±20% per call, so read the in-server column there.

https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu

… index once

The benchmarks of the derivable-token format (#1363) found every read
that enumerates slower than the gated walk: whole subtrees by 38 to 46%,
a 300-element array by 78%, json_tree_child_count on a small node by
59%, with a per-hit cost inside the walk far above the hashes. None of
it was the format. The post-install step pins search_path on every
function without the eql-inline-critical marker, and a pinned SQL
function is never inlined, so each of the seven chain primitives ran as
a full function call, about fourteen per level of the walk, 160 µs a
level on the wide corpus. json_tree_hit_token could not inline even
unpinned: a FROM clause, then STRICT with a CASE body, then arguments
the planner had flattened into expensive expressions. The two maps
copied every entry to build and were built twice by child_count. And
every reference to the argument decompressed the whole document again.

The seven primitives carry the marker. hit_token is FROM-less and not
STRICT, and the walk derives a hit's token in the probe beneath it from
the working table's columns. json_tree_index replaces json_tree_by_parent
and json_tree_by_self: one pass, tag to position, set-returning so it
inlines as an aggregate subquery computed once. Each read decompresses
the document once into a MATERIALIZED CTE. No stored byte changes.

Measured on the benchmark's own pass queries (500 documents per
scenario, in-server time per document, interleaved, medians), the SQL
as the PR first had it against the bundle built from the tree, on the
same rows on one cluster, identical rows from both:
  read/first_name                            0.10 ms ->  0.11 ms  +8.9%
  read/company.name                          1.27 ms ->  0.39 ms  -69.5%
  read/user.contact.emergency_contact.name   1.27 ms ->  0.37 ms  -70.5%
  read/orders[2]                             4.26 ms ->  0.83 ms  -80.5%
  read/orders[2].status                      1.26 ms ->  0.37 ms  -70.3%
  read/company                               2.06 ms ->  0.49 ms  -76.2%
  read/user                                  2.77 ms ->  0.59 ms  -78.6%
  read/orders (fallback)                     8.64 ms ->  0.84 ms  -90.3%
  read/orders (walked)                      24.03 ms ->  3.57 ms  -85.1%
  read/$ (sealed root)                       1.26 ms ->  0.39 ms  -69.1%
  read/$ (root open)                        28.21 ms ->  4.20 ms  -85.1%
  wildcard/orders[*].total N=8               2.41 ms ->  0.60 ms  -75.0%
  wildcard/orders[*].total N=64              1.79 ms ->  0.50 ms  -71.9%
  wildcard/orders[*].total N=256             2.81 ms ->  0.66 ms  -76.5%
  wildcard/orders[*].items[*].price N=8      3.57 ms ->  0.95 ms  -73.3%
  wildcard/orders[*].items[*].price N=64    25.03 ms ->  4.08 ms  -83.7%
  children/orders (nested)                   1.35 ms ->  0.42 ms  -68.6%
  child_count/orders (nested)                2.39 ms ->  0.31 ms  -87.1%
  children/attrs                            12.33 ms ->  5.12 ms  -58.4%
  child_count/attrs                         14.27 ms ->  1.20 ms  -91.6%
  read/attrs (walked)                       40.74 ms ->  7.22 ms  -82.3%
  children/list                             12.59 ms ->  5.08 ms  -59.7%
  child_count/list                          14.23 ms ->  1.21 ms  -91.5%
  read/list (walked)                        55.61 ms ->  8.22 ms  -85.2%

The oracle's tamper SQL reads the index's new shape. The Postgres suite
(11 json_tree tests), documented-sql and the eight SQL tasks pass. The
cipherstash/benches harness still names the two removed maps as floors
and moves to json_tree_index before the next full run.

Refs #1363

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

Copy link
Copy Markdown
Contributor Author

What the walk was paying for, and the fix (f94a6eb)

The benchmarks of the implementation (cipherstash/benches report/JSON_TREE_DERIVABLE_TOKENS.md, summarised on #1363) found the token-specific reads faster than the gated walk and everything that enumerates slower: whole-subtree reads +38 to +46%, a full walk of a 300-element array +78%, json_tree_child_count on a small node +59%, with the per-hit cost inside a walk (about 36 µs per member, 80 µs per element) well above what the hashes explain. Reading the plan found four costs, none of them in the format. All four are SQL-only and fixed in f94a6eb on this branch; the stored document is unchanged.

  1. A pinned search_path disabled inlining of every chain primitive. The post-install step (tasks/pin_search_path_v3.sql) sets search_path on every EQL function that does not carry the eql-inline-critical marker or take a jsonb-domain argument, and the planner does not inline a SQL function with a SET clause. So json_tree_h16, json_tree_le32, json_tree_parent_tag, json_tree_self_tag, json_tree_member_token, json_tree_element_token and json_tree_hit_token each ran as a full SQL-function call, about ten microseconds, where the hashes inside cost under one. A level of the walk makes about fourteen such calls, and EXPLAIN ANALYZE put the recursive step at 160 µs per level on the 300-wide corpus. The gated walk paid the same way, which is why the derivation's cost was invisible in the first comparison. The seven now carry the marker; the step is 14 µs per level.

  2. json_tree_hit_token could not inline even unpinned. Its body held a FROM clause, which blocks inlining; rewritten without one it was still refused, twice: as a STRICT function whose body is a CASE, which the planner inlines only when every argument is provably non-null, and then because its arguments, in the recursive term, were the expression that locates a hit, which the planner had flattened into the call, and a function that uses an expensive argument more than once is not inlined. It is now not STRICT, and the walk derives a hit's token where it consumes it, in the probe beneath, from the hit's stored s and x read off the working table, which are plain columns. An OFFSET 0 fence on the lateral subqueries also inlined it, but introduced a Memoize node keyed on the whole document, at 64 µs per row.

  3. The maps copied every entry, and were built twice. json_tree_by_parent and json_tree_by_self each aggregated elem || {"j": …} over sv, 0.55 ms per map on a 215-entry document, and every read built both; json_tree_child_count built them, then called json_tree_children, which built them again. One function, json_tree_index, now builds both maps as tag → position in one pass (0.14 ms), set-returning with the two maps as columns so the planner inlines it as an aggregate subquery of the caller's LATERAL, which is computed once: a scalar function the doc CTE named twice (idx -> 'p', idx -> 'x') was evaluated twice when the planner flattened the subquery. An entry is fetched as sv -> j only when it is returned.

  4. Every reference to the argument decompressed the document. The stored value is TOAST-compressed, and the doc CTE referenced e::jsonb five times (header, sv, metadata, entry count, index), each a decompression of the whole document, about 40 µs for a nested one: 0.2 ms of a 0.6 ms one-token read. Each read now takes e::jsonb #> '{}', the identity path, into a MATERIALIZED CTE and reads that.

Measured on the benchmark's own pass queries (500 documents per scenario, id = ANY(…), in-server time per document, three interleaved repetitions, medians), the SQL as #1363 first had it against the bundle built from the tree, on the same rows on one cluster; every scenario returned identical rows from both:

read gated walk (bench) derivable, as benched derivable, now change
read/first_name 0.06 ms 0.10 ms 0.11 ms +8.9%
read/company.name 1.28 ms 1.27 ms 0.39 ms -69.5%
read/user.contact.emergency_contact.name 1.76 ms 1.27 ms 0.37 ms -70.5%
read/orders[2] 2.89 ms 4.26 ms 0.83 ms -80.5%
read/orders[2].status 1.24 ms 1.26 ms 0.37 ms -70.3%
read/company 1.36 ms 2.06 ms 0.49 ms -76.2%
read/user 1.91 ms 2.77 ms 0.59 ms -78.6%
read/orders (fallback) 5.60 ms 8.64 ms 0.84 ms -90.3%
read/orders (walked) 16.76 ms 24.03 ms 3.57 ms -85.1%
read/$ (sealed root) – 1.26 ms 0.39 ms -69.1%
read/$ (root open) 20.01 ms 28.21 ms 4.20 ms -85.1%
wildcard/orders[*].total N=8 4.62 ms 2.41 ms 0.60 ms -75.0%
wildcard/orders[*].total N=64 4.47 ms 1.79 ms 0.50 ms -71.9%
wildcard/orders[*].total N=256 4.61 ms 2.81 ms 0.66 ms -76.5%
wildcard/orders[*].items[*].price N=8 16.06 ms 3.57 ms 0.95 ms -73.3%
wildcard/orders[*].items[*].price N=64 16.30 ms 25.03 ms 4.08 ms -83.7%
children/orders (nested) – 1.35 ms 0.42 ms -68.6%
child_count/orders (nested) – 2.39 ms 0.31 ms -87.1%
children/attrs 24.13 ms 12.33 ms 5.12 ms -58.4%
child_count/attrs 24.47 ms 14.27 ms 1.20 ms -91.6%
read/attrs (walked) 29.82 ms 40.74 ms 7.22 ms -82.3%
children/list 24.45 ms 12.59 ms 5.08 ms -59.7%
child_count/list 24.28 ms 14.23 ms 1.21 ms -91.5%
read/list (walked) 30.16 ms 55.61 ms 8.22 ms -85.2%

The gated-walk column is the benchmark's in-server figure for the same scenario, from a different run, and is there for orientation only. Every row the first comparison had as a regression against the gated walk is now faster than it. The cipherstash/benches harness measures json_tree_by_parent and json_tree_by_self as floors; both are gone, so its plan step must move to json_tree_index before the next full run.

Verification on the commit: mise run test:encryption:postgres (the json_tree suite 11 passed, every other suite ok), tasks/docs/validate/documented-sql.sh (0 errors), and docs:validate:source, test:docs_v3_grep, test:self_contained_v3, test:installer_complete, test:symbol_order_v3, test:v4_isolation, test:build_ordering_helpers, test:doc-anchors all exit 0. The installed bundle's plan shows the chain primitives expanded to sha256(...) and one aggregate for the index; EXPLAIN ANALYZE of the 300-wide walk went from 160 µs to 14 µs per level. Not re-run: the full cipherstash/benches matrix; its dt_bench plan step names the two removed map functions as floors and has to move to json_tree_index first.

https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu

The cap on what one subtree read may send was set where nothing would
ever reach it. A wildcard sends its bound plus one tokens under each node
it sits below, so two levels at the default bound of 64 send 4,161, the
largest read any measurement made. The server looks every token up in
every row before it knows whether the budget is blown, so a wide read
costs its tokens even when the whole document comes back instead, and the
entries-times-tokens LIMIT on the walk scales with the cap. 8,192 admits
every one-level wildcard up to 8,191 elements, two levels up to a bound
of 90, the default included, and never a third; 1,024 was considered and
would have refused the default two-level read. Owner's decision,
2026-10-25, recorded in the design doc with the reasons.

The same note records the decision not to chain the sealed root's
top-level entries from its token: a walk from the root is a capability
over the whole document's shape, so it would add leakage, and it waits
for a customer to ask.

Refs #1363

Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
tests/fixtures/json_tree_vectors.json is written by the pinned-vectors
test under STACK_ENCRYPT_UPDATE_JSON_TREE_VECTORS=1, in serde_json's
pretty form, and Biome formats every JSON file it is not told to skip:
the Lint (Biome) job on #1363 failed with one error, the formatter
wanting the fixture's one-element arrays on one line. Reformatting it
would be undone by the next regeneration, so it joins the other
generated fixtures the config already skips.

Refs #1363

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

Copy link
Copy Markdown
Contributor Author

Decisions locked in by the owner (2026-10-25)

Review of this PR raised six points that were not in the design's decisions D1–D10. The owner settled them as follows; each is also recorded in docs/plans/2026-10-17-json-tree-index.md under the implementation notes.

  1. Token cap: 8,192, not 65,536 (TermError::JsonWildcardTooWide, commit f3400a2). A wildcard sends its bound plus one tokens under each node it sits below, so two levels at the default bound of 64 send 4,161, the largest read any measurement made. 8,192 admits every one-level wildcard up to 8,191 elements and two levels up to a bound of 90, the default included, and never a third level. 1,024 was considered and rejected because it would refuse the default two-level read. The server looks every token up in every row before it knows whether the budget is blown, so a wider read costs its tokens even when the whole document comes back instead.

  2. json_tree_child_count of the sealed root is NULL, and json_tree_children at $ returns no rows. Accepted. The top-level entries carry no parent tag, because the format predates the root having a token; a developer counts top-level fields by reading $ and opening it. Chaining the heads from the root's token would remove the special case and is rejected for now: a walk from the root is a capability over the whole document's shape, so it adds leakage, SteVec offered nothing like it either, and it waits for a customer to ask.

  3. Withheld rows are an allowed deviation in the tamper oracle. Accepted. Nothing in a row says how many nodes a read should find, so a database that drops rows makes a node look absent or shortens a wildcard read. That is integrity against a lying database, outside the current threat model (confidentiality against an honest-but-curious one). Recorded for a future version: an authenticated count, for instance the array's child count read beside a wildcard.

  4. The walk's LIMIT is entries × tokens. Accepted. It bounds the work a lying database can make itself do with forged member tags; the client refuses the repeated positions, so correctness is not at stake. With the default sealed root and budget the read stops at budget + 1 rows regardless, so the LIMIT is the only bound when the budget is 0 or the root is open, and the lower token cap scales it down eightfold.

  5. The wildcard oracle compares with strict jsonb_path_query_first per concrete path. Accepted as a test method. The semantics a developer sees: a wildcard answers per element, an element that is not an object or lacks the key contributes nothing and raises no error (as jsonb's lax mode does on mixed arrays), and there is no implicit array unwrapping: $.items[*].price is written out, where jsonb lax would accept $.items.price.

  6. TermError::JsonSealedRoot removed. Not raised in review; it stands as implemented unless the owner says otherwise.

Also on this branch since the review began: f94a6eb (the walk's SQL cost: inlining the chain primitives, one index built once, one decompression per read; numbers in the comment above) and 0ec0c73 (the Lint (Biome) failure was the new json_tree_vectors.json fixture, which Biome formats; it is now skipped like the other generated fixtures). The float round-trip defect found by the property tests is #1364.

https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu

The crates job on #1363 failed on rustfmt alone: one assertion in the
cap test fits on a line. No behaviour changes.

Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
…rivable-tokens

Brings in the review fixes on #1358 through #1361.

Conflicts in `json/derive.rs`, `json/query.rs` and
`tests/json_read_checks.rs`, all where this branch replaced the gated and
ungated walks with token reads. This branch's `place` and
`JsonSubtreeKey::open` are kept: the leading-element check #1358 added
belongs to the walk with no gates, which no longer exists here, and every
read now returns the node it names with the child count that bounds its
children.

The truncation fix is carried over by hand: `rebuild_walked` here still
refused rows that ran out before a child count was met as `OpenedType`,
while the leakage page lists a truncated row among the `JsonWrongEntry`
refusals. It is now `JsonWrongEntry`, like a gap. `a_truncated_read_is_refused`
now expects that. #1358's ungated-walk tests are replaced by one that
pins what this design closes: an array read missing its first or its last
element is refused.

Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
… wrong entry

A subtree read's rows were refused as `Error::OpenedType` when the database
returned one entry twice or rows carrying two headers, while every other
refusal of a row the read did not ask for is `Error::JsonWrongEntry`, and
the json_tree leakage page lists a repeated row among those. `OpenedType`
says the ciphertext is authentic and was sealed as another shape, "not
tampered with"; neither case is that. An honest database never returns
either, which is exactly what `JsonWrongEntry` documents, so both now
return it, still before any key is retrieved.

The two checks sit in `open_subtree` ahead of the per-row placement. The
one assertion in tests/json_index.rs that pinned the old variant moves with
them (a one-line change; that file conflicts with #1345 anyway), and two
tests in tests/json_read_checks.rs pin the new answer and its `expected`
text. The error, `JsonSubtreeKey::open` and the CHANGELOG list both.

Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
…d model

`JsonDocument::find`, `children` and `subtree` are the in-crate model of
`json_tree_subtree` and `json_tree_children`, and the read-check tests
build their rows from it. Where a document repeats a self tag or a parent
tag, which no sealing produces but a hostile row can, the model took the
first entry carrying it. The SQL index is built by `jsonb_object_agg`,
which keeps the last value of a repeated key (checked on PostgreSQL 17:
`{"a": 2}` from `('a', 1), ('a', 2)`), so the server finds the last. The
model now searches from the end, and a test appends a copy of an entry
and checks both lookups land on the copy, as the SQL's do.

Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
…te from MAX_ORDINAL

Three rustdoc statements described the format before the derivable-token
change or miscounted it:

- `MAX_ORDINAL` still justified itself by "a position gate" PostgreSQL
  would refuse to cast. Gates are gone; a position reaches the server only
  inside a token, so the refusal now stands on what it says: no stored
  document has a node there, and the read would find nothing.
- `JsonSubtreeKey`, `subtree_key_for` and `json_subtree_key_with_bound`
  said a key carries a probe per wildcard level. It carries one for each
  array a wildcard expands: N + 1 for two levels, as `probes()` and the
  `nested_wildcards_multiply` test count them.

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

"The bound", the comment every json_tree read function points to, said
each read takes its recursion through LIMIT the entry count times the
number of tokens. Only json_tree_subtree does; json_tree_children and
json_tree_child_count probe one level, find each entry at most once, and
take LIMIT the entry count plus one, which is right for them. The comment
now names the function and says why the other two need less. Comment
only: no function body changes.

Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
…of one row

Two gaps on the json_tree leakage page's "What is authenticated":

- Withheld rows are an allowed deviation, and the page said a wildcard read
  with elements left out opens to the rest. It did not say the probe is one
  of them: a database that leaves out the probe's rows makes an array
  longer than the bound open as its first N elements rather than fail with
  `JsonWildcardTruncated`. That follows from the same allowance, and a
  reader relying on the truncation error should know it.
- The client refuses rows carrying two headers in one read (as
  `JsonWrongEntry` since the previous commit on this branch), which the
  list of refusals did not name; a whole read answered from another row
  still opens, as the page says.

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

Since the owner's decisions replaced the open questions, section 3 still
sent the reader to "the open questions" for the fallback's answer to a long
array, and stated the walk's limit as the entry count plus one, which
decision 9 changed to the entry count times the number of tokens. The
status line says the decisions override, but a bound stated twice with two
values reads as two answers, so each sentence now names the decision that
settled it. Decision 3 also said a probe is sent per wildcard level; it is
sent for each array a wildcard expands.

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

Copy link
Copy Markdown
Contributor Author

Review of the implementation (as of f42496c)

This review covers the derivations, the row check, the SQL, the wildcard expansion, the key's surface, the tests, and the docs and changelogs. It leaves out the owner's six decisions of 2026-10-25, the known non-findings in AGENTS.md (ADR 0010), the format version, x being unauthenticated, #1357, #1364, and #1362's port. Most important first; every finding below is confirmed against the code.

  1. [Medium] A repeated row and a read mixing two documents were refused as OpenedType, not JsonWrongEntry. In packages/stack-encrypt/src/json/derive.rs:1343 and :1350 (open_subtree), both checks return shape(…). If the database answers $.nested with deep (j = 3) returned twice, the read fails with OpenedType { expected: "rows of distinct entries" }. If it answers $.nested.list[*].x with one x from this row and the other from another row, the read fails with OpenedType { expected: "rows of one document" }. The leakage page lists "repeated" among the JsonWrongEntry refusals. OpenedType's own docs say the value "is not tampered with", and JsonWrongEntry's docs say "a database that answers the query it was sent never returns one", which describes both cases. This is the open item from the review of fix(stack-encrypt): check that a json_tree read returns the entry asked for #1358: the page was right and the code was wrong. The fix is two shape → wrong changes, plus a one-line assertion change in tests/json_index.rs:653-659. That file conflicts with test: hold json_tree containment to jsonb with proptest, and restructure the JSON index tests #1345 anyway, so the change is kept to the one line. Two new tests go in tests/json_read_checks.rs.
  2. [Low] The in-crate model of the SQL read keeps the first entry that carries a tag, and the SQL keeps the last. This is in packages/stack-encrypt/src/json/mod.rs:460 (find) and :489 (children). json_tree_index builds its maps with jsonb_object_agg, which keeps the last value of a repeated key (checked on PostgreSQL 17.10). Take a document with a copy of sv[1] appended: the server finds j = 10 and the model finds j = 1. The model documents itself as "what json_tree_subtree(doc, tokens, 0) returns", and the read-check tests build their rows from it, so it should agree. This only matters on hostile documents.
  3. [Low] The "The bound" comment says every read uses the entries × tokens LIMIT. In packages/eql/src/v3/json_tree/functions.sql:506, the comment says "each read reads its recursion through LIMIT the entry count times the number of tokens". Only json_tree_subtree does that. json_tree_children and json_tree_child_count take the entry count plus one, which is right for a one-level probe, since each entry is hit at most once. The bodies are correct; only the comment is wrong.
  4. [Low] Rustdoc carries the format from before the change. MAX_ORDINAL (derive.rs:61-71) still justifies its limit by "a position gate" that PostgreSQL would refuse to cast (error 22003); gates are gone. JsonSubtreeKey (query.rs:361), subtree_key_for (derive.rs:1760) and json_subtree_key_with_bound (derive.rs:1909) say a key carries a probe "per wildcard level". It carries one for each array a wildcard expands, which is N + 1 probes for two levels, as probes() counts them.
  5. [Low] The leakage page's "What is authenticated" leaves out two cases. At packages/eql/docs/reference/json-tree-leakage.md:56, the withheld-rows allowance covers the probe's rows too. A database that leaves them out makes an array longer than the bound open as its first N elements, where the read should fail with JsonWildcardTruncated. That follows from decision 3 of 2026-10-25, but a reader who relies on the truncation error should be told. Separately, the list of refusals does not mention rows of two documents.
  6. [Low] The design doc on this branch still has text from before the decisions. In §3, line 591 sends the reader to "the open questions", which the decisions replaced, and line 593 gives the walk's limit as "the entry count plus one", where decision 9 says entries × tokens. Decision 3 (line 643) says "for each wildcard level"; it should say for each array a wildcard expands.
  7. [Low] The PR body is stale. It still says the cap is 65,536 (twice: in the Changes section and in the departures), and that json_tree_by_self is a new internal, where json_tree_index replaced both json_tree_by_self and json_tree_by_parent in f94a6eb. Its test counts also predate f3400a2 and 683802b.

Found and left for the owner (not fixed):

  • json_subtree_key_with_bound refuses a bound above 2³¹ − 1 as TermError::JsonTooWide, even for a path with no wildcard, where the bound plays no part. The method's docs say so. JsonTooWide's help text ("Split the array, or ask for a position below that") does not mention the bound.
  • String ordering terms are case- and accent-folded ("Abc", "abc" and "ábc" get one term), so <, > and ORDER BY ord_term(…) at an ordered path do not order strings the way jsonb does. This predates this PR, since the scalar Ope index does the same, and the leakage page now says it on docs(plans): design json_tree tokens derivable from the path alone #1361. It may deserve a sentence wherever the ordering semantics are documented.
  • serde_json's preserve_order feature changes the stored member order to insertion order whenever any crate in a build turns it on. This predates this PR and is recorded in the design doc's Phase 4 notes. It is now on the leakage page too (docs(plans): design json_tree tokens derivable from the path alone #1361).

Checked and found sound:

  • Derivations. member_token, element_token, self_tag and parent_tag in Rust match json_tree_member_token, json_tree_element_token, json_tree_self_tag and json_tree_parent_tag in SQL byte for byte. Both use one-byte prefixes, SHA-256 truncated to 16 and 8 bytes, LE32, padded base64, and the iv at offset 22. The pinned vectors pin s, x and p and the six tokens under both root options. The PostgreSQL oracle exercises the SQL side of every rule.
  • hit_token. It agrees with json_tree_hit_token, including the NULL path.
  • The row check. A token answered twice, a hit whose x matches neither rule, the sealed root among the rows, the fallback row when $ was not asked for, probes and truncation, depth 127, a path ending in a key or a position, and empty containers and scalars at depth 0 are all refused, or answered, as the page says.
  • Wildcard expansion. The N^w + … + 1 count, the 8,192 cap with its test values (90 and 91, 8,191 and 8,192), probe placement last at each array, and document order are all right.
  • SQL. The ORDER BY k, j and the budget + 1 and entries × tokens limits are right. The eql-inline-critical markers sit on exactly the seven chain primitives the comment names, plus json_tree_index. Every unqualified name in the newly unpinned bodies is a pg_catalog built-in, and every EQL call is schema-qualified, which is the same posture as the existing markers. json_tree_children and json_tree_child_count agree with json_tree_subtree on which nodes exist.
  • JsonSubtreeKey. It prints counts only and serializes tokens only.
  • CHANGELOGs and the changeset. They describe the 8,192 cap, json_tree_index, and the removal of json_tree_subtree_at and json_tree_child_token.

Fixes, and the mutants run, follow.

https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu

coderdan added a commit that referenced this pull request Oct 25, 2026
…akage-profile

Brings in b992e78, #1363's correction to the v3 column of the leakage
page's comparison table. It touches a different part of the page from the
sentence this branch adds, and no code.

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

Two sentences the leakage-profile port (#1362) showed to be wrong. The
json_tree_subtree comment said the server cannot tell a probe from any
other token: it can, since the probe is the last token of its level, the
token count gives the bound away, and a hit on it carries the element's
selector rather than the leaf's; the executable profile pins this. And
the leakage page's q row still called element grouping the one thing v4
reveals that v3 did not, after the comparison table below it was
corrected to say v3 reveals it through document order and the a flag.
Comments and prose only; no SQL body changes.

Refs #1362, #1363

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

@cipherstash-agent cipherstash-agent left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Automated review attestation: /code-review medium + cross-check-review-findings

Reviewed head b992e787. No critical or blocking issues. One non-blocking new finding.

Scope reviewed: Rust derivation, row check and wildcard expansion (stack-encrypt/src/json, eql-bindings); EQL SQL (json_tree/functions.sql, types.sql, search_path/inline markers, NULL/STRICT, recursion bounds); changeset, fuzz seeds, vectors fixture, docs and removed-API callers.

New finding

  • major packages/stack-encrypt/src/json/derive.rs:605: MAX_DEPTH = 127 is off by one for empty containers. An empty []/{} at depth 127 is the 128th nested container; visit accepts it (depth > MAX_DEPTH), but the sealed root's JSON is read back with serde_json, which rejects 128 nested containers (verified: 127 nested arrays parse, 128 fail). The seal succeeds and every later open (full decrypt, $ read, sealed-root fallback) fails with OpenedType, which the MAX_DEPTH doc says the bound prevents. Refuse when depth > MAX_DEPTH || (value is a container && depth >= MAX_DEPTH), and add a test with nested(127) around json!([]) and json!({}) (existing test at tests/json_index.rs:322 only uses a scalar leaf).

Already covered: every other confirmed observation was already raised and handled in the PR's existing review comments.

Not blocking, low confidence: json_tree_sealed_root (functions.sql:189-194) raises "index out of range" on a forged header under 2 bytes (forged rows only); json_tree_iv and json_tree_sealed_root lack the eql-inline-critical marker the comment at functions.sql:478-481 implies (negligible cost).

Limits: SQL was reviewed by reading (no PostgreSQL run); Rust findings by trace plus a standalone serde_json check; existing PR feedback was read via the GitHub API (no review threads existed).


Generated by Claude Code

coderdan added a commit that referenced this pull request Oct 25, 2026
…xception in the token

Answers the two review threads on #1361.

The probe thread (open question 3): the question said a separate probes
argument, answered with existence only, would remove the leakage
exception for a probe that hits. It would not. A probe is the element's
token, and whoever holds a token can find that element in any row by
its self tag, probe its children's parent tags and derive every
descendant's token from the public derivations of sections 1 and 2,
whatever the SQL function returns, in rows written later too. The
capability is in the token, so the exception holds for every variant
that sends it. Only a different derivation, such as a one-way existence
value sent in place of the token and a tag stored from it, would be
existence-only; that is a format change, recorded as a future option.
Section 6 and the leakage page now say the same. The question also said
the server cannot tell a probe from any other token, which #1363
corrected in the SQL comment: it can.

The consecutive-position thread (open question 1 and section 4): the
example given for keeping the check was a swap the check does not see
in the form described. The new example is one it does refuse and
nothing else does: a read of $.items[0] answered with the element's own
entry and then $.items[1].price, its self tag rewritten, which has the
right selector, label and child count and the wrong position. The
question now also says what the rule does not reach (a depth-0 row,
such as a read of $.items[1].price itself). Section 4 no longer says the
self-tag check replaces the rule: x is a consistency check, and the
bound fields with the consecutive-position rule are the security check,
as the leakage page has it.

Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
…rivable-tokens

Brings in 0edead7, the answers to the two review threads on #1361:
the probe exception is in the token, not in what the SQL does with it,
so it holds for every variant that sends the element's token; and the
consecutive-position check is part of the security check, which the
self-tag check does not replace.

The design doc conflicted in section 4 and where #1361's open questions
became this branch's decisions. This branch's text is kept in both, and
the corrections are carried into it: section 4 says nothing replaces
the consecutive-position rule; decision 3 says a separate existence-only
argument would not remove the probe exception and records an
existence-only derivation as a future option; decision 8 gains the
example the rule alone refuses (a read of $.items[0] answered with
$.items[1].price) and says the allowed swap is of a depth-0 row; and
section 6 points at decision 3. The leakage page merged cleanly.

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

Copy link
Copy Markdown
Contributor Author

Merged #1361's branch at 0edead7 into this branch (ba9a87f). It carries the answers to #1361's two review threads: the probe exception is in the token, not the SQL (section 6, decision 3, leakage page), and the self-tag check does not replace the consecutive-position rule (section 4, decision 8, with an example only that rule refuses). Only the two documents changed.

https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu

…catches

Design decision 8 keeps the consecutive-position check, and the design
record's example for it (corrected on #1361) is a read of one array
element answered with the element itself and then the other element's
child in place of its own, self tag rewritten: selector, label, depth
and child count all agree, and only the row's authenticated position
refuses it. No committed test isolated that case; the closest ones are
also caught by the child counts or the selectors. This one opens to
{"x": 2} for {"x": 1} with the j comparison in rebuild_walked removed.

Refs #1363

Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
Base automatically changed from docs/json-tree-derivable-tokens to next October 25, 2026 08:34
coderdan added a commit that referenced this pull request Oct 25, 2026
…akage-profile

Takes #1363's probe-exception sentence on the wildcard bullet and
keeps this branch's hit-rule sentence on the token bullet.

Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
coderdan added a commit that referenced this pull request Oct 25, 2026
…assertions messages

After the merge of #1363, json_tree_subtree's comment says the server
"can pick it out"; mod.rs and the doc comment of
a_read_gives_away_its_bound_and_its_probes still quoted the old claim as
standing. Both now say it was corrected. The test's assert_eq! calls
gain the messages the rust-testing convention asks for, and mod.rs names
the two harness tests that show features no mutant can reach do fire.

Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
coderdan added a commit that referenced this pull request Oct 25, 2026
The net paragraph said the member-or-element rule "was already
disclosed by the gated walk's ordinals and #n: gates". Before #1363
every child's token took one rule, and an only child's ordinal is 0
either way, so a node that is an only child in every row had its kind
disclosed by nothing. The note's own finding said so. The paragraph now
says what was and was not disclosed, and that the net rests on the
ancestor token and the gates.

The SQL-comment paragraph records that #1363 corrected the comment, and
a closing section records what the review added: the ordering terms'
shared prefixes, which both profiles missed, the new checks, mutants and
corpus, and what is left to the owner.

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.

Human pass

Comment on lines +249 to +250
let rt = runtime();
rt.block_on(async {

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.

Why not use tokio::test?

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.

Because these are proptest tests. The proptest! macro generates ordinary synchronous #[test] functions and runs each body once per generated case through TestRunner::run, which takes a plain closure; there is no async form, so #[tokio::test] cannot be applied to a proptest! body, and an async block inside it has to be driven by hand. Hence runtime(): one current-thread Tokio runtime per test function, and block_on around each case's body. The non-proptest tests in the same crate use #[tokio::test] as usual. I'll put that reason in a doc comment on runtime() so it is not a question next time.

https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu

… seals

MAX_DEPTH is 127 because serde_json reads a sealed root back to 128
nested containers: a scalar 127 levels down is inside 127 containers and
reads back. An empty [] or {} at depth 127 is itself the 128th nested
container, and the check let it through, so such a document sealed and
then never opened from its sealed root. The review attestation on #1363
found it. A container at MAX_DEPTH is now refused as JsonTooDeep, under
either root option, and a test shows serde_json rejecting the document's
own text before asserting the refusal.

Also: a doc comment on the proptest runtime helper answering why those
tests do not use tokio::test, and the SQL inlining comment now names
which chain primitives carry the marker (not json_tree_iv and
json_tree_sealed_root, read once per call).

Refs #1363

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

Copy link
Copy Markdown
Contributor Author

On the review attestation's finding at derive.rs:605: confirmed and fixed in 8c843ee. An empty container at depth 127 is the 128th nested container, which serde_json refuses to read back from the sealed root; a container at MAX_DEPTH is now refused as JsonTooDeep under either root option, with a test that first shows serde_json rejecting the document's own text. Of the two low-confidence notes: the inlining comment now names which functions carry the marker (json_tree_iv and json_tree_sealed_root run once per call and stay pinned); json_tree_sealed_root raising on a header under two bytes is left as is, since only a forged row can produce one. The open thread on json_properties.rs is answered in place: proptest! bodies are synchronous closures, so a per-test runtime drives them.

https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu

coderdan added a commit that referenced this pull request Oct 25, 2026
…akage-profile

Carries 8c843ee (the deepest-level container refusal) so #1362 holds #1363's
final head before either merges.

Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
@coderdan
coderdan merged commit 25e9d69 into next Oct 25, 2026
53 checks passed
@coderdan
coderdan deleted the feat/json-tree-derivable-tokens branch October 25, 2026 10:55
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.

2 participants