Skip to content

Commit 909b02a

Browse files
authored
docs(plans): spellings settled by the grammar PR
Refs #1074.
1 parent 964ab60 commit 909b02a

1 file changed

Lines changed: 36 additions & 9 deletions

File tree

‎docs/plans/2026-10-04-plan-builder.md‎

Lines changed: 36 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -266,8 +266,12 @@ A plan takes its context from exactly one of three sources:
266266
field was sealed under.
267267

268268
Two sources is an error. `.extend(parts)` is the only way to add to the
269-
context, and it extends whichever source the plan has. `build()` refuses a
270-
fields plan whose label would be empty.
269+
context, and it extends whichever source the plan has. A plan given two
270+
sources is refused (`TwoContextSources`), at build or when the call adds one.
271+
A plan run with no source is refused at run, before any key request
272+
(`NoContext`), because only the call can supply one. A context field's value
273+
must be a plain label segment (a `String` or `Label`); an integer tenant id goes
274+
in `.extend(parts)`.
271275

272276
### The typed verb and the picker
273277

@@ -276,7 +280,7 @@ decides the field's layout and the queries it answers. It has two positions:
276280

277281
```rust
278282
// Field form: the field's layout is TextEq's.
279-
Plan::fields().context("users").encrypt_into::<TextEq>("email")
283+
Plan::fields().context("users").encrypt_into::<TextEq, _>("email")
280284

281285
// One-value form: each tuple element is an EncryptFrom<S>.
282286
Plan::value::<String>().encrypt_into::<(Ciphertext, Hmac256)>()
@@ -292,14 +296,36 @@ accessor:
292296

293297
```rust
294298
.encrypt_index::<String>("email", (Equality, Match::default())) // by name, through Field<String>
295-
.encrypt_index(("email", |u: &User| &u.email), (Equality, Match::default())) // picker: the type is inferred
299+
.encrypt_index(pick("email", |u: &User| &u.email), (Equality, Match::default())) // picker: the type is inferred
296300
```
297301

298302
A bare name is looked up through the value's `Field<F>` impl, which is what a
299303
dynamic value (`FfiValue`) provides. The picker reads the field directly and
300304
needs no turbofish. The derive emits the picker form; bindings use names. The
301305
picker is Rust-only.
302306

307+
Two spellings follow from how Rust infers types:
308+
309+
- A closure picker is written `pick("email", |u: &User| &u.email)`, not as a
310+
bare tuple `("email", |u| &u.email)`, because Rust infers no higher-ranked
311+
closure signature inside a tuple. A bare tuple holding a function item or
312+
function pointer, `("email", email_fn)`, works as well.
313+
- The field form of `encrypt_into` takes two type arguments, `::<T, _>(field)`
314+
(the field's type is inferred), because Rust has no partial turbofish. The
315+
one-value form takes one: `Plan::value::<S>().encrypt_into::<(A, B)>()`.
316+
317+
`EncryptFrom::indexes() -> Vec<IndexSpec>` is a defaulted method (empty unless
318+
overridden) with which a typed target says which queries it answers. A typed
319+
field whose target declares no indexes answers no queries; a query for anything
320+
else is refused. Each term type names its own index, `Encrypted<Terms>` names
321+
its set, and a tuple concatenates.
322+
323+
The built plan's accessors: `Plan::field_plans()` returns the field
324+
descriptions (`Plan::fields()` is the start, so it cannot be the accessor);
325+
`Plan::label()` and `FieldPlan::label()` return `Option<&Label>` (there is no
326+
label until the call supplies the context); `Plan::context_field()` returns
327+
`Option<&str>`.
328+
303329
### What `passthrough` means
304330

305331
Exactly what a passthrough leaf means in the Cipher today: the value is
@@ -317,14 +343,15 @@ this on the method.
317343
| `Plan::value::<S>()`, `Plan::fields()`, `.context(c)` | the `NonEmpty<impl IntoContext>` handed to `under` | with `fields()`, each field gets `Label::new([context, identity])` |
318344
| `with(idx)` / `encrypt_index(name, idx)` | `indexed::<S>(idx)`: `ciphertext().accepting::<CallerContext>().zip(..)…` then `map` into `Encrypted<Terms>` | each `Index<S>` returns its term operation; the tuple folds with `zip` |
319345
| `encrypt_into::<T>(name)`, and the one-value form | `<T as EncryptFrom<F>>::encryption().under(label)` | the target's own description; no builder work |
346+
| `EncryptFrom::indexes()` on a typed field | the indexes a query against that field may name | defaulted to empty: a typed field whose target declares none answers no queries (`IndexNotDeclared`) |
320347
| `with(idx)` as `encrypt_into::<Encrypted<Terms>>` | `Encrypted<Terms>: EncryptFrom<S>`, whose `encryption()` is `indexed()` | both spellings lower to the same `indexed()`; a standing byte-identity test covers every index combination |
321348
| `encrypt(name)` | `ciphertext::<S>()` alone | `under` accepts it: `AeadContext: From<CallerContext>` |
322349
| `index(name, idx)` | the index operations alone, zipped, no `ciphertext()` | `Json` is one such operation; it mints one document key and seals every entry under it, a **new core operation** (one generate request, a fulfilment that seals N entries with selector-derived nonces) |
323350
| `passthrough(name)` | `passthrough::<S>()` (#1069) | build is `Pending::ready(cipher, Ok(source))`, ignores the context |
324351
| `context_field(name)` | `passthrough()` for that field; its value is the context every other field runs `under` | on open, the field's value is the `ExpectedContext` each field is opened under, so a record whose context field was changed in storage does not open |
325352
| `fields()`, a field by name | `project_by` (crate-internal) through the value's `Field<F>`, `.under(label)`, `zip` across fields, `map` into the record | the path a dynamic value (`FfiValue`) takes |
326-
| `fields()`, a field by picker `(name, accessor)` | `project(accessor)` | the derive's `struct = ..` mode today; borrowed-only |
327-
| `build()` | a reusable recipe that produces a fresh `Encryption` per run | whole-plan rules checked here: names once, labels plain, no shared identity, passthrough not indexed, one context source, no empty label |
353+
| `fields()`, a field by picker `pick(name, accessor)` | `project(accessor)` | the derive's `struct = ..` mode today; borrowed-only |
354+
| `build()` | a reusable recipe that produces a fresh `Encryption` per run | whole-plan rules checked here: names once, labels plain, no shared identity, passthrough not indexed, one context source |
328355
| `using(&plan)` | `KeysetCipher::run(encryption, source, ctx)` (#1068) of `Plan::encryption()` | execute-by-value; `encrypt_as` does this for a type through `T::encryption()` |
329356
| `extend(parts)` | the runtime `DeclaredContext` value, `DeclaredContext::extend(own)` | not a combinator; it is the `ctx` argument |
330357
| `keyset(..)` | the `KeysetCipher` passed to build; `Pending::scoped_to` | |
@@ -546,7 +573,7 @@ are a bonus where the language has them.
546573

547574
**TypeScript already has a plan in another spelling.** `encryptedTable('users',
548575
{ email: types.TextEq() })` is `Plan::context("users").fields()
549-
.encrypt_into::<TextEq>("email")`, with the EQL domain as the field's target.
576+
.encrypt_into::<TextEq, _>("email")`, with the EQL domain as the field's target.
550577
Today that schema drives cipherstash-client through protect-ffi, a second
551578
engine. The intended path is to retire protect-ffi and ship a new major of
552579
`@cipherstash/stack` on stack-encrypt, with breaking changes; that release is
@@ -622,8 +649,8 @@ assembles EQL types from them:
622649

623650
```rust
624651
let users_plan = Plan::context("users").fields()
625-
.encrypt_into::<TextEq>("email") // TextEq's own EncryptFrom decides the layout
626-
.encrypt_into::<Json>("attrs") // EQL's JSON type frames the Json index's document
652+
.encrypt_into::<TextEq, _>("email") // TextEq's own EncryptFrom decides the layout
653+
.encrypt_into::<Json, _>("attrs") // EQL's JSON type frames the Json index's document
627654
.encrypt_index::<u32>("age", (Equality, Ore))
628655
.build()?;
629656
```

0 commit comments

Comments
 (0)