use async_trait::async_trait;
use crate::db::MemoryDatabase;
use crate::types::{
GlobalMemoryRecord, GlobalMemorySearchHit, GlobalMemoryWriteResult, MemoryChunk, MemoryError,
MemoryResult, MemoryTenantScope, MemoryTier,
};
fn reject_unsupported_narrowing(scope: &MemoryReadScope) -> MemoryResult<()> {
if scope.subject.is_some() {
return Err(MemoryError::InvalidConfig(
"MemoryStore SQLite backend does not yet enforce subject scope narrowing \
(TAN-648); refusing to widen the read to tenant scope"
.to_string(),
));
}
Ok(())
}
fn reject_unenforceable_chunk_read(scope: &MemoryReadScope) -> MemoryResult<()> {
if scope.subject.is_none() {
return Err(MemoryError::InvalidConfig(
"MemoryStore chunk search requires an explicit scope.subject: a None subject \
disables the query's subject filter and would return all subjects' chunks \
(TAN-648); pass the caller's subject rather than widen the read"
.to_string(),
));
}
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MemoryReadScope {
pub tenant: MemoryTenantScope,
pub org_unit: Option<String>,
pub subject: Option<String>,
}
impl MemoryReadScope {
pub fn tenant(tenant: MemoryTenantScope) -> Self {
Self {
tenant,
org_unit: None,
subject: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MemoryWriteScope {
pub tenant: MemoryTenantScope,
pub org_unit: Option<String>,
pub subject: Option<String>,
}
impl MemoryWriteScope {
pub fn tenant(tenant: MemoryTenantScope) -> Self {
Self {
tenant,
org_unit: None,
subject: None,
}
}
}
#[async_trait]
pub trait MemoryStore: Send + Sync {
async fn search_global_records(
&self,
scope: &MemoryReadScope,
user_id: &str,
query: &str,
limit: i64,
project_tag: Option<&str>,
) -> MemoryResult<Vec<GlobalMemorySearchHit>>;
async fn list_global_records(
&self,
scope: &MemoryReadScope,
user_id: &str,
query: Option<&str>,
project_tag: Option<&str>,
limit: i64,
offset: i64,
) -> MemoryResult<Vec<GlobalMemoryRecord>>;
async fn put_global_record(
&self,
record: &GlobalMemoryRecord,
) -> MemoryResult<GlobalMemoryWriteResult>;
async fn put_chunk(&self, chunk: &MemoryChunk, embedding: &[f32]) -> MemoryResult<()>;
async fn search_chunks(
&self,
scope: &MemoryReadScope,
query_embedding: &[f32],
tier: MemoryTier,
project_id: Option<&str>,
session_id: Option<&str>,
limit: i64,
) -> MemoryResult<Vec<(MemoryChunk, f64)>>;
}
#[async_trait]
impl MemoryStore for MemoryDatabase {
async fn search_global_records(
&self,
scope: &MemoryReadScope,
user_id: &str,
query: &str,
limit: i64,
project_tag: Option<&str>,
) -> MemoryResult<Vec<GlobalMemorySearchHit>> {
reject_unsupported_narrowing(scope)?;
self.search_global_memory_for_tenant_scoped(
&scope.tenant.org_id,
&scope.tenant.workspace_id,
scope.tenant.deployment_id.as_deref(),
user_id,
query,
limit,
project_tag,
None,
None,
scope.org_unit.as_deref(),
)
.await
}
async fn list_global_records(
&self,
scope: &MemoryReadScope,
user_id: &str,
query: Option<&str>,
project_tag: Option<&str>,
limit: i64,
offset: i64,
) -> MemoryResult<Vec<GlobalMemoryRecord>> {
reject_unsupported_narrowing(scope)?;
self.list_global_memory_for_tenant_scoped(
&scope.tenant.org_id,
&scope.tenant.workspace_id,
scope.tenant.deployment_id.as_deref(),
user_id,
query,
project_tag,
None,
limit,
offset,
scope.org_unit.as_deref(),
)
.await
}
async fn put_global_record(
&self,
record: &GlobalMemoryRecord,
) -> MemoryResult<GlobalMemoryWriteResult> {
self.put_global_memory_record(record).await
}
async fn put_chunk(&self, chunk: &MemoryChunk, embedding: &[f32]) -> MemoryResult<()> {
self.store_chunk(chunk, embedding).await
}
async fn search_chunks(
&self,
scope: &MemoryReadScope,
query_embedding: &[f32],
tier: MemoryTier,
project_id: Option<&str>,
session_id: Option<&str>,
limit: i64,
) -> MemoryResult<Vec<(MemoryChunk, f64)>> {
reject_unenforceable_chunk_read(scope)?;
self.search_similar_for_tenant(
query_embedding,
tier,
project_id,
session_id,
&scope.tenant,
limit,
scope.subject.as_deref(),
scope.org_unit.as_deref(),
)
.await
}
}
#[cfg(test)]
mod tests {
use super::*;
const _: fn() = || {
fn assert_impl<T: MemoryStore>() {}
assert_impl::<MemoryDatabase>();
};
#[test]
fn read_scope_tenant_only_has_no_narrowing() {
let scope = MemoryReadScope::tenant(MemoryTenantScope::local());
assert!(scope.org_unit.is_none());
assert!(scope.subject.is_none());
assert_eq!(scope.tenant, MemoryTenantScope::local());
}
#[test]
fn write_scope_tenant_only_has_no_stamping() {
let scope = MemoryWriteScope::tenant(MemoryTenantScope::local());
assert!(scope.org_unit.is_none());
assert!(scope.subject.is_none());
}
#[test]
fn chunk_read_guard_requires_subject_and_allows_org_unit() {
let mut ok = MemoryReadScope::tenant(MemoryTenantScope::local());
ok.subject = Some("user-a".to_string());
assert!(reject_unenforceable_chunk_read(&ok).is_ok());
let mut by_dept = MemoryReadScope::tenant(MemoryTenantScope::local());
by_dept.subject = Some("user-a".to_string());
by_dept.org_unit = Some("finance".to_string());
assert!(reject_unenforceable_chunk_read(&by_dept).is_ok());
let no_subject = MemoryReadScope::tenant(MemoryTenantScope::local());
assert!(reject_unenforceable_chunk_read(&no_subject).is_err());
}
#[test]
fn narrowed_read_scope_rejects_subject_but_allows_org_unit() {
let mut by_dept = MemoryReadScope::tenant(MemoryTenantScope::local());
by_dept.org_unit = Some("finance".to_string());
assert!(reject_unsupported_narrowing(&by_dept).is_ok());
let mut by_subject = MemoryReadScope::tenant(MemoryTenantScope::local());
by_subject.subject = Some("user-a".to_string());
assert!(reject_unsupported_narrowing(&by_subject).is_err());
let mut by_dept_and_subject = MemoryReadScope::tenant(MemoryTenantScope::local());
by_dept_and_subject.org_unit = Some("finance".to_string());
by_dept_and_subject.subject = Some("user-a".to_string());
assert!(reject_unsupported_narrowing(&by_dept_and_subject).is_err());
assert!(
reject_unsupported_narrowing(&MemoryReadScope::tenant(MemoryTenantScope::local()))
.is_ok()
);
}
}