Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 69 additions & 36 deletions docs/plans/2026-10-24-stack-encrypt-api-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -321,29 +321,44 @@ and checks each one.
#### Query a record field

The derive emits one marker for each field of a record, such as
`EncryptedMember::EMAIL`. A marker names the field, its plaintext type,
its accessor, and the field's index list. It is the value a query starts
from. The derive emits no plan: the marker is enough for a query, and a
binding sends a plan of its own.
`EncryptedMember::EMAIL`. A marker names the field, the context the
derive sealed it under (the record's scope, then the field's identity),
and the field's target type, whose `Answers` impls are the field's index
list. It is the value a query starts from. The derive emits no plan: the
marker is enough for a query, and a binding sends a plan of its own.
Built on 10 November 2026 (pull request #1595): the marker has no
plaintext type and no accessor, because the derive sees the record's
field types and not the plaintext struct's, and a query value converts
from the borrowed form a caller has (`&str` to `String`) through
`ToOwned`. So an equality marker takes a value of any type its index
accepts, and a field whose type is several indexes, a nullable field,
or a record field has a marker with no operator method yet. Both gaps
are #1596.

A typed query starts from the marker, and has one method for each
operator the field's indexes answer. A missing operator is a compile
error: "no method named `gt`", with a message that names the field's
indexes. Scopes chain on the query. A binding cannot, because the query
builder has no `bind` method, so a query accepts scopes only, by type.
The query ends with `encrypt(&keyset)`, and its output type is the EQL
query type the marker fixes. Decided on 7 November 2026.
indexes. A record that lists a caller scope gives its markers the
scope's type, and the query has no `encrypt` until `.scope(..)` is given
once, with a value of that type; a record with no caller scope gives a
query that is ready at once. A binding cannot be given, because the
query type has no `bind` method, so a query accepts its scope only, by
type. The query ends with `encrypt(&keyset)`, and its output type is the
EQL query type the marker fixes. Decided on 7 November 2026.

```rust
// Not on `next` yet: field markers and their query methods. Today a
// query names a plan written by hand: `cipher.query(op::Eq, value).on(&plan)`.
// On `next` since pull request #1595, with the index's query term as
// the output type (`EqualityTerm`, `RangeQuery<OreTerm<u32>>`,
// `MatchQuery`); the EQL query types follow with the EQL owned
// implementations. A value is passed by value; a string literal as it is.
let by_email: TextEqQuery = EncryptedMember::EMAIL
.eq("alice@example.com")
.scope(tenant)
.encrypt(&keyset)
.await?;
let adults: IntegerOrdQuery = EncryptedMember::AGE
.between(18, 65)
.scope(tenant)
.encrypt(&keyset)
.await?;

Expand Down Expand Up @@ -690,13 +705,12 @@ keyset as the argument. Made a target after the combinator spike of
9 November 2026 (ADR-0007, amended).

```rust
// Not on `next` yet: `Planned`. Today the chain does this:
// `cipher.encrypt(&user).using(&plan)` and `cipher.query(op, v).on(&plan)`.
// The value under a data plan is the dynamic interface's record,
// `FieldValues`, and the stored row is a `StackCipherText`. A typed
// struct uses the derive, not a plan.
// On `next` since pull request #1595. The value under a data plan is
// the dynamic interface's value, a `ValueFrame` of the record's fields,
// and the stored row is a `StackCipherText`. A typed struct uses the
// derive, not a plan.
let row: StackCipherText = Planned::new(&plan, values).encrypt_into(&keyset).await?;
let values = FieldValues::decrypt_from(Planned::new(&plan, row), &keyset).await?;
let values = ValueFrame::decrypt_from(Planned::new(&plan, row), &keyset).await?;

let row: StackCipherText = Planned::new(&plan, values)
.encrypt_into_with_context(&keyset, Scope::new(tenant).bind(row_id))
Expand Down Expand Up @@ -760,10 +774,10 @@ it declares about queries, so no declaration lists the queries a program
might run.

```rust
// Not on `next` yet: query plans.
// On `next` since pull request #1595. A query value is a `ValueFrame`.
let by_email = plan.query("email", Op::Eq)?; // refused here if no index answers Eq

let q: QueryTerm = Planned::new(&by_email, "alice@example.com")
let q: QueryTerm = Planned::new(&by_email, ValueFrame::of("alice@example.com")?)
.encrypt_into_with_context(&keyset, Scope::new(tenant))
.await?;
```
Expand All @@ -785,15 +799,18 @@ field](#query-a-record-field) shows the typed form.
#### The dynamic field handle

A program whose plan is data uses the same builder through a field
handle. The handle has every operator method. Each one is checked at run
time against the plan, and returns `IndexNotDeclared` when no index
answers it. The handle has no `bind` method either.
handle. The handle has one method per scalar operator, and `op(..)` for
any operator, the JSON ones included. Each one is checked at run time
against the plan, and returns `operator_not_answered` when no index
answers it. A field that names an EQL type takes its resolver at the
handle, `plan.field_with(name, &resolver)`. The handle has no `bind`
method either.

```rust
// Not on `next` yet: the dynamic field handle.
// On `next` since pull request #1595. A query value is a `ValueFrame`.
let email = plan.field("email")?;
let q: QueryTerm = email
.eq("alice@example.com")? // refused here if no index answers Eq
.eq(ValueFrame::of("alice@example.com")?)? // refused here if no index answers Eq
.scope(tenant)
.encrypt(&keyset)
.await?;
Expand Down Expand Up @@ -1550,10 +1567,15 @@ review.
The caller must not write a second plan by hand. The marker holds the
declaration the derive wrote, so the query term and the stored term
come from one source.
- **Done already.** `Answers` on a target type is today's compile-time
lookup of the index for an operator. The marker's index list extends
it to a record field. Until the markers land, a query works through a
plan written by hand: `cipher.query(op, value).on(&plan)`.
- **Done already.** `Answers` on a target type is the compile-time
lookup of the index for an operator; a marker's methods are bound by
it, so the marker's index list is its target type's `Answers` impls.
Pull request #1595 lands the markers, their query methods and
`assert_context!` over a marker (`src/marker.rs`, `tests/markers.rs`,
`tests/ui/marker_*.rs`). A field of several indexes, a nullable field
and a record field have markers with no operator method yet (#1596). The chain query
`cipher.query(op, value).on(&plan)` still exists until #1488 removes
it.
- **No hand-built plan.** The review of 7 November 2026 gave a
hand-built plan the same markers, through `Plan::<Member>::scope(..)`
with `pick!`. That is withdrawn. No Rust program builds a plan by hand.
Expand All @@ -1565,13 +1587,18 @@ review.
time too. Today the builder refuses it at run time, so that check
lands with the builder's removal. The dynamic grammar keeps its
run-time checks.
- **Tracked in.** Issue #1486 is open.
- **Tracked in.** Issue #1486 is open; pull request #1595 closes it.
Issue #1596 is the operator methods the markers still lack.
- **Finished when** these are all true:
- the [marker example](#query-a-record-field) compiles;
- the [marker example](#query-a-record-field) compiles, with the
index's query term as the output type; the EQL query types come
with the EQL owned implementations;
- the compile-fail cases in `tests/ui` cover an operator the field's
indexes do not answer;
indexes do not answer (`marker_operator_not_answered`), a binding
on a query (`marker_query_has_no_bind`), and a missing, mistyped or
repeated scope (`marker_query_needs_its_scope`);
- tests show that a query cannot run without its plan or its required
context.
context (`tests/markers.rs`, `tests/planned.rs`).

#### `Planned` and its serialised form

Expand Down Expand Up @@ -1603,11 +1630,17 @@ Decided on 7 November 2026, as area one of the query design review.
- **Rules.** A serialised `Planned` is a transport form, never a storage
form, because its value is plaintext. It is never logged or written to disk. Its shape
is not part of the stored format.
- **Done already.** The guest already sends the engine a plan frame, then
a value frame for each call, through `plan_with`, `encrypt_with`,
`query`, and `decrypt_with` in `dynamic::record`. The serialised `Planned` names and groups what those functions already
receive.
- **Tracked in.** Issue #1486 is open, in part. On the Go side, issue #1116 is open to hide or rename `record.Plan`. Issue #1307 is open for a Go plan-building API, for types `stashgen` cannot tag. It builds a data plan, so the Rust builder's removal does not affect it.
- **Done already.** `Planned`, the query plan, the field handle,
`QueryTerm` and `contexts()` on a `Plan` and a `Planned` are on pull
request #1595 (`src/dynamic/planned.rs`, `tests/planned.rs`). `Planned` calls `encrypt_with`, `query_term` and
`decrypt_with` in `dynamic::record` and `dynamic::query`, under the
plan extended by the caller's parts. The guest still sends the engine
a plan frame, then a value frame for each call. The serialised
`Planned` names and groups what those functions already receive, and
is not built yet.
- **Tracked in.** Issue #1486 is open, in part. Issue #1594 is the
serialised `Planned`, the Go module sending it, and the corpus
rewrite, split from pull request #1595. On the Go side, issue #1116 is open to hide or rename `record.Plan`. Issue #1307 is open for a Go plan-building API, for types `stashgen` cannot tag. It builds a data plan, so the Rust builder's removal does not affect it.
- **Finished when** these are all true:
- every case in the conformance corpus is a serialised `Plan` or
`Planned` with the engine's expected answer beside it;
Expand Down
19 changes: 19 additions & 0 deletions packages/stack-encrypt-derive/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,25 @@
//! cryptographic map-entry context. Vitamin C still binds keys inside plaintext
//! maps and preserves authenticated absence and empty-container markers.
//!
//! # Field markers
//!
//! A `struct` record also emits one marker per derived field, as an
//! associated constant named after the field in upper case:
//! `EncryptedUser::EMAIL` for a field `email` (`EncryptedDoc::TYPE` for
//! `r#type`). A marker is the value a typed query of the field starts
//! from. It has one method per operator the field's indexes answer,
//! checked at compile time, where the field's type is one index
//! (`Encrypted<EqualityTerm>`, `Encrypted<OreTerm<u32>>`, `MatchTerms`);
//! a field of several indexes, a nullable field or a record field has a
//! marker with no operator method yet. A record that lists a caller scope
//! gives its markers that scope's type, and the query takes it with
//! `.scope(..)`, once; a record with none gives a query that is ready at
//! once. No binding can be given. The query ends with `.encrypt(&keyset)`
//! and derives under the context the derive sealed the field under, so it
//! is the stored term. See `stack_encrypt::Marker`. The constant's name is
//! the field's in upper case, so a record cannot define another constant
//! of that name; a tuple field has no name, so no marker.
//!
//! Ciphertext and term operations compose before awaiting, preserving batched
//! key requests. Terms alone need only their respective PRF or ordering trait.
//! Query-only targets derive `EncryptFrom` alone.
Expand Down
64 changes: 63 additions & 1 deletion packages/stack-encrypt-derive/src/owned_encrypt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,69 @@ pub(crate) fn derive(input: DeriveInput) -> Result<TokenStream> {
value_derive(&input, &record, &derived)
};
let contexts = contexts_fn(&input, &record);
Ok(quote!(#emitted #decryptable #contexts))
let markers = markers(&input, &record);
Ok(quote!(#emitted #decryptable #contexts #markers))
}

/// The field markers of a `struct = ..` record: one associated constant
/// per derived field, named after the field in upper case
/// (`EncryptedUser::EMAIL`), carrying the field's name, the record's scope,
/// the field's identity, the record's `contexts`, and the type of the
/// scope the caller supplies (`Scope<u64>` for `scope(tenant: u64)` or a
/// stored `scope(field = ..)` of that type; `NoScope` for none). A tuple
/// field has no name, so it has no marker.
fn markers(input: &DeriveInput, record: &Record) -> TokenStream {
if !record.by_field {
return TokenStream::new();
}
let krate = &record.krate;
let name = &input.ident;
let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl();
let scope_ty = match (&record.parts.caller_scope, &record.parts.stored_scope) {
(Some((_, ty)), _) => quote!(#krate::target::Scope<#ty>),
(None, Some(stored)) => {
let ty = &stored.ty;
quote!(#krate::target::Scope<#ty>)
}
(None, None) => quote!(#krate::target::NoScope),
};
let consts = record.fields.iter().filter_map(|field| {
let Kind::Derived {
by_field: Some(ByField { context, .. }),
} = &field.kind
else {
return None;
};
let syn::Member::Named(ident) = &field.member else {
return None;
};
// The marker's name is the field's as `contexts()` and the label
// spell it, `r#` included; the constant's name cannot carry it.
let field_name = ident.to_string();
let plain = field_name.strip_prefix("r#").unwrap_or(&field_name);
let const_name = Ident::new(&plain.to_uppercase(), ident.span());
let ty = &field.ty;
let OwnContext { prefix, field: identity } = context;
let doc = format!(
"The marker of the field `{field_name}`: what a typed query of it starts from. Emitted by `#[derive(EncryptTarget)]`; see [`Marker`]({}::Marker).",
quote!(#krate)
);
// A public record may hold a private field type (a nested record);
// the constant names that type, which `private_interfaces` would
// warn about in the user's crate.
Some(quote_spanned! {ty.span()=>
#[doc = #doc]
#[allow(dead_code, private_interfaces)]
pub const #const_name: #krate::Marker<#ty, #scope_ty> =
#krate::Marker::new(#field_name, #prefix, #identity, Self::contexts);
})
});
quote! {
#[automatically_derived]
impl #impl_generics #name #ty_generics #where_clause {
#(#consts)*
}
}
}

/// The caller's input on the encrypt side. A record whose fields have their
Expand Down
51 changes: 51 additions & 0 deletions packages/stack-encrypt/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,57 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- **Field markers** (#1486, #1590). `#[derive(EncryptTarget)]` on a
`struct = ..` record emits one `Marker` per derived field. It is an
associated constant named after the field in upper case:
`EncryptedUser::EMAIL`. A marker names the field, the context the derive
sealed it under, and the field's target type. It also names the type of
the scope the record's caller supplies. Its methods are one per operator the field's
index answers, checked at compile time through `Answers`: `eq`, `lt`,
`lte`, `gt`, `gte`, `between`, and `matches`. A record that lists a
caller scope, `scope(tenant: u64)`, gives a query with no `encrypt`
until `.scope(..)` is given once, with a `Scope<u64>`. A record with no
caller scope gives a query that is ready at once. The query has no
`bind` method, and ends with `.encrypt(&keyset)`. The output is the
index's query term. A query from a marker derives under the declaration
the derive wrote, so it is the stored term. No plan is written by hand
beside the record. A method takes its value by value, and a string
literal as it is (`QueryInput`). The query's value is wiped when it is
derived, refused, or dropped. `assert_context!` takes a marker, and
`Marker::contexts()` is the record's contexts narrowed to the field.
Not yet (#1596): a field of several indexes, a nullable field, and a
record field have markers with no operator method. An equality marker
takes a value of any type its index accepts. A tuple field has no
marker.
- **`dynamic::Planned`, a value under a plan that is data** (#1486). A
record under a `Plan`, or a query value under a `QueryPlan`, each a
`ValueFrame`, is a target in the owned API's sense. `StackCipherText`
implements `EncryptTarget<Planned<Plan, ValueFrame>>`. `QueryTerm`
implements `EncryptTarget<Planned<QueryPlan, ValueFrame>>`.
`Planned<Plan, StackCipherText>` implements `DecryptSource<ValueFrame>`.
So `encrypt_into`, `decrypt_from`, and their `_with_context` forms run
them. The caller's scope and bindings are the one context argument,
after the plan's own. A query takes scopes alone, and refuses a binding
before any key is requested, as `PlanError::BindInQuery`. A refused
term keeps its kind (`Error::Term`); other dynamic refusals are
`Error::Other`. `Planned::contexts()` shows every field's context with
a placeholder for the caller's scope and one for the caller's binding,
before any value is encrypted.
- **Query plans and the dynamic field handle** (#1486). `Plan::query(field,
op)` and `Plan::query_with(.., &resolver)` narrow a record plan to one
field and one operator. They are refused where they are made when none
of the field's indexes answers the operator (`operator_not_answered`),
or when the plan has no such field. `Plan::field(name)` and
`Plan::field_with(name, &resolver)` are the handle a program whose plan
is data queries through. It has one method per scalar operator and
`op(..)` for any, each checked against the plan where it is called, then
`.scope(..)` and `.encrypt(&keyset)`. Neither has a `bind` method.
- `ValueFrame` implements `Zeroize`. `dynamic::query_term` takes any
`K: 'static`, as `record::encrypt_with` does. The key source's bounds
apply when the pending is awaited, not when the term is described.

### Breaking

- **Every operator is declared once, and `op::Supports` has one method**
Expand Down
Loading
Loading