Skip to content

test(stack-encrypt): check the json_tree leakage page against the scheme - #1362

Merged
coderdan merged 26 commits into
nextfrom
test/json-tree-leakage-profile
Oct 25, 2026
Merged

coderdan merged 26 commits into
nextfrom
test/json-tree-leakage-profile

Conversation

@coderdan

@coderdan coderdan commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Review pass (2026-10-25, head 294f03e50). After the port, a high-effort review found one real gap in the profile: L omitted the ordering terms' shared prefixes. A CLLW term is one byte per bit of the value's order encoding, so two terms agree up to the first differing bit, and the stored terms alone sort numbers into signs and binary orders of magnitude and show how far two strings' folded text agrees. The page said this in prose; the profile, simulator and distinguishers now carry it (Leakage::ordering_prefixes, ordering_term_prefixes, ordering_prefixes_hidden). The review also added cross-kind equality and term-field distinguishers, four missing mutants, a wider corpus (big integers, floats, folding strings, nulls and containers at ordered paths, 256 random columns), and fixed stale prose. Owner's decisions (head fd229008d). The page now states what a path ordered over numbers and strings shows (the order between the kinds depends on the column key and is not jsonb's; within each kind it is the values'), that a number's equality under its term is as a 64-bit float, and that the shared prefix is CLLW's known profile, with a prefix-hiding scheme planned for a later version. The profile states the first as a clause (Leakage::kind_order, held by the mutant mixed_kinds_jsonb_order) and the second as a reading (NumberOrder::Exact, held by given_integers_ordered_exactly). Totals now: 38 distinguishers, 50 mutants, 12 tests; nextest 889 passed. The counts below are as the port left them where not updated.

EQL v4's json_tree column type stores an encrypted JSON document so PostgreSQL can search it without the key. packages/eql/docs/reference/json-tree-leakage.md (the "leakage page") says in prose what the database learns from the stored rows and from each query. This PR turns the page into a test: if the scheme reveals something the page leaves out, a test fails and names what gave it away.

It writes the page's claims as code (a "profile"), builds a fake column and query log from those claims plus randomness alone (a "simulator"), and runs 38 checks an observer of the database could compute (the "distinguishers") on the fake column and on a real one sealed by Stack Encrypt. A calibration then drops each clause in turn (50 "mutant" profiles) and checks some distinguisher notices, so the test is known to be able to see what it claims to check.

This PR now targets #1363 and models its format. It was first written against the format before #1363 (one head token plus plaintext gates per read, member tokens carrying their ordinal, no self tag) and has been ported: every entry now carries a self tag x, a member's token has no ordinal, and a subtree read sends the tokens of exactly the nodes it names, with wildcards expanded on the client to a bound and a probe per expanded array. docs/plans/2026-10-25-json-tree-leakage-before-and-after.md compares the two profiles clause by clause.

