Skip to content

feat(stack-encrypt, eql): the JSON index and the json_tree EQL v4 domain family - #1288

Open
coderdan wants to merge 20 commits into
docs/json-index-stack-encrypt-spikefrom
feat/json-index-stack-encrypt
Open

coderdan wants to merge 20 commits into
docs/json-index-stack-encrypt-spikefrom
feat/json-index-stack-encrypt

Conversation

@coderdan

@coderdan coderdan commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Summary

The JSON index for Stack Encrypt (our Rust encryption engine), and the EQL v4 domain family that stores and queries what it writes. EQL is the SQL library we install into a customer's PostgreSQL so it can search encrypted values. Before this, Stack Encrypt could not encrypt a JSON document searchably at all: the JSON operators existed but nothing answered them. After it, a JSON document encrypts into one small ciphertext per node (so the ciphertext grows with the document, not with the document times its depth), the server can walk the subtree under a node from one token the client derives from a path, and containment (@>) agrees with PostgreSQL's own jsonb @> across array elements, which the v3 design gets wrong (#1231).

This is Phases 1 and 2 of the phased plan in #1249, built against the design in #1250 (this PR is stacked on it: read the design first, then this). Phase 0's oracle is folded into Phase 2. Phases 3 (measure) and 4 (decide the wire) are not here.

Changes

Engine (packages/stack-encrypt)

  • src/json/: the Json index (Index<serde_json::Value>), the sealing operation target::json_document, the stored JsonDocument (an EncryptFrom<Value> / DecryptInto<Value> target), the query terms (JsonQuery for containment and AtPath<Eq>, JsonLeafQuery for the orderings at a path), the extraction keys (JsonSelector, JsonSubtreeKey, derived on the keyset cipher), and what an extraction opens (JsonNode, JsonSubtree). The module docs are the reference for the wire form and the derivations.
  • op: Supports impls for Contains, ContainedBy, AtPath<Eq | Lt | Lte | Gt | Gte | Between>, and the Input<Pathed<One>> / Input<Pathed<Pair>> impls for (path, value) and (path, (lo, hi)) that feat(stack-encrypt): JSON operators Contains, ContainedBy and AtPath #1209 left open. JsonDocument answers them, and they default to Json.
  • IndexSpec::Json(JsonOptions), key "json", with the wire form {"json": {"sealed_root": bool}}. The data plan reads it and refuses to run it with a new reason, json_index_pending.
  • New TermError variants for a bad path, a document too deep or wide, the sealed root's missing token, and an ordering on a non-orderable leaf.
  • One new dependency, sha2, already compiled in the graph through vitaminc-hmac; the four lockfiles that record the edge move with it.

EQL (packages/eql)

  • src/v3/json_tree/{types,functions,operators,blockers}.sql, confined to v4 with -- ONLY EQL VERSION: 4: the four domains, the containment projection and operators, ->, the subtree walk, the array functions, the ordering extractors, and the native-jsonb firewall. The v3 bundle is byte-for-byte unchanged.
  • crates/eql-bindings/src/v4/json_tree.rs: the hand-written types with Stack Encrypt's targets. Not in the catalog yet, so not in the target table or v4::all().
  • tests/encryption/tests/json_tree.rs, added to mise run test:encryption:postgres.
  • .changeset/eql-json-tree-domain.md (@cipherstash/eql minor).

Docs

  • The design gains an "Implementation notes" section listing where the code departs from it. The two agent guides point at the family's homes.

Verification

  • cargo nextest run -p stack-encrypt --all-features: 716 tests pass, including the 14 new ones in tests/json_index.rs: round trips under both root options, the chain unwrapping every child from its parent's token and nothing from a wrong one, tamper evidence (reordered, regrafted, truncated, a flipped byte, another column, a lifted node), and containment over the projection agreeing with a Rust model of jsonb @> on the design's corpus and 300 random pairs. The model is pinned to the 23 answers in jsonb-semantics.sql. Clippy clean with all features and with none; rustdoc clean with warnings as errors; the doc examples pass; the trybuild snapshots were regenerated and differ only by the new implementors they list.
  • mise run test:encryption:postgres against PostgreSQL 17, with the v4 bundle built from the tree: all six suites pass. The new one stores 72 documents encrypted beside their plaintexts and, for 89 needles, checks that @> and <@ over the encrypted column select exactly the rows PostgreSQL's own jsonb operators select over the plaintext column, that EXPLAIN shows a Bitmap Index Scan on the GIN index, that -> opens to the leaf, that the walk from a client-derived token returns the six entries under $.nested and reassembles them, that a wrong token unlocks nothing in any row, that a wildcard path walks each element's subtree, that the array functions count and list elements, that the orderings at a path select and sort as the plaintexts do, and that a native operator on the column raises.
  • mise run test:encryption (both feature sets, clippy with warnings as errors), check:encryption:wasi, test:crates, build, codegen:parity, types:check, docs:validate:coverage, docs:validate:required-tags, and docs:validate:documented-sql (the whole v4 install order in one transaction): all pass. The Go guest checks and lints with its eql feature under --locked. The three EQL CI-derivation tests under scripts/__tests__ pass.
  • Two things the live run found and this PR fixes before review: a STRICT containment function with an OR body is not inlined by PostgreSQL, so the GIN index never engaged until it was made non-STRICT (recorded in the SQL and the design notes); and an untyped literal on the firewall test resolved to the native operator, as the domain docs warn.
  • Not verified: sizes and timings (Phase 3). Not done: the Go side, the catalog, stashgen, and the TypeScript export, which the design keeps out of the phased plan.

Related

Refs #1249 (Phases 1 and 2)
Refs #1231 (the v4 half: exact containment across array elements)
Refs #1209, #1060
Stacked on #1250

Review notes

Read the design's new "Implementation notes" section first; it lists the deviations in one place. Then packages/stack-encrypt/src/json/mod.rs for the wire form, derive.rs for the walk and the sealing, query.rs for the projection, and packages/eql/src/v3/json_tree/functions.sql for the server side of the same three things. The oracle in tests/encryption/tests/json_tree.rs is what holds the two sides together.

Deliberately deferred, with the reason in the design notes: a self tag per node (so a walk could return its anchor), the data-plan form, the catalog entry, and wiping the plaintext copies the walk makes.

… chain and exact containment

Stack Encrypt had no JSON index: `op::Contains`, `op::ContainedBy` and
`op::AtPath` were declared (#1209) and answered by nothing, and the v4 JSON
wire format was unconstrained by anything shipped. This is Phase 1 of the
spike in docs/plans/2026-10-17-json-index-stack-encrypt-spike.md (#1249).

`Json` is an `Index<serde_json::Value>` whose term, `JsonTerms`, is the
document's one-way terms: a path selector and a value selector per node, a
token per node below the chain's heads, the grouping by container array
element, and a leaf ordering term on numbers and strings. Sealing is the
separate operation `target::json_document`, which flattens the document,
requests one data key under the column's descriptor and seals every node's
own label, kind and scalar under it, so a document's ciphertext is linear
in its size. The stored `JsonDocument` carries the design's chain fields:
position-keyed parent tags, wrapped tokens, and group tags salted by the
document's iv, so nothing links across rows until a query supplies a
token. By default the root is sealed: the root entry is the whole document
and the top-level fields are the chain heads, so no token exists for the
root.

Queries go through the engine's own operators. `Supports<Contains>` and
`ContainedBy` send a `JsonQuery`, the element-nested projection of the
needle's selectors, which native `jsonb @>` compares exactly across array
elements (the v4 half of #1231). `AtPath<Eq>` sends the projection of the
needle document that holds the value at the path; the orderings at a path
send a `JsonLeafQuery`, the selector and the leaf's OPE term; `Between` a
`RangeQuery` of two. `Input<Pathed<One>>` and `Input<Pathed<Pair>>` are
implemented for `(path, value)` and `(path, (lo, hi))`, which #1209 left
open until the index existed. `JsonDocument` is an `EncryptFrom<Value>`
target and a `DecryptInto<Value>`, and an extracted node (`JsonNode`) or
the rows of a subtree walk (`JsonSubtree`) open on their own.

`IndexSpec::Json(JsonOptions)` is the data form, key `"json"`, with the
object form for a non-default `sealed_root`; a data plan reads it and
refuses to run it with `Reason::JsonIndexPending`, since the binding's half
is not written. The data form, the dynamic arms and the Go guest's and
fuzz crate's lockfiles move with it (stack-encrypt gains `sha2`, already in
the graph through vitaminc-hmac).

Two deviations from the design are recorded in the module docs and the
plan: nonces are the AEAD's own random ones rather than derived, so the
crate keeps one leaf cipher, and the ordinal field is `o`, since `i` is the
EQL envelope's identifier.

Refs #1249, #1231, #1209, #1060
…, EQL v4 only

A new family beside `public.eql_v4_json`, not a port of the SteVec
surface: `public.eql_v4_json_tree` (the document), `_json_tree_entry` (one
node, as the extractors return it), `eql_v4.query_json_tree` (a containment
needle) and `eql_v4.query_json_tree_leaf` (an ordering operand at a path).
Phase 2 of the spike in docs/plans/2026-10-17-json-index-stack-encrypt-spike.md
(#1249). Confined to v4 with `-- ONLY EQL VERSION: 4`, so the v3 bundle is
unchanged; the build's ordering, completeness, symbol-order and isolation
gates pass as they are.

Everything the server does is core PostgreSQL. Containment projects a
stored document from its group tags into the element-nested shape the
needle has (`eql_v4.json_tree_query`, a recursive plpgsql group builder)
and compares the two with native `jsonb @>`, so each needle array element
must fit inside one stored element: PostgreSQL's own rule, which closes the
cross-element false positives of selector-set containment. A GIN index on
that expression serves `@>`; the function is deliberately not STRICT,
because a STRICT SQL function whose body is an `OR` is not inlined and the
index never engaged until it was changed. The token chain is walked by a
recursive CTE from one client-derived token, with `sha256`, `set_byte` and
a sixteen-byte XOR: `json_tree_subtree`, `json_tree_subtree_at` (gated on a
selector for a wildcard below the anchor), `json_tree_children` and
`json_tree_child_count`. `->` by selector grafts the header and the entry's
ordinal, so the entry opens on its own; `ord_term` serves a functional
btree for the orderings. Every other native jsonb operator on the column is
blocked, as on the other domains.

`eql-bindings` gains the hand-written `v4::json_tree` types, with Stack
Encrypt's `EncryptFrom<Value>` / `DecryptInto<Value>` for the document, the
entry and a subtree's rows, under an `Identifier`; the family is not in the
catalog yet, so it is not in the target table or `v4::all()`.

The oracle is `tests/encryption/tests/json_tree.rs`, run by `mise run
test:encryption:postgres`: the design's corpus and random documents stored
encrypted beside their plaintexts, with `@>` and `<@` over the encrypted
column checked against PostgreSQL's own `jsonb` operators over the plaintext
column for every needle, the GIN index shown in `EXPLAIN`, extraction,
the walk, the array functions and the orderings. This is Phase 0's oracle
folded into Phase 2, against the real SQL and real encryption under the
fake key source, so it runs on a fork PR.

Refs #1249, #1231
…its design

The implementation notes for Phases 1 and 2: where the code is, which of
the ten decisions it took, and the deviations a reviewer reading the design
first should know before the code (random nonces, the `o` ordinal, the walk
excluding its anchor, the non-STRICT containment, the `<@` gap for a stored
root scalar, byte-for-byte string selectors, and what the data plan and the
catalog still lack). The agent guides point at the family's four homes.

Refs #1249
@changeset-bot

changeset-bot Bot commented Oct 19, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 8598446

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 19, 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: 5276ebda-ad54-48db-abb2-9f1a25d3ae1b

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.

…tence test

`v3_and_v4_install_side_by_side` asserted that eql_v3 and eql_v4 own the
same number of public domains. That held while the two bundles were one
SQL tree rendered under two names. The JSON tree family under
`src/v3/json_tree/` is confined to v4 with `-- ONLY EQL VERSION: 4`, so
v4 now owns `public.eql_v4_json_tree` and `public.eql_v4_json_tree_entry`
with no v3 counterpart, and the count assertion failed in CI (52 vs 54).

The test now compares the domain sets by name: every v3 public domain
must exist in v4 under the v4 prefix, and the domains v4 owns that v3 does
not must be exactly the JSON tree family. A future v4-only domain fails
the test until it is named here, which is the point: EQL v3's SQL must
not change, and a domain leaking from v4 into v3, or a v3 domain missing
from v4, is a build regression rather than a design decision.

Verified by installing both built bundles into a scratch PostgreSQL 17
and diffing the sets with the same query: 52 v3 domains, 54 v4, nothing
missing from v4, and the two JSON tree domains as the only surplus.

Refs #1288
coderdan added a commit that referenced this pull request Oct 19, 2026
…tence test

`v3_and_v4_install_side_by_side` asserted that eql_v3 and eql_v4 own the
same number of public domains. That held while the two bundles were one
SQL tree rendered under two names. The JSON tree family under
`src/v3/json_tree/` is confined to v4 with `-- ONLY EQL VERSION: 4`, so
v4 now owns `public.eql_v4_json_tree` and `public.eql_v4_json_tree_entry`
with no v3 counterpart, and the count assertion failed in CI (52 vs 54).

The test now compares the domain sets by name: every v3 public domain
must exist in v4 under the v4 prefix, and the domains v4 owns that v3 does
not must be exactly the JSON tree family. A future v4-only domain fails
the test until it is named here, which is the point: EQL v3's SQL must
not change, and a domain leaking from v4 into v3, or a v3 domain missing
from v4, is a build regression rather than a design decision.

Verified by installing both built bundles into a scratch PostgreSQL 17
and diffing the sets with the same query: 52 v3 domains, 54 v4, nothing
missing from v4, and the two JSON tree domains as the only surplus.

Refs #1288
@github-actions

github-actions Bot commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

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

caught missed unviable timeout
314 0 85 0

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

@blacksmith-sh

This comment has been minimized.

The Mutants gate on this branch reported 53 surviving and 6 timed-out
mutants across stack-encrypt's json module. Each is now caught, removed or
excluded:

- The path parser and the tree rebuild indexed their input by hand, so a
  `+= 1` mutated to `*= 1` never advanced and the test run hung. The parser
  now walks a shrinking &str and the rebuild pulls children from an
  iterator, so every step consumes input and no such mutant exists.
- New tests pin what the survivors showed was unchecked: refusal offsets
  and reasons for malformed paths, JSON escapes in quoted names, the
  integer/float boundary in number encoding, every truncation of a header
  and of a node label, the key tag's u16 length limit, the header and
  selector Debug forms (a subtree key never prints its token), stored
  wrapped tokens differing from the token, opening each stored form as a
  record field, documents without a sealed root, and accessor queries
  refused before any keyset load.
- Four mutants are equivalent and are excluded in .cargo/mutants.toml with
  the reason beside each, matched on the specific replacement.

Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
Comment thread packages/stack-encrypt/src/json/mod.rs Outdated
Comment thread packages/stack-encrypt/src/json/path.rs

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

First pass.

The hand-written impl printed the keyset id, the root flag and the epoch
and left the ZeroKMS iv and tag out. vitaminc's OpaqueDebug derive does
the same job declaratively: the three layout fields are marked
`#[non_sensitive]`, and the iv and tag, which retrieve the data key, print
as the mask. The masking is now part of the type's declaration rather than
a convention a later edit to the impl could drop, and the header carries
the OpaqueDebug marker like the crate's other secret-adjacent types.

The printed text changes (the derive names the type by its full path and
lists the masked fields), so the test that pins it is updated to the new
text and now asserts the masking too.

Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
The design document moved to `docs/plans/2026-10-17-json-tree-index.md`
in the merge from the document's branch. Every reference to the old
path follows it: the root and EQL `AGENTS.md`, the module docs of the
Stack Encrypt JSON index and the `eql-bindings` `v4::json_tree` types,
the `json_tree` SQL header, and the changeset, which also stops calling
this work a spike.

Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
Comment thread packages/stack-encrypt/src/json/query.rs
Comment thread packages/eql/src/v3/json_tree/blockers.sql
Comment thread docs/plans/2026-10-17-json-tree-index.md Outdated
…t reads back

The JSON index admitted 512 levels of nesting, but a sealed root's
plaintext is the document as JSON and serde_json parses JSON back only to
128 levels. A document nested 128 to 512 levels deep sealed without error
under the default option and then opened to OpenedType: the write
succeeded and the value was lost.

The walk now refuses a document deeper than 127 levels under either root
option, so the failure happens when the caller still holds the value, and
the option never changes which documents the index accepts. A test seals
the deepest admitted document of arrays and of objects under both options
and opens it, and checks that one level deeper is refused when sealed.

Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
A child's token was an unkeyed hash of its parent's token and its label,
H("t" || t(parent) || 0x01 || key), and an object member's rank another,
H("o" || t(parent) || key). The walk hands the server every descendant's
token, so a server that held one token could test guessed key names
against every child below it, in every row of the column, for as long as
the column lived. Object keys are data whenever an object is a map.

The design's decision 11 proposed keying the step, and it is free: only
the client derives a child token from a label (the server unwraps w), and
the client holds the index key. Both steps are now PRFs under the index
key with their own PAE domains, json/token-step/v1 and json/rank/v1. This
changes every stored token below a chain head and every object ordinal, a
format change; the types are unreleased, so no stored document carries
the old form. The SQL never derives either value, so it is unchanged. The
design's implementation notes record decision 11 as taken and count
eleven decisions, not ten.

Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
…ake a reader do

A stored document comes back from the database, so it is untrusted input,
and its chain fields are not authenticated. Three reads let it choose the
work:

- JsonDocument::children computed max(o) + 1 and derived a tag for every
  ordinal below it. An entry with o = u32::MAX overflowed (a panic in a
  debug build) and, in a release build, a large o allocated up to four
  billion tags. It now checks each entry against the parent tag at the
  entry's own ordinal, which is the same set the SQL probe finds.
- JsonDocument::subtree recursed with no bound, so a cycle of tags, or a
  chain longer than any sealed document, which whoever holds a token can
  write, overflowed the stack and aborted the process. It now stops below
  MAX_DEPTH and after as many entries as the document holds.
- The rebuild of a subtree's rows nested whatever containers the server
  lined up, and the server can repeat a row or return only the containers
  of a wide document. A repeated ordinal is now refused, and a nesting
  deeper than MAX_DEPTH is OpenedType.

The SQL walks get the same depth bound, so a cyclic document cannot keep
a recursive query running. Tests build each malformed document; the
rebuild test fails without the bound.

Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
JsonTerms said it held no plaintext, but each node keeps its positional
path, whose key names are the document's plaintext keys, and its chain
token, a capability over that subtree in every row. The derived Debug
printed both, so a JsonTerms or an AtPathTerms in a log wrote out the
document's key names and the tokens JsonSubtreeKey is careful never to
print.

Debug now prints the node count alone, and the doc comment says what the
terms do hold. A test formats terms over a document keyed by an email
address and checks that neither the key nor a token appears.

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

op::AtPath is documented as an exact operator over one value at a path,
and the JSON index answered AtPath<Eq> with the containment of the needle
that holds the value there. For a scalar below the root that is
equality. For an object or an array it is containment: ("$.a", {"x": 1})
matched {"a": {"x": 1, "y": 2}} and ("$.a", []) matched every array at
$.a. At the root, the needle's alt made ("$", 1) match a stored [1].

A container value is now refused when the query is awaited, with a new
TermError::JsonValueNotScalar whose help points at op::Contains, and an
accessor's needle carries no alt. Tests cover the refusals and the root
case against op::Contains on the same documents.

Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
The firewall's header said every native jsonb operator other than the
supported ones was blocked, but the entry domain blocked only = and <>
against another entry. (doc -> sel) < '{...}'::jsonb and = '{...}'::jsonb
resolved to the native jsonb operators, compared the entry's ciphertext
object and returned rows with no error, which is exactly what a query
gets when its ordering operand loses the query_json_tree_leaf cast.

The six comparisons between an entry and jsonb are now blocked in both
directions. ORDER BY col -> 'sel' cannot be blocked without an operator
class, which needs a superuser, so the firewall's header and ord_term say
to order by eql_v4.ord_term(col -> 'sel'). The PostgreSQL oracle checks
that all twelve forms raise.

Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
The pull request adds the JSON index, six TermError variants and a sixth
IndexSpec variant, but the crate's changelog did not mention any of them
and still said the JSON operators had no index to answer them. IndexSpec
is exhaustive on purpose, so the new variant is a breaking change and is
listed as one.

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

Copy link
Copy Markdown
Contributor Author

One more, found while fixing the review: packages/stack-encrypt/CHANGELOG.md did not record the JSON index, the new TermError variants or IndexSpec::Json (a breaking change, since IndexSpec is exhaustive on purpose), and it still said no index answered the JSON operators. fd89e6a adds the entries and corrects that sentence.

coderdan added a commit that referenced this pull request Oct 23, 2026
…-and-projection

Brings up the #1288 review fixes: the 127-level depth bound, the keyed
token step and member rank, the bounds on reading a stored document, the
JsonTerms Debug, AtPath<Eq> as equality, the entry-versus-jsonb blockers
and the changelog.

One conflict, in functions.sql: this branch rewrote json_tree_subtree and
json_tree_subtree_at around the parent map and the budget, and the fix
added a depth bound to the old walks. This branch's walks are kept, with
the bound re-applied to their recursive terms (WHERE c.entry IS NOT NULL
AND w.depth < 127), since they still unwrap w and a cyclic document could
otherwise keep an uncapped walk recursing.

Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
coderdan added a commit that referenced this pull request Oct 23, 2026
…-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
…build bound

The mutants gate found that the rebuild's depth bound was exercised only
through objects: replacing `depth + 1` with `depth * 1` in the array arm
survived, since the test lined up one-member objects alone. The test now
also lines up one-element arrays, each taken as the element of the one
before, and fails with that mutation.

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

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
coderdan added a commit that referenced this pull request Oct 23, 2026
…-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
coderdan added a commit that referenced this pull request Oct 23, 2026
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
coderdan added a commit that referenced this pull request Oct 23, 2026
The json_tree stack targets next, but this branch forked from main and
never took next, so every PR above it showed next's own changes (block
ORE, text search terms, typed queries) as its diff. Bringing next in here
shrinks #1288's view from 187 files to the 46 it changes.

Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
…ex-stack-encrypt

Carries the base's merge of next up the stack, so the pull request diff
reads against one merge base again.

Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
coderdan added a commit that referenced this pull request Oct 23, 2026
…-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
coderdan marked this pull request as ready for review October 23, 2026 13:26
@coderdan
coderdan requested a review from a team as a code owner October 23, 2026 13:26
@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:30:43.935638Z 8598446 Draft marked ready
🔒 Security Review ✅ Completed 2026-10-23T13:34:22.642167Z 8598446 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: 85984466ee

ℹ️ 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".

-- No sealed document nests deeper than 127 levels. A stored one
-- whose tags form a cycle or a longer chain, which whoever holds a
-- token can write, stops here instead of recursing without end.
WHERE w.depth < 127

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Cap recursive subtree walks by entry count

When a structurally valid stored payload has two children whose parent tags match the supplied token and whose wrapped tokens point back to that token, every recursive level doubles the working set. The depth check still permits up to 127 levels, so json_tree_subtree and json_tree_subtree_at can attempt an effectively unbounded number of rows and exhaust database resources; the Rust walker already limits output to the document's entry count. Add an equivalent row/visited-entry bound to both recursive CTEs rather than relying only on depth.

Useful? React with 👍 / 👎.

Comment on lines +254 to +257
SELECT COALESCE(jsonb_agg({{prefix}}_internal.json_tree_group(sv, elem ->> 'n') ORDER BY ord), '[]'::jsonb)
INTO groups
FROM jsonb_array_elements(sv) WITH ORDINALITY AS t(elem, ord)
WHERE elem ? 'n' AND (elem ->> 'g') IS NOT DISTINCT FROM parent;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Stop cycles while constructing containment groups

A domain-valid payload can make this recursion call itself with the same parent indefinitely—for example, a top-level entry with n = A makes the initial group descend into A, and another entry with n = A, g = A causes json_tree_group(sv, A) to recurse into json_tree_group(sv, A) again. Since the domain only checks that these fields are strings, projecting such a row (including while maintaining the documented GIN expression index) eventually hits PostgreSQL's stack-depth limit and aborts the operation. Track visited group tags or impose a document-sized recursion bound.

Useful? React with 👍 / 👎.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant