diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8ac6d4db..48a851d4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased]
+### Added
+
+- **Application-generated EQL values in statements**: SQL literals and bound parameters may now carry EQL v3 storage payloads or query-only SEM operands produced by an application. Proxy authenticates every stored ciphertext, requires its authenticated descriptor to name the inferred destination column, and independently re-derives every SEM term before forwarding it without double encryption. Query-only operands contain no ciphertext to authenticate, so Proxy instead validates their version, identifier, term shape, column capabilities, and syntactic query role; they are rejected in storage positions. This includes bare SteVec selector hashes matching `^[0-9a-f]{32}$`: in JSON selector query positions a match is treated as already hashed, while a non-match remains plaintext and is encrypted normally. A matching plaintext selector is inherently ambiguous and is intentionally treated as already hashed. Invalid payloads fail closed with one generic, transaction-aborting error so validation details cannot be used as an oracle.
+
+ Compatibility note: on encrypted columns, a JSON object with top-level `v` and `i` keys plus at least one of `c`, `h`, or `sv` is reserved as an advertised EQL storage payload. If it is not valid EQL, Proxy rejects it instead of encrypting it as plaintext, and there is no opt-out. Before upgrading, audit plaintext application writes for this key combination; [Invalid encrypted value](docs/errors.md#encrypt-invalid-inbound-ciphertext) includes a `jsonb` scan predicate.
+
## [3.0.1] - 2026-08-05
### Added
diff --git a/Cargo.lock b/Cargo.lock
index d3f166ca..346a6396 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -4320,6 +4320,8 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
name = "showcase"
version = "3.0.1"
dependencies = [
+ "cipherstash-client",
+ "cipherstash-config",
"rand 0.9.2",
"rustls",
"serde",
diff --git a/docs/errors.md b/docs/errors.md
index 8f9ae535..b7264088 100644
--- a/docs/errors.md
+++ b/docs/errors.md
@@ -16,6 +16,7 @@
- [Internal Error](#mapping-internal-error)
- Encrypt errors:
+ - [Invalid encrypted value](#encrypt-invalid-inbound-ciphertext)
- [Column could not be encrypted](#encrypt-column-could-not-be-encrypted)
- [Could not decrypt data for keyset](#encrypt-could-not-decrypt-data-for-keyset)
- [KeysetId could not be parsed](#encrypt-keyset-id-could-not-be-parsed)
@@ -331,6 +332,53 @@ If the error persists, please contact CipherStash [support](https://cipherstash.
# Encrypt errors
+## Invalid encrypted value
+
+CipherStash Proxy rejected an application-generated EQL storage payload or
+query operand because it was malformed, unauthentic, intended for another
+column, carried unexpected searchable encrypted metadata (SEM), or appeared in
+an invalid statement position. The response deliberately does not identify
+which validation failed.
+
+### Error message
+
+```
+Invalid encrypted value. For help visit https://github.com/cipherstash/proxy/blob/main/docs/errors.md#encrypt-invalid-inbound-ciphertext
+```
+
+### How to fix
+
+Regenerate the payload using the same column configuration, keyset, and
+credentials as Proxy. Storage payloads must target the inferred destination
+column and carry ciphertext plus exactly its configured SEM terms. Query-only
+payloads must be used only in query positions.
+
+### Plaintext compatibility
+
+On an encrypted column, Proxy treats a JSON object as an advertised EQL storage
+payload when it has top-level `v` and `i` keys together with at least one of
+`c`, `h`, or `sv`. An object matching that key pattern which is not a valid EQL
+payload is rejected rather than encrypted as plaintext. There is no opt-out for
+this fail-closed check.
+
+Before upgrading, audit JSON values supplied as plaintext to encrypted columns.
+For a `jsonb` source column named `value`, this predicate identifies the
+ambiguous shape:
+
+```sql
+WHERE value ? 'v'
+ AND value ? 'i'
+ AND value ?| ARRAY['c', 'h', 'sv']
+```
+
+For text sources, first restrict the scan to values that your application knows
+are valid JSON, then apply the same predicate after casting them to `jsonb`.
+Rename one of these top-level keys or generate the value as an EQL payload
+before sending it through Proxy.
+
+
+
+
## Column could not be encrypted
The column could not be encrypted.
diff --git a/packages/cipherstash-proxy-integration/src/inbound_ciphertext.rs b/packages/cipherstash-proxy-integration/src/inbound_ciphertext.rs
new file mode 100644
index 00000000..8e0fb18d
--- /dev/null
+++ b/packages/cipherstash-proxy-integration/src/inbound_ciphertext.rs
@@ -0,0 +1,496 @@
+//! End-to-end coverage for application-encrypted EQL payloads entering Proxy.
+
+#[cfg(test)]
+mod tests {
+ use crate::common::{clear_with_client, connect_with_tls, random_id, PROXY};
+ use cipherstash_client::{
+ encryption::{Plaintext, QueryOp, ScopedCipher},
+ eql::{
+ encrypt_eql_v3, EqlCiphertextV3, EqlEncryptOpts, EqlOperation, EqlOutputV3, Identifier,
+ PreparedPlaintext,
+ },
+ schema::{column::Index, ColumnConfig, ColumnType},
+ zerokms::{ClientKey, ZeroKMSBuilder},
+ AutoStrategy, IdentifiedBy,
+ };
+ use cipherstash_config::column::{ArrayIndexMode, IndexType, SteVecMode};
+ use std::{borrow::Cow, sync::Arc};
+ use tokio_postgres::error::SqlState;
+ use uuid::Uuid;
+
+ const INVALID_INBOUND_PAYLOAD: &str = "Invalid encrypted value. For help visit \
+ https://github.com/cipherstash/proxy/blob/main/docs/errors.md#encrypt-invalid-inbound-ciphertext";
+
+ async fn cipher() -> Arc> {
+ let client_id = env("CS_CLIENT_ID", "CS_ENCRYPT__CLIENT_ID")
+ .parse()
+ .expect("CS_CLIENT_ID must be a UUID");
+ let client_key =
+ ClientKey::from_hex_v1(client_id, &env("CS_CLIENT_KEY", "CS_ENCRYPT__CLIENT_KEY"))
+ .expect("CS_CLIENT_KEY must be valid");
+ let zerokms = ZeroKMSBuilder::auto()
+ .expect("ZeroKMS credentials must be configured")
+ .with_client_key(client_key)
+ .build()
+ .expect("ZeroKMS client must initialize");
+ let keyset_id: Uuid = env("CS_DEFAULT_KEYSET_ID", "CS_ENCRYPT__DEFAULT_KEYSET_ID")
+ .parse()
+ .expect("CS_DEFAULT_KEYSET_ID must be a UUID");
+ Arc::new(
+ ScopedCipher::init(Arc::new(zerokms), Some(IdentifiedBy::Uuid(keyset_id)))
+ .await
+ .expect("scoped cipher must initialize"),
+ )
+ }
+
+ fn env(primary: &str, nested: &str) -> String {
+ std::env::var(primary)
+ .or_else(|_| std::env::var(nested))
+ .unwrap_or_else(|_| panic!("{primary} must be configured"))
+ }
+
+ fn text_search_config(table: &str, column: &str) -> ColumnConfig {
+ ColumnConfig::build(format!("{table}/{column}"))
+ .casts_as(ColumnType::Text)
+ .add_index(Index::new_unique())
+ .add_index(Index::new_ope())
+ .add_index(Index::new_match())
+ }
+
+ fn json_search_config(table: &str, column: &str) -> ColumnConfig {
+ ColumnConfig::build(format!("{table}/{column}"))
+ .casts_as(ColumnType::Json)
+ .add_index(Index::new(IndexType::SteVec {
+ prefix: format!("{table}/{column}"),
+ term_filters: Vec::new(),
+ array_index_mode: ArrayIndexMode::ALL,
+ mode: SteVecMode::default(),
+ }))
+ }
+
+ async fn encrypt_text(table: &str, column: &str, plaintext: &str) -> String {
+ let prepared = PreparedPlaintext::new(
+ Cow::Owned(text_search_config(table, column)),
+ Identifier::new(table, column),
+ Plaintext::from(plaintext),
+ EqlOperation::Store,
+ );
+ let mut outputs =
+ encrypt_eql_v3(cipher().await, vec![prepared], &EqlEncryptOpts::default())
+ .await
+ .expect("application-side encryption must succeed");
+ let EqlOutputV3::Store(ciphertext) = outputs.remove(0) else {
+ panic!("store encryption must return a stored payload");
+ };
+ serde_json::to_string(&ciphertext).unwrap()
+ }
+
+ async fn encrypt_json(
+ table: &str,
+ column: &str,
+ plaintext: serde_json::Value,
+ ) -> serde_json::Value {
+ let prepared = PreparedPlaintext::new(
+ Cow::Owned(json_search_config(table, column)),
+ Identifier::new(table, column),
+ Plaintext::Json(Some(plaintext)),
+ EqlOperation::Store,
+ );
+ let mut outputs =
+ encrypt_eql_v3(cipher().await, vec![prepared], &EqlEncryptOpts::default())
+ .await
+ .expect("application-side JSON encryption must succeed");
+ let EqlOutputV3::Store(ciphertext) = outputs.remove(0) else {
+ panic!("JSON encryption must return a stored payload");
+ };
+ serde_json::to_value(ciphertext).unwrap()
+ }
+
+ async fn query_text(table: &str, column: &str, plaintext: &str) -> String {
+ let stored: EqlCiphertextV3 =
+ serde_json::from_str(&encrypt_text(table, column, plaintext).await).unwrap();
+ serde_json::to_string(&stored.into_query_operand()).unwrap()
+ }
+
+ async fn query_json(
+ table: &str,
+ column: &str,
+ plaintext: serde_json::Value,
+ ) -> serde_json::Value {
+ let config = json_search_config(table, column);
+ let index_type = config.indexes[0].index_type.clone();
+ let prepared = PreparedPlaintext::new(
+ Cow::Owned(config),
+ Identifier::new(table, column),
+ Plaintext::Json(Some(plaintext)),
+ EqlOperation::Query(&index_type, QueryOp::Default),
+ );
+ let mut outputs =
+ encrypt_eql_v3(cipher().await, vec![prepared], &EqlEncryptOpts::default())
+ .await
+ .expect("application-side query encryption must succeed");
+ let EqlOutputV3::Query(query) = outputs.remove(0) else {
+ panic!("query encryption must return a query-only payload");
+ };
+ serde_json::to_value(query).unwrap()
+ }
+
+ async fn query_json_selector(table: &str, column: &str, path: &str) -> String {
+ let config = json_search_config(table, column);
+ let index_type = config.indexes[0].index_type.clone();
+ let prepared = PreparedPlaintext::new(
+ Cow::Owned(config),
+ Identifier::new(table, column),
+ Plaintext::from(path),
+ EqlOperation::Query(&index_type, QueryOp::SteVecSelector),
+ );
+ let mut outputs =
+ encrypt_eql_v3(cipher().await, vec![prepared], &EqlEncryptOpts::default())
+ .await
+ .expect("application-side selector encryption must succeed");
+ let EqlOutputV3::Query(query) = outputs.remove(0) else {
+ panic!("selector encryption must return a query-only payload");
+ };
+ let serde_json::Value::String(selector) = serde_json::to_value(query).unwrap() else {
+ panic!("selector encryption must return a bare selector hash");
+ };
+ selector
+ }
+
+ #[tokio::test]
+ async fn accepts_pre_encrypted_parameter_for_storage_and_search() {
+ let client = connect_with_tls(*PROXY).await;
+ clear_with_client(&client).await;
+ let id = random_id();
+ let plaintext = "encrypted in the application";
+ let payload = encrypt_text("encrypted", "encrypted_text", plaintext).await;
+
+ client
+ .execute(
+ "INSERT INTO encrypted (id, encrypted_text) VALUES ($1, $2)",
+ &[&id, &payload],
+ )
+ .await
+ .unwrap();
+
+ let rows = client
+ .query(
+ "SELECT encrypted_text FROM encrypted WHERE encrypted_text = $1",
+ &[&payload],
+ )
+ .await
+ .unwrap();
+ assert_eq!(rows[0].get::<_, String>(0), plaintext);
+ }
+
+ #[tokio::test]
+ async fn accepts_pre_encrypted_literal_for_storage() {
+ let client = connect_with_tls(*PROXY).await;
+ clear_with_client(&client).await;
+ let id = random_id();
+ let plaintext = "application encrypted literal";
+ let payload = encrypt_text("encrypted", "encrypted_text", plaintext).await;
+ let payload = payload.replace('\'', "''");
+
+ client
+ .simple_query(&format!(
+ "INSERT INTO encrypted (id, encrypted_text) VALUES ({id}, '{payload}')"
+ ))
+ .await
+ .unwrap();
+
+ let row = client
+ .query_one("SELECT encrypted_text FROM encrypted WHERE id = $1", &[&id])
+ .await
+ .unwrap();
+ assert_eq!(row.get::<_, String>(0), plaintext);
+ }
+
+ #[tokio::test]
+ async fn accepts_query_only_parameter_for_search() {
+ let client = connect_with_tls(*PROXY).await;
+ clear_with_client(&client).await;
+ let id = random_id();
+ let plaintext = "queried with application SEM terms";
+
+ client
+ .execute(
+ "INSERT INTO encrypted (id, encrypted_text) VALUES ($1, $2)",
+ &[&id, &plaintext],
+ )
+ .await
+ .unwrap();
+
+ let payload = query_text("encrypted", "encrypted_text", plaintext).await;
+ let rows = client
+ .query(
+ "SELECT encrypted_text FROM encrypted WHERE encrypted_text = $1",
+ &[&payload],
+ )
+ .await
+ .unwrap();
+ assert_eq!(rows[0].get::<_, String>(0), plaintext);
+ }
+
+ #[tokio::test]
+ async fn accepts_query_only_literal_for_search() {
+ let client = connect_with_tls(*PROXY).await;
+ clear_with_client(&client).await;
+ let id = random_id();
+ let plaintext = "queried with literal SEM terms";
+
+ client
+ .execute(
+ "INSERT INTO encrypted (id, encrypted_text) VALUES ($1, $2)",
+ &[&id, &plaintext],
+ )
+ .await
+ .unwrap();
+
+ let payload = query_text("encrypted", "encrypted_text", plaintext)
+ .await
+ .replace('\'', "''");
+ let rows = client
+ .simple_query(&format!(
+ "SELECT encrypted_text FROM encrypted WHERE encrypted_text = '{payload}'"
+ ))
+ .await
+ .unwrap();
+ let row = rows
+ .iter()
+ .find_map(|message| match message {
+ tokio_postgres::SimpleQueryMessage::Row(row) => Some(row),
+ _ => None,
+ })
+ .expect("query-only literal must match one row");
+ assert_eq!(row.get(0), Some(plaintext));
+ }
+
+ #[tokio::test]
+ async fn rejects_query_only_parameter_for_storage() {
+ let client = connect_with_tls(*PROXY).await;
+ clear_with_client(&client).await;
+ let id = random_id();
+ let payload = query_text("encrypted", "encrypted_text", "not writable").await;
+
+ let error = client
+ .execute(
+ "INSERT INTO encrypted (id, encrypted_text) VALUES ($1, $2)",
+ &[&id, &payload],
+ )
+ .await
+ .expect_err("query-only payloads must not be accepted for storage");
+ assert_eq!(
+ error.as_db_error().unwrap().message(),
+ INVALID_INBOUND_PAYLOAD
+ );
+ }
+
+ #[tokio::test]
+ async fn accepts_query_only_ste_vec_parameter_for_json_search() {
+ let client = connect_with_tls(*PROXY).await;
+ clear_with_client(&client).await;
+ let id = random_id();
+ let plaintext = serde_json::json!({
+ "patient": { "name": "Ada Lovelace" },
+ "active": true
+ });
+
+ client
+ .execute(
+ "INSERT INTO encrypted (id, encrypted_jsonb) VALUES ($1, $2)",
+ &[&id, &plaintext],
+ )
+ .await
+ .unwrap();
+
+ let payload = query_json("encrypted", "encrypted_jsonb", plaintext.clone()).await;
+ let rows = client
+ .query(
+ "SELECT encrypted_jsonb FROM encrypted WHERE encrypted_jsonb @> $1",
+ &[&payload],
+ )
+ .await
+ .unwrap();
+ assert_eq!(rows[0].get::<_, serde_json::Value>(0), plaintext);
+ }
+
+ #[tokio::test]
+ async fn accepts_bare_selector_hashes_as_parameters_and_literals() {
+ let client = connect_with_tls(*PROXY).await;
+ clear_with_client(&client).await;
+ let id = random_id();
+ let plaintext = serde_json::json!({
+ "patient": { "name": "Ada Lovelace" }
+ });
+
+ client
+ .execute(
+ "INSERT INTO encrypted (id, encrypted_jsonb) VALUES ($1, $2)",
+ &[&id, &plaintext],
+ )
+ .await
+ .unwrap();
+
+ let selector = query_json_selector("encrypted", "encrypted_jsonb", "$.patient.name").await;
+ let row = client
+ .query_one(
+ "SELECT encrypted_jsonb -> $1 FROM encrypted WHERE id = $2",
+ &[&selector, &id],
+ )
+ .await
+ .unwrap();
+ assert_eq!(
+ row.get::<_, serde_json::Value>(0),
+ serde_json::json!("Ada Lovelace")
+ );
+
+ let row = client
+ .simple_query(&format!(
+ "SELECT jsonb_path_query_first(encrypted_jsonb, '{selector}') \
+ FROM encrypted WHERE id = '{id}'"
+ ))
+ .await
+ .unwrap()
+ .into_iter()
+ .find_map(|message| match message {
+ tokio_postgres::SimpleQueryMessage::Row(row) => row.get(0).map(str::to_owned),
+ _ => None,
+ })
+ .expect("bare selector literal must return an extracted value");
+ assert_eq!(row, "\"Ada Lovelace\"");
+ }
+
+ #[tokio::test]
+ async fn rejects_a_ste_vec_payload_with_tampered_non_root_ciphertext() {
+ let client = connect_with_tls(*PROXY).await;
+ clear_with_client(&client).await;
+ let id = random_id();
+ let mut payload = encrypt_json(
+ "encrypted",
+ "encrypted_jsonb",
+ serde_json::json!({
+ "patient": { "name": "Ada Lovelace", "active": true }
+ }),
+ )
+ .await;
+
+ let entries = payload["sv"].as_array_mut().unwrap();
+ assert!(entries.len() > 1);
+ let mut ciphertext = entries[1]["c"]
+ .as_str()
+ .unwrap()
+ .chars()
+ .collect::>();
+ let different = ciphertext
+ .iter()
+ .position(|candidate| *candidate != ciphertext[0])
+ .unwrap();
+ ciphertext.swap(0, different);
+ entries[1]["c"] = ciphertext.into_iter().collect::().into();
+
+ let error = client
+ .execute(
+ "INSERT INTO encrypted (id, encrypted_jsonb) VALUES ($1, $2)",
+ &[&id, &payload],
+ )
+ .await
+ .expect_err("tampered non-root ciphertext must be rejected");
+ assert_eq!(
+ error.as_db_error().unwrap().message(),
+ INVALID_INBOUND_PAYLOAD
+ );
+ }
+
+ #[tokio::test]
+ async fn invalid_payload_aborts_only_the_current_transaction() {
+ let mut client = connect_with_tls(*PROXY).await;
+ clear_with_client(&client).await;
+ let id = random_id();
+ let malformed = serde_json::json!({
+ "v": 3,
+ "i": { "t": "encrypted", "c": "encrypted_jsonb" },
+ "c": "not a ciphertext"
+ });
+ let transaction = client.transaction().await.unwrap();
+
+ let error = transaction
+ .execute(
+ "INSERT INTO encrypted (id, encrypted_jsonb) VALUES ($1, $2)",
+ &[&id, &malformed],
+ )
+ .await
+ .expect_err("invalid payload must abort the statement");
+ assert_eq!(error.code(), Some(&SqlState::INVALID_TEXT_REPRESENTATION));
+ assert_eq!(
+ error.as_db_error().unwrap().message(),
+ INVALID_INBOUND_PAYLOAD
+ );
+
+ let aborted = transaction
+ .query_one("SELECT 1", &[])
+ .await
+ .expect_err("transaction must remain aborted until rollback");
+ assert_eq!(aborted.code(), Some(&SqlState::IN_FAILED_SQL_TRANSACTION));
+ transaction.rollback().await.unwrap();
+
+ let row = client.query_one("SELECT 1", &[]).await.unwrap();
+ assert_eq!(row.get::<_, i32>(0), 1);
+ }
+
+ #[tokio::test]
+ async fn rejects_payload_for_a_different_destination_with_generic_error() {
+ let client = connect_with_tls(*PROXY).await;
+ clear_with_client(&client).await;
+ let id = random_id();
+ let payload = encrypt_text("some_other_table", "encrypted_text", "secret").await;
+ let mut payload: serde_json::Value = serde_json::from_str(&payload).unwrap();
+ payload["i"]["t"] = "encrypted".into();
+ let payload = payload.to_string();
+
+ let error = client
+ .execute(
+ "INSERT INTO encrypted (id, encrypted_text) VALUES ($1, $2)",
+ &[&id, &payload],
+ )
+ .await
+ .expect_err("destination mismatch must fail closed");
+ assert_eq!(
+ error.as_db_error().unwrap().message(),
+ INVALID_INBOUND_PAYLOAD
+ );
+ }
+
+ #[tokio::test]
+ async fn rejects_sem_terms_spliced_from_another_plaintext() {
+ let client = connect_with_tls(*PROXY).await;
+ clear_with_client(&client).await;
+ let id = random_id();
+ let x: serde_json::Value = serde_json::from_str(
+ &encrypt_text("encrypted", "encrypted_text", "indexed as x").await,
+ )
+ .unwrap();
+ let mut y: serde_json::Value = serde_json::from_str(
+ &encrypt_text("encrypted", "encrypted_text", "decrypts as y").await,
+ )
+ .unwrap();
+ for term in ["hm", "bf", "ob", "op"] {
+ if let Some(value) = x.get(term) {
+ y[term] = value.clone();
+ }
+ }
+ let payload = y.to_string();
+
+ let error = client
+ .execute(
+ "INSERT INTO encrypted (id, encrypted_text) VALUES ($1, $2)",
+ &[&id, &payload],
+ )
+ .await
+ .expect_err("spliced SEM terms must fail closed");
+ assert_eq!(
+ error.as_db_error().unwrap().message(),
+ INVALID_INBOUND_PAYLOAD
+ );
+ }
+}
diff --git a/packages/cipherstash-proxy-integration/src/lib.rs b/packages/cipherstash-proxy-integration/src/lib.rs
index 756ab8d2..d2a713d5 100644
--- a/packages/cipherstash-proxy-integration/src/lib.rs
+++ b/packages/cipherstash-proxy-integration/src/lib.rs
@@ -7,6 +7,7 @@ mod empty_result;
mod encryption_sanity;
mod eql_regression;
mod extended_protocol_error_messages;
+mod inbound_ciphertext;
mod insert;
mod legacy_v2_column;
mod map_concat;
diff --git a/packages/cipherstash-proxy/src/error.rs b/packages/cipherstash-proxy/src/error.rs
index dffc1493..3f436b5a 100644
--- a/packages/cipherstash-proxy/src/error.rs
+++ b/packages/cipherstash-proxy/src/error.rs
@@ -75,6 +75,7 @@ impl Error {
// stores plaintext in a column its operator believes is encrypted
// (CIP-3688). No configuration may turn that back on.
Error::Mapping(MappingError::UnmappableEncryptedColumn { .. })
+ | Error::Encrypt(EncryptError::InvalidInboundCiphertext)
)
}
}
@@ -255,6 +256,15 @@ pub enum TlsConfigError {
#[derive(Error, Debug)]
pub enum EncryptError {
+ /// Deliberately contains no payload or validation detail: inbound
+ /// ciphertext failures are attacker-controlled and detailed responses can
+ /// become an oracle.
+ #[error(
+ "Invalid encrypted value. For help visit {}#encrypt-invalid-inbound-ciphertext",
+ ERROR_DOC_BASE_URL
+ )]
+ InvalidInboundCiphertext,
+
#[error(transparent)]
CiphertextCouldNotBeSerialised(#[from] serde_json::Error),
diff --git a/packages/cipherstash-proxy/src/postgresql/context/mod.rs b/packages/cipherstash-proxy/src/postgresql/context/mod.rs
index 3ea0cbd5..24ae38bc 100644
--- a/packages/cipherstash-proxy/src/postgresql/context/mod.rs
+++ b/packages/cipherstash-proxy/src/postgresql/context/mod.rs
@@ -753,6 +753,12 @@ where
plaintexts: Vec