Skip to content
Open
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
13 changes: 13 additions & 0 deletions .changeset/eql-types-as-record-fields.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@cipherstash/eql': minor
---

`eql-bindings`, the Rust crate released with this package: an EQL v4 type can now be a field of a Stack Encrypt record.

- A `#[derive(EncryptTarget, DecryptSource)]` record with `struct = ..` can have fields such as `email: TextEq` or `profile: JsonTreeDocument`. The field's column is the record's scope and the field's name, so `scope = "members"` and the field `email` store `{"t": "members", "c": "email"}` in `i`.
- With no scope and no binding, a field writes the same terms as the type written on its own under that column, and as a data plan that names the type. A data plan is how the Go module writes the type.
- A record can list a scope or a binding, such as `scope(tenant: u64)` or `bind(row: u128)`. The terms of an EQL field then derive under the column and the scope. The ciphertext is sealed under the column, the scope and the binding. A field of any other type works the same way. `i` stores the column alone, so the reader supplies the scope and the binding again. The same is true of an EQL field of a record nested in another.
- A query can carry a scope: `.scope(..)` on a query from a field's marker, or `.extend(..)` on a plan's query. It gives the term of the values stored under that scope.
- `JsonTreeDocument` now implements `EncryptTarget` and `DecryptSource` for `JsonPlaintext`. It encrypts under the default options, with the column as its context.

The stored format, the SQL bundle, and the TypeScript types do not change.
69 changes: 42 additions & 27 deletions docs/plans/2026-10-24-stack-encrypt-api-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,8 @@ The field is then stored as one EQL column, such as

