Skip to content

Commit b9f84a5

Browse files
committed
feat(stack-encrypt): let a target sealed under its own context opt in to queries on its plan
A typed plan answers an operator through its target type's `op::Answers` impl, but the chain required `Typed<T>: ValueShape<S>`: a label that converts into `T`'s context. An EQL v4 type is sealed under its column (`NonEmpty<Identifier>`), so no plan for one could be queried, and eql-bindings had nowhere to implement `Answers` (#1238). `op::LabelledTarget` is the opt-in: a target type says its stored terms were derived under the context a plan's label names. For such a type, `Chooses` asks only `ValueLayout<S>` (the indexes `T` declares), since a query derives through the index and never runs `T`'s encryption. Every other type keeps needing `ValueShape`, so a type sealed under, say, a tenant id cannot be queried under a label that would match nothing. This crate's term targets implement the trait, which keeps every query that compiled before compiling. `Chooses` now resolves the plan itself (`choose`) rather than through `Resolves`, which keeps `ValueShape`: a named index (`.via(..)`) on a plan whose type only opts in would send a term the type does not send. An EQL query value stores the column it queries, so its index needs the context its terms were derived under, and must refuse one it cannot record. `QueryToken`, which only the chain makes, now carries the label and whether the call extended it (`label`, `is_extended`): with `.extend(..)` the terms are derived under the label and the extension, so a term that records the label alone would match nothing. The defaulted `Supports::check_label` refuses such a context, and the chain runs it in `check`, where the label is resolved, before any keyset is loaded or term derived (and again in `prepare`, as for `check`). `Supports::query` stays infallible: the chain calls it only after `check_label` accepted the token's context. Every index here takes any context. Tests: a labelled target is queried under the plan's or the call's label, and its term reads whether the call extended it; a label its index refuses loads no keyset; and a UI case pins that a type with an `Answers` impl but no `LabelledTarget` answers no query on its plan.
1 parent c612ed4 commit b9f84a5

10 files changed

Lines changed: 447 additions & 35 deletions

‎packages/stack-encrypt/CHANGELOG.md‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2929
whole integer into a decimal at scale 0). `tests/value_kinds.rs` and its
3030
fixture hold the record path, the data query path and the typed path to
3131
the same bytes, and the Go SDK reads the fixture too.
32+
- **A target sealed under a context of its own can opt in to queries on
33+
its plan** (#1238). `op::LabelledTarget` says a target type's stored terms
34+
were derived under the context a plan's label names. A typed plan for such
35+
a type (`Plan::value::<S>().encrypt_into::<T>()`) answers the operators its
36+
`op::Answers` impls name, and needs only the indexes `T` declares, not a
37+
context the label converts into: an EQL v4 type, sealed under its column,
38+
is queried through its plan though it cannot be encrypted through it.
39+
This crate's term targets implement it; a type sealed under a context no
40+
label names must not, and its plan answers no query. Naming an index with
41+
`.via(..)` still needs a type sealed under the label itself.
42+
`op::QueryToken` carries the context the query's terms were derived under
43+
(`label`, `is_extended`), for a query term that records it, and
44+
`op::Supports::check_label` (defaulted to `Ok`) lets such an index refuse
45+
a context it cannot record when the query is awaited, before any keyset
46+
is resolved or term derived.
3247
- **A binding queries by operator** (`dynamic` feature; ADR-0009, ADR-0007
3348
amended 2026-10-16). `dynamic::query_term(keyset, &plan, field, op,
3449
input, &resolver)` derives the query term for a field of a data plan, an

‎packages/stack-encrypt/docs/adr/0009-operators-select-the-index-and-a-query-term-is-not-the-stored-term.md‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,11 @@ pub trait Supports<Op: Operator, In: Input<Op::Arity>>: Index<In::Plaintext> {
157157
`query` makes it from the verifier and the terms the index derived for the
158158
query's input. `QueryToken` can be made only inside the crate, so only a
159159
query chain calls `query`, and no caller can turn a stored term into a query
160-
term by hand.
160+
term by hand. The token also carries the context the terms were derived
161+
under, for a query term that records it, as an EQL query value stores its
162+
column; such an index refuses a context it cannot record in a defaulted
163+
`check_label`, which the chain runs with `check`, before any term exists
164+
(#1238).
161165

162166
`QueryTerm` and the two traits that refine it are defined in decision 4.
163167
`Query` is not `Term`. An ORE index's `Supports<Lt, S>::Query` is the left

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

Lines changed: 81 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -661,27 +661,65 @@ pub trait Supports<Op: Operator, In: Input<Op::Arity>>: Index<In::Plaintext> {
661661
self.check(input)?;
662662
Ok(self.verifier(input))
663663
}
664+
/// Why this index cannot answer a query whose terms are derived under
665+
/// `label`, the plan's or the call's, extended by parts the call gave
666+
/// (`.extend(..)`) when `extended` is true: for an index whose query term
667+
/// records the context it was derived under, a context it cannot record.
668+
/// An EQL query value stores the column it queries, so its index refuses
669+
/// a label that names no column, and any extension, which the column
670+
/// cannot hold. Run when the query is awaited, before [`check`](Self::check)
671+
/// and before any keyset is resolved; every index in this crate takes
672+
/// any context and returns `Ok(())`.
673+
fn check_label(&self, _label: &Label, _extended: bool) -> Result<(), Error> {
674+
Ok(())
675+
}
664676
/// The query term, from the verifier and the terms the index derived
665677
/// for the input's plaintexts. Only a query chain calls this:
666678
/// `QueryToken` cannot be made outside this crate, so no caller can
667679
/// pair one query's tokens with another value's filter, or pass a
668680
/// stored term off as a query.
681+
///
682+
/// The token carries the context the terms were derived under
683+
/// ([`QueryToken::label`], [`QueryToken::is_extended`]), for a query
684+
/// term that records it beside its terms. The chain calls this only
685+
/// after [`check_label`](Self::check_label) accepted that context, so a
686+
/// term built from it need not refuse it again.
669687
fn query(
670688
verifier: Self::Verifier,
671689
terms: In::Terms<Self::Term>,
672690
token: QueryToken,
673691
) -> Self::Query;
674692
}
675693

676-
/// Proof that [`Supports::query`] is called by a query chain. Only this
677-
/// crate can make one, so an index in another crate can implement
678-
/// [`Supports`] but no caller can turn a term into a query term by hand.
694+
/// Proof that [`Supports::query`] is called by a query chain, and the
695+
/// context the query's terms were derived under. Only this crate can make
696+
/// one, so an index in another crate can implement [`Supports`] but no
697+
/// caller can turn a term into a query term by hand, or name a context its
698+
/// terms were not derived under.
679699
#[derive(Debug)]
680-
pub struct QueryToken(());
700+
pub struct QueryToken {
701+
label: Label,
702+
extended: bool,
703+
}
681704

682705
impl QueryToken {
683-
pub(crate) fn new() -> Self {
684-
Self(())
706+
pub(crate) fn new(label: Label, extended: bool) -> Self {
707+
Self { label, extended }
708+
}
709+
710+
/// The label the query's terms were derived under, before any
711+
/// extension: the plan's or the call's, the context the write used for
712+
/// the field. It is the whole context exactly when
713+
/// [`is_extended`](Self::is_extended) is false.
714+
pub fn label(&self) -> &Label {
715+
&self.label
716+
}
717+
718+
/// Whether the call extended the label by parts of its own
719+
/// (`.extend(..)`), so the terms were derived under the label followed
720+
/// by those parts rather than the label alone.
721+
pub fn is_extended(&self) -> bool {
722+
self.extended
685723
}
686724
}
687725

@@ -813,6 +851,43 @@ pub trait Answers<Op: Operator, In: Input<Op::Arity>> {
813851
type Via: Supports<Op, In> + Default;
814852
}
815853

854+
/// A target type whose stored terms were derived under the context a
855+
/// query's label names, so a plan for it
856+
/// (`Plan::value::<S>().encrypt_into::<T>()`) answers the operators its
857+
/// [`Answers`] impls name, with each query derived under the plan's label.
858+
///
859+
/// Implementing it is a claim about the stored values: a query term derived
860+
/// under the label equals the stored term only if the stored term was
861+
/// derived under the same context. This crate's term targets
862+
/// ([`EqualityTerm`], [`MatchTerms`], [`OreTerm`], [`OpeTerm`], and
863+
/// [`Encrypted`](crate::target::Encrypted) or a ciphertext beside one
864+
/// term) are sealed under the caller's context, which is the label. An EQL
865+
/// v4 type is sealed under the column it stores, which a two-segment label
866+
/// names, and its index refuses any other label
867+
/// ([`Supports::check_label`]). A type sealed under a context no label
868+
/// names, such as an integer tenant id, must not implement it: a query on
869+
/// its plan would match nothing, with no error.
870+
///
871+
/// A query on such a plan derives through the index alone and never runs
872+
/// the target's own encryption, so the target need not accept the label as
873+
/// its context: an EQL type, sealed under a column identifier, cannot be
874+
/// encrypted through its plan and is queried through it all the same.
875+
#[diagnostic::on_unimplemented(
876+
message = "`{Self}` is not queried under a plan's label",
877+
label = "a typed plan for this target type answers no query",
878+
note = "a target type answers queries on its plan when it implements `op::LabelledTarget`: its stored terms were derived under the context the plan's label names"
879+
)]
880+
pub trait LabelledTarget {}
881+
882+
impl LabelledTarget for EqualityTerm {}
883+
impl<O> LabelledTarget for MatchTerms<O> {}
884+
impl<S: CllwOreEncrypt> LabelledTarget for OreTerm<S> {}
885+
impl<S: CllwOpeEncrypt> LabelledTarget for OpeTerm<S> {}
886+
impl<T: LabelledTarget> LabelledTarget for (crate::StackCipherText, T) {}
887+
/// As the terms beside it are: a ciphertext is sealed under the caller's
888+
/// context, so the terms decide whether the label names theirs.
889+
impl<T: LabelledTarget> LabelledTarget for crate::target::Encrypted<T> {}
890+
816891
impl<S: crate::Encrypt + 'static> Answers<Eq, S> for EqualityTerm {
817892
type Via = Equality;
818893
}

