Skip to content

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

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

coderdan wants to merge 33 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.

… to queries on its plan

A typed plan answers an operator through its target type's `op::Answers`
impl, but the chain required `Typed<T>: ValueShape<S>`: a label that
converts into `T`'s context. An EQL v4 type is sealed under its column
(`NonEmpty<Identifier>`), so no plan for one could be queried, and
eql-bindings had nowhere to implement `Answers` (#1238).

`op::LabelledTarget` is the opt-in: a target type says its stored terms
were derived under the context a plan's label names. For such a type,
`Chooses` asks only `ValueLayout<S>` (the indexes `T` declares), since a
query derives through the index and never runs `T`'s encryption. Every
other type keeps needing `ValueShape`, so a type sealed under, say, a
tenant id cannot be queried under a label that would match nothing.
This crate's term targets implement the trait, which keeps every query
that compiled before compiling. `Chooses` now resolves the plan itself
(`choose`) rather than through `Resolves`, which keeps `ValueShape`: a
named index (`.via(..)`) on a plan whose type only opts in would send a
term the type does not send.

An EQL query value stores the column it queries, so its index needs the
context its terms were derived under, and must refuse one it cannot
record. `QueryToken`, which only the chain makes, now carries the label
and whether the call extended it (`label`, `is_extended`): with
`.extend(..)` the terms are derived under the label and the extension, so
a term that records the label alone would match nothing. The defaulted
`Supports::check_label` refuses such a context, and the chain runs it in
`check`, where the label is resolved, before any keyset is loaded or term
derived (and again in `prepare`, as for `check`). `Supports::query` stays
infallible: the chain calls it only after `check_label` accepted the
token's context. Every index here takes any context.

Tests: a labelled target is queried under the plan's or the call's label,
and its term reads whether the call extended it; a label its index
refuses loads no keyset; and a UI case pins that a type with an `Answers`
impl but no `LabelledTarget` answers no query on its plan.
…query chain

Rust could store an EQL v4 type but query it only through a data plan:
no v4 type implemented `op::Answers`, so `cipher.query(op::Eq, v).on(&plan)`
did not compile on a plan for `TextEq`, while the Go SDK already had an
`Eq` method per equality type and `Matches` with `Verify` on `TextMatch`
(#1216). This gives Rust the same operators, with the same bytes (#1238).

eql-codegen writes into `v4/answers.rs`, for each producible type with a
query twin: `op::LabelledTarget` (it is sealed under the column a
two-segment label names), the twin's assembly, and one `op::Answers` impl
per operator. The operators come from the type's index
keys, the input the data path's `TargetDescriptor::operators` reads:
`"eq"` answers `Eq`, `"match"` answers `Matches`. It stops on any other
index, or more than one, so making an ordered type producible has to
extend the rule. The same rows give `ANSWERS`, which the `typed_queries`
test holds to `TargetDescriptor::operators()` for every type.

The file sits beside the target table rather than in it: `v4/targets.rs`
is built on Stack Encrypt's `dynamic` layer and needs the `targets`
feature, while a typed query needs neither, so `answers.rs` needs only
`stack-encrypt`.

The index each impl names is `TwinIndex<I, Q>`: Stack Encrypt's own index
`I` (the same spec and derivation), whose query term is written into the
twin `Q` with the column the query's label names, by the encodings the
stored values use. It answers only for the twin's own plaintext
(`QueryTwin::Plaintext`), so no plan can send one type's twin for another
type's value, and its `check_label` refuses a label that names no column
and any extension before any keyset is loaded: an extended query would be
derived under more than the column the twin stores, and match nothing.
Both refusals are a `QueryLabelError` in `Error::Other`, so a caller
matches the variant rather than the message.
`TextMatch` returns `MatchTwin<Q>`, which serializes as the twin and keeps
the query's tokens for `verify` and `target_limit` under `EqlMatch`.
`Hmac256` and `BloomFilter` now declare their indexes, so a type holding
one declares the index a query is checked against, and `EqlMatch`
implements `MatchCase`.

Tests: every typed query is the data path's `query_term` bytes and domain
(the float values the semantic baseline folds included), with the data
path's inputs built as frames of Rust values; `TextMatch`'s
`verify` and `target_limit` agree with the data path's; short text, a
non-column label and an extended query are refused before any keyset is
loaded; and compile-fail doctests for `op::Lt` on `TextEq`, `op::Matches`
on `IntegerEq`, `.via(Equality)` and another type's `TwinIndex` on a
`TextEq` plan, and `verify` on an exact term. The new module's example
runs as a test in the encryption crate.
…nd pin the twin's plaintext tie

`op::Answers` and the stack-encrypt CHANGELOG read as if only a type
sealed under a context no label names needed `op::LabelledTarget`. Any
type queried on a typed plan needs both, whatever its context; both now
say so.

The cross-type `TwinIndex` compile-fail doctest is rejected by
`Resolves`' `ValueShape` bound before the plaintext tie is reached, so it
would still pass if `QueryTwin::Plaintext` were removed. A doctest pair
now checks the tie alone, through a `Supports<op::Eq, String>` bound:
`TwinIndex<Equality, TextEqQuery>` meets it, and
`TwinIndex<Equality, IntegerEqQuery>` fails with
`<IntegerEqQuery as QueryTwin<Hmac256>>::Plaintext == String`.
feat(eql-bindings): query EQL v4 types through Stack Encrypt's typed query chain
…perator reads

eql_v4.query_text_search required hm, op and bf on every query value. bf
is the bloom filter built from 3-grams, so text shorter than one 3-gram has
none, and `col = 'Al'` or `col < 'Al'` could not be written against an
eql_v4_text_search column. That is why Stack Encrypt could not produce the
type.

In v4 the search query domains (query_text_search and its _ore twin) now
require at least one of their terms. Each extractor over them reads its
term through eql_v4_internal.require_query_term, which raises
invalid_parameter_value naming the domain, the term and the operators that
compare it when the term is absent, so `=` on a value with no hm, `<` with
no op and `@@` with no bf are refused rather than reading NULL and
matching nothing. The extractors stay LANGUAGE sql, so the wrappers still
inline and the eq_term/ord_term/match_term indexes still engage.

v3 is cipherstash-client's format and does not change. The SQL source is
rendered once per version and had no way to say "only in v4", so the
renderer gains version blocks: lines between `-- {{#v3}}`/`-- {{/v3}}`
or `-- {{#v4}}`/`-- {{/v4}}` render for that version only, and the
directive lines for neither. The generator emits them for the two search
query domains, and the helper sits in a v4 block of
src/v3/scalars/functions.sql. release/cipherstash-encrypt.sql and its
uninstaller are byte-identical to the build before this change.

ADR-0002 gains a dated amendment recording the decision.
…s operator

TargetResolver::query took a type name, a label and a value, so a
resolver had to build the type's whole query twin, every term of it, for
every operator. EQL v4's TextSearch needs to emit only the term its
operator reads: a match term cannot be made from text shorter than one
3-gram, and `=` or `<` on such text must not be refused for want of one.

query_term already knows the answering index (OpSpec::answered_by over
the type's indexes). It now passes it through record::query to
TargetResolver::query as an &IndexSpec after the type's name. A resolver
whose twins carry every term ignores it.

eql-bindings' encryption::targets::query takes it the same way and
refuses an index the type does not carry (TargetError::NoIndex) before
deriving anything. The Go guest forwards it; an index the type lacks can
only come from an engine bug, so the guest reports it as internal.

BREAKING CHANGE: TargetResolver::query and dynamic::record::query take an
&IndexSpec; eql_bindings::encryption::targets::query takes one after the
name.
…perator's term

TextSearch was unproducible because its query twin carried hm, op and bf
on every query, and text shorter than one 3-gram has no bf: `=` or `<` on
a two-letter name would have been refused. EQL v4's search query domain
now takes any one term, so the twin can carry just the one its operator
reads.

In v4, TextSearchQuery (and the unproducible TextSearchOreQuery) hold
each term as an Option, skipped in the JSON when None. The generator
emits one projection per term, TextSearchEqualityQuery,
TextSearchOrderQuery and TextSearchMatchQuery, each deriving
EncryptFrom<String> over the envelope and its one term and converting
into TextSearchQuery. The by-name query dispatch, which now receives the
answering index, runs the projection for that index, so a match term is
derived only for `matches`. The twin itself carries no derive.

The stored TextSearch carries all three terms, the same bytes TextEq,
TextOrd and TextMatch store, so text too short for a 3-gram is refused on
write with EmptyTermText, exactly as TextMatch refuses it: a stored row
with an empty filter would be one no match query finds.

TextSearch joins ENCRYPTION_DOMAINS, the regenerated target table marks
it producible, and the Go encrypt/eql table follows. The engine answers
eq, lt, lte, gt, gte and matches on it.

Tests: each index's query carries its term alone and equal to the
stored one; "Al" is queried by eq and ope and refused for match; the
typed projections serialize to what the dispatch writes; and, against an
EQL v4 database, `=`, `<>`, `<`, `>=`, `@@` and the function forms find
the plaintexts' rows, a one-term operand is a query_text_search and a
termless one is not, and each operator and extractor refuses an operand
missing its term with invalid_parameter_value naming the term and never
the operand.
…ng one term

A directory Entry model holds one TextSearch field. Generated over the
deterministic EQL guest, its stored value carries bf, c, hm, i, op and v
and opens back; Eq, Lt and Gte return a query with only hm or op, the
stored term, for "Alice" and for the two-letter "Al"; Matches returns
one with only bf, which Verify checks; and Matches on "Al", like storing
"Qz" or "", is refused with ErrTerm without quoting the value.

stashgen gets a golden case for the same field over its static engine,
which now lists TextSearch with its three indexes, so the generated
methods (Eq, Lt, Lte, Gt, Gte, Matches) are pinned and compile against
the stub SDK.
…index

`IndexSpec::OreBlock` (key `ore_block`) derives ore-rs 0.9.0's 6-bit block
ORE under its own key domain, over the order keys the CLLW indexes use:
`ore_rs::vitaminc::Bit6BlockOre` for targets of at most ten bytes (every
integer to 64 bits, both floats) and `ChainedBlockOre` for text, bytes and
the 128-bit integers. The legacy 8-bit scheme is not used.

`sem::OreBlockTerm` is the whole ciphertext. It carries a random nonce, so
it compares by the scheme's comparator, never by its bytes, and
`from_bytes` checks a stored term's structure. A query is the full
ciphertext too: EQL v4's comparator reads the right half of the operand a
btree index passes second. Text whose fold is longer than 1023 bytes or
ends in NUL is refused, on the query path before any key is touched.

The CLLW `Ore` and `Ope` indexes are unchanged, byte for byte.

BREAKING CHANGE: `IndexSpec` is exhaustive and gains `OreBlock`, so a
`match` over it needs an arm for it.
… 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: c645a98

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

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

…ional-terms

feat(eql)!: EQL v4 text search queries carry only the term their operator reads; produce TextSearch
EQL v4's `ob` is one ore-rs ciphertext: the fixed-width 6-bit scheme
(header `02 02`) or the chained one (`02 03`, then the padding policy).
v3's is cipherstash-client's 8-bit blocks, which need another comparator,
so the v4 SQL holds its own in a version block:

- `{{ore_block}}_hash_bit`: ore-rs's sigma-MMO hash, the GF(2^128)
  doubling on two bigint halves with no branch on the data, then AES under
  the public key `ORE-rs.v2.H-pi.1` through pgcrypto.
- `{{ore_block}}_scheme`: the shape checks ore-rs's comparators make
  (header, block count, exact length, padding policy and the block counts
  it can produce, every symbol below 64). A malformed term raises, naming
  its length and never its bytes.
- `compare_{{ore_block}}_term`: raises on terms of different schemes,
  fixed-width terms of different block counts and chained terms padded
  under different policies. For terms of one shape it runs a constant
  operation count: one statement per block evaluates both comparisons and
  latches the first difference arithmetically, the hash runs once on every
  path, and the result is selected without a branch. Chained terms compare
  lexicographically, the shorter first when its blocks all match.
- `compare_{{ore_block}}_terms` over an array requires exactly one term.

The v4 bundle does not reuse the 8-bit format's name. The renderer gains a
placeholder, `{{ore_block}}`, rendered `ore_block_256` in v3 and
`ore_block_v2` (ore-rs's wire version 2) in v4, and refuses a literal
`ore_block_256` outside the source directory's path. The hand-written SQL,
the pin script, the generated extractors and the ORE fallback take it.
`release/cipherstash-encrypt.sql` and its uninstaller are byte-identical
before and after.

`tests/timing/ore_block_v2/` is a hand-run timing harness for the v4
comparator, with a detached ciphertext generator. ADR-0002 is amended.
An EQL type whose `ob` term is block ORE lists the index `ore_block`
(`record.OreBlock`) in `se_targets`, and the engine answers its orderings
through it. The checker accepts it in a type's indexes and as an operator's
index, and stashgen maps it to `IndexOreBlock`. It is not a tag word: a
field reaches it only by naming such a type with `encrypt_into=`, so it has
no entry in `indexNames` or `outputOfIndex`, and a plan's own outputs still
refuse it.

The static test engine produces `TextOrdOre`, `TextSearchOre` and
`IntegerOrdOre`, so the refusal test names `BigintOrdOre` as the type it
does not produce.
`IntegerOrdOre`, `SmallintOrdOre`, `BigintOrdOre`, `RealOrdOre`,
`DoubleOrdOre`, `TextOrdOre` and `TextSearchOre` are producible. Their
`ob` term is Stack Encrypt's block ORE term, one hex element holding the
whole ore-rs ciphertext: `OreBlock256` implements `EncryptFrom` for any orderable
plaintext and never decrypts. `TextSearchOre` follows `TextSearch`: its
stored value takes the empty filter, and its query carries only the term
its operator reads.

In the target table an `ob` term is the index `ore_block`, not `ore`,
which is CLLW ORE and has no EQL domain, and the block-ORE reason in
`encryption_gap` is gone. The unproducible types are the date, timestamp
and numeric families, which have no Rust plaintext type yet, and the two
JSON types.

`tests/encryption/tests/v4_ore_block.rs` compares real ore-rs ciphertexts
in PostgreSQL, both ways round, against the plaintext order and ore-rs's
own comparator: equal values, a first difference in every block, shared
prefixes and text in three padding buckets. It checks the hash bit against
ore.rs's vectors, that malformed and mismatched terms raise, and that a
btree index answers `<`, `>=`, `=` and `ORDER BY`. `test:encryption:postgres`
runs it. The Go catalog, the guest's tests and the Go operator test follow.
ADR-0009 is amended.

The EQL v4 range tests (tests/encryption/tests/v4_range.rs) now derive
their order terms from the engine. They arrived from the range domains
with cipherstash-client's ORE and OPE indexers writing the `ob` and `op`
of every row and bound, which was the only source of block ORE terms
while EQL's ordered types were not producible. The comparator this
series adds refuses that 8-bit layout, and this commit is where the
`OrdOre` types become producible, so the rows are real `IntegerOrdOre`,
`TextOrdOpe`, ... values and each bound is the term the single-bound
query of that type carries. The smoke test ranges over the families
whose ordered types are producible and records that the date, timestamp
and numeric ones are not.
…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
375 0 118 0

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

feat(eql)!: produce the block ORE EQL v4 types through ore-rs 6-bit block ORE
@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
@coderdan
coderdan removed this pull request from stack #1333 October 23, 2026 02:33
@coderdan
coderdan force-pushed the feat/json-index-stack-encrypt branch from 6427424 to b9ac367 Compare October 23, 2026 02:38
@coderdan
coderdan force-pushed the docs/json-index-stack-encrypt-spike branch from b8d1c87 to 55bbd66 Compare October 23, 2026 02:38
…ex-stack-encrypt

Brings up the review fixes to the json_tree design record and its SQL
prototypes.

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

@coderdan coderdan left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The JSON index and the EQL v4 json_tree family do what the description says: I ran cargo nextest run -p stack-encrypt --all-features (746 pass), clippy with all features and targets, mise run test:encryption, and mise run test:encryption:postgres against PostgreSQL 17, and the containment oracle agrees with jsonb @> on every needle. The defects below are in what the code accepts and how it opens what the server returns: one loses data, one exposes key names to anyone who holds a token, and several let a malformed or tampered stored document crash the client.

Findings, most severe first

  1. High: a sealed-root document nested 128 to 512 levels deep encrypts and then never decrypts. packages/stack-encrypt/src/json/derive.rs:40 admits 512 levels, but the sealed root's plaintext is the document as JSON, and derive.rs:733 reads it back with serde_json::from_slice, whose parser stops at 128 levels. I sealed [[[…1…]]] at depths 100 to 500 under both options: with the default sealed root, every depth from 128 up seals without error and opens to OpenedType; with sealed_root: false every depth opens. The write succeeds and the value is unreadable. Fix: refuse at the depth the reader can parse (127), so sealing fails instead of the read, and add a test at the boundary under both options.

  2. High: anyone who holds a token can test guessed key names, offline, in every row of the column. derive.rs:239 derives a child's token as an unkeyed hash, H("t" ‖ t(parent) ‖ 0x01 ‖ key), and derive.rs:265 ranks object members by another, H("o" ‖ t(parent) ‖ key). The walk hands the server every descendant's token, so with one token the server can check H("t" ‖ t ‖ 0x01 ‖ guess) against each child at every level below it, in every row, for as long as the column lives. Object keys are data whenever an object is a map (keyed by email address, user ID or SKU). Decision 11 in the design proposes the remedy, and it is free: the server unwraps child tokens from w and never derives one from a label, so only the client, which holds the index key, computes the step. Fix: derive the label step and the rank under the index key with their own PAE domains. Later in the stack, #1329 replaces the label step with an ordinal chain but keeps the unkeyed rank, so the rank fix has to carry up.

  3. Medium: an extracted node opens under whatever selector the row carries, so the server can answer one path with another path's value. DecryptInto<Value> for JsonNode (derive.rs:921) builds the AAD from self.entry.s, which comes from the server's row, and nothing asks the caller which path it requested. The entry's AAD does bind its selector, but the binding only helps if the opener supplies the selector it expects. As it stands, a server answering col -> sel($.salary) with the entry for $.bonus from the same row gets that entry decrypted and returned as the salary, with no error. This is a leaf not bound to its key from the caller's side, not the ADR-0010 container case. JsonSubtree has the same shape: the rows of a walk are not checked against the path walked. Fix: make the expected selector part of what opens a node (for example, a context that carries the JsonSelector, compared with the row's s before opening), and for a subtree, check each opened row's label and selector against the path. This changes the decrypt contract of JsonNode, JsonSubtree and the bindings' JsonTreeEntry / JsonTreeSubtree across the stack, so it is the owner's call.

  4. Medium: a malformed or tampered stored document can crash the client. These are reads of untrusted input:

    • packages/stack-encrypt/src/json/mod.rs:366: max(o) + 1 overflows for an entry whose o is u32::MAX. In a debug build that panics (I checked). In a release build it wraps, and any o near the top makes children build a vector of up to four billion tags before it can return.
    • mod.rs:396: walk recurses with no bound. A document whose tags form a cycle, or a chain thousands of entries long, overflows the stack, and a Rust stack overflow aborts the process rather than unwinding. The server can build either one for any token it has seen.
    • derive.rs:815 / derive.rs:993: rebuild recurses once per container it consumes, and open_subtree takes the server's rows as given. Rows that repeat one authentic container entry, or that list only the containers of a wide document, drive the recursion as deep as the server likes.

    Fix: in children, check each entry's own p against parent_tag(iv, token, o), which is the same answer with no fan-out loop; bound the walk by depth and by the number of entries; bound rebuild by the same depth; and refuse duplicate ordinals among a subtree's rows. Add tests that build each malformed document. #1329's bounded probe replaces the first, but the walk and rebuild recursion are still unbounded at the top of the stack.

  5. Medium: JsonTerms prints the document's key names and every chain token. derive.rs:152 says the terms hold "no plaintext and no key", but every TermNode keeps its positional JsonPath, which holds the plaintext key names and array positions, and its token. #[derive(Debug)] on JsonTerms (and on AtPathTerms, which wraps it) prints both, so a {:?} in a log writes out the shape and key names of the document, plus capabilities that JsonSubtreeKey is careful never to print. Fix: a hand-written Debug that prints the node count, and correct the doc comment.

  6. Low: AtPath<Eq> is containment, not equality, for a container value or at the root. packages/stack-encrypt/src/json/query.rs:534 builds the needle that holds the value at the path and answers it with @>. For a scalar at a path below the root, that is equality. For ("$.a", {"x": 1}) it matches {"a": {"x": 1, "y": 2}}, ("$.a", []) matches every array at $.a, and ("$", 1) matches a stored root [1] through the alt probe. op::AtPath is documented as an exact operator over a single value (op/mod.rs:172). Fix: refuse a container value in check, with a new TermError, and send no alt for an accessor.

  7. Low: an extracted entry compared with plain jsonb falls through to native jsonb. packages/eql/src/v3/json_tree/blockers.sql:11 says every other native operator is blocked, but public.eql_v4_json_tree_entry only blocks = and <> against another entry. Against PostgreSQL 17, (doc -> sel) < '{…}'::jsonb and (doc -> sel) = '{…}'::jsonb run native jsonb comparison over the ciphertext object and return rows with no error. That is exactly what a caller gets if they forget the ::eql_v4.query_json_tree_leaf cast on the operand. ORDER BY doc -> sel likewise sorts by jsonb's ordering of the entry object (I checked: it sorts by ciphertext), and it can only be prevented with an operator class, which needs a superuser, so it can only be documented. Fix: block <, <=, >, >=, = and <> between the entry and jsonb in both directions, and say on ord_term that ordering goes through it.

  8. Docs: the implementation notes say "Of the ten listed below" (docs/plans/2026-10-17-json-tree-index.md:341). There are eleven decisions now, and the notes should record what the code did about 11.

Not reported: the ADR-0010 non-findings (stored container membership, order and length; passthrough fields; interchangeable leaves under one context). Random nonces and the unwiped plaintext copies are deviations the design notes already record. The walk's cost (fan-out times entries per level) and the ordering term on every leaf are replaced by #1325 and #1329.


/// Deeper than this is refused rather than walked: the walk recurses, and a
/// document built in memory can nest deeper than any parser admits.
const MAX_DEPTH: usize = 512;

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.

  1. High: MAX_DEPTH admits 512 levels, but the sealed root's plaintext is the document as JSON and decode_node (line 733) parses it back with serde_json::from_slice, which stops at 128 levels. Sealing [[…1…]] from 128 levels deep up to 512 succeeds under the default sealed root, and the document then opens to OpenedType, so the value is unreadable. Refuse at 127 levels, the depth the reader can parse, and add a boundary test under both options.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 497c01e. MAX_DEPTH is now 127, the depth serde_json parses back, under either root option, so a document that is too deep is refused when it is sealed and not when it is read. the_deepest_document_the_index_takes_opens_again seals 127 levels of arrays and of objects under both options and opens them, and checks that 128 levels is JsonTooDeep. The design's implementation notes record the bound.

}

/// `H("t" ‖ t(parent) ‖ 0x01 ‖ key)` or `H("t" ‖ t(parent) ‖ 0x02 ‖ LE64(index))`.
fn child_token(parent: &Tag, label: &Label) -> Tag {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

  1. High: the label step is an unkeyed hash, so once the server holds a token it can test guessed key names against every child token the walk unwraps, at every level below the token, in every row, for as long as the column lives. rank (line 265) has the same property. Only the client ever derives this step (the server unwraps w), so keying it under the index key, as decision 11 proposes, costs the server nothing. Derive both under the index PRF with their own PAE domains.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in aa90e8b. The token step is now PRF(json/token-step ‖ column, PAE(t(parent), 0x01, key)) (0x02, LE64(index) for an element) and the rank is PRF(json/rank ‖ column, PAE(t(parent), key)), each with its own PAE domain. This is a format change: every token below a chain head and every object ordinal changes. The types are unreleased, the SQL never derives either value, and the design's implementation notes now record decision 11 as taken (and count eleven decisions). the_token_step_and_the_rank_are_keyed checks that both depend on the key and are not the unkeyed hash. In #1329 the label step becomes an ordinal chain, but the keyed rank carries up the stack.

/// a sealed root. A container node holds no value of its own and is
/// refused with [`Error::OpenedType`]; its subtree opens through
/// [`JsonSubtree`].
impl DecryptInto<Value> for JsonNode {

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.

  1. Medium: the AAD is built from self.entry.s, the selector the server's row carries, and the caller cannot say which selector it asked for. A server answering col -> sel($.salary) with the $.bonus entry of the same row gets that entry decrypted and returned without an error. The same holds for the rows of a JsonSubtree. Have the opener supply the expected selector, compare it with the row's s, and open under it. This changes the decrypt contract of JsonNode, JsonSubtree and the bindings' entry types, so it is the owner's call.

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.

Left for the owner. The fix changes what opens a node: the opener supplies the selector it asked for, it is compared with the row's s, and the entry opens under it. For a subtree, each opened row's label is checked against the path walked. That changes the decrypt contract of JsonNode, JsonSubtree and the bindings' JsonTreeEntry / JsonTreeSubtree in every PR of the stack (#1329 adds JsonSubtree::at, which is the natural place for the subtree half). JsonTreeDocument already takes an ExpectedContext<Identifier>, and an expected selector beside it would follow that precedent. Until then, a caller can compare node.entry().selector() with the JsonSelector it sent before decrypting.

Comment thread packages/stack-encrypt/src/json/mod.rs Outdated
.iter()
.filter_map(|e| e.o)
.max()
.map_or(0, |o| o + 1);

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.

  1. Medium: o + 1 overflows for an entry whose o is u32::MAX (it panics in a debug build; I checked), and in a release build any large o makes this allocate up to four billion tags. walk (line 396) recurses with no bound, so a cycle or a long chain of tags overflows the stack and aborts the process. rebuild in derive.rs has the same problem with rows the server repeats. Check each entry's own p against parent_tag(iv, token, o) instead of looping over a fan-out, bound the walk by depth and entry count, bound rebuild by depth, and refuse duplicate ordinals in a subtree.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 151409c. children checks each entry against the parent tag at the entry's own ordinal, which is the set the SQL probe finds but with no fan-out loop. subtree stops below MAX_DEPTH and after as many entries as the document holds. rebuild refuses nesting deeper than MAX_DEPTH as OpenedType, and open_subtree refuses a repeated ordinal. The SQL walks stop below 127 levels too. The new tests build each malformed document: o = u32::MAX, a self-cycle that doubles at every level, a 1000-entry chain, a repeated row, and 200 one-member objects lined up as rows (that test fails without the bound). #1329's bounded probe replaces children, but the walk and rebuild bounds carry up the stack.

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.

Follow-up in c645a98: the mutants gate found that only the object arm of the rebuild bound was tested (depth + 1 to depth * 1 in the array arm survived). The test now also lines up one-element arrays, and fails with that mutation.

/// The one-way terms of a document: one term node per node, in the
/// walk's order, with the root first. The term of the
/// [`Json`](struct@crate::target::Json) index, and what every query term is built
/// from; it holds no plaintext and no key.

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.

  1. Medium: "it holds no plaintext" is not true. Each TermNode keeps its positional JsonPath (the plaintext key names and positions) and its chain token, and the derived Debug prints all of them, so a logged JsonTerms or AtPathTerms writes out the document's key names and capabilities that JsonSubtreeKey never prints. Write a Debug that prints the node count, and correct this 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.

Fixed in 71ffa57. JsonTerms now has a hand-written Debug that prints JsonTerms { nodes: N, .. }, and the doc comment says what the terms do hold (the key names and the tokens) and that they should not be logged. the_terms_print_neither_key_names_nor_tokens formats terms over a document keyed by an email address.

/// Equality at a path: the needle that holds the value at the path,
/// projected, which containment answers exactly. A position in the path
/// reads as the wildcard, since selectors do not tell positions apart.
impl<P, V> Supports<AtPath<Eq>, (P, V)> for Json

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.

  1. Low: answered through containment, AtPath<Eq> with a container value is not equality. ("$.a", {"x": 1}) matches {"a": {"x": 1, "y": 2}}, and ("$.a", []) matches every array at $.a. At the root, ("$", 1) matches [1] through alt. op::AtPath is documented as an exact operator over a single value. Refuse a container value in check with its own TermError, and send no alt for an accessor.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 03a9223. check refuses an object or an array with the new TermError::JsonValueNotScalar (code stack_encrypt::json_value_not_scalar, whose help points at op::Contains), and an accessor's needle is built with no alt (JsonQuery::at_path). equality_at_the_root_is_not_containment checks that ("$", 1) matches a stored 1 and not [1], while op::Contains with 1 still matches [1], and an_accessor_asks_at_a_path checks the refusals.

--!
--! public.{{prefix}}_json_tree SUPPORTS @> <@ -> ->> (see operators.sql), and
--! public.{{prefix}}_json_tree_entry the ordered comparisons < <= > >= against an
--! ordering operand or another entry. Entry equality = <> is blocked: an

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.

  1. Low: for the entry domain only = and <> against another entry are blocked. (doc -> sel) < '{…}'::jsonb and = '{…}'::jsonb resolve to native jsonb over the ciphertext object and return rows with no error, which is what a caller gets for forgetting the query_json_tree_leaf cast. Block the six comparisons between the entry and jsonb in both directions. ORDER BY doc -> sel sorts by ciphertext too, and since an operator class needs a superuser, document ord_term as the way to order.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in fe7e457. <, <=, >, >=, = and <> between an entry and jsonb are blocked in both directions, and the header here and ord_term's docs say to order by eql_v4.ord_term(col -> 'sel'), since ORDER BY col -> 'sel' can only be redirected by an operator class, which needs a superuser. The PostgreSQL oracle checks that all twelve forms raise.


**Where it is.** The engine half is `packages/stack-encrypt/src/json/`: `Json` is the index (`Index<serde_json::Value>`, term `JsonTerms`), `target::json_document` the sealing operation, `JsonDocument` the stored term and an `EncryptFrom<Value>` / `DecryptInto<Value>` target, `JsonQuery` the needle for `Contains`, `ContainedBy` and `AtPath<Eq>`, `JsonLeafQuery` the operand for the orderings at a path, and `JsonSelector` / `JsonSubtreeKey` the extraction keys, derived through `KeysetCipher::json_selector` and `json_subtree_key`. `IndexSpec::Json(JsonOptions)` is the data form, with the key `"json"` and `{"json": {"sealed_root": …}}` as its wire form; a data plan reads it and refuses to run it (`Reason::JsonIndexPending`). The SQL is `packages/eql/src/v3/json_tree/`, confined to EQL v4; the EQL types are `packages/eql/crates/eql-bindings/src/v4/json_tree.rs`; the oracle is `packages/eql/tests/encryption/tests/json_tree.rs`, which stores the design's corpus and random documents encrypted beside their plaintexts and checks that `@>` and `<@` over the encrypted column select the rows PostgreSQL's own `jsonb` operators select over the plaintext column, with the GIN index shown in `EXPLAIN`. The pure-Rust half of that oracle is `packages/stack-encrypt/tests/json_index.rs`, against a model of `jsonb @>` pinned to the answers in `jsonb-semantics.sql`.

**Decisions taken by the code.** Of the ten listed below: 1, hex for every sixteen-byte field and base64 for `c` and `h`, pending Phase 3; 2, leaf ordering terms bound to the column only, numbers as the `f64` they round to and strings as text, under the scalar `Ope` index's derivation; 3, wildcard arrays only; 4, group tags on, and only on container array elements; 5, the child count is in every container node's ciphertext, and a truncated subtree is `OpenedType`, not a shorter array; 7, plain SHA-256; 8, the option is the boolean `sealed_root`, on by default, recorded in the header's flags; 9, the projection is built from the tags server-side (`eql_v4.json_tree_query`) and `groups_ok` was not needed; 10, position-keyed parent tags on, the ordinal stored as `o`, object members ranked by a hash under the parent's token.

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.

  1. Docs: there are eleven decisions now. Say what the code did about decision 11.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in aa90e8b: the notes say "Of the eleven" and record what the code did about decision 11 (keyed, with its own PAE domains). The notes also gained a bullet for each of the other fixes.

…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

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