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 38 commits into
perf/json-tree-walk-and-projectionfrom
feat/json-tree-ordinal-chain
Open

coderdan wants to merge 38 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: 733a1ea0-0bc6-481d-98ae-abaefd71d1e8

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
97 0 37 0

cargo-mutants exited with no surviving mutants recorded. The run may not have completed cleanly; check the workflow run logs.

…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: 9cc4c8a

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
…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 thread packages/stack-encrypt/src/json/derive.rs Outdated

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

/// 'base64')` writes it. Column-wide: the same path has the same selector
/// in every row, which is what `->` and containment match on.
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, TS, JsonSchema)]
pub struct JsonTreeSelector(pub String);

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.

It would be nice if these named types contained named wrapper types that represented the encoding that the value was in, instead of just a string, like a base64 wrapper type or a base85 wrapper type, et cetera, because that's really important to how these types are handled.

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.

Done in 14ccc86 (changeset wording in 5003d08). There was no encoding wrapper in eql-bindings to reuse: the v3 terms newtypes are named by role (Selector, OpeCllw, KeyHeader), not by encoding, and nothing else in the crate wraps base64 or base85. So there is a new encoding module with two #[serde(transparent)] newtypes:

  • Base64Text: padded standard base64, as PostgreSQL's encode(x, 'base64') writes it.
  • Base85Text: RFC 1924 base85, which no SQL function decodes.

JsonTreeSelector, JsonTreeTag and JsonTreeOrderingTerm now wrap Base64Text, and JsonTreeCiphertext wraps Base85Text. h was the v3 SteVec KeyHeader, whose docs describe base85 text that SQL never parses. That is wrong for v4, where h is base64 and the SQL decodes it for the iv and the sealed-root flag. It is now its own JsonTreeKeyHeader(Base64Text), and v3 is untouched.

Each newtype is still the bare string in JSON, and renders as string in TypeScript and JSON Schema. A unit test in encoding.rs pins all three. Deserializing still checks only that the value is a string. Stack Encrypt decodes the bytes and refuses text in the wrong encoding, and repeating that here would decode every field twice.

The blast radius was mechanical. The json_tree family is not in the catalog and none of its types is #[ts(export)], so no generated TypeScript, JSON Schema or Go output moves: mise run types:check and cargo test -p eql-codegen pass. The only code that read the inner strings was two assertions in the encryption test crate, which now call .as_str().

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.

Making the inner value public violates the contract.
A consumer of the base64 text type can't trust that the inner type is actually base64, because there's no guarantee that a caller upstream has just put a random string inside it.

Should have a constructor that encodes into the inner type. That should be the only way to create it.

Comment on lines +606 to +615
/// A target's layout chosen by value rather than by type. [`EncryptFrom`]
/// is a type's own layout, fixed where the type is written; a layout is a
/// value with options, which seals the same target under them. The JSON
/// index is one: `Json::new(options)` seals a [`JsonDocument`] as
/// `encrypt_into::<JsonDocument>()` does, under its options rather than
/// the defaults. A plan takes one with
/// [`encrypt_as`](crate::plan::ValueStart::encrypt_as), and a query
/// through that plan runs through the layout's own index
/// ([`Layout::index`]), so it derives under the plan's options without
/// naming them again.

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.

This needs some examples to explain what the layout is actually for

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.

Done in 021603c. The Layout trait's docs now say what a layout is for in short sentences and lead with a runnable example: a plan built with encrypt_with_layout(Json::new(options)), where the options order $.age, seals a JsonDocument with an ordering term on that leaf and answers op::AtPath(op::Gte) through the plan. The same target laid out by type, encrypt_into::<JsonDocument>(), uses the default options, which order no path, and the example shows that plan refusing the same query with TermError::JsonPathNotOrdered. The docs end by saying that the cipher's encrypt_as is a different layer: it seals one value as the type's own EncryptFrom does, with no plan and no options.