‎packages/stack-encrypt/src/plan/chain.rs‎

Lines changed: 62 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,9 @@ use uuid::Uuid;
99

1010
use super::build::{FieldPlan, FieldsBuilder, IntoLabel, Opens, PlanContext, Runs};
1111
use super::field_ref::FieldRef;
12-
use super::value::{Indexed, Typed, ValuePlan, ValueShape};
12+
use super::value::{Indexed, Typed, ValueLayout, ValuePlan, ValueShape};
1313
use super::PlanError;
14-
use crate::op::{Answers, DefaultIndex, Input, Operator, QueryToken, Supports};
14+
use crate::op::{Answers, DefaultIndex, Input, LabelledTarget, Operator, QueryToken, Supports};
1515
use crate::target::{
1616
ciphertext, Borrowed, CallerContext, DeclaredContext, DecryptField, Decryptable, EncryptFrom,
1717
Index, Indexes, Pending, PendingFuture, Select, Whole,
@@ -650,7 +650,22 @@ impl<'a, Op: Operator, F: Input<Op::Arity>, K: 'static> QueryBuilder<'a, Op, F,
650650
where
651651
P: Chooses<Op, F, At>,
652652
{
653-
self.via(P::Via::default()).on(plan)
653+
let QueryBuilder {
654+
mut common,
655+
value,
656+
op,
657+
} = self;
658+
let (index, label) = match common.call_context() {
659+
Ok(call) => plan.choose(call),
660+
Err(error) => (P::Via::default(), Err(error)),
661+
};
662+
QueryOp {
663+
common,
664+
value,
665+
index,
666+
label,
667+
op,
668+
}
654669
}
655670
}
656671

@@ -711,7 +726,12 @@ where
711726
///
712727
/// A field of a fields plan and a one-value plan built `with` indexes
713728
/// answer through the operator's [`DefaultIndex`]; a one-value plan for a
714-
/// target type answers through the type's [`Answers`] impl. The plan's
729+
/// target type answers through the type's [`Answers`] impl, when the type
730+
/// is a [`LabelledTarget`], whose stored terms were derived under the
731+
/// context the plan's label names. Such a plan is queried through the
732+
/// indexes the type declares ([`ValueLayout`]) even when the type cannot be
733+
/// encrypted through it, as an EQL type sealed under its column cannot.
734+
/// The plan's
715735
/// plaintext is the input's ([`Input::Plaintext`]): `S` for a query given
716736
/// an `S`, and for a `Between` given an `(S, S)`. `At` is the position of
717737
/// that index in a plan built `with` several, and is inferred.
@@ -720,15 +740,21 @@ where
720740
label = "no index answers this operator on this plan",
721741
note = "a field of a fields plan or a plan built `with` indexes answers an operator through its default index (`op::Eq`: `Equality`; `op::Matches`: `Match` with the default configuration, case-insensitive and over text only; `op::Lt`, `Lte`, `Gt`, `Gte` and `Between`: `Ore` over an integer, `Between` given a pair); a typed plan answers the operators its target type implements `Answers` for; `.via(index)` names an index the plan declares"
722742
)]
723-
pub trait Chooses<Op: Operator, In: Input<Op::Arity>, At>:
724-
Resolves<In::Plaintext, Self::Via, At>
725-
{
743+
pub trait Chooses<Op: Operator, In: Input<Op::Arity>, At>: plan_sealed::Sealed {
726744
/// The index that answers `Op` on this plan.
727745
type Via: Supports<Op, In> + Default;
746+
/// The index the query runs through on this plan, and the label the
747+
/// write used, or why the plan refuses the query, as
748+
/// [`Resolves::resolve`] says. `call` is the context the call named, if
749+
/// any.
750+
fn choose(&self, call: Option<Label>) -> (Self::Via, Result<Label, PlanError>);
728751
}
729752

730753
impl<Op: DefaultIndex<In>, In: Input<Op::Arity>> Chooses<Op, In, Whole> for FieldPlan {
731754
type Via = Op::Via;
755+
fn choose(&self, call: Option<Label>) -> (Op::Via, Result<Label, PlanError>) {
756+
Resolves::<In::Plaintext, Op::Via, Whole>::resolve(self, Op::Via::default(), call)
757+
}
732758
}
733759

734760
impl<Op, S, In, X, At> Chooses<Op, In, At> for ValuePlan<S, Indexed<X>>
@@ -740,18 +766,25 @@ where
740766
X: Indexes<S> + Select<Op::Via, At>,
741767
{
742768
type Via = Op::Via;
769+
fn choose(&self, call: Option<Label>) -> (Op::Via, Result<Label, PlanError>) {
770+
Resolves::<S, Op::Via, At>::resolve(self, Op::Via::default(), call)
771+
}
743772
}
744773

745774
impl<Op, S, In, T> Chooses<Op, In, Whole> for ValuePlan<S, Typed<T>>
746775
where
747776
Op: Operator,
748777
In: Input<Op::Arity, Plaintext = S>,
749778
S: 'static,
750-
T: Answers<Op, In>,
751-
T::Via: Default,
752-
Typed<T>: ValueShape<S>,
779+
T: Answers<Op, In> + LabelledTarget,
780+
Typed<T>: ValueLayout<S>,
753781
{
754782
type Via = T::Via;
783+
fn choose(&self, call: Option<Label>) -> (T::Via, Result<Label, PlanError>) {
784+
let index = T::Via::default();
785+
let label = self.query_label(&index.spec(), call);
786+
(index, label)
787+
}
755788
}
756789

757790
mod plan_sealed {
@@ -769,7 +802,11 @@ mod plan_sealed {
769802
/// A field of a fields plan ([`Plan::field`](super::Plan::field)) and a
770803
/// one-value plan for a target type take the index as given, and refuse
771804
/// one the plan does not declare when the query is awaited
772-
/// ([`PlanError::IndexNotDeclared`]). A one-value plan built `with`
805+
/// ([`PlanError::IndexNotDeclared`]). A one-value plan for a target type
806+
/// takes a named index only when the type is sealed under the label
807+
/// ([`ValueShape`]): a type that answers through its [`Answers`] impls
808+
/// alone, as an EQL type does, cannot have another index named on its
809+
/// plan, which would send a term the type's SQL does not take. A one-value plan built `with`
773810
/// indexes selects its own index of that type ([`Indexes::select`]), at
774811
/// the position `At`, which is inferred; a plan without that index does
775812
/// not compile.
@@ -841,9 +878,9 @@ where
841878
self.common.keyset.take()
842879
}
843880
fn check(&self) -> Result<(), Error> {
844-
if let Err(error) = &self.label {
845-
return Err(error.clone().into());
846-
}
881+
let label = self.label.as_ref().map_err(|error| error.clone())?;
882+
self.index
883+
.check_label(label, self.common.extend.is_extended())?;
847884
self.index.check(&self.value)
848885
}
849886
fn prepare<'p>(self, keyset: &'p KeysetCipher<'a, K>, _: bool) -> Pending<'p, Self::Output, K>
@@ -855,16 +892,22 @@ where
855892
Err(error) => return Pending::failed(keyset, error.into()),
856893
};
857894
// `check` ran before any keyset was resolved; a chain prepared on
858-
// its own is held to the same refusal, so no term is derived from a
859-
// value the index refuses. `admit` checks and takes the verifier in
860-
// one pass over the value.
861-
let verifier = match self.index.admit(&self.value) {
895+
// its own is held to the same refusals, so no term is derived under
896+
// a context or from a value the index refuses. `admit` checks and
897+
// takes the verifier in one pass over the value.
898+
let extended = self.common.extend.is_extended();
899+
let verifier = match self
900+
.index
901+
.check_label(&label, extended)
902+
.and_then(|()| self.index.admit(&self.value))
903+
{
862904
Ok(verifier) => verifier,
863905
Err(error) => return Pending::failed(keyset, error),
864906
};
907+
let token = QueryToken::new(label.clone(), extended);
865908
self.value
866909
.derive(&self.index, keyset, label, self.common.extend)
867-
.map(move |terms| I::query(verifier, terms, QueryToken::new()))
910+
.map(move |terms| I::query(verifier, terms, token))
868911
}
869912
}
870913

