Skip to content

Commit e2c0572

Browse files
committed
feat(stack-encrypt)!: declare each operator once and prepare a query once
Every operator is declared once, with its type, its arity and its wire name; the dynamic enum `Op` (`OpSpec` until now, kept as a deprecated alias) is generated from that list. Each index declares the operators it answers once, and a plan reads that declaration: a field declares at most one index per operator, so `(Ore, Ope)` is refused when the plan is built, on both paths. The second order term cost a write and told anyone who read the column how its values order. `Supports::check`, `verifier` and `admit` are one `prepare`, called once per query before any keyset is resolved, and the refusals a query makes are written once, in `sem::query`, for the typed chain and the dynamic path. The typed chain now refuses text with no normal form before the keyset is resolved, as the binding did; the conformance rows that recorded the gap are flipped. `op::Matches` has no case parameter: case is the match index's configuration alone. Closes #1279 Closes #1318 Closes #1317 Closes #1321 Closes #1233
1 parent 4a26ea2 commit e2c0572

62 files changed

Lines changed: 2187 additions & 1623 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎conformance/stack-encrypt/query-refusals-contract.md‎

Lines changed: 25 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -74,47 +74,42 @@ same diagnostic, and observes that no keyset lookup, data-key generation or
7474
retrieval happens on any path. Go's deterministic guest makes no network
7575
request; that is not a logical key-call count.
7676

77-
The operator vocabulary is closed in Rust (`OpSpec`), so the unknown
77+
The operator vocabulary is closed in Rust (`dynamic::Op`), so the unknown
7878
operator case is observed at the binding boundary alone; the Rust runner
7979
checks that the case expects exactly that and continues.
8080

8181
## The typed chain
8282

8383
`typed` records how the typed Rust query chain answers the same query, for
8484
the queries it can spell (text on the text fields, a `uint32` range under
85-
ORE and an `int128` range under OPE): `before_keyset`, `after_keyset` or `none`. The Rust runner
86-
builds a one-field typed plan with the index that answers the operator,
87-
runs the chain under a keyset named for the case, and counts the keyset
88-
load. A refusal before the load is `before_keyset`; one after it is
89-
`after_keyset`. The error code must equal the binding's.
90-
91-
Today the two paths refuse the same inputs with the same codes, but not at
92-
the same stage:
93-
94-
| Refusal | Binding (`check_query`, `query_term`) | Typed chain |
95-
|---|---|---|
96-
| Match text with no token or no normal form | before any keyset | before any keyset |
97-
| Equality text with no normal form | before any keyset | after the keyset is loaded |
98-
| Order text with no normal form (ORE, OPE) | before any keyset | after the keyset is loaded |
99-
| Block ORE text too long or ending in NUL | before any keyset | after the keyset is loaded |
100-
101-
The binding checks every index's value refusals in `check_input`; the
102-
typed chain's `Supports::check` runs before the keyset for the match index
103-
alone, and the other indexes refuse the value when they derive its term.
104-
This profile pins the current stage as a documented difference. Moving the
105-
typed refusals earlier is step 2 of "The engine's rules, and the order of
106-
the work" in the API plan (`docs/plans/2026-10-24-stack-encrypt-api-plan.md`),
107-
which runs every check that needs no key before a key is used. When it
108-
lands, the `typed` entries change to `before_keyset` on purpose, and this
109-
table with them.
85+
ORE and an `int128` range under OPE): `before_keyset`, `after_keyset` or
86+
`none`. The Rust runner builds a one-field typed plan with the index that
87+
answers the operator, runs the chain under a keyset named for the case, and
88+
counts the keyset load. A refusal before the load is `before_keyset`; one
89+
after it is `after_keyset`. The error code must equal the binding's.
90+
91+
The two paths refuse the same inputs with the same codes at the same
92+
stage: before any keyset is resolved. Every `typed` entry of a refused case
93+
is `before_keyset`. Both paths call the one set of rules in the engine's
94+
`sem::query` module: the typed chain through each index's
95+
`Supports::prepare`, and the binding through `check_query`, `query_term`
96+
and `verify`. A rule written there is a rule on both paths, and a rule
97+
written anywhere else is a drift this profile fails on.
98+
99+
Until the API plan's step 2 ("The engine's rules, and the order of the
100+
work", `docs/plans/2026-10-24-stack-encrypt-api-plan.md`) the typed chain
101+
refused equality text with no normal form, order text with no normal form
102+
and block ORE text over its limit only after the keyset was loaded, and the
103+
`typed` entries of those rows said `after_keyset`. They were flipped on
104+
purpose with that step.
110105

111106
## Scope and remaining work
112107

113108
The corpus covers scalar operators under `NoTargets`. Match options the
114109
index cannot use (an n-gram length of zero) are a declaration refusal, in
115110
`v1/record-declarations-v1.cases.json` (`match-invalid-ngram-zero`). JSON document
116-
operators against JSON fields, EQL target refusals, `target_limit` inputs and the match-case
117-
mismatch only the typed chain can express remain with their existing
118-
tests. The match-case mismatch goes away with the API plan's step 3,
119-
which removes `Matches`'s case parameter and `.via`. The Go runner does not exercise generated query methods; those keep
111+
operators against JSON fields, EQL target refusals and `target_limit`
112+
inputs remain with their existing tests. The match-case mismatch is gone:
113+
`Matches` has no case parameter, and a match index's configuration is the
114+
one place case is declared. The Go runner does not exercise generated query methods; those keep
120115
their fixture tests. No existing test is removed.

‎conformance/stack-encrypt/v1/record-query-refusals-v1.cases.json‎

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1299,7 +1299,7 @@
12991299
"code": "stack_encrypt::term_normalize"
13001300
},
13011301
"typed": {
1302-
"refusal": "after_keyset"
1302+
"refusal": "before_keyset"
13031303
}
13041304
},
13051305
{
@@ -1316,7 +1316,7 @@
13161316
"code": "stack_encrypt::term_normalize"
13171317
},
13181318
"typed": {
1319-
"refusal": "after_keyset"
1319+
"refusal": "before_keyset"
13201320
}
13211321
},
13221322
{
@@ -1342,7 +1342,7 @@
13421342
"code": "stack_encrypt::term_normalize"
13431343
},
13441344
"typed": {
1345-
"refusal": "after_keyset"
1345+
"refusal": "before_keyset"
13461346
}
13471347
},
13481348
{
@@ -1368,7 +1368,7 @@
13681368
"code": "stack_encrypt::term_normalize"
13691369
},
13701370
"typed": {
1371-
"refusal": "after_keyset"
1371+
"refusal": "before_keyset"
13721372
}
13731373
},
13741374
{
@@ -1385,7 +1385,7 @@
13851385
"code": "stack_encrypt::ore_block_too_long"
13861386
},
13871387
"typed": {
1388-
"refusal": "after_keyset"
1388+
"refusal": "before_keyset"
13891389
}
13901390
},
13911391
{
@@ -1402,7 +1402,7 @@
14021402
"code": "stack_encrypt::ore_block_trailing_nul"
14031403
},
14041404
"typed": {
1405-
"refusal": "after_keyset"
1405+
"refusal": "before_keyset"
14061406
}
14071407
},
14081408
{
@@ -1428,7 +1428,7 @@
14281428
"code": "stack_encrypt::ore_block_trailing_nul"
14291429
},
14301430
"typed": {
1431-
"refusal": "after_keyset"
1431+
"refusal": "before_keyset"
14321432
}
14331433
},
14341434
{
@@ -1454,7 +1454,7 @@
14541454
"code": "stack_encrypt::ore_block_too_long"
14551455
},
14561456
"typed": {
1457-
"refusal": "after_keyset"
1457+
"refusal": "before_keyset"
14581458
}
14591459
},
14601460
{
@@ -1480,7 +1480,7 @@
14801480
"code": "stack_encrypt::ore_block_too_long"
14811481
},
14821482
"typed": {
1483-
"refusal": "after_keyset"
1483+
"refusal": "before_keyset"
14841484
}
14851485
},
14861486
{
@@ -1506,7 +1506,7 @@
15061506
"code": "stack_encrypt::ore_block_trailing_nul"
15071507
},
15081508
"typed": {
1509-
"refusal": "after_keyset"
1509+
"refusal": "before_keyset"
15101510
}
15111511
},
15121512
{

‎docs/plans/2026-10-24-stack-encrypt-api-plan.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2001,7 +2001,7 @@ Decided on 7 November 2026, as area two of the query design review.
20012001
does not know which bound the caller meant. The `InvertedRange` error
20022002
asked for in #1279 predates that commit and is dropped.
20032003
- **Stored format and wire.** Unchanged. A query sends the operator's wire name. That name does not change.
2004-
- **Tracked in.** Issues #1318 and #1279 are open. Issues #1317, #1319, and #1321 are open. Issue #1320, the inverted-range rule, is open. This plan's decision supersedes it, so it should be closed.
2004+
- **Tracked in.** The pull request for #1279, #1318, #1317, #1321 and #1233 does this area's `Op` enum and the `Matches` case change. Issues #1319 and #1321 were open; #1321 closes with that pull request. Issue #1320, the inverted-range rule, is closed: this plan's decision superseded it.
20052005
- **Finished when** these are all true:
20062006
- the dynamic enum is generated from the `op` types;
20072007
- `Matches` has no type parameter;
@@ -2130,7 +2130,7 @@ review. The rules are the query artifact's.
21302130
`.via`. The `MatchConfig` documentation says a field and its query agree "by
21312131
construction". That is true only for one-value plans today. Both
21322132
are amended, not contradicted in silence.
2133-
- **Tracked in.** Issues #1318 and #1279 are open. Issue #1233 is open: refuse a second order index. That is rule 2. Issues #1317 and #1321 are open.
2133+
- **Tracked in.** Steps 1 and 2 are one pull request, which closes #1279, #1318, #1317, #1321 and #1233 (rule 2, the second order index). It brings `prepare`, the `sem::query` rules and `Op`, and drops `Matches`'s case parameter; the analyzer of "Match configuration: one analyzer" is its own work (#1319). Steps 3 and 4 are #1486 and #1488.
21342134
- **Finished when** these are all true:
21352135
- every index's operators appear in one declaration, and a test shows
21362136
the typed and dynamic lookups agree on every index;

‎languages/golang/CHANGELOG.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,16 @@ listed under **Breaking**. Releases are tagged `languages/golang/vX.Y.Z`.
1212

1313
### Breaking
1414

15+
- **A field declares at most one order index** (#1233). The engine refuses
16+
a field tagged `index=ore;ope` (or `ore;ore_block`), so `stashgen` now
17+
reports it as a field error at generate time, and a plan built by hand
18+
with both is refused by the guest (`overlapping_outputs`) before any key
19+
is requested. Before, the field stored both terms and every query used
20+
the ORE one; the OPE term cost a write and told anyone who read the
21+
column how its values order. `stashgen`'s notice for a second order index
22+
is gone with the case it described; the notice for an order index on
23+
bytes stays.
24+
1525
- **`Cipher.Extend` is now `Cipher.Scope`, and `Cipher.Bind` is new**
1626
(#1487). A scope narrows the data and the queries over it. A binding ties
1727
a ciphertext to its row: it is in the stored context, after every scope,

‎languages/golang/encrypt/guest/src/ops.rs‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -250,7 +250,7 @@ where
250250
/// The input is one value for every operator but `between`, whose input is
251251
/// a list of two, low then high, and a document's operators, whose input
252252
/// is an object of named inputs (`{"json"}`, `{"path", "json"}`, ...; see
253-
/// [`dynamic::OpSpec::input`]). A value is read as the field's declared
253+
/// [`dynamic::Op::input`]). A value is read as the field's declared
254254
/// kind first, so a host's float `34.0` queries a `uint64` field. A field
255255
/// with indexes derives the term of the index that answers the operator,
256256
/// as bytes; `between` derives one per bound, as a list of two. A field
@@ -390,18 +390,18 @@ fn parse_field<'f>(plan: &Bytes, field: &'f [u8]) -> Result<(dynamic::record::Pl
390390
}
391391

392392
/// The plan, field name and operator a query names: [`parse_field`]'s two,
393-
/// and the operator one the engine knows ([`dynamic::OpSpec::parse`]); an
393+
/// and the operator one the engine knows ([`dynamic::Op::parse`]); an
394394
/// unknown operator is refused naming the field, as an operator the field
395395
/// does not answer is.
396396
fn parse_query<'f>(
397397
plan: &Bytes,
398398
field: &'f [u8],
399399
operator: &[u8],
400-
) -> Result<(dynamic::record::Plan, &'f str, dynamic::OpSpec), u32> {
400+
) -> Result<(dynamic::record::Plan, &'f str, dynamic::Op), u32> {
401401
let (plan, field) = parse_field(plan, field)?;
402402
let operator = std::str::from_utf8(operator)
403403
.ok()
404-
.and_then(dynamic::OpSpec::parse)
404+
.and_then(dynamic::Op::parse)
405405
.ok_or_else(|| {
406406
fail_dynamic(&dynamic::Error::Source {
407407
field: Some(field.to_owned()),
@@ -767,8 +767,8 @@ mod tests {
767767
#[test]
768768
fn an_encoded_answer_has_no_spare_capacity() {
769769
let entries = [
770-
dynamic::OperatorEntry::new(dynamic::OpSpec::Eq, &IndexSpec::Equality),
771-
dynamic::OperatorEntry::new(dynamic::OpSpec::Between, &IndexSpec::Ope),
770+
dynamic::OperatorEntry::new(dynamic::Op::Eq, &IndexSpec::Equality),
771+
dynamic::OperatorEntry::new(dynamic::Op::Between, &IndexSpec::Ope),
772772
];
773773
for out in [
774774
encode_answer(&vec![true, false, true]),

‎languages/golang/encrypt/guest/src/targets.rs‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -288,7 +288,7 @@ mod tests {
288288
#[cfg(feature = "eql")]
289289
#[test]
290290
fn a_document_names_the_leaf_element_and_predicate_domains() {
291-
use stack_encrypt::dynamic::{self, record, OpSpec};
291+
use stack_encrypt::dynamic::{self, record, Op};
292292
let targets = resolver().targets();
293293
let document = targets
294294
.iter()
@@ -307,7 +307,7 @@ mod tests {
307307
Some("eql_v4.query_json_predicate")
308308
);
309309
assert!(
310-
document.operators().contains(&OpSpec::Match),
310+
document.operators().contains(&Op::Match),
311311
"the type lists match: {:?}",
312312
document.operators()
313313
);
@@ -331,12 +331,12 @@ mod tests {
331331
.collect::<Vec<_>>()
332332
};
333333
let ordered = field_operators(&["$.contacts[*].relationship"]);
334-
for present in [OpSpec::ElementMatch, OpSpec::Match] {
334+
for present in [Op::ElementMatch, Op::Match] {
335335
assert!(ordered.contains(&present), "{present:?}: {ordered:?}");
336336
}
337337
let unordered = field_operators(&[]);
338-
assert!(unordered.contains(&OpSpec::Match), "{unordered:?}");
339-
assert!(!unordered.contains(&OpSpec::ElementMatch), "{unordered:?}");
338+
assert!(unordered.contains(&Op::Match), "{unordered:?}");
339+
assert!(!unordered.contains(&Op::ElementMatch), "{unordered:?}");
340340
}
341341

342342
#[cfg(feature = "eql")]

0 commit comments

Comments
 (0)