Go records already do this through the dynamic interface. A field there
names its EQL type, such as `"target": "TextEq"`, and the engine runs that
type for the field. The Rust record derive does not support it yet.
type for the field. The Rust record derive does the same (#1658). The
field's column is the record's scope and the field's name.

```rust
use eql_bindings::v4::{integer::IntegerOrd, text::TextEq};
Expand All @@ -265,8 +266,6 @@ struct Member {
age: i32,
}

// Not on `next` yet in Rust: EQL types as fields of a derived record.
// Go records do this today through the dynamic interface.
#[derive(EncryptTarget, DecryptSource)]
#[stash(struct = Member, scope = "members")]
struct EncryptedMember {
Expand Down Expand Up @@ -401,9 +400,8 @@ use stack_encrypt::json::JsonPlaintext;

let column = Identifier::for_column("customers", "profile")?;

// Not on `next` yet: the new traits for EQL types, and the public
// `JsonPlaintext`. Today this needs `keyset.encrypt_as(&value, context)`,
// a `serde_json::Value`, and an owned context.
// Not on `next` yet: a context built with `Scope::new`. Today this
// passes the column itself: `.encrypt_into_with_context(&keyset, column.clone())`.
let profile: JsonPlaintext = json!({ "name": "Ada", "age": 35 }).into();
let encrypted: JsonTreeDocument = profile
.encrypt_into_with_context(&keyset, Scope::new(&column))
Expand Down Expand Up @@ -1483,31 +1481,48 @@ Base64.
#### EQL types as fields

- **What to build.** EQL types such as `TextEq` and `JsonTreeDocument` as
field types of the Rust record derive. In Rust today, an EQL type works
only on its own, through the older structural API, with a
`NonEmpty<Identifier>` context. The EQL types do derive the structural
`EncryptFrom` and `DecryptInto`. A record still cannot use them as
fields.
A record with a `TextEq` field, built against `next` on 7 November 2026,
fails with three errors. `NonEmpty<Identifier>` and
`ExpectedContext<Identifier>` do not implement `From<CallerContext>`, and
the record derive gives each field a `CallerContext`.
- **The smallest fix.** Convert a `CallerContext` of two parts into an
`Identifier`, with the table first and the column second. `Identifier`
already gives the same context as that pair, so the stored bytes do not
change.
field types of the Rust record derive.
- **How it works.** The record derive gives each field a context of its
own: the record's scope, then the field's name. A field type that stores
its context stores that own context, as the type it stores. The stored
type implements `target::ContextFromField`. `Identifier` takes an own
context of two parts, the table first and the column second, and gives
the same context as that pair, so the stored bytes do not change.
- **What `i` stores.** The declared names only: the table and the column.
A record can also list a scope or a binding, supplied by the caller or
stored in the row. The terms of an EQL field derive under the query
context: the column, then the scope. The ciphertext is sealed under the
stored context: the column, then the scope, then the binding. A field
of any other type works the same way. `i` does not store the scope or
the binding, so the reader supplies them again, and a query supplies
the scope. An EQL field of a record nested in another stores its own
column in the same way.
- **Why.** This gives the [EQL field example](#store-a-field-as-an-eql-type)
and the [JSON example](#encrypt-a-json-document).
- **Done already.** The dynamic interface supports EQL fields, so Go
records use them today. A field there names its EQL type, and the engine
looks the type up by name through `eql-bindings` (the `TargetResolver`
in `dynamic/record.rs`). `Identifier` already gives the same context as
the `(table, column)` pair that a record derive builds.
- **Tracked in.** Issue #1486 is open, in part. No dedicated issue. Pull request #1504 defers the EQL owned implementations. `eql-bindings` builds against the crates.io `stack-encrypt`, so a `stack-encrypt` release must come first.
- **Done already.** In #1658:
- A Rust record with EQL fields encrypts and decrypts through the new
API, with or without a scope and a binding, and when it is nested in
another record.
- For a field of each kind (equality, storage only, order, and a JSON
document), a record with no scope writes the terms that a data plan
writes for the same declaration. A data plan is the path Go takes.
Each path opens the other's values
(`packages/eql/tests/encryption/tests/record_fields.rs`).
- `JsonTreeDocument` implements `EncryptTarget<JsonPlaintext>` and
`DecryptSource<JsonPlaintext>`.
- A query from a field's marker works for the equality and match
types, with the record's scope. A plan's query for an EQL type takes
`.extend(..)`. The order types do not implement `Answers` yet, so a
marker cannot query them.
- **Not done.** A data plan still refuses a scope or a binding on a field
that names an EQL type (`TargetError::Extended`). So a Go record cannot
yet write an EQL field under a scope.
- **Tracked in.** Issue #1658. `eql-bindings` builds against the crates.io `stack-encrypt`, so a `stack-encrypt` release with `ContextFromField` must come before the `eql-bindings` release that uses it.
- **Finished when** these are all true:
- a Rust record with EQL fields encrypts, decrypts, and queries through
the new API;
- it writes the same bytes as a Go record with the same declaration.
the new API, including order queries;
- it writes the same terms as a Go record with the same declaration,
with and without a scope.

#### A ready-made EQL resolver

Expand Down
1 change: 1 addition & 0 deletions packages/eql/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

41 changes: 38 additions & 3 deletions packages/eql/crates/eql-bindings/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,42 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **EQL types as record fields** (#1658). An EQL v4 type can be a field
of a Stack Encrypt `#[derive(EncryptTarget, DecryptSource)]` record
with `struct = ..`. The field's column is the record's scope and the
field's name: `scope = "members"` and the field `email` store
`{"t": "members", "c": "email"}` in `i`. The field writes the terms the
type writes on its own under `Identifier::for_column("members",
"email")`, and the terms a data plan that names the type writes.
- `Identifier` implements `stack_encrypt::target::ContextFromField`.
- A record can list a scope or a binding, such as `scope(tenant: u64)`
or `bind(row: u128)`, supplied by the caller or stored in the row. The
terms of an EQL field then derive under the column and the scope. The
ciphertext is sealed under the column, the scope and the binding. A
field of any other type works the same way. `i` stores the column
alone, and nothing a caller supplies. The reader supplies the scope
and the binding again.
- An EQL field of a record nested in another stores its own column too.
The outer record's names come after it in the field's context.
- A query from the field's marker, such as `EncryptedMember::EMAIL.eq(..)`,
gives the term stored in the row, for the types that answer it. For a
record with a scope, pass the scope: `.scope(Scope::new(tenant))`.
- A plan's query for an EQL type can carry a scope too, with
`.extend(..)`. It gives the term of the values stored under that
scope. `QueryLabelError` has no `Extended` variant.
- The encrypt input of an EQL type is the hidden
`stack_encrypt::target::StoredContext<Identifier>`. A caller passes a
`NonEmpty<Identifier>`. Generic code names the input as
`ContextInput: AcceptsContext<Value = NonEmpty<Identifier>>`.

- **`JsonTreeDocument` implements `EncryptTarget<JsonPlaintext>` and
`DecryptSource<JsonPlaintext>`** (#1658). It encrypts from a
`JsonPlaintext` under the default options, with the column as its
context, and writes the search terms that `JsonTree::default()` writes
for the same document. It can be a record field too. It also
implements `DecryptInto<JsonPlaintext>`, the form a record field opens
through, as every other EQL type does.

- **`Target::leaf_query_sql_domain`** (`targets` feature, #1350): the
domain a document type's ordering at a path binds as,
`eql_v4.query_json_leaf` for `JsonSearch` and `None` for every
Expand Down Expand Up @@ -138,9 +174,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
does not answer, or another index named with `.via(..)`, does not
compile. Each answering type is an
`op::LabelledTarget`. The label must name the column, table then column,
and the query must not be extended, or the awaited query is refused
before any keyset is resolved, with a `QueryLabelError` in
`Error::Other`. `eql_bindings::encryption::query` holds `TwinIndex`
or the awaited query is refused before any keyset is resolved, with a
`QueryLabelError` in `Error::Other`. `eql_bindings::encryption::query` holds `TwinIndex`
(Stack Encrypt's index, its query sent as the twin, for the twin's own
plaintext only), `MatchTwin`, `QueryTwin` and `QueryLabelError`.
`Hmac256` and `BloomFilter` now declare their indexes (`EncryptFrom::indexes`),
Expand Down
75 changes: 75 additions & 0 deletions packages/eql/crates/eql-bindings/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,81 @@ the domain check on insert. A plain `=` between a v3 column and a v4 query
term still returns zero rows with no error, so keep each column to one
version.

### EQL types as record fields

An EQL type can be a field of a Stack Encrypt record. The record derive gives
each field the record's scope and the field's name, and the EQL type stores
that pair as its column in `i`:

```rust,ignore
use eql_bindings::v4::{integer::IntegerOrd, json_tree::JsonTreeDocument, text::TextEq};
use stack_encrypt::json::JsonPlaintext;
use stack_encrypt::{DecryptFrom, DecryptSource, EncryptInto, EncryptTarget};

#[derive(zeroize::Zeroize)]
struct Member {
id: u64,
email: String,
age: i32,
profile: JsonPlaintext,
}

#[derive(EncryptTarget, DecryptSource)]
#[stash(struct = Member, scope = "members")]
struct EncryptedMember {
#[stash(passthrough)]
id: u64,
email: TextEq, // i = {"t": "members", "c": "email"}
age: IntegerOrd, // i = {"t": "members", "c": "age"}
profile: JsonTreeDocument, // i = {"t": "members", "c": "profile"}
}

let stored: EncryptedMember = member.encrypt_into(&keyset).await?;
let query = EncryptedMember::EMAIL.eq("alice@example.com").encrypt(&keyset).await?;
let opened = Member::decrypt_from(stored, &keyset).await?;
```

Each field writes the terms its type writes on its own under
`Identifier::for_column("members", <field>)`, and the terms a data plan that
names the type writes. On decrypt, each field's stored `i` must be its own
column, checked before any key is requested.

A record can also list a scope or a binding, such as a tenant or a row ID:

```rust,ignore
#[derive(EncryptTarget, DecryptSource)]
#[stash(struct = Member, scope = "members", scope(tenant: u64), bind(row: u128))]
struct EncryptedMember {
#[stash(passthrough)]
id: u64,
email: TextEq, // i = {"t": "members", "c": "email"}
age: IntegerOrd, // i = {"t": "members", "c": "age"}
profile: JsonTreeDocument, // i = {"t": "members", "c": "profile"}
}

let parts = || Scope::new(tenant).bind(row);
let stored: EncryptedMember = member.encrypt_into_with_context(&keyset, parts()).await?;
let query = EncryptedMember::EMAIL
.eq("alice@example.com")
.scope(Scope::new(tenant))
.encrypt(&keyset)
.await?;
let opened = Member::decrypt_from_with_context(stored, &keyset, parts()).await?;
```

`i` stores the table and the column, and nothing a caller supplies. A field
of any other type uses the scope and the binding in the same way:

- the terms of the field derive under the column, then the scope. So a
query supplies the scope, and finds only the values stored under it;
- a binding is in no query, so it does not change the terms;
- the ciphertext is sealed under the column, then the scope, then the
binding. So the reader supplies the scope and the binding again, and a
value does not open without them.

An EQL field of a record nested in another stores its own column in the same
way. The outer record's names come after the column in the field's context.

### EQL types as plan field targets

A binding has no Rust type to name: its plan arrives as data, and a field of
Expand Down
31 changes: 30 additions & 1 deletion packages/eql/crates/eql-bindings/src/encryption.rs
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,8 @@ use stack_encrypt::sem::{
};
use stack_encrypt::target::transcode::{Reader, Transcode, Visitor};
use stack_encrypt::target::{
self, AeadContext, CallerContext, DecryptSource, EncryptTarget, IndexSpec, RequiredContext,
self, AeadContext, CallerContext, ContextFromField, DecryptSource, EncryptTarget, IndexSpec,
RequiredContext,
};
use stack_encrypt::{
CipherText, ContextPiece, Decrypt, DecryptField, DecryptInto, Decryptable, Decryption,
Expand Down Expand Up @@ -200,6 +201,34 @@ impl<'a> IntoContext<'a> for &'a Identifier {
}
}

/// An EQL type can be a field of a derived `struct = ..` record. The record
/// gives the field a context of its own: the record's scope, then the
/// field's name, such as `("members", "email")`. The EQL type stores that
/// pair in `i` as its column: table `members`, column `email`.
///
/// `i` holds those two declared names, and nothing a caller supplies. A
/// record can also list a scope or a binding, such as a tenant or a row ID.
/// They come after the column, as they do for a field of any other type.
/// The field's terms derive under the column and the scope. Its
/// ciphertext is sealed under the column, the scope and the binding. None
/// of that is stored in `i`, so the reader supplies the scope and the
/// binding again. The names of a record that holds this one as a field
/// come after the column as a scope does.
///
/// With no scope and no binding, the field writes the terms that the type
/// writes on its own under `Identifier::for_column("members", "email")`.
impl ContextFromField for Identifier {
fn from_field(own: &ContextPiece<'_>) -> Option<NonEmpty<Self>> {
let ContextPiece::List(parts) = own else {
return None;
};
let [ContextPiece::Text(table), ContextPiece::Text(column)] = parts.as_slice() else {
return None;
};
Self::for_column(table.as_ref(), column.as_ref()).ok()
}
}

/// Builds the final EQL ciphertext string from a native sealed leaf.
#[doc(hidden)]
pub struct CiphertextVisitor;
Expand Down
Loading
Loading