#[derive(Serialize)]
struct JsonOut {
sealed_root: bool,
ordered: Vec<String>,

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.

What does ordered hold? String erases types.

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.

In memory it was already typed: IndexSpec::Json(JsonOptions), and JsonOptions::ordered is a BTreeSet<JsonPath>. The String was in the wire structs only. The writer rendered each path to text by hand, and the reader read Vec<String> and re-parsed each one in JsonWire's TryFrom through JsonOptions::ordering. So the type was lost one step before the serde boundary rather than at it, and I agree that was worse than it needed to be.

Changed in e10c93b. JsonPath now implements Serialize (its Display text, which is canonical and parses back) and Deserialize (through JsonPath::parse, refusing text that is not a path with the JsonPathError message). The writer's JsonOut holds &BTreeSet<JsonPath>, the reader's JsonOptionsWire holds BTreeSet<JsonPath>, and the TryFrom now checks only what a parse cannot, that an ordered path names no position (JsonOptions::check). The plan error's structured payload writes the set the same way. The wire form does not change: paths in canonical order, as text.

Tests: json_ordered_paths_round_trip_typed in dynamic/term.rs round-trips an IndexSpec::Json with two ordered paths (one with a quoted key) through serde and checks that a non-path and a positional path are refused. a_path_round_trips_through_serde_as_its_text in json/path.rs covers JsonPath alone. The existing wire tests in tests/json_index.rs pass unchanged.

Comment on lines +365 to +370
/// A one-value plan's layout when it was given one by value
/// ([`encrypt_as`](ValueStart::encrypt_as)): the layout `X`, whose target
/// `T` is its output, and a query runs through the layout's own index,
/// with its options ([`Layout::index`]). `T` is the layout's
/// [`Target`](Layout::Target), spelled out so that opening one `T` and a
/// `Vec<T>` are told apart as they are for a [`Typed`] plan.

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.

These docs are hard to follow. Can you simplify the language and include an example?

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.

Done in ba0c2a2 (the method has been encrypt_with_layout since 8930415). The method docs now say what it does and what the caller gets in two sentences ("Encrypt the value as layout does, with the layout's options. The plan's output is the layout's target."), lead with the example, and then say in one short paragraph when to use it rather than encrypt_into::<JsonDocument>(). The module docs at the top of plan/value.rs are now a list of the three ways to choose a one-value plan's output, one line each. The note about the cipher's encrypt_as moved to the Layout trait, which has its own example of the difference (021603c).

Self(self.0.clone(), PhantomData)
}
}
impl<X: fmt::Debug, T> fmt::Debug for Laid<X, T> {

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.

Is opaque debug not suitable here? I guess the any type trick makes it impossible?

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.

OpaqueDebug would compile here, but I've kept the hand-written impls, because it does not fit what these types are.

#[derive(Debug)] is the one that cannot be used. It puts a T: Debug bound on the impl for every type parameter, and a target need not be Debug. It would also print the field as PhantomData<fn() -> T> rather than naming the target.

vitaminc's #[derive(OpaqueDebug)] (vitaminc-protected-derive 0.6.1, which JsonHeader and ValueFrame already use) does not have that problem. It only adds a Debug bound for a field marked #[non_sensitive], and it names the type with type_name::<Self>(), the same trick the manual impl uses, so the Any part is not what stops it. What it does is mask every field as "***" and mark the type with the OpaqueDebug trait, which says "this type holds something sensitive and its Debug hides it". Typed<T> holds nothing but a PhantomData, so Typed<JsonDocument> would print as stack_encrypt::plan::value::Typed<stack_encrypt::json::JsonDocument>("***"): a mask where nothing is hidden, and a claim the type has no reason to make. For Laid<X, T> the mask would hide the layout itself, Json { options: … }, which is index configuration (sealed_root and the ordered paths), not plaintext, and is what you want to see when debugging a plan. Marking both fields #[non_sensitive] would avoid the mask but print the target twice, once in the type name and once in PhantomData<…>. The manual impls print Typed<…JsonDocument> and Laid<…JsonDocument>(Json { … }), with no bound on T, so I left them as they are.

…ith_layout

The cipher layer already has KeysetCipher::encrypt_as(&source, context)
and EncryptInto::encrypt_into(&keyset), which are one operation seen
from the target's side and from the source's side. The plan layer used
the same two verbs with a different split: encrypt_into::<T>() lays a
plan out by a target type, and encrypt_as(layout) laid it out by a
layout value. The same names meant opposite things depending on the
layer.

The plan-layer method that takes a layout value is now
ValueStart::encrypt_with_layout(layout). Its rustdoc says that the
cipher's encrypt_as is unrelated. The cipher-layer encrypt_as,
encrypt_into, encrypt_into_with_context and encrypt_from are unchanged,
and so is the plan-layer encrypt_into. The tests, the changeset, the
CHANGELOG entry and the design plan now use the new name.

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

Copy link
Copy Markdown
Contributor Author

Renamed the plan-layer ValueStart::encrypt_as(layout) to encrypt_with_layout(layout) in 8930415.

The verbs collided across layers. On the cipher, KeysetCipher::encrypt_as(&source, context) and EncryptInto::encrypt_into(&keyset) are one operation seen from two sides. On the plan, encrypt_into::<T>() lays a plan out by a target type, and encrypt_as(layout) laid it out by a layout value. So the same two names meant different things depending on the layer. The new name says what the method takes, and its rustdoc now says that the cipher's encrypt_as is unrelated.

The cipher-layer encrypt_as, encrypt_into, encrypt_into_with_context and encrypt_from are unchanged, and so is the plan-layer encrypt_into. The call sites in tests/json_index.rs and the EQL json_tree test, the changeset, the CHANGELOG entry and the json_tree design plan all use the new name. The rename is merged up into #1336.

https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu

A path such as `$.items[2147483648]` fits the u32 the subtree key used
for ordinals, so its position reached the server as the gate
`#2147483648:<selector>`, and `json_tree_children` and
`json_tree_subtree_at` cast that ordinal to `integer`: PostgreSQL raised
22003 where the query should have found nothing.

The bound now lives in one place, `MAX_ORDINAL` (2^31 - 1), and is
applied where the index numbers children: when a document is sealed (an
array or object with more children is refused, so every ordinal and child
count fits `integer`), and when a subtree key is derived (a position past
it is refused with `TermError::JsonTooWide`, whether it becomes a gate or
a position the client chains itself). Refusing at derivation rather than
at `JsonPath::parse` keeps `JsonPath` a plain description of a path, used
also for plaintext selection where any position is meaningful, and keeps
the refusal next to the other index-specific one (`JsonSealedRoot`). No
stored document has a node past the bound, so no answer is lost.

Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
The trait's docs said what a layout is in the abstract and gave no
example, so a reader could not see why it exists beside `EncryptFrom`.
They now say it in short sentences and lead with a runnable example: a
plan built with `encrypt_with_layout(Json::new(options))` orders `$.age`
and answers an ordering query, while the same target laid out by type
(`encrypt_into::<JsonDocument>()`) uses the defaults and refuses it with
`JsonPathNotOrdered`. The docs also say that the cipher's `encrypt_as` is
a different layer: one value, the type's own layout, no plan.

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

The module docs were one long sentence naming three layouts, and the
method docs explained `encrypt_with_layout` through the `Layout` trait's
abstractions before saying what a caller gets. The module docs now list
the three ways to choose a one-value plan's output, one line each. The
method docs say what it does and what it returns in two sentences, lead
with the example, and then say when to use it rather than
`encrypt_into::<JsonDocument>()`. The note that the cipher's `encrypt_as`
is a different layer moved to the `Layout` trait, which now explains the
distinction with an example of its own.

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

`JsonOptions::ordered` holds `JsonPath`s, but the wire structs that write
and read it held `Vec<String>`: the writer rendered each path to text by
hand and the reader re-parsed the strings in a `TryFrom`. The type was
lost one step before the serde boundary rather than at it.

`JsonPath` now implements `Serialize` (its `Display` text) and
`Deserialize` (through `JsonPath::parse`, refusing text that is not a
path with the `JsonPathError` message). The wire structs hold the
`BTreeSet<JsonPath>` itself, so the paths stay typed until serde turns
them into strings, and what the `TryFrom` still checks is only the one
rule a parse cannot: an ordered path must not name a position. The wire
form is unchanged (paths in canonical order, as text), and the plan
error's structured payload writes the set the same way.

Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
The unreleased JSON index entry now says that `JsonTooWide` also refuses
a subtree key for a path naming a position past 2^31 - 1, which the SQL
walk casts to `integer`.

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

The json_tree wire types held a bare `String`, so a field's type did not
say how its text was to be read, and `h` reused the v3 SteVec
`KeyHeader`, whose docs describe base85 text the database never parses,
where the v4 header is base64 that the SQL decodes for the `iv` and the
sealed-root flag.

The new `encoding` module has two `#[serde(transparent)]` newtypes,
`Base64Text` (padded standard base64, as `encode(x, 'base64')` writes it)
and `Base85Text` (RFC 1924 base85, which no SQL function decodes). The
selector, parent tag and ordering term wrap `Base64Text`, the ciphertext
wraps `Base85Text`, and `h` is a new `JsonTreeKeyHeader(Base64Text)`. The
wire form does not change: each newtype is the bare string in JSON, and
`string` in TypeScript and JSON Schema. Deserializing still checks only
that the value is a string; Stack Encrypt decodes the bytes and refuses
text that is not its encoding. The json_tree family is not in the
catalog, so no generated TypeScript, JSON Schema or Go output moves.

Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
`json_tree_subtree_at` limited its walk only when the root was sealed and
the budget was above zero, and `json_tree_children` not at all, so with
`budget = 0` or an open root nothing in the SQL itself said how many rows
a walk of a hostile stored document could make. The bound held, but only
as a consequence of two facts elsewhere: the parent map keeps one entry
per tag, so entries that share a `p` collapse into one, and a hit's token
is a hash of the probe that found it, so no probe is made twice.

Both walks now read their recursion through `LIMIT` the entry count plus
the seed row, as #1325 had them before the ordinal chain replaced the
stored tokens. PostgreSQL computes a recursive query only as far as its
reader pulls rows, so the limit stops the recursion and not only the
output, and a walk of a sealed document never reaches it. The budget's
fallback is unchanged: `found` reads the bounded walk, so a document with
fewer entries than the budget is returned walked, and one with more falls
back past the budget as before. The comment block above the walks now
states the bound and both reasons for it.

The Postgres suite gains a hostile document whose 60 levels are each
written twice, same `p` and `s`, different `c`. Walked once per copy it
would make 2^60 rows; it returns one row per level under no budget, an
open root, and a budget above the entry count, and falls back under a
budget below it. It passes with and without the new limit, which is the
evidence that the map already collapsed the copies.

Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
The ordinal-chain changeset's byte-encodings paragraph now says that the
eql-bindings wire types name their encoding (`Base64Text`, and
`Base85Text` for `c`) and that the wire form is unchanged.

Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
…, not copy them

When a walk falls back to the sealed root, the client opens the whole
document and selects the walked path from it. The selection borrowed the
document and cloned the node (or the nodes, or a node's children) out,
so for a moment the selected plaintext existed twice: once in the
document, which was then wiped, and once in the copy handed over.

`JsonPath::select_mut` finds the same nodes as `select`, borrowed
mutably, and the new `plain::take_protected` is the moving counterpart
of `read_protected`: it borrows the `Protected` inner value through
vitaminc's `update`, moves out what the closure returns into a
`Protected` of its own, and leaves the source wrapped. Both fallbacks
(`DecryptInto<Value>` and `DecryptInto<Vec<Value>>` for `JsonSubtree`)
now `mem::take` the selected nodes, leaving `null` in the document, and
the children of a `json_tree_subtree` fallback are taken the same way,
an object's keys staying behind to be wiped with the document. No
`risky_unwrap` was added, and the plaintext exists once.

Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
coderdan added a commit that referenced this pull request Oct 24, 2026
…nal tokens and the header

Two review findings on the record (#1250) read the design as written and
found two gaps that the implementation closed in #1329, with nothing in
the record saying so. A positional token as first written named the
element's children rather than the element, so a scalar element could not
be returned or identified; the implementation reaches a position from the
parent's token through a `#n:<selector>` gate and returns the reached entry
itself. The wire shape called the header opaque to SQL while the walk
needs the document's iv and the sealed-root flag from it; the
implementation reads both at fixed offsets of the frozen header layout.
Each passage now carries a dated note pointing at the implementation and
the phase notes, in the form the record already uses for superseded
sections.

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

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