Repository navigation
feat(eql, stack-encrypt): chain json_tree tokens by ordinal and probe the walk to the first miss - #1329
feat(eql, stack-encrypt): chain json_tree tokens by ordinal and probe the walk to the first miss#1329coderdan wants to merge 38 commits into
Conversation
… the walk to the first miss
The stored entry shrinks from nine fields to four, `s c p op`, and the walk
stops depending on the document's width.
Three fields nothing read: once the projection `q` was stored, no SQL function
read an entry's value selector `vs` or its group tags `n` and `g`, and the
engine read them only to rebuild `q` on deserialize. The engine now reads `q`
back as the projection. That changes no trust boundary: only `c` is
authenticated, through its AAD, and `q` was always what containment ran over.
Two fields the walk did not need: a child's token is now
`H("t" ‖ t(parent) ‖ LE32(o))`, which the server derives as it walks, so the
wrapped token `w` is not stored; and the ordinals under a node are dense from
zero, so the walk probes `0, 1, 2, …` until the first miss and neither `o` nor
a fan-out bound is stored. A subtree of N nodes costs about 2N hashes where it
cost the widest node in the document per node visited.
What the client gives up is deriving an object member's token, since an
ordinal is a rank among sibling keys it does not know. `JsonSubtreeKey` now
holds the deepest token the client can derive (a chain head, then array
positions) and one gate per further step, a selector or `#n`, and
`json_tree_subtree_at(doc, token, gates text[], budget)` descends through
them, returning the reached node at `d = 0` and its descendants beneath. The
children functions take the same optional gates.
Verified: stack-encrypt nextest (733), eql-bindings tests, the Postgres oracle
suite against a fresh v4 install built from the tree, clippy and fmt, the EQL
schema gate and documentation coverage. The documented-sql validation step
needs the Docker database and was not run.
Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the
|
Mutation testing (cargo-mutants,
|
| caught | missed | unviable | timeout |
|---|---|---|---|
| 97 | 0 | 37 | 0 |
cargo-mutants exited with no surviving mutants recorded. The run may not have completed cleanly; check the workflow run logs.
…nts, and drop the old format from the docs The benchmark run against this branch has come in, so the design doc's Phase 3b section now carries the measured outcome in place of "results to follow": the walks are 30 to 55% faster at 10,000 and 100,000 rows, the stored nested document is 25% smaller, containment and field queries are unchanged within the run's noise, and the benches correctness check passed all 462 of its comparisons at both tiers. The earlier sections of the design still describe the wrapped token, the group tags and the stored ordinal; they are kept as the record, with a dated note wherever one would now mislead. Several doc comments still described the old format: the header's SQL reader computing group tags, the projection being built from group tags, the test suite unwrapping tokens, `json_tree_subtree_at` matching a single selector, and the parent map costing a level its fan-out. They now say what the code does. The walk's cost was stated as about 2N hashes; a subtree of N nodes takes about 2N probes, each a hash and a lookup, plus a hash per hit for its token, so the SQL comment, the design doc and the changeset say that. The changeset also says that the gates argument replaces the earlier selector one and that the fallback root still comes back at d = 0. No SQL or Rust behaviour changes. Checked with clippy and fmt for stack-encrypt and eql-bindings, the EQL schema gate, and the EQL documentation coverage and required-tags checks; the documented-sql step needs the Docker database and could not connect. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
🦋 Changeset detectedLatest commit: 9cc4c8a The changes in this PR will be included in the next version bump. This PR includes changesets to release 12 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
…s opening and the projection's wire The in-diff mutants gate on the ordinal-chain commit left four survivors. Two were timeouts: `JsonDocument::children` probed ordinals up to `u32::MAX` until the first miss, so a mutant that never misses never ends. A node has at most as many children as the document has entries, so the loop is now bounded by that count; the behaviour on real documents is unchanged. Two were misses with no test behind them. A gated walk's rows carry the node the gates reached at depth 0, and `DecryptInto<Value>` returns that node's value rather than assembling a container from the rows; no engine test exercised that branch, only the Postgres oracle did. And `JsonProjection` gained its own serde impls for the stored `q` without a round-trip test. Both are pinned now. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
…'s walk The count bound alone ends a walk over tags that do not form a tree: every hit adds an entry, and the walk stops once it has returned as many entries as the document holds. The depth bound added beside it could never fire in a test, so the mutants gate reported its three operators as unkilled. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
…e index declares
The leaf ordering term `op` was derived for every number and string leaf
of a JSON document. After the ordinal-chain change it was the largest
field of a stored document (24,832 of 85,157 characters on the nested
benchmark document, about 80 bytes of hex per leaf), and the one field
that says something across rows at rest: the order of a leaf's value
among every row that holds one, on paths nobody asked to order.
A JSON index now names the paths it orders. `JsonOptions` gains
`ordered`, a set of `JsonPath`s beside `sealed_root`; the walk derives a
leaf's term only when some ordered path covers the node's positional
path (`JsonPath::covers`): a wildcard step covers every array position,
a position covers that element alone, so `$.orders[*].total` and
`$.scores[0]` are both allowed. No path is ordered by default. The set is
part of the index's identity and crosses the data-plan wire as
`{"json": {"sealed_root": true, "ordered": ["$.age", ...]}}`; it is not in
the header, since a stored leaf shows whether it carries a term.
An ordering query at a path the index does not order is refused when the
query is awaited (`TermError::JsonPathNotOrdered`), before any keyset
loads. Without the refusal `eql_v4.ord_term` is NULL for every stored
leaf at the path and the comparison silently matches no row.
The typed plan had no place for options: `encrypt_into::<JsonDocument>()`
reads the type's static `EncryptFrom`, so every typed plan sealed under
the defaults and a query under other options was refused as
`PlanError::IndexOptions`. `target::Layout<S>` is a target's layout
chosen by value, with the index it answers queries through;
`Json::new(options)` implements it, and `Plan::value().encrypt_as(layout)`
builds a `ValuePlan<_, Laid<X, T>>` that seals under the options and
resolves every query through the plan's own index, so a query does not
name the options again. `eql_bindings::v4::JsonTree` is the same layout
for the EQL type, under an identifier.
The SQL is unchanged: `ord_term` was already NULL for an entry without
`op`, and the entry validator already admitted its absence. The
PostgreSQL oracle seals its corpus with `$.hello` and `$.number` ordered,
the paths its ordering checks ask at.
Refs #1231
Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
…ntext check cargo-mutants over the opt-in commit left five mutants alive, all on the new `Laid` plan shape: its `specs` (and the `Layout::specs` default it calls) could return nothing, its `Debug` could print nothing, and the `Opens::check` of one document and of a batch could accept a call that names a context the plan already has. `ValuePlan::specs` is added so a test can read the indexes a plan declares, as `FieldPlan::indexes` does, and the laid-out plan test asserts the four. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
…rm opt-in The benches re-sealed both corpora with one ordered path each and measured the stored sizes at 10,000 and 100,000 rows; the Phase 3c note carries the table in place of the estimate it gave, and the two side effects the measurement found (the flat document leaving TOAST, the flat GIN growing with its row spread). Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
… before its keyset loads The two `Opens::check` impls of the `Laid` plan survived mutation: the opening itself resolves the context again, so a call that names a second context failed either way and the test could not tell the early refusal from the late one. The counting cipher can: the refusal in `check` runs before any keyset loads, and the test now asserts that count. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
…-ordinal-chain Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
…base85, and the parent tag in eight bytes The stored document's largest costs after the opt-in ordering term were its encodings: every sixteen-byte tag took 32 hex characters, and the ciphertext took base64's eight characters per six bytes on the field that is most of a document. The rule for what may change is the one the design set: a field the SQL compares, derives or decodes stays in an alphabet core PostgreSQL encodes and decodes natively, and only a field the SQL never opens may take a denser one. So every field the SQL reads is padded standard base64, as `encode(x, 'base64')` writes it: the selectors `s` and the `s`/`vs` pairs of `q`, the parent tag `p`, the ordering term `op`, and the tokens and gates a walk takes. Padded, because `decode(x, 'base64')` refuses the unpadded form, and not base64url, because PostgreSQL has no decoder for it. The ciphertext `c`, which no SQL function decodes, is RFC 1924 base85 through `base85-simd` 0.1.0 (SIMD on aarch64 and x86_64, scalar code on wasm32). The header `h` stays base64: the SQL reads the `iv` and the sealed-root flag out of it. The parent tag `p` is now the first eight bytes of its hash. It is a lookup key for the server's walk within one document and nothing else: an entry's AAD binds its selector and its ordinal, not its parent tag, so the truncation authenticates nothing less, and eight bytes keep a collision in a ten-thousand-node document below one in 10^11. `eql_v4.ord_term` decodes `op` through a new `eql_v4_internal.json_tree_ope_cllw`, because the `ope_cllw(jsonb)` the scalar types share reads hex and those wires are released. The bindings get `JsonTreeSelector`, `JsonTreeCiphertext` and `JsonTreeOrderingTerm` so the v4 wire is documented apart from the v3 hex types. Re-encoding 1,000 stored rows of each benchmark corpus and reloading them as jsonb: a nested document's `pg_column_size` goes from 40,808 to 31,894 bytes (22% smaller), a flat one's from 1,596 to 1,398 (12%). The corpora are not re-ingested and no query is re-timed here. The design doc's Phase 3d note records the decision and the measurement. Refs #1324 Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
Seven percent of a nested document is what a nonce derived from the header and the ordinal would save. Taking it needs vitaminc to accept a caller-chosen nonce, and that is the one thing its AEAD layer withholds on purpose: its misuse resistance rests on never letting a caller repeat a (key, nonce) pair. The safety argument here, a fresh data key per seal, is a stack-encrypt invariant vitaminc cannot check. The random nonce stays. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
…-ordinal-chain # Conflicts: # docs/plans/2026-10-17-json-tree-index.md
d80a33a to
cfd5dea
Compare
fdf1eb9 to
9657508
Compare
…-ordinal-chain Brings up the review fixes to the json_tree design record and its SQL prototypes from docs/json-index-stack-encrypt-spike. One conflict, in section 3 of the design record: this branch added a dated note after the native-projection paragraph and the review added a ContainedBy paragraph at the same place. Both are kept, the dated note first since it annotates the paragraph above it. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
…-ordinal-chain Brings up the #1288 review fixes. This branch replaced several of the places they touched, and its behaviour is kept wherever both sides edited the same code: - derive.rs: the ordinal chain is kept. Its child token is H(t || o), which the server derives as it walks and which carries no label, so the keyed label step from #1288 (json/token-step/v1) is dropped with it. The keyed member rank (json/rank/v1) stays: the server sees which selector sits at each ordinal under a token, so an unkeyed rank would let it rank guessed key names against them. Its doc and test say so. - mod.rs: this branch's bounded probe in children() is kept, and the walk takes both bounds, the entry count from here and MAX_DEPTH from #1288. The malformed-document test is rewritten for the ordinal chain (a 1000-link chain any token holder can write); the cycle and u32::MAX ordinal tests do not apply to a chain derived by hash with no stored ordinal. - functions.sql: this branch's walks are kept with no depth bound. A chain derived by hash cannot cycle and is no longer than the document, and a recursive CTE iterates rather than recursing on the stack. - codes.rs, sem/mod.rs: both new TermError variants, JsonPathNotOrdered and JsonValueNotScalar. - json_index.rs: both sides' tests, adapted to this branch's options API. - The design notes count eleven decisions on top of this branch's amendments, and record that only the keyed rank survives the ordinal chain. The changelog entry describes this branch's form of the index. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
Brings up the #1288 review fixes as #1329's branch carries them: the 127-level depth bound, the keyed member rank, the walk and rebuild bounds, the JsonTerms Debug, AtPath<Eq> as equality, the entry-versus-jsonb blockers and the changelog. No conflicts. With the rank keyed, the leakage reference's statement that nothing it describes reveals a key name holds for a token's holder too: the member order a token exposes is an order under the index key. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
…-ordinal-chain Brings up the array case of the rebuild-bound test from #1288, which the mutants gate asked for. No conflicts. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
…gh the key that keyed it Past its budget a walk returns the sealed root, and the rows open only at the walked path, below the path's node for json_tree_subtree and at the nodes it reaches for json_tree_subtree_at. The caller had to attach that path with `below` or `at`, and a caller who did not knew nothing of it until a document grew past the budget and the open failed with OpenedType. The owner chose option (c) from the #1325 review: make a path-less open impossible. The path is in the caller's hand when the key is derived, so the key now carries it (`JsonSubtreeKey::path`) and builds the wrapper: `key.open(rows) -> JsonSubtree` in stack-encrypt and `JsonTreeSubtree::from_key(&key, rows)` in eql-bindings. The key knows from its gates which walk the rows came from. `JsonSubtree::new`, `below` and `at` are gone, as are `JsonTreeSubtree::new`, `below`, `at` and its public fields; neither wrapper deserializes any more, since that was a path-less constructor too. The path is not part of the key's wire form. The test that the fallback refuses without a path is now a compile-time property. the_key_built_fallback_opens_to_what_the_walk_opens_to holds, for ten paths with and without gates, that the key-built fallback opens to what the walked rows open to, as a list and as one value; the gated rows are walked in the test as the SQL walks them. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
…nd wipe the fallback's document The JSON module was the one place in stack-encrypt that held plaintext outside vitaminc's Protected. Sealing cloned every scalar and key out of the caller's value into a NodePlain and serialised the sealed root with serde_json::to_vec, and the copies were freed unwiped. Opening unwrapped the cipher's Protected<Vec<u8>> into a plain Vec with risky_unwrap, parsed it into serde_json::Value, and the sealed-root fallback parsed the whole document, selected the walked path and freed the rest unwiped, on every subtree read past the budget. Following the vitaminc skills (wrap at the source, transform without unwrapping, unwrap only at the boundary): - PlainJson is a newtype over serde_json::Value whose Zeroize wipes every string, values and object keys alike (keys taken out by ownership), and overwrites numbers and booleans in place. It has no Debug, Clone, PartialEq, Default or Deref. A parsed value lives in Protected<PlainJson>. - The walk encodes each node's plaintext straight from the caller's value into a Protected<Vec<u8>>, sized before it is written so it never reallocates, and hands that to the cipher. A number's text is made wrapped. The sealed root's JSON is measured, then written once into its own Protected buffer. Nothing is cloned out of the caller's value. - An opened entry's bytes stay in the cipher's Protected while they are parsed; each key, scalar and sealed-root document is read out through zip_ref and wrapped as it is made. Subtrees are rebuilt inside a Protected, each member moved in from its own, so a rebuild that fails part-way wipes what it had. The fallback selects the walked path inside the document's Protected, so only the selected nodes come out. A value leaves its wrapper in one place, hand_over, where the caller receives it. Numbers and booleans have no volatile overwrite without unsafe code, which this crate denies, so theirs is best-effort. The query and term side still holds plaintext vitaminc does not reach from here (an accessor's needle document, the value selector's PAE input, the key names in term paths); the design document's Phases 1 and 2 note records both. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
Brings in the #1329 review fixes (the selector-bound chain step and position gates, the refusal of positions in ordered paths, the JSON wire fuzz target), the key-built subtree wrapper, and the json plaintext held under Protected. One conflict, in packages/eql/tests/encryption/tests/json_tree.rs: both sides added a PostgreSQL test after postgres_extracts_walks_and_orders_by_path (this branch's postgres_the_planner_picks_the_indexes_without_help with its flat_document and nested_document helpers, and #1329's postgres_a_position_reaches_array_elements_only). Both are kept, the planner test first, each with its own body; git had interleaved their shared opening lines. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
The CI mutants gate found the position arm of JsonPath::covers unpinned: since ordered paths and ordering queries refuse positions, no test reached it any more, though covers is public and still matches positions. A unit test now holds that a position covers that position alone, and that a wildcard covers it. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
Brings in the JsonPath::covers test the #1329 mutants gate asked for. No conflicts. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
|
The CI mutants gate failed on 63bec8f. Two mutants survived in the position arm of |
…-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
Carries origin/next up from #1250 through #1329, so this PR reads against one merge base. Content-empty. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 62f213233b
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| /// 'base64')` writes it. Column-wide: the same path has the same selector | ||
| /// in every row, which is what `->` and containment match on. | ||
| #[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, TS, JsonSchema)] | ||
| pub struct JsonTreeSelector(pub String); |
There was a problem hiding this comment.
It would be nice if these named types contained named wrapper types that represented the encoding that the value was in, instead of just a string, like a base64 wrapper type or a base85 wrapper type, et cetera, because that's really important to how these types are handled.
There was a problem hiding this comment.
Done in 14ccc86 (changeset wording in 5003d08). There was no encoding wrapper in eql-bindings to reuse: the v3 terms newtypes are named by role (Selector, OpeCllw, KeyHeader), not by encoding, and nothing else in the crate wraps base64 or base85. So there is a new encoding module with two #[serde(transparent)] newtypes:
Base64Text: padded standard base64, as PostgreSQL'sencode(x, 'base64')writes it.Base85Text: RFC 1924 base85, which no SQL function decodes.
JsonTreeSelector, JsonTreeTag and JsonTreeOrderingTerm now wrap Base64Text, and JsonTreeCiphertext wraps Base85Text. h was the v3 SteVec KeyHeader, whose docs describe base85 text that SQL never parses. That is wrong for v4, where h is base64 and the SQL decodes it for the iv and the sealed-root flag. It is now its own JsonTreeKeyHeader(Base64Text), and v3 is untouched.
Each newtype is still the bare string in JSON, and renders as string in TypeScript and JSON Schema. A unit test in encoding.rs pins all three. Deserializing still checks only that the value is a string. Stack Encrypt decodes the bytes and refuses text in the wrong encoding, and repeating that here would decode every field twice.
The blast radius was mechanical. The json_tree family is not in the catalog and none of its types is #[ts(export)], so no generated TypeScript, JSON Schema or Go output moves: mise run types:check and cargo test -p eql-codegen pass. The only code that read the inner strings was two assertions in the encryption test crate, which now call .as_str().
There was a problem hiding this comment.
Making the inner value public violates the contract.
A consumer of the base64 text type can't trust that the inner type is actually base64, because there's no guarantee that a caller upstream has just put a random string inside it.
Should have a constructor that encodes into the inner type. That should be the only way to create it.
| /// A target's layout chosen by value rather than by type. [`EncryptFrom`] | ||
| /// is a type's own layout, fixed where the type is written; a layout is a | ||
| /// value with options, which seals the same target under them. The JSON | ||
| /// index is one: `Json::new(options)` seals a [`JsonDocument`] as | ||
| /// `encrypt_into::<JsonDocument>()` does, under its options rather than | ||
| /// the defaults. A plan takes one with | ||
| /// [`encrypt_as`](crate::plan::ValueStart::encrypt_as), and a query | ||
| /// through that plan runs through the layout's own index | ||
| /// ([`Layout::index`]), so it derives under the plan's options without | ||
| /// naming them again. |
There was a problem hiding this comment.
This needs some examples to explain what the layout is actually for
There was a problem hiding this comment.
Done in 021603c. The Layout trait's docs now say what a layout is for in short sentences and lead with a runnable example: a plan built with encrypt_with_layout(Json::new(options)), where the options order $.age, seals a JsonDocument with an ordering term on that leaf and answers op::AtPath(op::Gte) through the plan. The same target laid out by type, encrypt_into::<JsonDocument>(), uses the default options, which order no path, and the example shows that plan refusing the same query with TermError::JsonPathNotOrdered. The docs end by saying that the cipher's encrypt_as is a different layer: it seals one value as the type's own EncryptFrom does, with no plan and no options.
| #[derive(Serialize)] | ||
| struct JsonOut { | ||
| sealed_root: bool, | ||
| ordered: Vec<String>, |
There was a problem hiding this comment.
What does ordered hold? String erases types.
There was a problem hiding this comment.
In memory it was already typed: IndexSpec::Json(JsonOptions), and JsonOptions::ordered is a BTreeSet<JsonPath>. The String was in the wire structs only. The writer rendered each path to text by hand, and the reader read Vec<String> and re-parsed each one in JsonWire's TryFrom through JsonOptions::ordering. So the type was lost one step before the serde boundary rather than at it, and I agree that was worse than it needed to be.
Changed in e10c93b. JsonPath now implements Serialize (its Display text, which is canonical and parses back) and Deserialize (through JsonPath::parse, refusing text that is not a path with the JsonPathError message). The writer's JsonOut holds &BTreeSet<JsonPath>, the reader's JsonOptionsWire holds BTreeSet<JsonPath>, and the TryFrom now checks only what a parse cannot, that an ordered path names no position (JsonOptions::check). The plan error's structured payload writes the set the same way. The wire form does not change: paths in canonical order, as text.
Tests: json_ordered_paths_round_trip_typed in dynamic/term.rs round-trips an IndexSpec::Json with two ordered paths (one with a quoted key) through serde and checks that a non-path and a positional path are refused. a_path_round_trips_through_serde_as_its_text in json/path.rs covers JsonPath alone. The existing wire tests in tests/json_index.rs pass unchanged.
| /// A one-value plan's layout when it was given one by value | ||
| /// ([`encrypt_as`](ValueStart::encrypt_as)): the layout `X`, whose target | ||
| /// `T` is its output, and a query runs through the layout's own index, | ||
| /// with its options ([`Layout::index`]). `T` is the layout's | ||
| /// [`Target`](Layout::Target), spelled out so that opening one `T` and a | ||
| /// `Vec<T>` are told apart as they are for a [`Typed`] plan. |
There was a problem hiding this comment.
These docs are hard to follow. Can you simplify the language and include an example?
There was a problem hiding this comment.
Done in ba0c2a2 (the method has been encrypt_with_layout since 8930415). The method docs now say what it does and what the caller gets in two sentences ("Encrypt the value as layout does, with the layout's options. The plan's output is the layout's target."), lead with the example, and then say in one short paragraph when to use it rather than encrypt_into::<JsonDocument>(). The module docs at the top of plan/value.rs are now a list of the three ways to choose a one-value plan's output, one line each. The note about the cipher's encrypt_as moved to the Layout trait, which has its own example of the difference (021603c).
| Self(self.0.clone(), PhantomData) | ||
| } | ||
| } | ||
| impl<X: fmt::Debug, T> fmt::Debug for Laid<X, T> { |
There was a problem hiding this comment.
Is opaque debug not suitable here? I guess the any type trick makes it impossible?
There was a problem hiding this comment.
OpaqueDebug would compile here, but I've kept the hand-written impls, because it does not fit what these types are.
#[derive(Debug)] is the one that cannot be used. It puts a T: Debug bound on the impl for every type parameter, and a target need not be Debug. It would also print the field as PhantomData<fn() -> T> rather than naming the target.
vitaminc's #[derive(OpaqueDebug)] (vitaminc-protected-derive 0.6.1, which JsonHeader and ValueFrame already use) does not have that problem. It only adds a Debug bound for a field marked #[non_sensitive], and it names the type with type_name::<Self>(), the same trick the manual impl uses, so the Any part is not what stops it. What it does is mask every field as "***" and mark the type with the OpaqueDebug trait, which says "this type holds something sensitive and its Debug hides it". Typed<T> holds nothing but a PhantomData, so Typed<JsonDocument> would print as stack_encrypt::plan::value::Typed<stack_encrypt::json::JsonDocument>("***"): a mask where nothing is hidden, and a claim the type has no reason to make. For Laid<X, T> the mask would hide the layout itself, Json { options: … }, which is index configuration (sealed_root and the ordered paths), not plaintext, and is what you want to see when debugging a plan. Marking both fields #[non_sensitive] would avoid the mask but print the target twice, once in the type name and once in PhantomData<…>. The manual impls print Typed<…JsonDocument> and Laid<…JsonDocument>(Json { … }), with no bound on T, so I left them as they are.
…ith_layout The cipher layer already has KeysetCipher::encrypt_as(&source, context) and EncryptInto::encrypt_into(&keyset), which are one operation seen from the target's side and from the source's side. The plan layer used the same two verbs with a different split: encrypt_into::<T>() lays a plan out by a target type, and encrypt_as(layout) laid it out by a layout value. The same names meant opposite things depending on the layer. The plan-layer method that takes a layout value is now ValueStart::encrypt_with_layout(layout). Its rustdoc says that the cipher's encrypt_as is unrelated. The cipher-layer encrypt_as, encrypt_into, encrypt_into_with_context and encrypt_from are unchanged, and so is the plan-layer encrypt_into. The tests, the changeset, the CHANGELOG entry and the design plan now use the new name. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
|
Renamed the plan-layer The verbs collided across layers. On the cipher, The cipher-layer |
A path such as `$.items[2147483648]` fits the u32 the subtree key used for ordinals, so its position reached the server as the gate `#2147483648:<selector>`, and `json_tree_children` and `json_tree_subtree_at` cast that ordinal to `integer`: PostgreSQL raised 22003 where the query should have found nothing. The bound now lives in one place, `MAX_ORDINAL` (2^31 - 1), and is applied where the index numbers children: when a document is sealed (an array or object with more children is refused, so every ordinal and child count fits `integer`), and when a subtree key is derived (a position past it is refused with `TermError::JsonTooWide`, whether it becomes a gate or a position the client chains itself). Refusing at derivation rather than at `JsonPath::parse` keeps `JsonPath` a plain description of a path, used also for plaintext selection where any position is meaningful, and keeps the refusal next to the other index-specific one (`JsonSealedRoot`). No stored document has a node past the bound, so no answer is lost. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
The trait's docs said what a layout is in the abstract and gave no example, so a reader could not see why it exists beside `EncryptFrom`. They now say it in short sentences and lead with a runnable example: a plan built with `encrypt_with_layout(Json::new(options))` orders `$.age` and answers an ordering query, while the same target laid out by type (`encrypt_into::<JsonDocument>()`) uses the defaults and refuses it with `JsonPathNotOrdered`. The docs also say that the cipher's `encrypt_as` is a different layer: one value, the type's own layout, no plan. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
…out docs The module docs were one long sentence naming three layouts, and the method docs explained `encrypt_with_layout` through the `Layout` trait's abstractions before saying what a caller gets. The module docs now list the three ways to choose a one-value plan's output, one line each. The method docs say what it does and what it returns in two sentences, lead with the example, and then say when to use it rather than `encrypt_into::<JsonDocument>()`. The note that the cipher's `encrypt_as` is a different layer moved to the `Layout` trait, which now explains the distinction with an example of its own. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
…o serde `JsonOptions::ordered` holds `JsonPath`s, but the wire structs that write and read it held `Vec<String>`: the writer rendered each path to text by hand and the reader re-parsed the strings in a `TryFrom`. The type was lost one step before the serde boundary rather than at it. `JsonPath` now implements `Serialize` (its `Display` text) and `Deserialize` (through `JsonPath::parse`, refusing text that is not a path with the `JsonPathError` message). The wire structs hold the `BTreeSet<JsonPath>` itself, so the paths stay typed until serde turns them into strings, and what the `TryFrom` still checks is only the one rule a parse cannot: an ordered path must not name a position. The wire form is unchanged (paths in canonical order, as text), and the plan error's structured payload writes the set the same way. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
The unreleased JSON index entry now says that `JsonTooWide` also refuses a subtree key for a path naming a position past 2^31 - 1, which the SQL walk casts to `integer`. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
… field The json_tree wire types held a bare `String`, so a field's type did not say how its text was to be read, and `h` reused the v3 SteVec `KeyHeader`, whose docs describe base85 text the database never parses, where the v4 header is base64 that the SQL decodes for the `iv` and the sealed-root flag. The new `encoding` module has two `#[serde(transparent)]` newtypes, `Base64Text` (padded standard base64, as `encode(x, 'base64')` writes it) and `Base85Text` (RFC 1924 base85, which no SQL function decodes). The selector, parent tag and ordering term wrap `Base64Text`, the ciphertext wraps `Base85Text`, and `h` is a new `JsonTreeKeyHeader(Base64Text)`. The wire form does not change: each newtype is the bare string in JSON, and `string` in TypeScript and JSON Schema. Deserializing still checks only that the value is a string; Stack Encrypt decodes the bytes and refuses text that is not its encoding. The json_tree family is not in the catalog, so no generated TypeScript, JSON Schema or Go output moves. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
`json_tree_subtree_at` limited its walk only when the root was sealed and the budget was above zero, and `json_tree_children` not at all, so with `budget = 0` or an open root nothing in the SQL itself said how many rows a walk of a hostile stored document could make. The bound held, but only as a consequence of two facts elsewhere: the parent map keeps one entry per tag, so entries that share a `p` collapse into one, and a hit's token is a hash of the probe that found it, so no probe is made twice. Both walks now read their recursion through `LIMIT` the entry count plus the seed row, as #1325 had them before the ordinal chain replaced the stored tokens. PostgreSQL computes a recursive query only as far as its reader pulls rows, so the limit stops the recursion and not only the output, and a walk of a sealed document never reaches it. The budget's fallback is unchanged: `found` reads the bounded walk, so a document with fewer entries than the budget is returned walked, and one with more falls back past the budget as before. The comment block above the walks now states the bound and both reasons for it. The Postgres suite gains a hostile document whose 60 levels are each written twice, same `p` and `s`, different `c`. Walked once per copy it would make 2^60 rows; it returns one row per level under no budget, an open root, and a budget above the entry count, and falls back under a budget below it. It passes with and without the new limit, which is the evidence that the map already collapsed the copies. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
The ordinal-chain changeset's byte-encodings paragraph now says that the eql-bindings wire types name their encoding (`Base64Text`, and `Base85Text` for `c`) and that the wire form is unchanged. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
…, not copy them When a walk falls back to the sealed root, the client opens the whole document and selects the walked path from it. The selection borrowed the document and cloned the node (or the nodes, or a node's children) out, so for a moment the selected plaintext existed twice: once in the document, which was then wiped, and once in the copy handed over. `JsonPath::select_mut` finds the same nodes as `select`, borrowed mutably, and the new `plain::take_protected` is the moving counterpart of `read_protected`: it borrows the `Protected` inner value through vitaminc's `update`, moves out what the closure returns into a `Protected` of its own, and leaves the source wrapped. Both fallbacks (`DecryptInto<Value>` and `DecryptInto<Vec<Value>>` for `JsonSubtree`) now `mem::take` the selected nodes, leaving `null` in the document, and the children of a `json_tree_subtree` fallback are taken the same way, an object's keys staying behind to be wiped with the document. No `risky_unwrap` was added, and the plaintext exists once. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
…nal tokens and the header Two review findings on the record (#1250) read the design as written and found two gaps that the implementation closed in #1329, with nothing in the record saying so. A positional token as first written named the element's children rather than the element, so a scalar element could not be returned or identified; the implementation reaches a position from the parent's token through a `#n:<selector>` gate and returns the reached entry itself. The wire shape called the header opaque to SQL while the walk needs the document's iv and the sealed-root flag from it; the implementation reads both at fixed offsets of the frozen header layout. Each passage now carries a dated note pointing at the implementation and the phase notes, in the form the record already uses for superseded sections. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
Summary
EQL (Encrypt Query Language) is the SQL bundle that stores and queries encrypted values in PostgreSQL; EQL v4 is the version for values written by Stack Encrypt, the Rust encryption engine in
packages/stack-encrypt. Itsjson_treetype (unreleased) stores an encrypted JSON document as one entry per JSON node: a small ciphertextcholding that node alone, plus keyed tags that let PostgreSQL search and walk the document without the decryption key. Two of those tags matter here. A selectorsis a keyed hash of a node's path. A token is a secret 16-byte value per node that the client hands the server in a query: from it the server can find that node's children, through each child's parent tagp, and so walk the subtree under the node.This PR shrinks what each entry stores from nine fields to four (
s,c,p,op) and changes how the server walks a subtree, so a walk no longer costs the document's widest node for every node it visits. Stacked on #1325. The stored format changes with it, so it has to land before the family ships.qwas stored (perf(eql, stack-encrypt): store the json_tree projection, walk by parent map, budget the subtree #1325).qis the document's selector pairs grouped by array element, so PostgreSQL's ownjsonb @>over it decides containment. Those three fields were the per-entry value selectorvsand the two group tagsnandg. The engine now readsqback as the projection instead of rebuilding it from the entries.wand the ordinalo. A child's token is nowH("t" ‖ t(parent) ‖ s ‖ LE32(o)), a hash of its parent's token, its own selectorsand its position among its siblings, which the server derives as it walks. The positions under a node run 0, 1, 2, … with no gaps, so the walk probes them in turn and stops at the first miss. A subtree of N nodes costs about 2N probes (a SHA-256 and a lookup each) plus N token hashes, where it cost every position up to the document's widest node at every node visited.#n:<selector>for an array position, with the array elements' selector. The server descends through the gates and returns the node they reach at depthd = 0, with its descendants beneath it.opis opt-in per path (commitba62e3fc5).opis an order-preserving ciphertext of a number or string leaf, about 80 bytes each, whichORDER BYand range queries at that path use througheql_v4.ord_term. It was derived for every number and string leaf, which made it the largest field of a nested document (24.8 KB of 85 KB) and the one field that reveals something across rows at rest (the order of that leaf's value among every row that holds one), on paths nobody orders. A JSON index now names the paths it orders, with[*]covering every element of an array (a position such as[2]is refused, since->reads it as[*]); nothing is ordered by default; and an ordering query at a path the index does not order is refused before any key loads, instead of silently matching no row.Changes
packages/eql/src/v3/json_tree/, rendered into EQL v4 only):s,c,pandoponly, so a document written by the earlier build of this unreleased type is refused.eql_v4.json_tree_subtree_at(doc, token, gates text[], budget DEFAULT 64)replaces the earlier(doc, token, selector, budget)form. It walks the gates and returns the reached node atd = 0with its descendants.json_tree_subtree(doc, token, budget)is now that function with no gates, and still returns the token's node's descendants fromd = 1.json_tree_childrenandjson_tree_child_counttake the same optionalgates.budgetentries, the walk returns the root entry alone atj = 0,d = 0, and the client opens it and selects the path itself.eql_v4_internal.json_tree_child_tokenis added;json_tree_unwrap,json_tree_xor16andjson_tree_fanoutare removed.src/json/):JsonEntryis{s, c, p, op}, andJsonDocumentcarries its projection, which the wire form requires asq.JsonSubtreeKeyholds a token andgates(); a position gate is#n:<selector>.JsonDocument::children()probes positions in turn.JsonOptionsgainsordered: BTreeSet<JsonPath>(so it isClone, no longerCopy),JsonOptions::ordering([..])parses paths into it, andJsonPath::coversis the match rule the walk and the query share. The data-plan wire is{"json": {"sealed_root": true, "ordered": ["$.age", "$.orders[*].total"]}}; the header does not record the set, since a stored leaf shows whether it has a term.JsonHeader::options()is removed andjson_subtree_keytakes&JsonOptions.TermError::JsonPathNotOrdered(codestack_encrypt::json_path_not_ordered) refusesLt,Lte,Gt,GteandBetweenat an unordered path when the query is awaited.src/plan/,src/target/): a typed plan had no place for index options, sinceencrypt_into::<JsonDocument>()reads the type's staticEncryptFrom.target::Layout<S>is a target's layout chosen by value, with the index it answers queries through;Json::new(options)implements it, andPlan::value().encrypt_as(layout)builds aValuePlan<_, Laid<X, T>>that seals under the options and resolves every query through the plan's own index, so a query does not name the options again.ErrorgainsFrom<JsonPathError>.src/v4/json_tree.rs):JsonTreeEntrydropsvs,o,w,nandg, and the typed document handsqto the engine.JsonTree::new(options)is thejson_treelayout under options (aLayout<Value>whose target isJsonTreeDocument); the type's ownEncryptFromis that layout under the defaults.docs/plans/2026-10-17-json-tree-index.md): the Phase 3b implementation note carries the measured outcome below, and a Phase 3c note records the opt-in and its measured sizes. The earlier sections, which still describe the wrapped token, group tags and stored ordinal, are kept as the record, with dated notes where they would otherwise mislead.@cipherstash/eqlminor.37e02ab42): every field the SQL compares, derives or decodes is padded standard base64 (s, thes/vspairs ofq,p,op, tokens and gates), which PostgreSQL'sencode/decodehandle natively and whichdecoderefuses unpadded;cis RFC 1924 base85 through thebase85-simd0.1.0 crate (no SQL function decodesc);hstays base64 because the SQL reads theivand the sealed-root flag from it.pis the first eight bytes of its hash: a lookup key within one document, and the AAD bindssandj, notp.eql_v4_internal.json_tree_ope_cllwis added because the sharedope_cllw(jsonb)of the scalar types reads hex.eql-bindingsgetsJsonTreeSelector,JsonTreeCiphertextandJsonTreeOrderingTermnewtypes so the v4 wire is documented apart from the v3 hex types. Design doc: a Phase 3d note.Verification
cargo nextest run -p stack-encrypt --all-features: 733 passed.cargo test -p eql-bindings --all-features json_tree: passed.mise run test:encryption:postgresagainst a fresh database with the v4 bundle built from this tree: all suites passed, including the json_tree oracle, which compares containment over the encrypted column withjsonb @>over the plaintexts (postgres_containment_over_the_encrypted_column_is_jsonb_containment,postgres_extracts_walks_and_orders_by_path).After the docs follow-up commit (
3860ecc3c):cargo clippy --all-features --all-targets -D warningsandcargo fmt --checkclean for stack-encrypt and eql-bindings,mise run test:schemapassed, andmise run docs:validate's coverage and required-tags checks passed. Itsdocumented-sqlstep needs the Docker database on port 7432 and could not connect locally; CI runs it.For the opt-in commit (
ba62e3fc5):cargo nextest run -p stack-encrypt --all-features737 passed (new:an_ordering_term_sits_only_at_an_ordered_path,a_laid_out_plan_opens_what_it_wrote, the refusal cases inan_accessor_asks_at_a_pathandan_accessor_is_refused_before_its_keyset_loads, theorderedwire round trip); the 46 doctests, including the newencrypt_asexample; rustdoc with-D warnings; clippy and fmt clean; the trybuild snapshotop_matches_on_integer.stderrregenerated, its only change being the newLaidimpl in rustc's help list.mise run test:encryption(eql-bindings and the encryption test crate, no database) passed. The json_tree PostgreSQL oracle (postgres_extracts_walks_and_orders_by_path,postgres_containment_over_the_encrypted_column_is_jsonb_containment) passed against a database carrying this branch's v4 bundle, with the corpus sealed under$.helloand$.numberordered; the SQL did not change in that commit. Docker was not running locally, so the fulltest:encryption:postgrestask was not re-run. cargo-mutants over the commit's diff found five survivors, all on the newLaidplan shape, pinned by2e405c462(ValuePlan::specsadded for the test).Benchmarks, cipherstash/benches@2d9d5f2 (branch
feat/json-tree-benches,report/JSON_TREE_COMPARISON.md, "Third measurement"). They ran at 10,000 and 100,000 rows on one machine (Apple M1 Max, PostgreSQL 17.10), over the same documents before and after, so every scenario returned the same rows. Query-only criterion medians in ms, before → after, on the nested corpus (about 213 entries a document):subtree/orders, default budgetsubtree_at/items($.orders[*].items), default budgetsubtree/orders, walked out (budget 0)subtree_at/items, walked out (budget 0)subtree/userpath_query/wildcardfield_order/nestedchildren/orderschild_count/orderschildrenandchild_countmoved only 6 to 9%. The report's explanation, not yet tested, is that a per-call fixed cost (detoasting the document and building the parent-tag map) dominates them, and this change does not touch it.Containment and field queries are unchanged within noise. The flat corpus read +7% to +13% on byte-identical operands, plans and indexes, and the native
jsonbcontrols over unchanged plaintext drifted +5% to +19% in the same run, so this reads as machine noise rather than the format.Sizes:
sv94,324 → 66,023 (−30%).pg_column_size: 81,264 → 62,143 bytes.pg_column_size2,452 → 2,250 bytes.Sizes with the ordering term opt-in (cipherstash/benches@cd9643d, "Fourth measurement"), each corpus re-sealed with the one path its scenarios order (
$.ageflat,$.company.salarynested), correctness 462 of 462 at both tiers:pg_column_size: 62,143 → 40,578 bytes (−35%); heap 621 → 408 MiB at 10,000 rows, 6,201 → 4,069 MiB at 100,000; GIN unchanged.pg_column_size: 2,250 → 1,596 bytes (−29%); heap 27.1 → 17.0 MiB at 10,000 rows. A flat document now sits under the TOAST threshold, so the flat heap moves into the main relation and the flat GIN grows 12% at 100,000 rows from the wider row spread. Query timings were not re-run.Correctness: a new benches task,
check:v4:json_tree <N>, ran at both tiers. It decrypted every walk form for 25 random rows and compared each with the plaintext, and it compared the encrypted id sets with nativejsonbfor 10 needles per predicate. 462 passed, 0 failed, at both tiers.For the encodings commit:
cargo nextest run -p stack-encrypt --all-features751 passed; doctests 46 passed; clippy and rustdoc (-D warnings) clean;mise run wasm:wasi-checkpassed (base85-simd compiles for wasm32-wasip1 on its scalar path);mise run test:encryption:postgrespassed against the v4 bundle built from the tree, including the json_tree oracle. Size (1,000 stored rows of each corpus from the Phase 3c tables, re-encoded field by field as the code now writes them, reloaded as jsonb, PostgreSQL 17.10,pg_column_sizeaveraged afterVACUUM FULL): nested 40,808 → 31,894 bytes (−22%), flat 1,596 → 1,398 bytes (−12%); wire characters nested 58,111 → 47,038, flat 1,419 → 1,224. The corpora were not re-ingested and no query re-timed for this commit.Related
37e02ab42(a further 22% off a nested document); the nonce lever is rejected (a caller-chosen nonce would weaken vitaminc's misuse resistance for a 7% saving; design doc Phase 3d), and->(item 1) is 15 to 19% faster only becausesvis shorter.Review notes
functions.sqlandjson_tree_subtree_at, thensubtree_key_forinstack-encrypt/src/json/derive.rs, which decides what the client derives and what it gates.DecryptInto<Value> for JsonSubtree: ad = 0row means "the node the gates reached", so its value is returned, rather than a container assembled from the rows. A sealed-root fallback also comes back atd = 0, atj = 0, and is told apart by that.cis authenticated, through its AAD (the associated data the AEAD binds), andqwas already what containment ran over. Thewmask's binding tocis gone. A swapped ciphertext no longer breaks the walk on the server, but it still fails when the client opens the entry, becausecis bound tosand its positionjin the document.What a query looks like to a user
Users never see selectors, tokens or gates. The SDK derives them from a path and a plaintext needle, and the only encrypted thing in the SQL text is one typed operand.
SQL, as the v4 bundle ships it, for a column
profileof typepublic.eql_v4_json_tree:Rust, as stack-encrypt has it on this branch:
TypeScript through Drizzle, as the EQL v3 surface reads today; a v4 column keeps it and gains the walk, which has no adapter shape yet:
Review fixes (2026-10-23)
The review is the comment above. The fixes are on this branch:
67b1f543e: the chain step binds the child's selector,H("t" ‖ t(parent) ‖ s ‖ LE32(o)), and a position gate is#n:<selector>. Without the selector, a position could reach an object member in rows whose node at the path is an object.eql_v4_internal.json_tree_child_tokennow takes(token, s, o).d72d9fe15: a path that names an array position is refused inJsonOptions::ordering, in the data plan'sordered, in an ordering query, and in a seal (TermError::JsonOrderingAtPosition).d267735af: ajson_wire_decodefuzz target, and the fuzz crate's refreshedCargo.lock.d3d7f764b,151fd5d7c,6d9ece2e7: the docs intypes.sql, both CHANGELOGs, and the design doc's eight-bytepbound.Two decisions from the owner followed the review, as separate commits:
f1f27f218: this is option (c) for the walk budget.JsonSubtreeKeycarries its path and builds the rows' wrapper:key.open(rows)in stack-encrypt,JsonTreeSubtree::from_key(&key, rows)in eql-bindings. From its gates the key decides whether the rows open below the path's node or at it, so a path-less open no longer compiles.63bec8f71: the json module's plaintext is held under vitaminc'sProtected. Node plaintexts are encoded straight intoProtected<Vec<u8>>. Opened keys, scalars and documents stay inProtected<PlainJson>until they are handed to the caller. The sealed-root fallback's document is wiped once the path is selected from it.Verification counts above predate these fixes. With them:
cargo nextest run -p stack-encrypt --all-features764 passed, andmise run test:encryption:postgrespassed against PostgreSQL 17.10, including the newpostgres_a_position_reaches_array_elements_only.https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu