Skip to content

Commit 2ced795

Browse files
committed
docs(plans): the derive emits the plan (#1076)
1 parent 11cf873 commit 2ced795

2 files changed

Lines changed: 56 additions & 12 deletions

File tree

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

Lines changed: 44 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -229,10 +229,20 @@ let (record, q) = stack_encrypt::all((
229229
cipher.query("bob@example.com").using(&email_plan).equality(),
230230
)).await?;
231231

232-
// Typed, from the derive, which emits the same plan.
233-
let record: EncryptedUser = cipher.encrypt(&user).using(EncryptedUser::plan()).await?;
232+
// Typed, from the derive, which emits the same plan. `plan()` returns a
233+
// `Result` and is generic over the key source, so it is borrowed after `?`.
234+
let record: EncryptedUser = cipher.encrypt(&user).using(&EncryptedUser::plan()?).await?;
234235
```
235236

237+
The one-value start has a context-field form too: `Plan::value::<S>().context_field::<C>()`
238+
carries the call's context (a `NonEmpty<C>`) out beside the output, which is
239+
`(C, T)`. `encryption_with_context` and `decryption_with_context(record,
240+
ExpectedContext<C>)` run it, and the stored context is checked against the
241+
`ExpectedContext` before any key request. A tuple of targets takes the context
242+
every element accepts (`JoinContext`): the shared one when all agree, an
243+
`AeadContext` for ciphertexts alone, and a `CallerContext` for a ciphertext
244+
beside a term.
245+
236246
`keyset(..)` may also sit on the cipher as it does today
237247
(`cipher.keyset("tenant-42").encrypt(..)`); both forms coexist because a
238248
`KeysetCipher` already exists. The chain form is there so a saved call site
@@ -265,6 +275,15 @@ A plan takes its context from exactly one of three sources:
265275
record can be opened, and on open it is checked against the context each
266276
field was sealed under.
267277

278+
A one-value plan also has a typed call-context path,
279+
`encryption_with_context::<K, C>()`, which runs the plan under the caller's
280+
context exactly as given: a `NonEmpty` of any context type, an integer or an
281+
AEAD-only context, not only a `Label`. This is how a derived `plaintext = T`
282+
record receives its caller's context. Parsing it into a label would change its
283+
bytes (`nonempty!("users/email")` is one escaped part, `Label::parse("users/email")`
284+
is two segments), so a two-part caller context stays one escaped part. A plan
285+
built with its own context refuses this path (`TwoContextSources`), without I/O.
286+
268287
Two sources is an error. `.extend(parts)` is the only way to add to the
269288
context, and it extends whichever source the plan has. A plan given two
270289
sources is refused (`TwoContextSources`), at build or when the call adds one.
@@ -408,7 +427,7 @@ Items 1 to 4 shipped in #1068 and #1069; #1071 built the chain on them.
408427
### Consolidation
409428

410429
- **The derive emits the plan, after the derive is narrowed and the plan
411-
widened.** The derive and a data plan are two authors of one grammar over
430+
widened (done in #1076).** The derive and a data plan are two authors of one grammar over
412431
one executor; the derive is not kept on the combinators.
413432
`#[derive(EncryptFrom)]` generates `EncryptedUser::plan()` as a plan chain
414433
in the picker form, and `EncryptFrom::encryption()` returns the plan's
@@ -435,6 +454,20 @@ Items 1 to 4 shipped in #1068 and #1069; #1071 built the chain on them.
435454
typed verb `encrypt_into` in both positions, with `.with(indexes)` as its
436455
sugar; `Encrypted<Terms>: EncryptFrom<S>` with its byte-identity test; and
437456
the picker.
457+
458+
What #1076 settled, beyond the list above:
459+
- A `struct` record's `DecryptInto` opens each field through its own
460+
`DecryptField` under `<context>/<identity>` (extended by the caller's
461+
context), which is what the plan's opener does for a typed field, rather
462+
than through `Plan::decryption`. A decrypt-only derive has field types with
463+
no `EncryptFrom`, and a plan cannot be built without it. For the same
464+
reason a `plaintext = T` record's `DecryptInto` calls the tuple's
465+
`DecryptInto` directly.
466+
- Two outputs of a `plaintext = T` record that declare the same index are
467+
refused when run (`PlanError::DuplicateIndex`, before any key request), not
468+
at compile time: the derive sees type names, not the indexes they declare.
469+
- No derive attribute maps to `passthrough` (a `default` field is filled with
470+
a default, not carried from the source), so the derive emits none.
438471
- **`dynamic::record` becomes a lowering, not an executor.** It parses the
439472
data plan and drives the builder, with `dynamic::term`'s dispatch from a
440473
runtime scalar to a typed index as the one step that stays dynamic. Its own
@@ -689,15 +722,15 @@ Done:
689722
3. **The builder** (#1071): the one-value chain, the fields chain, `Plan`,
690723
`using`, `query`, `open`, `all`.
691724

692-
Next, as stacked drafts on #1071:
725+
Next, as stacked drafts on #1071 (4, 5 and 6 are open drafts):
693726

694-
4. **Narrow the derive**: the four removals in "Consolidation". The byte
695-
changes are isolated in this PR and listed.
696-
5. **Widen the plan**: the two starts, the three context sources,
697-
`encrypt_into` in both positions, `Encrypted<Terms>: EncryptFrom<S>`, the
698-
picker, and the match and JSON options on the wire.
699-
6. **The derive emits the plan** (#1058), with byte identity proven on the
700-
narrowed grammar.
727+
4. **Narrow the derive** (#1073, open draft): the four removals in
728+
"Consolidation". The byte changes are isolated in this PR and listed.
729+
5. **Widen the plan** (#1074, open draft): the two starts, the three context
730+
sources, `encrypt_into` in both positions, `Encrypted<Terms>: EncryptFrom<S>`,
731+
the picker, and the match and JSON options on the wire.
732+
6. **The derive emits the plan** (#1058, PR #1076, open draft), with byte
733+
identity proven on the narrowed grammar.
701734

702735
Then:
703736

‎packages/stack-encrypt/docs/adr/0006-descriptors-render-with-a-slash-and-describe-is-open.md‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,8 @@ Option 3. In detail:
6262
- A column is the pair. The derive's `struct = .., context = "<prefix>"`
6363
form binds a field under `("<prefix>", "<field>")`. A `context = ".."`
6464
literal on a field is one text part, exactly as written, and renders
65-
escaped if it contains `/`. The derive still knows no tables (ADR-0003); a
65+
escaped if it contains `/`. (Amended 2026-10-04: the field-level literal was
66+
removed in #1073; see the note at the end.) The derive still knows no tables (ADR-0003); a
6667
consumer whose prefix is a table gets EQL's shape from it.
6768
- **`Describe` is an open trait** for a value whose parts are a descriptor
6869
of its own, the identity data is keyed under. An implementor returns a
@@ -108,3 +109,13 @@ Option 3. In detail:
108109
one width, still render alike (documented coarseness, unchanged). The
109110
rendering is one-to-one over part *trees* up to that coarseness, not over
110111
every encoding.
112+
113+
## Amended 2026-10-04
114+
115+
Field-level literal `#[stash(context = "..")]` and `#[stash(nested)]` were
116+
removed from the derive in #1073. A field is sealed under
117+
`<context>/<identity>`, where `#[stash(identity = "..")]` pins the identity
118+
part and otherwise the field name is used; a nested record is an ordinary typed
119+
field sealed under `<context>/<field>`, its inner layout being its own type's
120+
business. The struct-level `context = "<prefix>"` form and the rest of this
121+
decision are unchanged.

0 commit comments

Comments
 (0)