Skip to content

Commit c8a0301

Browse files
committed
fix(stack-encrypt): address the review of the tuple, nullable and record field markers
OreBlockTerm had no LabelledTarget impl, so a block ORE field, alone or beside an equality index, had a marker with no operator method. It is sealed under the caller's context like the other order terms. field() now bounds the inner record's scope with NestedScope, so an inner record with a scope of its own is refused with a message that says why, rather than a bare type mismatch. QueryValue is sealed for every type parameter. Root's outer labels are pushed by an empty method, so no equivalent mutant is left for the mutants gate. The docs say that an untyped nullable marker takes an Option, and that None derives a term no row holds; query = .. refuses it, and a ui case pins that. The tests add a caller binding and stored bindings around a nested record, an outer field under another identity, a nullable field of two indexes, an empty nested scope, block ORE, and the chain's tuple query without .via. The CHANGELOG describes the final Marker. Refs #1596
1 parent fcb00c7 commit c8a0301

11 files changed

Lines changed: 390 additions & 83 deletions

File tree

‎docs/plans/2026-10-24-stack-encrypt-api-plan.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1573,7 +1573,7 @@ review.
15731573
it, so the marker's index list is its target type's `Answers` impls.
15741574
Pull request #1595 lands the markers, their query methods and
15751575
`assert_context!` over a marker (`src/marker.rs`, `tests/markers.rs`,
1576-
`tests/ui/marker_*.rs`). The fix for #1596 gives a field of several
1576+
`tests/ui/marker_*.rs`). Pull request #1599 gives a field of several
15771577
indexes, a nullable field and a record field their operator methods,
15781578
and a field its query type (`#[stash(query = ..)]`). The chain query
15791579
`cipher.query(op, value).on(&plan)` still exists until #1488 removes
@@ -1590,8 +1590,8 @@ review.
15901590
lands with the builder's removal. The dynamic grammar keeps its
15911591
run-time checks.
15921592
- **Tracked in.** Issue #1486 is open; pull request #1595 closes it.
1593-
Issue #1596, the operator methods the markers lacked, is closed by the
1594-
pull request stacked on #1597.
1593+
Issue #1596, the operator methods the markers lacked, is closed by
1594+
pull request #1599.
15951595
- **Finished when** these are all true:
15961596
- the [marker example](#query-a-record-field) compiles, with the
15971597
index's query term as the output type; the EQL query types come

‎packages/stack-encrypt-derive/src/attrs.rs‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -340,7 +340,8 @@ pub(crate) struct FieldAttrs {
340340
/// `#[stash(query = String)]`: with `struct = ..`, the type a query of
341341
/// this field takes, so its marker's methods take that type alone. The
342342
/// derive checks it against the plaintext field, which is that type or
343-
/// an `Option` of it.
343+
/// an `Option` of it. On a field whose marker has no operator method
344+
/// (a record field, a ciphertext alone), only the check applies.
344345
pub(crate) query: Option<Type>,
345346
}
346347

‎packages/stack-encrypt-derive/src/lib.rs‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,9 @@
140140
//! it against the plaintext struct's field, which is that type or an
141141
//! `Option` of it. Without it, an equality marker takes any type its index
142142
//! accepts, since the derive does not see the plaintext struct's field
143-
//! types otherwise. A record that lists a caller scope
143+
//! types otherwise. On a field whose marker has no operator method, such
144+
//! as a record field or a `StackCipherText`, it does nothing beyond that
145+
//! check. A record that lists a caller scope
144146
//! gives its markers that scope's type, and the query takes it with
145147
//! `.scope(..)`, once; a record with none gives a query that is ready at
146148
//! once. No binding can be given. The query ends with `.encrypt(&keyset)`

‎packages/stack-encrypt/CHANGELOG.md‎

Lines changed: 36 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -9,40 +9,43 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12-
- **Field markers** (#1486, #1590). `#[derive(EncryptTarget)]` on a
13-
`struct = ..` record emits one `Marker` per derived field. It is an
12+
- **Field markers** (#1486, #1590, #1596). `#[derive(EncryptTarget)]` on
13+
a `struct = ..` record emits one `Marker` per derived field. It is an
1414
associated constant named after the field in upper case:
15-
`EncryptedUser::EMAIL`. A marker names the field, the context the derive
16-
sealed it under, and the field's target type. It also names the type of
17-
the scope the record's caller supplies. Its methods are one per operator the field's
18-
index answers, checked at compile time through `Answers`: `eq`, `lt`,
19-
`lte`, `gt`, `gte`, `between`, and `matches`. A record that lists a
20-
caller scope, `scope(tenant: u64)`, gives a query with no `encrypt`
21-
until `.scope(..)` is given once, with a `Scope<u64>`. A record with no
22-
caller scope gives a query that is ready at once. The query has no
23-
`bind` method, and ends with `.encrypt(&keyset)`. The output is the
24-
index's query term. A query from a marker derives under the declaration
25-
the derive wrote, so it is the stored term. No plan is written by hand
26-
beside the record. The query's copy of the value is wiped when it is
27-
derived, refused, or dropped. `assert_context!` takes a marker, and
28-
`Marker::contexts()` is the record's contexts narrowed to the field.
29-
A tuple field has no marker.
30-
- **Markers for fields of several indexes, nullable fields, and record
31-
fields** (#1596). A field of several indexes,
32-
`Encrypted<(EqualityTerm, MatchTerms)>`, answers each operator through
33-
the one index that answers it. Each term states its answer to each
34-
operator with `op::OperatorAnswer`. An operator that no index answers,
35-
or that two answer, has no method. An `Option<..>` field answers what a
36-
present value answers, and a present value's term is a bare value's.
37-
`EncryptedAccount::ADDRESS.field(EncryptedAddress::STREET)` is the
38-
inner field's marker under the outer field's context. It takes the
39-
outer record's scope, and only a marker of the record the field holds.
40-
`#[stash(query = String)]` states the type a field's queries take, so
41-
`EMAIL.eq(&42u64)` no longer compiles on a text field. The derive checks
42-
the type against the plaintext struct's field. `Marker` now has the
43-
parameters `Marker<W, T, C, P>`: `W` is where the field sits
44-
(`marker::Root<R>` or `marker::Nested<W>`), and `P` is the query type
45-
(`marker::TakesAny` or `marker::Takes<V>`).
15+
`EncryptedUser::EMAIL`. A marker is `Marker<W, T, C, P>`. `W` is where
16+
the field sits: `marker::Root<R>` for a field of the record `R`, or
17+
`marker::Nested<W>` for one reached through a record field. `T` is the
18+
field's target type. `C` is the scope the record's caller supplies.
19+
`P` is the type a query takes: `marker::TakesAny`, or `marker::Takes<V>`
20+
for a field marked `#[stash(query = V)]`.
21+
- Its methods are one per operator the field's indexes answer, checked
22+
at compile time through `Answers`: `eq`, `lt`, `lte`, `gt`, `gte`,
23+
`between`, and `matches`. A field of several indexes,
24+
`Encrypted<(EqualityTerm, MatchTerms)>`, answers each operator through
25+
the one index that answers it. Each term states its answer with
26+
`op::OperatorAnswer`. An operator no index answers, or two answer, has
27+
no method. An `Option<..>` field has the methods a present value has.
28+
- `EncryptedAccount::ADDRESS.field(EncryptedAddress::STREET)` is the
29+
inner field's marker under the outer field's context. It takes the
30+
outer record's scope. `field` accepts only a marker of the inner record.
31+
- `#[stash(query = String)]` states the type a field's queries take. The
32+
derive checks it against the plaintext struct's field.
33+
- A record that lists a caller scope, `scope(tenant: u64)`, gives a
34+
query with no `encrypt` until `.scope(..)` is given once, with a
35+
`Scope<u64>`. A record with no caller scope gives a query that is
36+
ready at once. The query has no `bind` method, and ends with
37+
`.encrypt(&keyset)`. The output is the index's query term.
38+
- A query from a marker derives under the declaration the derive wrote,
39+
so it is the stored term. No plan is written by hand beside the
40+
record. The query's copy of the value is wiped when it is derived,
41+
refused, or dropped. `assert_context!` takes a marker, and
42+
`Marker::contexts()` is the record's contexts narrowed to the field.
43+
A tuple field has no marker.
44+
- **Tuples of terms answer the query chain too** (#1596). A query of
45+
`op::Eq` or `op::Matches` on a plan of
46+
`Encrypted<(EqualityTerm, MatchTerms)>` needs no `.via(index)`. The
47+
query uses the one term that answers the operator. `OreBlockTerm` implements `LabelledTarget`, so a block ORE field
48+
is queried under its label.
4649
- **`dynamic::Planned`, a value under a plan that is data** (#1486). A
4750
record under a `Plan`, or a query value under a `QueryPlan`, each a
4851
`ValueFrame`, is a target in the owned API's sense. `StackCipherText`

‎packages/stack-encrypt/src/marker.rs‎

Lines changed: 58 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@
9797
//! `Outer::ACCOUNT.field(EncryptedAccount::ADDRESS).field(EncryptedAddress::STREET)`.
9898
//! The inner record must list no scope of its own, stored or supplied: a
9999
//! query of it would take two scopes, and `field` takes a marker whose
100-
//! scope is [`NoScope`].
100+
//! scope is [`NoScope`] ([`NestedScope`]).
101101
//!
102102
//! # The type a query takes
103103
//!
@@ -109,6 +109,12 @@
109109
//! type alone ([`Takes`]), and the derive checks it against the plaintext
110110
//! struct's field, which is that type or an `Option` of it. An order marker
111111
//! fixes the type without the attribute, since `OreTerm<S>` names it.
112+
//!
113+
//! A nullable field's marker without the attribute also takes an `Option`,
114+
//! since the equality index accepts one. `NICK.eq(&None::<String>)`
115+
//! compiles and derives a term that no stored row holds: a `None` field
116+
//! stores no term, and a marker query is never `IS NULL`. Mark the field
117+
//! `#[stash(query = String)]` to refuse it.
112118
use std::marker::PhantomData;
113119

114120
use stack_kms::MaybeSend;
@@ -158,7 +164,7 @@ impl<W: Place, T, C, P> std::fmt::Debug for Marker<W, T, C, P> {
158164
.field("name", &self.name)
159165
.field("scope", &self.scope)
160166
.field("identity", &self.identity)
161-
.field("within", &self.place.outers())
167+
.field("within", &outers(&self.place))
162168
.finish_non_exhaustive()
163169
}
164170
}
@@ -221,8 +227,12 @@ impl<W: Place, T, C, P> Marker<W, T, C, P> {
221227
///
222228
/// `inner` must be a marker of the record `T` this field holds, so a
223229
/// marker of another record does not compile. That record must list no
224-
/// scope of its own ([`NoScope`]): a query of it would take two scopes.
225-
pub fn field<U, Q>(self, inner: Marker<Root<T>, U, NoScope, Q>) -> Marker<Nested<W>, U, C, Q> {
230+
/// scope of its own ([`NestedScope`]): a query of it would take two
231+
/// scopes.
232+
pub fn field<U, C2: NestedScope, Q>(
233+
self,
234+
inner: Marker<Root<T>, U, C2, Q>,
235+
) -> Marker<Nested<W>, U, C, Q> {
226236
Marker {
227237
name: inner.name,
228238
scope: inner.scope,
@@ -246,7 +256,7 @@ impl<W: Place, T, C, P> Marker<W, T, C, P> {
246256
{
247257
MarkedQuery {
248258
label: self.label(),
249-
outers: self.place.outers(),
259+
outers: outers(&self.place),
250260
value: Some(value),
251261
wipe,
252262
scopes: DeclaredContext::default(),
@@ -263,16 +273,40 @@ fn label(scope: &'static str, identity: &'static str) -> Label {
263273
})
264274
}
265275

266-
/// Where a marked field sits: [`Root`] for a field of the record a marker
267-
/// was emitted for, [`Nested`] for one reached through a record field.
268-
/// Sealed: [`Marker::field`] makes the one, the derive the other.
276+
/// The labels of the record fields that hold a field at `place`,
277+
/// outermost first: empty for a field of the record itself.
278+
fn outers(place: &impl Place) -> Vec<Label> {
279+
let mut outers = Vec::new();
280+
place.push_outers(&mut outers);
281+
outers
282+
}
283+
284+
/// The scope a record lists for its own caller, where the record is the
285+
/// type of another record's field: [`NoScope`] alone. A query of a field of
286+
/// a record with its own scope would take that scope and the outer
287+
/// record's.
288+
#[diagnostic::on_unimplemented(
289+
message = "`field` takes a marker of a record that lists no scope of its own, not `{Self}`",
290+
label = "a query of this field would take two scopes",
291+
note = "the inner record's fields are reached by `field` only when the inner record has no `scope(..)` of its own, stored or supplied"
292+
)]
293+
pub trait NestedScope: nested_sealed::Sealed {}
294+
mod nested_sealed {
295+
pub trait Sealed {}
296+
}
297+
impl nested_sealed::Sealed for NoScope {}
298+
impl NestedScope for NoScope {}
299+
300+
/// Where a marked field sits: [`Root`] for a field of the record the
301+
/// derive emitted the marker for, [`Nested`] for one that
302+
/// [`Marker::field`] reached through a record field. Sealed.
269303
pub trait Place: Copy + place_sealed::Sealed {
270304
/// The outermost record's contexts, narrowed to the record that holds
271305
/// the field.
272306
fn narrow(&self, contexts: Contexts) -> Contexts;
273-
/// The labels of the record fields that hold the field, outermost
274-
/// first: empty for a field of the record itself.
275-
fn outers(&self) -> Vec<Label>;
307+
/// Push the labels of the record fields that hold the field, outermost
308+
/// first: none for a field of the record itself.
309+
fn push_outers(&self, outers: &mut Vec<Label>);
276310
}
277311
mod place_sealed {
278312
pub trait Sealed {}
@@ -292,9 +326,7 @@ impl<R> Place for Root<R> {
292326
fn narrow(&self, contexts: Contexts) -> Contexts {
293327
contexts
294328
}
295-
fn outers(&self) -> Vec<Label> {
296-
Vec::new()
297-
}
329+
fn push_outers(&self, _: &mut Vec<Label>) {}
298330
}
299331

300332
/// A field of a record that is itself the field named here, which sits at
@@ -311,38 +343,37 @@ impl<W: Place> Place for Nested<W> {
311343
fn narrow(&self, contexts: Contexts) -> Contexts {
312344
self.outer.narrow(contexts).narrowed(self.name)
313345
}
314-
fn outers(&self) -> Vec<Label> {
315-
let mut outers = self.outer.outers();
346+
fn push_outers(&self, outers: &mut Vec<Label>) {
347+
self.outer.push_outers(outers);
316348
outers.push(label(self.scope, self.identity));
317-
outers
318349
}
319350
}
320351

321352
/// The type a marker's queries take: [`TakesAny`], any type the field's
322353
/// index accepts, or [`Takes<V>`], `V` alone. A field marked
323-
/// `#[stash(query = V)]` is `Takes<V>`.
324-
#[diagnostic::on_unimplemented(
325-
message = "this field's queries take `{Self}`, not `{V}`",
326-
label = "a query of another type matches nothing",
327-
note = "the record states the field's query type with `#[stash(query = ..)]`"
328-
)]
329-
pub trait QueryValue<V>: value_sealed::Sealed {}
354+
/// `#[stash(query = V)]` is `Takes<V>`. Sealed, for every `V`.
355+
pub trait QueryValue<V>: value_sealed::Sealed<V> {}
330356
mod value_sealed {
331-
pub trait Sealed {}
357+
pub trait Sealed<V> {}
332358
}
333359

334360
/// A marker whose queries take any type the field's index accepts: the
335361
/// default, where the record does not state the field's query type.
336362
#[derive(Debug)]
337363
pub enum TakesAny {}
338-
impl value_sealed::Sealed for TakesAny {}
364+
impl<V> value_sealed::Sealed<V> for TakesAny {}
339365
impl<V> QueryValue<V> for TakesAny {}
340366

341367
/// A marker whose queries take `V` alone: a field marked
342368
/// `#[stash(query = V)]`. A query value is copied from its borrowed form,
343369
/// so a marker of `Takes<String>` accepts `"ada"` as well as a `&String`.
370+
///
371+
/// A value of another type is refused at compile time. Since `V` is the one
372+
/// type the marker accepts, the compiler reports it as a mismatch of the
373+
/// value's owned form: "type mismatch resolving `<u64 as ToOwned>::Owned
374+
/// == String`".
344375
pub struct Takes<V>(PhantomData<fn() -> V>);
345-
impl<V> value_sealed::Sealed for Takes<V> {}
376+
impl<V> value_sealed::Sealed<V> for Takes<V> {}
346377
impl<V> QueryValue<V> for Takes<V> {}
347378

348379
/// A plaintext field whose queries take `V`: `V` itself, or `Option<V>`

‎packages/stack-encrypt/src/op/mod.rs‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -969,7 +969,8 @@ pub trait Answers<Op: Operator, In: Input<Op::Arity>> {
969969
/// Implementing it is a claim about the stored values: a query term derived
970970
/// under the label equals the stored term only if the stored term was
971971
/// derived under the same context. This crate's term targets
972-
/// ([`EqualityTerm`], [`MatchTerms`], [`OreTerm`], [`OpeTerm`], and
972+
/// ([`EqualityTerm`], [`MatchTerms`], [`OreTerm`], [`OpeTerm`],
973+
/// [`OreBlockTerm`], a tuple of them, an `Option` of one, and
973974
/// [`Encrypted`](crate::target::Encrypted) or a ciphertext beside one
974975
/// term) are sealed under the caller's context, which is the label. An EQL
975976
/// v4 type is sealed under the column it stores, which a two-segment label
@@ -993,6 +994,7 @@ impl LabelledTarget for EqualityTerm {}
993994
impl<O> LabelledTarget for MatchTerms<O> {}
994995
impl<S: Orderable> LabelledTarget for OreTerm<S> {}
995996
impl<S: Orderable> LabelledTarget for OpeTerm<S> {}
997+
impl<S: Orderable> LabelledTarget for OreBlockTerm<S> {}
996998
impl<T: LabelledTarget> LabelledTarget for (crate::StackCipherText, T) {}
997999
/// As the terms beside it are: a ciphertext is sealed under the caller's
9981000
/// context, so the terms decide whether the label names theirs.
@@ -1033,6 +1035,12 @@ impl<Op: Operator, In: Input<Op::Arity>, T: Answers<Op, In>> Answers<Op, In>
10331035
/// A nullable field answers what a present value answers. A `None` is a
10341036
/// database NULL, with no term, which no query matches; a `Some` holds
10351037
/// the terms a bare value of `T` holds, derived under the same context.
1038+
///
1039+
/// The input is a present value's. An equality index also accepts an
1040+
/// `Option`, so a query of `None` compiles where the input is not fixed,
1041+
/// and derives a term that no stored row holds: it is not `IS NULL`. A
1042+
/// field marked `#[stash(query = String)]` takes a `String` alone, and
1043+
/// refuses `None`.
10361044
impl<Op: Operator, In: Input<Op::Arity>, T: Answers<Op, In>> Answers<Op, In> for Option<T> {
10371045
type Via = T::Via;
10381046
}
@@ -1059,7 +1067,9 @@ impl<T: LabelledTarget> LabelledTarget for Option<T> {}
10591067
/// Implemented for this crate's terms and the operators [`Eq`](struct@Eq),
10601068
/// [`Lt`], [`Lte`], [`Gt`], [`Gte`], [`Between`] and
10611069
/// [`Matches`](struct@Matches). A term defined in another crate implements
1062-
/// it for each of those to sit in a tuple.
1070+
/// it for each of those to sit in a tuple, and implements
1071+
/// [`LabelledTarget`] too: a tuple is queried only when each of its terms
1072+
/// was derived under the field's label.
10631073
#[diagnostic::on_unimplemented(
10641074
message = "`{Self}` does not say whether it answers `{Op}`",
10651075
label = "a term in a tuple of terms states its answer to each operator",

0 commit comments

Comments
 (0)