Changes

  • packages/stack-encrypt/tests/json_leakage/ (entry tests/json_leakage.rs):
    • profile.rs: the leakage function over plaintext only. Every field is one clause, with the page sentence it encodes quoted in its doc comment. Every keyed value (selector, value selector, token, ordering term, self tag) is an opaque integer, equal exactly where the real bytes are. A read is its token list (each token named by its head and its member and element steps, in the order the client sends them), with the node each token names in each row, what it reaches below, whether it is a probe, and the budget.
    • simulator.rs: builds wire-shaped rows and query terms from the profile output and a seeded RNG. It never sees plaintext or keys. It writes the real self tag on each node a sent token names and chains the parent and self tags below it with the real hash, as far as the profile says the token reaches; everywhere else the tags are random.
    • chain.rs: the public hash functions (parent tag, self tag, member and element rules, and the database's test of a hit against the two rules).
    • real.rs and observe.rs: the real side through the public API (json_subtree_key_with_bound), and a parser of both worlds' wire JSON into one shape.
    • distinguishers.rs: 38 exact features (35 at the port; the review pass added ordering_term_prefixes, cross_kind_equalities and term_fields). Of the 27 the profile had, 26 are kept and gated_walk_results becomes subtree_results (with the sealed-root budget fallback). Eight are new: stored_lengths, self_tag_equalities, token_counts, bound_recovery, token_hits, wildcard_positions, hit_rules, ancestor_links. One feature needs a probability bound (member_ordinals_follow_key_order, 2⁻⁴⁰), written beside it.
    • mutants.rs: 50 mutant profiles (44 at the port, 5 added by the review pass, 1 for the owner's mixed-kinds decision; 34 before: 28 kept, 4 redefined for token lists, the 2 gate mutants removed, 12 added, among them the pre-feat(stack-encrypt, eql): json_tree tokens derivable from the path, a self tag, client-side wildcards #1363 top-level token and member rule).
    • corpus.rs: four handcrafted columns and a proptest generator of random columns, now with reads at bounds the rows overflow, two-level wildcards, $ under both root options, a read past its budget, and strings that fold.
    • calibration.rs: the tests. mod.rs holds the README-style documentation.
  • packages/eql/docs/reference/json-tree-leakage.md: one sentence added (finding 1 below).
  • docs/plans/2026-10-25-json-tree-leakage-before-and-after.md: the comparison of the two profiles.
  • packages/stack-encrypt/Cargo.toml: proptest as a dev-dependency. feat(stack-encrypt, eql): json_tree tokens derivable from the path, a self tag, client-side wildcards #1363 also takes it; the merge keeps one line with one comment.

Verification

All run from the worktree root on 09a9dfc0b (the merge of #1363 at 997c64b1e plus the port). #1363 then moved to b992e787e, a one-line correction to the leakage page's v3 column; after merging it (561f273be) the leakage suite (9 passed) and Biome (992 files, no errors) were re-run, and nothing else, since it changes no code.

  • mise x --env test -- cargo nextest run -p stack-encrypt --all-features: Summary [ 133.419s] 885 tests run: 885 passed (1 slow), 1 skipped. That is feat(stack-encrypt, eql): json_tree tokens derivable from the path, a self tag, client-side wildcards #1363's 876 and this PR's 9; the slow one is the ui trybuild test.
  • mise x --env test -- cargo nextest run -p stack-encrypt --all-features --test json_leakage: 9 passed. The calibration is the slowest test, 9.6 s alone (12.0 s in the full parallel run); every other leakage test is under 0.5 s.
  • cargo test -p stack-encrypt --doc --all-features: test result: ok. 50 passed; 0 failed.
  • cargo clippy -p stack-encrypt --all-features --all-targets -- -D warnings: clean (exit 0).
  • cargo fmt --all --check: exit 0 at the root and exit 0 inside packages/eql.
  • RUSTDOCFLAGS=-D warnings cargo doc -p stack-encrypt --all-features --no-deps: clean (exit 0).
  • npx -y @biomejs/biome@2.5.9 check --diagnostic-level=error: Checked 992 files, no errors. No JSON fixture was added.
  • Not run: pnpm run test:scripts (nothing under scripts/ or .cargo/mutants.toml changed); cargo mutants (nothing under src/ changed).
  • No changeset: test and documentation only. No skill mentions json_tree.
  • Deterministic: the simulator, the mutants and the proptest runner draw from one fixed seed; the real side's random IVs and nonces reach a feature only through equality patterns, which change only on a random collision of eight-byte tags, below about 10^-13 at these sizes.

Calibration: which check catches each dropped clause

All 50 mutants are caught; none is listed as unnoticed. The positive control passes on the five handcrafted columns and on 256 random ones (48 at the port). The full table prints with --no-capture on every_clause_is_load_bearing; the comparison document lists every mutant and the clause it drops.

Findings

Against the page as #1363 published it:

  1. A walk tells members from elements. Nothing stored says whether a node is an object member or an array element (the design's "no kind bit"). But to derive each reached node's token, the database tests its self tag against the member rule and then the element rule, so below any token it is sent it learns which each node is, and each element's index. The page now says so; the_hit_rule_separates_it_from_the_real_scheme shows the sentence is needed (without it, hit_rules, token_reach, subtree_results and token_derivability separate the worlds).

Against the SQL's own comment, not the page (reported, not changed here):

  1. The server can tell a probe from the other tokens. json_tree_subtree's comment in packages/eql/src/v3/json_tree/functions.sql says it cannot. It cannot from a token's bytes, but the probe is always the last token at its level, the count gives the bound away (65 tokens is one level at N = 64 and nothing else), and a probe that finds something finds the element, whose selector differs from the leaf the other tokens find. a_read_gives_away_its_bound_and_its_probes.

Against the profile itself:

  1. Strings order folded. The first profile ordered strings by their bytes; the code (and the page) fold case and accents. The old corpus held only lower-case ASCII, where the two agree. The corpus now holds "Carol"/"carol" and "ál"/"al", and given_strings_ordered_by_their_bytes holds the sentence.

The three findings against the page before #1363 (an ordering term's length, equal values at two ordered paths, UTF-8 key order) are on the page now; each keeps a mutant or a reading the calibration catches. The "This page is checked by …" pointer is still not added to the page; that waits for finding 2 to be settled on #1363.

What the test shows #1363 changed, in short: at rest, only the self tag is new, and it reveals nothing (unlinkable across rows, distinct within one, a fixed 19 characters per entry). Under a query, the database now learns the token count (and with it the bound), which positions below the bound hold the rest of a wildcard path, which arrays are longer than the bound, the subtree of an element a probe finds, and member-or-element for every reached node. It no longer receives any ancestor's token (#1360: reading $.profile.address.city used to hand over $.profile's), the gates with their plaintext positions and selectors, or the head token. The stored order still reveals each row's shape (#1357).

Related

Refs #1357, #1360, #1355, #1356, #1358, #1361, #1363.

Review notes

https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu

The leakage page for EQL v4's json_tree index states in prose what a
database holding the column learns at rest and from each query. Nothing
held the code to it: a leak the page leaves out would pass every test.

This adds an executable form of the page. `profile.rs` writes each of its
sentences as a clause of a leakage function over the plaintext alone, with
every keyed value replaced by an opaque name that is equal exactly where
the real bytes are. A simulator builds a wire-shaped column and query
transcript from that function's output and seeded randomness only, chaining
parent tags with the real hash below every token the profile says a query
reaches. Twenty-seven distinguishers, each an exact feature an observer of
the column could compute (including the database's own walk of the parent
tags from a token it was sent), compare the simulated column with one the
crate actually sealed under the fake key source.

Three kinds of check make that mean something: the full profile is
indistinguishable from the real scheme on handcrafted and 48 random
columns; each of 34 mutant profiles, one clause dropped, is told apart by
at least one distinguisher; and the page as written (without the one
clause it lacks) is told apart by exactly the distinguisher that sees the
missing clause.

What it found: an ordering term's length (65 bytes for a number,
8 * (len + 1) + 1 for a string) is not on the page; the page's summary
"not revealed at rest: whether two different paths hold the same value"
contradicts its own op row, since equal values at two ordered paths have
equal terms; and "sorted order of their key names" is UTF-8 byte order,
not jsonb's. The two issues it was built to check (#1357, the stored order
reveals shape; #1360, a subtree read's token reaches its whole top-level
field) come out as the page states them. The page is left unchanged.

The profile and the real-side adapter are expected to change with the
2026-10-24 design change (ordinal-free member tokens, a self tag, no
gates); the module docs say how.

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

changeset-bot Bot commented Oct 24, 2026 •

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 7f7f5ce

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@coderabbitai

coderabbitai Bot commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Organization UI; Enforced overrides: Organization
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: df6ffa30-54b5-4478-9da8-2a50cdcc07d6

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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

@github-actions

github-actions Bot commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

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

No mutants were generated for the changed lines.

…e-leakage-profile

Brings in the review fixes on #1358: a walk cut short is JsonWrongEntry,
a walk with no gates refuses an array that does not start at element 0,
and the leakage page says what that walk still does not check. No
conflicts; the leakage profile's tests pass unchanged.

Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
…e-leakage-profile

Brings in next after #1345, through #1358's merge of it.

Conflict: packages/stack-encrypt/Cargo.toml only. Both sides added the same
proptest dev-dependency line (#1345 for the generated containment property,
this branch for tests/json_leakage) with different comments. Kept the one
line and one comment naming both uses.

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

Brings in #1363's format (a self tag on every entry, ordinal-free member
tokens, client-side wildcard expansion with a token list, no gates) so the
leakage profile can be ported to it. The only conflict was the comment on
stack-encrypt's proptest dev-dependency, which both branches use; one line,
one comment naming both users.

Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
@coderdan
coderdan changed the base branch from fix/json-tree-extracted-entry-check to feat/json-tree-derivable-tokens October 25, 2026 07:38
…format

The profile was written against entries {s, c, p?, op?}, member tokens
carrying their ordinal, and a subtree read that sent one head token plus
plaintext gates. #1363 adds a self tag x to every entry, drops the
ordinal from member tokens, and has the client send the tokens of
exactly the nodes a read names, wildcards expanded to a bound with a
probe per expanded array. Run unchanged against it, the module did not
compile (a key now holds a list of tokens), and with that patched the
positive control failed on wire_fields, token_reach, token_derivability,
gated_walk_results and transcripts while every mutant still read as
caught, which is what a broken positive control looks like.

L now states the self tag (one fresh name per entry, so unlinkable
across rows and distinct within one), each read's token list with the
node each token names in each row and what it reaches below, the probe
flag, the budget, and the member or element rule each reached node's
token takes. The simulator programs x and p with the real hash only
where a sent token links them. Of the 27 distinguishers 26 are kept and
gated_walk_results becomes subtree_results (with the sealed-root budget
fallback); eight are new (stored_lengths, self_tag_equalities,
token_counts, bound_recovery, token_hits, wildcard_positions, hit_rules,
ancestor_links). The mutants go from 34 to 44: the two gate mutants are
gone, four are redefined for token lists, and twelve are new, among them
the pre-#1363 top-level token and member rule. All 44 are caught, the
positive control passes on the handcrafted columns and 48 random ones,
and string ordering terms are now modelled folded, as the code and the
page have them.

One finding against the page as #1363 published it: the database's
member-rule-then-element-rule test on every reached node tells it
whether each node below a token is an object member or an array
element, which nothing stored says. The page now says so, and
the_hit_rule_separates_it_from_the_real_scheme keeps the sentence
load-bearing. The SQL comment that the server cannot tell a probe from
any other token is not true of its place in the list or of what it
finds; a_read_gives_away_its_bound_and_its_probes shows it.

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

token_derivability derived member and element children of every token a
read sends, which for a wildcard at the default bound is 65 tokens, most
of them naming positions no row has, and their children name nothing
either. Deriving only from tokens that find a node in some row halves the
calibration's time (14.8 s to 9.6 s) with the same 44 of 44 mutants
caught by the same distinguishers.

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

The leakage profile in #1362 was written against the format before #1363
and has been ported to it. This records, from the code at 9a7ca37 and
at the port, every clause, distinguisher and mutant of the two profiles
side by side; the failures the old test showed on first meeting the new
format and what each one taught; the calibration before and after; and
what the database learns now that it did not (the self tag, the token
count and the bound it gives away, the probes, the member-or-element rule
under a token) and what it no longer learns (an ancestor's capability in
every row, the gates with their plaintext positions and selectors, the
head token).

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

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

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

coderdan commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor Author

Read the tables as a measure of the profile, not of the scheme. More clauses, distinguishers and mutants mean more of what the database sees is written down and tested; they do not mean the format leaks more. Net, the format leaks less, and where it matters: a read no longer sends any token above the node it asks for (the old deep read sent an ancestor's token, a capability over that whole field in every row for the life of the column), and the gates, which spelled each step and position out in the clear, are gone. What is new is small: the self tag x, a fresh unlinkable name per entry that at rest adds nothing beyond the entry count; the member-or-element rule and element index at nodes a walk reaches, which the gated walk's ordinals and #n: gates already disclosed for a node with siblings in some row, and which is new for a node that is an only child in every row; the page had not stated it and now does; and the token count, bound and probes of a wildcard read, a smaller form of what the plaintext gates showed. The closing section of the write-up sets this out in full.

Ported this profile from the json_tree format it was written against to #1363's, and retargeted the PR onto #1363's branch. The full write-up, with the first run's failures and what the database learns now and no longer learns, is docs/plans/2026-10-25-json-tree-leakage-before-and-after.md; these are its "what changed" tables.

The clauses, before and after

The "before" column is the profile at 9a7ca372c; the "after" column is the port at 839c0f6fd (with fa26cb540, which only speeds one distinguisher up). A clause is a field of the profile's output, or a switch in Clauses.

"Not measured" in the before column means the old profile had no such clause, for one of two reasons, and the row says which: the old format had no such thing to state (x, the hit rule as a rule, probes), or the old format disclosed it and the old test did not model it, so no distinguisher could see it (string folding, whose corpus was lower-case ASCII, and the budget, which the old json_tree_subtree also had). The second kind is the method's limit: the calibration proves the page accounts for everything the distinguishers measure, not everything the database sees, and a clause both omit is invisible to it. Each new distinguisher is a way of finding one.

clause before (9a7ca372c) after (port) what changed and why
EntryLeakage::selector which paths, how many nodes at each, equal across rows, elements share their array's same unchanged
EntryLeakage::parent each node's parent, used to place the chain same unchanged
EntryLeakage::step Root, Member, Element(i): decides the ordinal, and in the old chain the token too Root, Member, Element(i): decides the ordinal (the o of the parent tag) only the token rule moved to rule
EntryLeakage::rule not measured (the old format had no such thing) Member or Element for every entry that chains, None for the root and sealed-root top-level entries new: the database tests each reached node against the member rule then the element rule, and learns which. Beyond the page as #1363 published it; this branch adds it
EntryLeakage::head the head token an entry is (top-level fields under the sealed root, the root otherwise) removed a head is now named only inside a token name (TokenName::head); nothing at rest refers to it
EntryLeakage::has_parent_tag absent on the root and, under the sealed root, the top-level fields same unchanged
EntryLeakage::self_tag not measured (the old format had no such thing) a fresh opaque name per entry new: x on every entry. A fresh name per entry states that it is distinct within a row and unlinkable across rows; where a sent token links an entry the simulator writes its real self tag instead
EntryLeakage::ciphertext_len AEAD overhead (29 bytes) + kind, child count, label, scalar same unchanged: x sits beside the ciphertext. It adds a fixed 12 base64 characters per entry, 19 with its key in compact JSON (,"x":"…"), 21 in PostgreSQL's jsonb text (, "x": "…"); #1363 measured +9.8% on a nested document and +9.4% on a flat one
EntryLeakage::ordering / OrderingLeak::class equal values share a class, ordered by ordering_ranks; strings ordered by their bytes equal values share a class; strings ordered by their folded encoding (Unicode 16 NFC, case and accents folded) the code always folded; the old corpus held only lower-case ASCII, where the two agree. The page now says "folded", and the corpus holds "Carol"/"carol" and "ál"/"al"
OrderingLeak::len beyond the page: 65 bytes for a number, 8 × (len + 1) + 1 for a string on the page: 65 for a number, 8 × (n + 1) + 1 for a string of folded length n #1363's page states it; it now counts the folded length
Clauses::ordering_term_length on/off switch for the beyond-the-page length removed on the page now; its mutant (ordering_term_length_constant) still checks it
Clauses::key_order a reading: UTF-8 byte order vs jsonb's length-first order same the page now says "byte order of their key names' UTF-8 encodings"; the reading test stays to keep that sentence load-bearing
Clauses::string_order not measured (the old format had it; the old profile did not model it) a reading: folded vs raw bytes new, with a test that the raw-bytes reading is told apart
Clauses::hit_rule not measured (the old format had no such thing) on/off switch for the member-or-element rule new, the one clause beyond the page as #1363 published it
RowLeakage::{sealed_root, keyset, data_key, entries, projection} header flag, keyset, data key unique to the row, entries in depth-first order with members in UTF-8 key order, projection q same unchanged. data_key also names the iv that salts every p and x in the row
GroupLeakage::{pairs, elements} {s, vs} pairs in walk order, nested by array element same unchanged
QueryLeakage::Extract the selector of the path asked same unchanged
QueryLeakage::Subtree token: TokenName{head, positions} (one head token, chained through leading array positions), gates (Selector(s) or Slot(n, s), positions in the clear), reach (every descendant of the token's node, in every row), named reads: Vec<ReadLeak> and budget rewritten. See the next three rows
ReadLeak::token (one per read, see above) TokenName{head, steps}, each step Member(s) or Element(s, i): one per concrete path, in the order the client sends them the list is what the database receives; its length gives the bound away, its order places each token
ReadLeak::probe not measured (the old format had no such thing) whether the token is a probe new; the database is not told, and can tell (see findings)
ReadLeak::hits / ReadLeak::reach reach: descendants of the head's node; named: what the gates reached hits: the entry the token names in each row, if any; reach: every descendant of it that chains from it, in each row a token now names exactly its node; nothing above it. Under the sealed root the root's token reaches nothing below the root
QueryLeakage::Subtree::budget not measured (the old read had a budget too; the old profile did not model it) the budget argument new; the fallback to the root entry is computed from it and the walk, so the database learns nothing new from it
QueryLeakage::{Contains, Equals, Order} needle projection with alt; equality needle; selector and the bound's ordering term same unchanged
Leakage::{key_tag_len, ordering_ranks} the key tag's length, the order of every ordering class same unchanged

The distinguishers, before and after

27 before, 35 after: 26 kept unchanged in meaning, 1 renamed, 1 removed (by the rename), 8 added. The walk-based ones were rewritten for the new chain, but what each measures is the same.

distinguisher status what it measures
wire_fields kept the wire keys of every row and entry (now {c, op?, p?, s, x})
entry_counts kept entries per row
parent_tag_presence kept which entries carry p
ordering_term_presence kept which entries carry op
ciphertext_lengths kept every ciphertext's length, by position
ciphertext_length_multisets kept the same, order removed
ciphertext_framing kept the ciphertexts' version bytes, and how many pairs are equal (a deterministic cipher)
selector_pattern_within_rows kept which entries of a row share a selector
selector_pattern_across_rows kept which entries of the column share a selector
parent_tag_equalities kept pairs of entries sharing a p (expected none)
headers kept header fields, and equality of keyset, iv and key tag across rows
projections kept each row's q: grouping, pair order, selector and value-selector equality
value_selector_partitions kept per path, the rows grouped by value
chain_head_runs kept the runs after each entry with no p: each top-level field's subtree size
selector_precedence kept which of two selectors comes first across rows: sibling key order
depth_recovery kept an attack that reconstructs every row's depth sequence from the stored order alone
token_reach kept per read, row and token: the node it finds and everything its walk links, with parents and depths
ordinal_patterns kept elements' ordinals in position order; members ranked consistently under one token
member_ordinals_follow_key_order kept whether members' ordinals follow key order, reported only below a 2⁻⁴⁰ chance bound
token_derivability kept which reads' tokens the database can derive from which others' (now by the member rule and by the element rule at an index below 4, from the tokens that find a node in some row)
transcripts kept every query term with its tags named: determinism, shared selectors, needles; a read is now its token list and budget instead of a token and gates
extraction_matches kept which entries each -> selector matches
containment_matches kept which rows each needle matches
ordering_term_order kept every ordering term's rank, stored and sent
ordering_term_lengths kept every ordering term's length
ordering_matches kept which entries each ordering bound holds for
gated_walk_results → subtree_results renamed what a read returns from each row: before, the walk through the gates; after, every token's node and subtree, or the root entry alone past the budget under the sealed root
stored_lengths added each row's and each entry's length in characters: sees x's fixed 19 characters
self_tag_equalities added pairs of entries whose x equals another x or a p anywhere (expected none)
token_counts added how many tokens each read sends
bound_recovery added the (levels, N) an observer recovers from the count: 65 is one level at 64 and nothing else; 4,161 is two levels at 64 or one at 4,160
token_hits added per read and row, which tokens (by place in the list) find a node, and where
wildcard_positions added for a one-level wildcard read: per row, which positions below N hold the rest of the path, whether the probe found an element, and whether the probe's find carries a selector the others do not
hit_rules added for every node a walk reaches, which rule its x matched: member, element, or neither
ancestor_links added whether the database can link each node a read names to its parent with any token it holds: the "no ancestor capability" check

The mutants, before and after

34 before, 44 after: 28 kept unchanged, 4 redefined for token lists, 2 removed, 12 added.

mutant status clause it drops
selectors_all_distinct, selectors_per_row_only, positions_in_selectors kept selector equality; column-wide; array positions folded
entry_count_padded kept the number of entries
order_random_permutation, members_unsorted kept the stored order; members in key order
parent_tag_on_every_entry, parent_tag_on_no_entry kept which entries lack p
ciphertext_length_bucketed, sealed_root_length_hidden kept ciphertext length
value_selectors_all_distinct, value_selectors_path_only kept value-selector equality
projection_flat, projection_shuffled kept element grouping and pair order in q
ordering_terms_absent, ordering_terms_unordered, ordering_terms_all_distinct, ordering_term_length_constant, ordering_terms_per_path kept op presence, order, equality, length, equality across paths
member_ranks_follow_key_order kept a member's ordinal is a keyed rank
sealed_flag_flipped, data_key_shared, keyset_per_row kept the header
extraction_selector_fresh, queries_nondeterministic kept -> sends the stored selector; queries are deterministic
needle_flat, needle_alt_dropped, bound_rank_random kept needle grouping, alt, the ordering bound's rank
tokens_per_row redefined before: head tokens per row; after: a token finds its node in one row only
parent_tags_unchained → tokens_find_nothing redefined a token finds and links nothing
token_reaches_named_nodes_only → token_reaches_its_node_only redefined before: the head token reaches only the path asked (the narrower reach #1360 wanted); after: a token finds its node and links nothing below it
position_tokens_unchained → tokens_not_chained redefined a token with a step below its head is unrelated to its head's
slot_positions_hidden, gates_dropped removed gates no longer exist
self_tags_absent, self_tags_linkable_across_rows, self_tags_per_selector added x on every entry; salted by the row; distinct per entry
hit_rule_uniform added the member-or-element rule (the page fix)
probe_reaches_nothing added a probe that finds an element links its subtree (the page's exception)
ancestor_token_sent added no ancestor's token: the pre-#1363 read, which also sent the top-level field's token
member_tokens_carry_ordinal added a member's token has no ordinal: the pre-#1363 member rule, under which reads through a key step find nothing
element_tokens_without_index added an element's token carries its index
probes_dropped, token_list_shuffled, token_list_padded added a probe per expanded array; the list's order; the list's length
budget_not_sent added the budget, in the clear

Calibration, before and after

before (the profile at 9a7ca372c, on the old format) first run on the new format (step 1) after the port (839c0f6fd, fa26cb540)
tests 7, all passing, about 3 s (the PR measured 6.3 s for the binary) did not compile; with throwaway patches 1 of 7 passing 9, all passing, 11.3 s for the binary: the calibration 9.6 s, every other test under 0.5 s
positive control, handcrafted columns 4 columns, no distinguisher separates fails: 5 distinguishers separate 4 columns, no distinguisher separates
positive control, random columns 48 cases pass fails on the first case (wire_fields, transcripts) 48 cases pass
mutants caught 34 of 34 34 of 34, meaningless (the control failed) 44 of 44
page-as-written test exactly ordering_term_lengths separates 6 separate renamed the_hit_rule_separates_it_from_the_real_scheme: exactly hit_rules, subtree_results, token_derivability, token_reach
member_ordinals_follow_key_order decided on the wide-objects column, 2⁻⁴⁰ bound not reached decided on the wide-objects column (Some(false): members' ordinals do not follow key order), same bound

https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu

coderdan added a commit that referenced this pull request Oct 25, 2026
…lement

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

Refs #1362, #1363

Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
The note's tables grow from the old profile to the new one, and read
as a worsening to a reader who stops there; the owner read them that
way. The conclusion was in the last two sections. It now opens the note:
the growth measures the test, and the format discloses less, with the
ancestor capability and the gates gone and the additions small.

Refs #1362

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

An old-profile clause marked (none) was either a thing the old format
did not have, or a thing it had that the old profile failed to model;
the table did not say which, and the owner asked. Each row now says,
and a paragraph above the table names the method's limit: a clause the
profile and the distinguishers both omit is invisible to the calibration.

Refs #1362

Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
…(none)'

'(none)' read as 'no leakage'. The cell says what is true of the old
test: it did not measure the clause, with the reason beside it.

Refs #1362

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

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

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

Copy link
Copy Markdown
Contributor Author

Review at high effort: findings

Reviewed d6f02a413 merged with #1363 at ba9a87f28. The merge was not clean: packages/eql/docs/reference/json-tree-leakage.md conflicted on the subtree-read bullets. I kept #1363's probe-exception sentence on the wildcard bullet and this branch's hit-rule sentence on the token bullet.

What I checked: the real scheme (derive.rs: walk, encode_node, shuffled_ordinals, subtree_key_for, Expansion, leaf_op, seal, place; the SQL json_tree_subtree, json_tree_hit_token, json_tree_index), and every module of tests/json_leakage/. I also read cllw_ore::encrypt_ope_bits and vitaminc_ore's order spaces, and sealed a probe column of numbers and strings at one ordered path to measure the terms. Known non-findings (ADR 0010, the owner's #1363 decisions, integrity, the "not measured" wording, the 10 s runtime) are left out.

The simulator does not cheat. simulator.rs reads only Leakage: the selector, step, rule, parent, hits, reach and ranks. It reads no plaintext, no key and no real document. The distinguishers I did not list are exact functions of the observed world. The 2⁻⁴⁰ bound in member_ordinals_follow_key_order is derived correctly. A member's rank is a keyed function of its parent's token, so the widest object under each distinct sent token is in key order with probability 1/k!, the tokens are independent inputs, and the product over them bounds every row together.

  1. [High] L leaves out the ordering terms' shared prefixes, which are exact and which the page states. mod.rs, "Out of reach", said the prefix two terms share "is not an exact function of the plaintexts". That is true of the byte prefix and false of the bit prefix. cllw_ore::encrypt_ope_bits writes a carry byte, then one byte per plaintext bit: PRF(bits before it) + 128·bit + a carry of 0 or 1 from the right. So two terms differ by 0 or ±1 (mod 256) at every byte before the first bit their encodings differ in, and by 127 to 129 at that byte. The database reads the exact common-prefix length of every pair of values from the stored terms alone. For a number (the f64 order encoding), that sorts every number at an ordered path into sign and binary exponent, with no bound sent. No distinguisher measured it, and the old simulator's terms (0, rank big-endian, random bytes) had no prefix structure, so a profile that omitted it passed. Confirmed: I read the code and sealed a probe column. The page's sentence also said "leading bytes" where the leak is bits. Fix: a new clause Leakage::ordering_prefixes (the adjacent-rank bit LCPs, computed from vitaminc_ore's encodings), a simulator that builds CLLW-shaped terms over codes with exactly those prefixes, the distinguisher ordering_term_prefixes, the mutant ordering_prefixes_hidden (caught only by the new distinguisher), and the page sentence reworded to bits and to what they mean for a number and for a string.

  2. [Medium] No distinguisher compares values across kinds. transcripts, projections and the others name selectors, value selectors, tokens, keysets and ivs in separate Canons, and self_tag_equalities compares x only with x and p. Suppose a token equalled the selector of its path, a value selector equalled a selector, or a self tag equalled the front of a token. The database would see it, and every feature would be unchanged. Confirmed by reading. The real scheme separates the domains, so this is a gap in the method, not a leak. Fix: a distinguisher cross_kind_equalities over every 16-byte value (s, vs, tokens, keyset, iv) and every 8-byte tag against the others and against the front of every 16-byte value, plus a harness test that it fires on a world where one token is a stored selector.

  3. [Medium] A path ordered over both numbers and strings is refused, and nothing says what it shows. Ids::ordering_ranks asserts a world orders one kind, the generator forces numbers under n, and the page is silent. A JSON path holding a number in one row and a string in another is an ordinary column. Confirmed by measuring. vitaminc_ore's salt names the domain, so number terms and string terms are under different keys. How a number's term compares with a string's is set by the column key and the two values' leading bits, not by any order between them: in the column I measured, every string sorted below every number. A bound of either kind compares with stored terms of both. Fix: listed under "Out of reach" in mod.rs with the measurement. The page's wording is the owner's to decide.

  4. [Low] The wire field names of query terms and of q are not checked. wire_fields covers row and entry keys only. observe::query reads t, budget, sv, el, alt, s and op, and silently drops anything else, so a future term that carried, say, its bound or path would leave every feature unchanged. Confirmed by reading. Fix: a distinguisher term_fields over the key paths of every q and every query term.

  5. [Low] token_derivability tries the element rule only at indices below 4. If a read's tokens at index 4 or more find nothing ($.tags[10] beside a read of $.tags), the database can still link them by the element rule, and a profile that unchained them would pass this feature. Confirmed by reading. Fix: also try the element rule up to the longest token list sent, under the selectors of the children each found token's walks reach. That is the only selector an element under it can carry, so the check stays cheap.

  6. [Low] Four clauses have no mutant: Leakage::key_tag_len, QueryLeakage::Order's selector, an element's ordinal being its index (Step::Element; element_tokens_without_index changes token names, not the stored chain), and EntryLeakage::parent. Confirmed. Fix: a mutant for each.

  7. [Low] The corpus does not exercise several clauses the positive control should hold. No ordered path holds null, a boolean or a container, so op existing "only on number and string leaves" is never tested. No ordered number is past 2⁵³: 9007199254740992 and 9007199254740993 carry equal terms and different value selectors (measured), which the profile states (OrderKey::Number over the f64) and nothing checks. The random columns hold only lower-case ASCII strings and never order strings, so folding and multi-byte lengths appear only in the handcrafted columns. 48 random cases take 0.5 s. Fix: widen the generator (big integers, floats, strings that fold, non-scalars kept at ordered paths, random columns that order strings) and raise the case count. The page could also say that a number's equality under its term is equality as an f64. I left that to the owner.

  8. [Low] The write-up's net paragraph, and the PR comment that copies it, overstate what was already disclosed. They say the member-or-element rule "was already disclosed by the gated walk's ordinals and #n: gates". Before feat(stack-encrypt, eql): json_tree tokens derivable from the path, a self tag, client-side wildcards #1363, every child's token took one rule. An only child's ordinal is 0 whether it is a member or an element, and a gate names only the path asked. So the kind of a node that is an only child in every row was not disclosed. The same note's "What the port found" says exactly this ("for a node that is an only child in every row, it does"). Confirmed against the old format as the note describes it. The net, less leakage where it matters, still holds, because no token above the node read is sent. That argument rests on the ancestor token and the gates, not on this sentence. Fix: correct the sentence in the note.

  9. [Low] Stale after the merge. feat(stack-encrypt, eql): json_tree tokens derivable from the path, a self tag, client-side wildcards #1363 (b2a20abb9) corrected json_tree_subtree's comment: it now says the server "can pick it out". But mod.rs finding 2, the doc comment of a_read_gives_away_its_bound_and_its_probes and the note's "One inaccuracy in a SQL comment, reported, not changed" still quote the old text. Confirmed. Fix: say it was reported and feat(stack-encrypt, eql): json_tree tokens derivable from the path, a self tag, client-side wildcards #1363 corrected it.

  10. [Nit] Some assert_eq! calls in a_read_gives_away_its_bound_and_its_probes have no message (the rust-testing convention), and mutants.rs repeats its "At rest" banner. Fix both.

The hit-rule sentence the port added to the page is exact. It covers every node beneath a sent token, not the named node itself, which is found by x. The element index is the ordinal it is probed at, and the member rule is tested first, as json_tree_hit_token does.

I'll fix 1 to 10 on this branch, except the page wording in 3 and 7, which I'll leave for the owner.

https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu

…leakage profile

A CLLW OPE term is a carry byte, then one byte per plaintext bit: the
PRF of the bits before it, plus the bit at weight 128, plus a carry of
zero or one from the right. Two terms therefore agree to within one at
every byte before the first bit their encodings differ in, and differ
by 127 to 129 there. The database reads the exact common-prefix length
of every pair of values off the stored terms; for numbers that is their
signs and binary exponents.

The page states the disclosure ("the term also shows how long that
common prefix is"), but the profile listed it as out of reach on the
ground that it was not an exact function of the plaintexts. That holds
for the byte prefix, which carries move, and not for the bit prefix. Its
simulator drew terms with no prefix structure at all, so a profile that
left it out passed.

The profile now states the bit LCP of every two classes adjacent in
rank (Leakage::ordering_prefixes, from vitaminc_ore's encodings). The
simulator builds CLLW-shaped terms under its own key over codes that
have exactly those prefixes, and ordering_term_prefixes reads them. The
mutant ordering_prefixes_hidden is caught by that distinguisher alone.
The page's sentence says bits, and what they mean for a number and a
string.

Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
Every feature named selectors, value selectors, tokens, keysets and ivs
in a space of its own, and self_tag_equalities compared x only with x
and p. A token equal to its path's selector, a value selector equal to a
selector, or a self tag equal to the front of a token would be visible to
the database, and no feature would change.

cross_kind_equalities reports every pair of kinds that share a value: the
sixteen-byte values with each other, and the eight-byte tags against the
first eight bytes of each. It is empty on the real scheme and in the
simulator. No mutant can state such a world, since the profile names each
kind on its own, so a harness test shows it fires: replacing one read's
token with a stored selector gives exactly ("s", "token").

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

wire_fields covered the keys of rows and entries only. The parser reads
t, budget, sv, el, alt, s and op out of a query term and the projection,
and drops anything else unread, so a term that also carried its bound or
its path would have left every feature unchanged.

term_fields reports every key path inside each row's q and each query
term as sent. needle_alt_dropped, needle_flat and projection_flat are now
caught by it as well as by their own features.

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

The database derives an element's token from its array's at any index.
The feature tried indices 0 to 3 only, so a read whose tokens all sat at
index 4 or more and found nothing (`$.tags[10]` beside `$.tags`, in a
column whose arrays are shorter) read as unlinked. A profile that
unchained such a token would have passed it.

It now also tries every index up to the longest token list sent or 64,
whichever is more, under the selector of the children each found
token's walks reach: the one selector an element under it can carry, so
the check stays cheap. The ordered-strings column reads `$.tags[10]`, and
a harness test holds that the real scheme's tokens link it to `$.tags`.

Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
…s a mutant each

Four clauses had no mutant, so nothing showed the calibration could see
them: the key tag's length, an ordering bound's selector, an element's
ordinal being its index (element_tokens_without_index changes the names
of tokens sent, not the stored chain), and each node's parent.

key_tag_shortened is caught by headers and stored_lengths,
order_selector_fresh by transcripts and ordering_matches,
element_ordinals_reversed by ordinal_patterns among others, and
parents_lifted by token_reach and parent_tag_equalities among others.
Also drops mutants.rs's repeated "At rest" banner.

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

Several clauses the positive control should hold were never tested.
No ordered path held a null, a boolean or a container, so "op only on
number and string leaves" was never checked. No ordered number was past
2^53, where 9007199254740992 and 9007199254740993 share a term under two
value selectors. The random columns held only lower-case ASCII strings
and never ordered strings, so folding and multi-byte lengths showed up
in one handcrafted column alone.

The random generator now holds big integers, floats, and strings that
fold or are wider in bytes than in characters. It keeps non-scalars at
the ordered paths, and orders numbers or strings by a coin toss (one
kind per world, as the profile requires). It runs 256 cases instead of
48, in about three seconds. A small handcrafted column orders numbers of
both signs and many magnitudes, the two big integers, and leaves that
carry no term, which also gives the shared-prefix clause something to
bite on.

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

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

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

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

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

Copy link
Copy Markdown
Contributor Author

Review fixes pushed: head 294f03e50

The branch now carries #1363 at ee025bde9 (merges bdea70a3d and 294f03e50; the first resolved the conflict on the leakage page described above).

finding fate commit
1 [High] ordering terms' shared prefixes fixed: clause Leakage::ordering_prefixes, CLLW-shaped simulated terms, distinguisher ordering_term_prefixes, mutant ordering_prefixes_hidden (caught by that distinguisher alone), page sentence now in bits 750960062
2 [Medium] no cross-kind equalities fixed: cross_kind_equalities, and a harness test that it fires on a token that is a stored selector 076a9212e
3 [Medium] numbers and strings at one ordered path recorded under "Out of reach" in mod.rs with the measurement (750960062). Left to the owner: what the page should say, after which the profile can state it —
4 [Low] term and projection field names fixed: term_fields e52fe64f3
5 [Low] token_derivability below index 4 fixed: also the element rule up to max(64, longest list) under the found node's children's selector; the ordered-strings column reads $.tags[10], and a harness test holds the link 6b3846320
6 [Low] four clauses without a mutant fixed: key_tag_shortened, order_selector_fresh, element_ordinals_reversed, parents_lifted, all caught 7ca5b0df2
7 [Low] corpus gaps fixed: big integers, floats, strings that fold, non-scalars at ordered paths, random columns that order strings, 256 cases (3.5 s), and a fifth handcrafted column of ordered numbers. Left to the owner: the page could say a number's term equality is equality as an f64 faff23345
8 [Low] the note's net overstated what was already disclosed fixed in the note (5455ae0d8). The long comment above still carries the old sentence. Read it as: the element index was already disclosed, but whether a node is a member or an element was disclosed only where selectors showed it, and is new for a node that is an only child in every row 5455ae0d8
9 [Low] stale probe-comment text fixed in mod.rs, the test's doc comment and the note 981360faf, 5455ae0d8
10 [Nit] assertion messages, repeated banner fixed 981360faf, 7ca5b0df2

Now 38 distinguishers, 49 mutants, 11 leakage tests; every mutant is caught and UNNOTICED is still empty. The PR description's counts (35, 44, 9, 48 random cases) predate this.

Verification, from the worktree root on 294f03e50:

  • mise x --env test -- cargo nextest run -p stack-encrypt --all-features: 888 tests run: 888 passed (1 slow), 1 skipped. That is 885, plus the two new harness tests, plus feat(stack-encrypt, eql): json_tree tokens derivable from the path, a self tag, client-side wildcards #1363's new json_read_checks test. The leakage suite alone: 11 passed in 12.6 s. The calibration is 12.6 s (was 9.6 s); the random positive control is 3.5 s.
  • cargo test -p stack-encrypt --doc --all-features: ok. 50 passed.
  • cargo clippy -p stack-encrypt --all-features --all-targets -- -D warnings: exit 0.
  • cargo fmt --all --check: exit 0 at the root and inside packages/eql.
  • RUSTDOCFLAGS=-D warnings cargo doc -p stack-encrypt --all-features --no-deps: exit 0.
  • npx -y @biomejs/biome@2.5.9 check --diagnostic-level=error: 992 files, no errors.
  • packages/eql: mise run test:doc-anchors (123 links resolve), mise run docs:validate:source (passed).

https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu

@coderdan
coderdan marked this pull request as ready for review October 25, 2026 09:41
@coderdan
coderdan requested a review from a team as a code owner October 25, 2026 09:41
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 25, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-25T09:44:01.717433Z 294f03e Draft marked ready
🔒 Security Review ✅ Completed 2026-10-25T09:43:51.193610Z 294f03e Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@cipherstash-agent cipherstash-agent left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review attestation — /code-review medium plus company-skills:cross-check-review-findings (loaded from cipherstash/skills), run against head 294f03e.

Critical / blocking: none.

  • Test-only plus doc change. cargo test -p stack-encrypt --all-features --test json_leakage at head: 11 passed.
  • 1 new finding (minor, inline): the leakage page says ordering terms "share leading bytes", but the CLLW layout only agrees to within ±1 per byte.
  • Existing feedback (the author's 10-point review, fixed in 294f03e) was checked; nothing repeated here. The PR description's counts (35/44/9) are stale per the author's own note.
  • Cross-check was done inline, without verification subagents.

Generated by Claude Code

| `x`, the self tag on every entry | Nothing on its own. It is a hash of the entry's own token salted by the document's `iv`, so it is unrelated bytes in every row and links no entry to another, and since every entry carries one, its presence marks none. Given a token, it is how the database finds that token's node in any row. |
| each ciphertext's length, and the number of entries | **The node count**, and for each node the length of its key name (for an object member) plus, for a scalar, the length of its value, exactly, less the AEAD's fixed overhead. A string's length in bytes and a number's length in digits are therefore visible per node. With the sealed root on, the root entry's length is the document's length as JSON. |
| `op`, the ordering term, only on number and string leaves at the paths the index orders | **The order of that leaf's value among every row that holds one, and which rows hold equal values.** The term is order-preserving encryption (CLLW OPE) under one key per column, so leaves at two ordered paths compare with each other too, and two ordered paths holding equal values carry equal terms. Strings are ordered and compared after their case and accents are folded, so `"Abc"`, `"abc"` and `"ábc"` get the same term: the equality it shows is of folded values. It is deterministic and not a random value: two values whose encodings share leading bytes (for a string, its first characters) get terms that share leading bytes, so the term also shows how long that common prefix is. **Its length is the leaf's length**: one byte per bit of the value's order encoding plus one, so 65 bytes for every number and `8 × (n + 1) + 1` bytes for a string whose folded UTF-8 form is `n` bytes. A term's presence at an ordered path also says the leaf is a number or a string. By default no path is ordered; see the options below. |
| `op`, the ordering term, only on number and string leaves at the paths the index orders | **The order of that leaf's value among every row that holds one, and which rows hold equal values.** The term is order-preserving encryption (CLLW OPE) under one key per column, so leaves at two ordered paths compare with each other too, and two ordered paths holding equal values carry equal terms. Strings are ordered and compared after their case and accents are folded, so `"Abc"`, `"abc"` and `"ábc"` get the same term: the equality it shows is of folded values. It is deterministic and not a random value: two values whose encodings share leading bits (for a number, its sign and then its exponent's leading bits; for a string, its first characters) get terms that share leading bytes, one byte per bit, so the term also shows how long that common prefix is, to the bit. So the stored terms alone sort the numbers at the ordered paths into signs and binary orders of magnitude, and show how far into their folded text two strings agree. **Its length is the leaf's length**: one byte per bit of the value's order encoding plus one, so 65 bytes for every number and `8 × (n + 1) + 1` bytes for a string whose folded UTF-8 form is `n` bytes. A term's presence at an ordered path also says the leaf is a number or a string. By default no path is ordered; see the options below. |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

minor "terms that share leading bytes" is not what the term layout gives: chain::cllw_ope (packages/stack-encrypt/tests/json_leakage/chain.rs) adds a right-to-left carry, so bytes before the first differing bit agree only to within ±1 (mod 256), not exactly. Say "agree to within one" so the page matches the profile's ordering_term_prefixes reading.


Generated by Claude Code

…gs shows

The profile refused a column whose ordered path held numbers in some
rows and strings in others, and the page said nothing about one. The
owner's decision: the page states it, and so does the profile.

vitaminc_ore's order space salts its key with the domain, so a number's
term and a string's are under different keys. A CLLW term begins with a
carry byte and then the key's constant byte plus 128 for a first bit of
1. Under one key, then, every string compares the same way with every
negative number and the same way with every non-negative one: the
strings sort as one block, which the key places below the numbers,
between the negative and non-negative ones, or above them. That is three
placements, not the two of a single coin. Within each kind the order is
the values'. The terms also show which leaves are numbers and which are
strings.

The profile now ranks classes within their kind, states each class's
kind, and leaves the placement to the key (Leakage::kind_order). The
simulator puts each kind's terms under a key of its own and draws key
pairs until the strings fall at the placement asked. It refuses pairs
whose front bytes tie to within a carry (about one in forty), under
which a few pairs compare by later keyed bytes, a case no clause states.
The real column's key picked one placement and the simulator cannot
know which, so the calibration simulates each and counts worlds as told
apart only when none agrees.

The mutant mixed_kinds_jsonb_order fixes jsonb's numbers-first order. It
is caught by ordering_term_order, ordering_matches and
ordering_term_prefixes, because the test key puts the strings below the
numbers; one world cannot tell a key that chose numbers-first from jsonb.
A handcrafted column and a third kind of random column order numbers and
strings at one path, with bounds of both kinds.

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

The owner's decision: the page says that a number's equality under its
ordering term is as a 64-bit float. So two different integers past 2^53
can round to one float, share a term and tie under ORDER BY, while their
value selectors differ. The profile already modelled this (the term is
derived from as_f64), and the ordered-numbers column holds
9007199254740992 and 9007199254740993.

What holds the sentence is a reading, NumberOrder::Exact, under which
every distinct number gets its own term. It is a reading rather than a
mutant in mutants.rs because a mutant transforms the leakage and never
sees the values it would have to split. given_integers_ordered_exactly
shows that ordering_term_order tells it from the real scheme on that
column.

Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
The owner's decision: the reworded prefix sentence stands, and the page
says, after it, that this is CLLW's known profile and that a scheme which
hides the prefix is planned for a later version.

Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
The note's review section listed mixed kinds at an ordered path and
f64 equality as left to the owner. Both are now decided, stated on the
page and held by the profile, so the section says what was decided and
how the test holds it, with the new totals.

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

Copy link
Copy Markdown
Contributor Author

The owner's three decisions, applied: head fd229008d

  • 905791dfc Mixed kinds at an ordered path. The page's op row now says the order between a number and a string depends on the column key and is not jsonb's, and that within each kind the order is the values'. The profile states it as a clause instead of refusing such a column.
    • The real behaviour is more specific than one coin per column. The two kinds' terms are under different keys (vitaminc_ore salts the key with the domain), and a CLLW term begins with the key's constant byte plus 128 for a first bit of 1.
    • So under one key the strings sort as one block, and the key puts that block in one of three places: below every number, between the negative and non-negative numbers, or above every number.
    • The terms also show which leaves are numbers and which are strings. The page says so too.
    • The profile ranks within each kind and leaves the placement to the key (Leakage::kind_order). The simulator puts each kind under a key of its own, and the calibration tries all three placements, since the real key picked one the simulator cannot know.
    • The mutant mixed_kinds_jsonb_order (numbers first, whatever the key) is caught by ordering_term_order, ordering_matches and ordering_term_prefixes. It is caught because the test key happens to put strings below numbers. A key that put them above would look exactly like jsonb's order.
    • Out of reach, and noted in mod.rs: about one key pair in forty ties the two kinds' front bytes to within a carry, and then a few cross-kind pairs compare by later keyed bytes.
    • Corpus: a handcrafted column and a third kind of random column hold numbers and strings at one ordered path, with bounds of both kinds.
  • 7c2f4701a Equality as a 64-bit float. The page's sentence is added. It is held by a reading, NumberOrder::Exact, rather than a mutant: a mutant transforms the leakage and never sees the values it would have to split. given_integers_ordered_exactly shows ordering_term_order tells the reading apart on the column holding 9007199254740992 and 9007199254740993.
  • e50c18bb5 CLLW's known profile. The sentence follows the prefix sentence on the page. No code change.
  • fd229008d The note's review section records the decisions.

Totals: 38 distinguishers (unchanged), 50 mutants, 12 leakage tests. Verification on fd229008d:

  • cargo nextest run -p stack-encrypt --all-features: 889 passed (1 slow), 1 skipped.
  • Leakage suite: 12 passed in 14.7 s.
  • Doctests: 50 passed.
  • Clippy (--all-targets -D warnings), cargo fmt --all --check (root and packages/eql), rustdoc with -D warnings and Biome: all exit 0.
  • packages/eql: test:doc-anchors (123 links resolve) and docs:validate:source: both pass.

The PR description's totals are updated.

https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu

…akage-profile

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

Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
Base automatically changed from feat/json-tree-derivable-tokens to next October 25, 2026 10:55
@coderdan
coderdan merged commit bf42fc5 into next Oct 25, 2026
52 checks passed
@coderdan
coderdan deleted the test/json-tree-leakage-profile branch October 25, 2026 10:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants