Skip to content

Commit fd7e4e5

Browse files
committed
docs(stack-encrypt): amend ADR-0007 for queries named by operator, proposed until ADR-0009 is accepted
The data grammar gains the operator and its input, the term comes back with its query domain, verify and target_limit become engine exports, and the catalog reports each EQL type's operators and their fidelity. The amendment rests on ADR-0009, which is proposed, and takes effect when it is accepted. Part of #1188
1 parent 543b4c6 commit fd7e4e5

1 file changed

Lines changed: 105 additions & 0 deletions

File tree

‎packages/stack-encrypt/docs/adr/0007-bindings-enter-through-a-plan-never-a-second-executor.md‎

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
status: accepted
33
date: 2026-10-04
44
extends: ADR-0003, ADR-0004
5+
amended: 2026-10-16, proposed with ADR-0009 and in effect when ADR-0009 is accepted
56
---
67

78
# Bindings enter the engine through a plan lowered from data, never through a second executor
@@ -29,6 +30,14 @@ extends: ADR-0003, ADR-0004
2930
> Go SDK, as the `context_field` tag word. The typed parts that stay
3031
> Rust-only are the typed verb `encrypt_into`, the picker and the one-value
3132
> start.
33+
>
34+
> **Amended 2026-10-16**, by #1188, **proposed**. This amendment depends on
35+
> ADR-0009, which is proposed, and takes effect when ADR-0009 is accepted;
36+
> until then it is proposed with it, and changes with it. The decision is
37+
> unchanged. Added, under its last bullet: the operator and its input in the
38+
> data grammar, the query term and its query domain back, `verify` and
39+
> `target_limit` as engine exports, and the operators each EQL type answers,
40+
> with their fidelity, in the catalog.
3241
3342
Stack Encrypt has one execution engine: the `Encryption` and `Decryption`
3443
descriptions in `target/` and the batched `Pending` they produce. ADR-0003
@@ -188,3 +197,99 @@ to Go (G1: one engine). The reasons recorded against this on 2026-10-04 were
188197
guest size and an EQL-free guest for programs that never store into EQL. Two
189198
guest builds answer the second, and the first is measured before the SDK ships
190199
one build or two (G7).
200+
201+
## Amended 2026-10-16 (#1188), proposed with ADR-0009
202+
203+
**Status: proposed.** ADR-0009 is proposed, and this amendment rests on it:
204+
it takes effect when ADR-0009 is accepted, and is revised or withdrawn with
205+
it. The code it describes lands first, as ADR-0009's Rust side has
206+
(#1182 to #1186), because Stack Encrypt has no stored data and no released
207+
binding a change could break.
208+
209+
ADR-0009 makes the operator, not the index, what a query names, and gives a
210+
probabilistic term `verify` and `target_limit`. The data grammar a binding
211+
lowered named a field and, for a query, took the term the field's EQL type
212+
chose. It had no operator and no operator input, and no export could verify
213+
a decrypted value or size a fetch. The last bullet of the decision says what
214+
to do with a capability the grammar cannot express: add it to the grammar,
215+
once, so every author has it. This amendment does that for the query side.
216+
ADR-0009's appendix B is the sketch it was amended from.
217+
218+
**A query names a field, an operator and the operator's input.** The
219+
operator crosses as its name: `eq`, `lt`, `lte`, `gt`, `gte`, `between` or
220+
`matches`. The input is one value, or for `between` a list of two, low then
221+
high. The engine reads the input as the field's declared type first
222+
(`dynamic::read`), so a host's float `34.0` queries a `uint64` field as the
223+
`u64` the field stored. It then finds the index that answers the operator by
224+
the table the typed path's `DefaultIndex` impls state, written over a type
225+
and the indexes a field declares (`dynamic::OpSpec::answered_by`): `eq`
226+
through an equality index, `matches` through a match index over text, the
227+
orderings through an ORE index over an integer, or OPE when the field
228+
declares no ORE. A field that does not answer the operator is refused with
229+
the same shape of error as a mistyped field: an input error naming the field,
230+
with the reason `operator_not_answered` (or `unknown_operator`,
231+
`not_a_range`). The refusals the typed path makes before a term exists, a
232+
range the wrong way round and match text that cuts no token, are refused the
233+
same way here, before the cipher is consulted.
234+
235+
**The term comes back with its query domain.** The guest's
236+
`se_query_term(input, plan, field, operator, options)` returns one object:
237+
the operator, its fidelity, the index that answered, the PostgreSQL domain
238+
the term is cast to, and the term. A field with indexes derives that index's
239+
term under the field's context, by the derivation its stored terms came
240+
from, as bytes, and `between` derives one term per bound under the one
241+
context, as a list of two. A field that names an EQL type runs the type's
242+
query twin, and the term is the twin's JSON and the domain its
243+
`query_sql_domain`. One query domain per operator is EQL v4's (ADR-0009,
244+
decision 8, #1179); until then a type's twin carries every term of the type.
245+
246+
**`verify` and `target_limit` are engine exports.** `se_verify(input, values,
247+
plan, field, operator)` answers one boolean per decrypted value, a page in
248+
one call. `se_target_limit(term, stats, plan, field, operator, want)` answers
249+
the rows to fetch, or null for no finite fetch. Neither uses a key, so each
250+
runs in a guest that holds no cipher, as `se_plan_check` does. `verify` names
251+
the query as it was derived, by the field, the operator and the input, and
252+
the engine cuts the input's tokens again with the field's own tokenizer and
253+
options. The tokens are plaintext, so no term carries them across the
254+
binding. Sending the input and a page of decrypted values back in is no new
255+
exposure: the input came from the host, and the values came out of the
256+
guest as a decrypt. The alternative, a tokenizer in each language, is the
257+
"lives twice" cost this ADR accepts only for an encoder, and a tokenizer that
258+
drifts from the one that built the stored filter verifies the wrong thing.
259+
`target_limit` takes the term's bytes, because the number of bits the query
260+
sets is a property of the term the engine built, and the fill formula
261+
belongs beside the index configuration. Both refuse an exact operator, which
262+
has nothing to verify and no fetch to size, as the typed path does not
263+
compile the call.
264+
265+
**The catalog reports the operators each EQL type answers, and their
266+
fidelity.** Each `se_targets` entry gains `operators`: one object per
267+
operator the engine answers for a field that names the type, with its name,
268+
its fidelity (`exact` or `probabilistic`), its arity (`one` or `pair`) and
269+
the type's index that answers it. The engine computes the list from the
270+
type's row by the same table every plan field is held to, so a generator
271+
asks the engine it embeds rather than holding a copy of the rules, as the
272+
2026-10-06 amendment asks of every rule. ADR-0009 rejects a fidelity value
273+
on a term, because Rust says it with a trait. A generator asks before the
274+
program runs, so it needs the answer as data, and the code it generates
275+
carries `Verify` only where the answer is `probabilistic`, which rebuilds
276+
decision 4 of ADR-0009 in a language without traits. Today `TextEq` answers
277+
`eq` and no other type is produced. A type answers no probabilistic operator
278+
and no `between` yet: the catalog names a type's match index but not its
279+
options, which `verify` must cut tokens by, and a twin is one value, not a
280+
range. Both wait for the EQL v4 types (#1179).
281+
282+
What does not change: a binding still reaches the engine through a plan, and
283+
nothing here derives a term outside the engine's own derivation, so a query
284+
from Go and the typed query chain derive the same bytes. A cross-language
285+
fixture holds them together (`tests/fixtures/query_operators.json`): the Rust
286+
side checks each recorded term against the typed chain, and the Go SDK's
287+
tests derive, verify and size the same queries through the guest.
288+
`se_query`, which derives a target field's twin with no operator, stays until
289+
the generated Go code names operators (#1189), and is removed then.
290+
`se_term`, a term by index under an explicit context, stays for the generated
291+
code of fields declared with indexes until that code names operators too.
292+
293+
A data plan's match index declares no `expected_tokens` prior, so
294+
`target_limit` on a field declared as data is unbounded without a measured
295+
histogram. A data form for the prior is later work.

0 commit comments

Comments
 (0)