‎packages/stack-encrypt/src/plan/value.rs‎

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,14 @@ mod sealed {
337337
/// into that context. A target whose context is, say, a `NonEmpty<u64>`
338338
/// builds, and runs through
339339
/// [`encryption_with_context`](ValuePlan::encryption_with_context).
340+
///
341+
/// A query through a [`Typed`] plan whose target is a
342+
/// [`LabelledTarget`](crate::op::LabelledTarget) needs only this: the query
343+
/// derives through the index the target's `Answers` impl names, under the
344+
/// plan's label, and never runs the target's encryption. So an EQL type,
345+
/// sealed under its column rather than the label, is queried through its
346+
/// plan though it cannot be encrypted through it. Naming another index with
347+
/// `.via(..)` needs [`ValueShape`].
340348
pub trait ValueLayout<S>: sealed::Sealed + Clone {
341349
/// What one run produces.
342350
type Output: 'static;
@@ -346,7 +354,9 @@ pub trait ValueLayout<S>: sealed::Sealed + Clone {
346354

347355
/// How a one-value plan lays its value out under a [`Label`]: [`Indexed`]
348356
/// or [`Typed`]. What [`ValuePlan::encryption`], `cipher.encrypt(..).using`
349-
/// and a query through the plan ask. Sealed.
357+
/// and a query that names its index with `.via(..)` ask. A query that lets
358+
/// a [`Typed`] plan choose the index asks only [`ValueLayout`], when the
359+
/// target is a [`LabelledTarget`](crate::op::LabelledTarget). Sealed.
350360
pub trait ValueShape<S>: ValueLayout<S> {
351361
/// The description of one value, run under its whole context.
352362
fn lower<'s, K: 'static>(&self) -> Encryption<'s, S, Self::Output, K, CallerContext>
@@ -542,10 +552,12 @@ impl<S, X> ValuePlan<S, Indexed<X>> {
542552

543553
impl<S, T> ValuePlan<S, Typed<T>>
544554
where
545-
Typed<T>: ValueShape<S>,
555+
Typed<T>: ValueLayout<S>,
546556
{
547557
/// The label a query through `index` is derived under, or why the plan
548-
/// cannot answer it.
558+
/// cannot answer it. It reads only the indexes `T` declares
559+
/// ([`ValueLayout`]); which plans may be queried at all is the query
560+
/// chain's (`Chooses`, `Resolves`).
549561
pub(crate) fn query_label(
550562
&self,
551563
index: &IndexSpec,

‎packages/stack-encrypt/src/target/context.rs‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,11 @@ impl<'a, T: IntoContext<'a>> From<NonEmpty<T>> for DeclaredContext {
172172
}
173173
}
174174
impl DeclaredContext {
175+
/// Whether any part extends the field's own context.
176+
pub(crate) fn is_extended(&self) -> bool {
177+
!self.0.is_empty()
178+
}
179+
175180
/// Extend by one more part, after any already given: the field's own
176181
/// context, extended by every part so far, is extended by `part`.
177182
pub fn with(mut self, part: impl Into<CallerContext>) -> Self {

0 commit comments

Comments
 (0)