|
2 | 2 | status: accepted |
3 | 3 | date: 2026-10-04 |
4 | 4 | extends: ADR-0003, ADR-0004 |
| 5 | +amended: 2026-10-16, proposed with ADR-0009 and in effect when ADR-0009 is accepted |
5 | 6 | --- |
6 | 7 |
|
7 | 8 | # Bindings enter the engine through a plan lowered from data, never through a second executor |
@@ -29,6 +30,14 @@ extends: ADR-0003, ADR-0004 |
29 | 30 | > Go SDK, as the `context_field` tag word. The typed parts that stay |
30 | 31 | > Rust-only are the typed verb `encrypt_into`, the picker and the one-value |
31 | 32 | > 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. |
32 | 41 |
|
33 | 42 | Stack Encrypt has one execution engine: the `Encryption` and `Decryption` |
34 | 43 | 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 |
188 | 197 | guest size and an EQL-free guest for programs that never store into EQL. Two |
189 | 198 | guest builds answer the second, and the first is measured before the SDK ships |
190 | 199 | 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