use crate::models::ConfidenceSource;
use crate::models::field_names;
use std::time::Duration;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use sqlx::postgres::{PgConnectOptions, PgPoolOptions};
use sqlx::{PgPool, Row};
use super::{
CallerContext, Capabilities, CaptureTurnResult, CaptureTurnWrite, Filter, KgBackend,
KgInvalidateRow, KgQueryRow, KgTimelineRow, MemoryStore, StoreError, StoreResult, UpdatePatch,
VerifyFilter, VerifyLinkReport, VerifyReport, is_visible_to_caller,
};
use crate::models::{AgentRegistration, Memory, MemoryLink, Tier};
const TRACE_TARGET: &str = "store::postgres";
const TRACE_TARGET_KG: &str = "store::postgres::kg";
use crate::quotas::{QuotaStatus, quota_defaults};
const SQL_DELETE_MEMORY_BY_ID: &str = "DELETE FROM memories WHERE id = $1";
const SQL_DELETE_NAMESPACE_META_BY_STANDARD_ID: &str =
"DELETE FROM namespace_meta WHERE standard_id = $1";
const SQL_SELECT_MEMORY_ID_BY_ID: &str = "SELECT id FROM memories WHERE id = $1";
const SQL_SELECT_METADATA_BY_NS_TITLE: &str =
"SELECT metadata FROM memories WHERE namespace = $1 AND title = $2";
const SQL_LOAD_AGE: &str = "LOAD 'age'";
const SQL_SET_AGE_SEARCH_PATH: &str = "SET LOCAL search_path = ag_catalog, \"$user\", public";
pub(crate) const SQL_CREATE_AGE_GRAPH: &str = "SELECT create_graph('memory_graph')";
pub(crate) const PG_ERR_ALREADY_EXISTS: &str = "already exists";
const CTX_BEGIN_AGE_TX: &str = "begin AGE tx";
const CTX_COMMIT_AGE_TX: &str = "commit AGE tx";
const CTX_SET_SEARCH_PATH: &str = "set search_path";
const CTX_VERIFY_LINK_SELECT: &str = "verify_link select";
const COL_CONTENT_LEN: &str = "content_len";
const TABLE_ARCHIVED_MEMORIES: &str = "archived_memories";
const INIT_SCHEMA: &str = include_str!("postgres_schema.sql");
const MIGRATION_V31_REFLECTION_DEPTH: &str =
include_str!("../../migrations/postgres/0013_v0700_reflection_depth.sql");
const MIGRATION_V32_LINK_RELATION_CHECK: &str =
include_str!("../../migrations/postgres/0014_v07_memory_links_relation_check.sql");
const MIGRATION_V33_SIGNED_EVENTS_CHAIN: &str =
include_str!("../../migrations/postgres/0015_v07_signed_events_chain.sql");
const MIGRATION_V34_OFFLOADED_BLOBS: &str =
include_str!("../../migrations/postgres/0016_v07_offloaded_blobs.sql");
const MIGRATION_V35_ATOMISATION: &str =
include_str!("../../migrations/postgres/0017_v07_atomisation.sql");
const MIGRATION_V36_PERSONA: &str = include_str!("../../migrations/postgres/0018_v07_persona.sql");
const MIGRATION_V37_FORM4_PROVENANCE: &str =
include_str!("../../migrations/postgres/0019_v07_form4_provenance.sql");
const MIGRATION_V38_FORM5_CONFIDENCE: &str =
include_str!("../../migrations/postgres/0020_v07_form5_confidence_calibration.sql");
const MIGRATION_V39_SIGNED_EVENTS_DLQ: &str =
include_str!("../../migrations/postgres/0021_v07_signed_events_dlq.sql");
const MIGRATION_V40_SHADOW_RETENTION: &str =
include_str!("../../migrations/postgres/0022_v07_shadow_retention.sql");
const MIGRATION_V41_AUTO_PERSONA_ENTITY_ID: &str =
include_str!("../../migrations/postgres/0023_v07_auto_persona_entity_id.sql");
const MIGRATION_V42_MEMORY_VERSION: &str =
include_str!("../../migrations/postgres/0025_v07_memory_version.sql");
const MIGRATION_V43_SOURCE_URI_UPGRADE: &str =
include_str!("../../migrations/postgres/0026_v07_source_uri_upgrade.sql");
const MIGRATION_V44_RECALL_OBSERVATIONS: &str =
include_str!("../../migrations/postgres/0027_v07_recall_observations.sql");
const MIGRATION_V45_EDIT_SOURCE_ARCHIVE: &str =
include_str!("../../migrations/postgres/0028_v07_edit_source_archive_metadata.sql");
const MIGRATION_V46_LINKS_TEMPORAL_COLUMNS: &str =
include_str!("../../migrations/postgres/0029_v07_links_temporal_columns.sql");
const MIGRATION_V47_PERSONA_SIGNING_ATOMICITY: &str =
include_str!("../../migrations/postgres/0024_v07_persona_signing_atomicity.sql");
const MIGRATION_V48_FEDERATION_PUSH_DLQ: &str =
include_str!("../../migrations/postgres/0030_v07_federation_push_dlq.sql");
const CURRENT_SCHEMA_VERSION: i32 = 57;
const MIGRATION_ADVISORY_LOCK_KEY: i64 = 0x4149_4D45_4D49_4701;
pub const DEFAULT_EMBEDDING_DIM: u32 = 384;
const SUPPORTED_EMBEDDING_DIMS: &[i32] = &[384, 768];
const EMBEDDING_DIM_PLACEHOLDER: &str = "{EMBEDDING_DIM}";
#[must_use]
pub fn render_schema_sql(template: &str, dim: u32) -> String {
template.replace(EMBEDDING_DIM_PLACEHOLDER, &dim.to_string())
}
pub use crate::store::PoolConfig;
pub const DEFAULT_STATEMENT_TIMEOUT_SECS: u64 = 30;
pub const DEFAULT_LOCK_TIMEOUT_SECS: u64 = 5;
#[derive(Clone)]
pub struct PostgresStore {
pool: PgPool,
kg_backend: KgBackend,
}
const REASON_UNSTAMPED_TENANT_WRITE: &str =
"memory has no agent_id stamp; tenant writes refused (use admin path)";
const REASON_UNSTAMPED_TENANT_DELETE: &str =
"memory has no agent_id stamp; tenant deletes refused (use admin path)";
impl PostgresStore {
pub async fn connect(url: &str) -> StoreResult<Self> {
Self::connect_with_dim(url, DEFAULT_EMBEDDING_DIM).await
}
pub async fn connect_with_timeout(url: &str, secs: u64) -> StoreResult<Self> {
Self::connect_with_dim_and_timeout(url, DEFAULT_EMBEDDING_DIM, secs, PoolConfig::default())
.await
}
pub async fn connect_with_dim(url: &str, dim: u32) -> StoreResult<Self> {
Self::connect_with_dim_and_timeout(
url,
dim,
DEFAULT_STATEMENT_TIMEOUT_SECS,
PoolConfig::default(),
)
.await
}
pub async fn connect_with_dim_and_timeout(
url: &str,
dim: u32,
statement_timeout_secs: u64,
pool_config: PoolConfig,
) -> StoreResult<Self> {
if !SUPPORTED_EMBEDDING_DIMS.contains(&i32::try_from(dim).unwrap_or(-1)) {
return Err(StoreError::InvalidInput {
detail: format!(
"unsupported embedding dim {dim}: expected one of {SUPPORTED_EMBEDDING_DIMS:?}"
),
});
}
let options: PgConnectOptions =
url.parse()
.map_err(|e: sqlx::Error| StoreError::BackendUnavailable {
backend: "postgres".to_string(),
detail: crate::logging::redact_urls_in_message(&format!("parse url: {e}")),
})?;
let stmt_secs = statement_timeout_secs;
let lock_secs = if stmt_secs == 0 {
0
} else {
DEFAULT_LOCK_TIMEOUT_SECS
};
let pool = PgPoolOptions::new()
.max_connections(pool_config.max_connections)
.min_connections(pool_config.min_connections)
.acquire_timeout(Duration::from_secs(pool_config.acquire_timeout_secs))
.after_connect(move |conn, _meta| {
Box::pin(async move {
use sqlx::Executor;
if stmt_secs == 0 {
return Ok(());
}
let stmt_ms = stmt_secs.saturating_mul(crate::MILLIS_PER_SEC);
let lock_ms = lock_secs.saturating_mul(crate::MILLIS_PER_SEC);
let sql =
format!("SET statement_timeout = {stmt_ms}; SET lock_timeout = {lock_ms};");
conn.execute(sql.as_str()).await.map(|_| ())
})
})
.connect_with(options)
.await
.map_err(|e| StoreError::BackendUnavailable {
backend: "postgres".to_string(),
detail: format!("connect: {e}"),
})?;
let mut bootstrap_lock_conn =
pool.acquire()
.await
.map_err(|e| StoreError::BackendUnavailable {
backend: "postgres".to_string(),
detail: format!("acquire bootstrap lock connection: {e}"),
})?;
sqlx::query("SET statement_timeout = 0")
.execute(&mut *bootstrap_lock_conn)
.await
.map_err(|e| StoreError::BackendUnavailable {
backend: "postgres".to_string(),
detail: format!("clear statement_timeout on bootstrap lock connection: {e}"),
})?;
sqlx::query("SET lock_timeout = 0")
.execute(&mut *bootstrap_lock_conn)
.await
.map_err(|e| StoreError::BackendUnavailable {
backend: "postgres".to_string(),
detail: format!("clear lock_timeout on bootstrap lock connection: {e}"),
})?;
sqlx::query("SELECT pg_advisory_lock($1)")
.bind(MIGRATION_ADVISORY_LOCK_KEY)
.execute(&mut *bootstrap_lock_conn)
.await
.map_err(|e| StoreError::BackendUnavailable {
backend: "postgres".to_string(),
detail: format!("acquire bootstrap pg_advisory_lock: {e}"),
})?;
let bootstrap_result: StoreResult<Self> = async move {
let init_sql = render_schema_sql(INIT_SCHEMA, dim);
let mut last_err: Option<sqlx::Error> = None;
for attempt in 0..5_u32 {
match sqlx::raw_sql(&init_sql).execute(&pool).await {
Ok(_) => {
last_err = None;
break;
}
Err(e) => {
let msg = e.to_string();
let is_concurrent_init = msg.contains("pg_extension_name_index")
|| msg.contains("tuple concurrently updated")
|| msg.contains("duplicate key value")
|| msg.contains("deadlock detected")
|| msg.contains("concurrent update")
|| msg.contains("could not serialize access");
if !is_concurrent_init || attempt == 4 {
last_err = Some(e);
break;
}
let backoff_ms = 50u64 << attempt;
tokio::time::sleep(tokio::time::Duration::from_millis(backoff_ms)).await;
}
}
}
if let Some(e) = last_err {
return Err(StoreError::BackendUnavailable {
backend: "postgres".to_string(),
detail: format!("init schema: {e}"),
});
}
let extver: Option<(String,)> =
sqlx::query_as("SELECT extversion FROM pg_extension WHERE extname = 'vector'")
.fetch_optional(&pool)
.await
.map_err(|e| StoreError::BackendUnavailable {
backend: "postgres".to_string(),
detail: format!("read pgvector version: {e}"),
})?;
if let Some((ver,)) = extver
&& !(ver.starts_with("0.7") || ver.starts_with("0.8"))
{
tracing::warn!(
target: TRACE_TARGET,
version = %ver,
"pgvector version outside the tested range 0.7.x–0.8.x; HNSW recall may differ"
);
}
let typmod: Option<(i32,)> = sqlx::query_as(
"SELECT atttypmod FROM pg_attribute a
JOIN pg_class c ON c.oid = a.attrelid
JOIN pg_namespace n ON c.relnamespace = n.oid
WHERE c.relname = 'memories' AND a.attname = 'embedding'
AND n.nspname = 'public'",
)
.fetch_optional(&pool)
.await
.ok()
.flatten();
let expected_dim = i32::try_from(dim).unwrap_or(384);
if let Some((typmod,)) = typmod
&& typmod != expected_dim
{
tracing::warn!(
target: TRACE_TARGET,
dim = typmod,
expected = expected_dim,
"memories.embedding column dimension ({typmod}) does not match the requested embedder dim ({expected_dim}); run `ai-memory schema-init --store-url <url> --embedding-dim {expected_dim}` to convert in place"
);
}
let kg_backend = detect_kg_backend(&pool).await;
tracing::info!(
target: TRACE_TARGET,
kg_backend = %kg_backend,
"Postgres KG backend: {}",
match kg_backend {
KgBackend::Age => "AGE",
KgBackend::Cte => "CTE",
}
);
if matches!(kg_backend, KgBackend::Age)
&& let Err(e) = ensure_memory_graph(&pool).await
{
tracing::warn!(
target: TRACE_TARGET,
error = %e,
"ensure memory_graph projection failed at connect; KG link projection will degrade silently"
);
}
let store = Self { pool, kg_backend };
store.migrate_locked().await?;
Ok(store)
}
.await;
let _ = sqlx::query("SELECT pg_advisory_unlock_all()")
.execute(&mut *bootstrap_lock_conn)
.await;
drop(bootstrap_lock_conn);
bootstrap_result
}
pub async fn connect_with_dim_and_timeout_auto_migrate(
url: &str,
dim: u32,
statement_timeout_secs: u64,
pool_config: PoolConfig,
) -> StoreResult<Self> {
let store =
Self::connect_with_dim_and_timeout(url, dim, statement_timeout_secs, pool_config)
.await?;
let current = store.current_embedding_dim().await?;
let target_i32 = i32::try_from(dim).unwrap_or(384);
match current {
Some(cur) if cur == target_i32 => Ok(store),
_ => {
tracing::warn!(
target: TRACE_TARGET,
current = ?current,
target = target_i32,
"issue #877 auto-migrate: existing memories.embedding column dim disagrees \
with configured embedder dim ({target_i32}); converting in place. \
Existing embeddings will be NULLed — re-embed required after this completes."
);
store.migrate_embedding_dim(dim).await?;
Ok(store)
}
}
}
#[must_use]
pub fn kg_backend(&self) -> KgBackend {
self.kg_backend
}
#[must_use]
pub fn pool(&self) -> &PgPool {
&self.pool
}
async fn migrate(&self) -> StoreResult<()> {
let mut lock_conn = self
.pool
.acquire()
.await
.map_err(|e| to_store_err("acquire migration lock connection", e))?;
sqlx::query("SET statement_timeout = 0")
.execute(&mut *lock_conn)
.await
.map_err(|e| to_store_err("clear statement_timeout on migration lock connection", e))?;
sqlx::query("SET lock_timeout = 0")
.execute(&mut *lock_conn)
.await
.map_err(|e| to_store_err("clear lock_timeout on migration lock connection", e))?;
sqlx::query("SELECT pg_advisory_lock($1)")
.bind(MIGRATION_ADVISORY_LOCK_KEY)
.execute(&mut *lock_conn)
.await
.map_err(|e| to_store_err("acquire pg_advisory_lock", e))?;
let result = self.migrate_locked().await;
let _ = sqlx::query("SELECT pg_advisory_unlock_all()")
.execute(&mut *lock_conn)
.await;
drop(lock_conn);
result
}
async fn migrate_locked(&self) -> StoreResult<()> {
let current_version: Option<i32> =
sqlx::query_scalar(crate::storage::migrations::SELECT_SCHEMA_VERSION_SQL)
.fetch_optional(&self.pool)
.await
.map_err(|e| to_store_err(crate::errors::msg::READ_SCHEMA_VERSION, e))?;
let current_version = current_version.unwrap_or(0);
if current_version >= CURRENT_SCHEMA_VERSION {
return Ok(());
}
if current_version < 15 {
self.migrate_v15().await?;
}
if current_version < 17 {
self.migrate_v17().await?;
}
if current_version < 18 {
self.migrate_v18().await?;
}
if current_version < 19 {
self.migrate_v19().await?;
}
if current_version < 20 {
self.migrate_v20().await?;
}
if current_version < 21 {
self.migrate_v21().await?;
}
if current_version < 22 {
self.migrate_v22().await?;
}
if current_version < 23 {
self.migrate_v23().await?;
}
if current_version < 24 {
self.migrate_v24().await?;
}
if current_version < 25 {
self.migrate_v25().await?;
}
if current_version < 26 {
self.migrate_v26().await?;
}
if current_version < 27 {
self.migrate_v27().await?;
}
if current_version < 28 {
self.migrate_v28().await?;
}
if current_version < 29 {
self.migrate_v29_stamp().await?;
}
if current_version < 30 {
self.migrate_v30().await?;
}
if current_version < 31 {
self.migrate_v31().await?;
}
if current_version < 32 {
self.migrate_v32().await?;
}
if current_version < 33 {
self.migrate_v33().await?;
}
if current_version < 34 {
self.migrate_v34().await?;
}
if current_version < 35 {
self.migrate_v35().await?;
}
if current_version < 36 {
self.migrate_v36().await?;
}
if current_version < 37 {
self.migrate_v37().await?;
}
if current_version < 38 {
self.migrate_v38().await?;
}
if current_version < 39 {
self.migrate_v39().await?;
}
if current_version < 40 {
self.migrate_v40().await?;
}
if current_version < 41 {
self.migrate_v41().await?;
}
if current_version < 42 {
self.migrate_v42().await?;
}
if current_version < 43 {
self.migrate_v43().await?;
}
if current_version < 44 {
self.migrate_v44().await?;
}
if current_version < 45 {
self.migrate_v45().await?;
}
if current_version < 46 {
self.migrate_v46().await?;
}
if current_version < 47 {
self.migrate_v47().await?;
}
if current_version < 48 {
self.migrate_v48().await?;
}
if current_version < 49 {
self.migrate_v49().await?;
}
if current_version < 50 {
self.migrate_v50().await?;
}
if current_version < 51 {
self.migrate_v51().await?;
}
if current_version < 52 {
self.migrate_v52().await?;
}
if current_version < 53 {
self.migrate_v53().await?;
}
if current_version < 54 {
self.migrate_v54().await?;
}
if current_version < 55 {
self.migrate_v55().await?;
}
if current_version < 56 {
self.migrate_v56().await?;
}
if current_version < CURRENT_SCHEMA_VERSION {
self.migrate_v57().await?;
}
Ok(())
}
async fn migrate_v30(&self) -> StoreResult<()> {
let mut tx = self
.pool
.begin()
.await
.map_err(|e| to_store_err("begin v30 tx", e))?;
let exists: Option<(String,)> = sqlx::query_as(
"SELECT conname FROM pg_constraint
WHERE conname = 'memories_metadata_is_object'
AND conrelid = 'memories'::regclass",
)
.fetch_optional(&mut *tx)
.await
.map_err(|e| to_store_err("probe memories_metadata_is_object", e))?;
if exists.is_none() {
let bad_count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM memories \
WHERE jsonb_typeof(metadata) IS DISTINCT FROM 'object'",
)
.fetch_one(&mut *tx)
.await
.map_err(|e| to_store_err("count non-object metadata rows", e))?;
if bad_count > 0 {
return Err(StoreError::IntegrityFailed {
detail: format!(
"v30 migration aborted: {bad_count} memories have non-object metadata; \
repair them before re-running"
),
});
}
sqlx::query(
"ALTER TABLE memories \
ADD CONSTRAINT memories_metadata_is_object \
CHECK (jsonb_typeof(metadata) = 'object') NOT VALID",
)
.execute(&mut *tx)
.await
.map_err(|e| to_store_err("add memories_metadata_is_object constraint", e))?;
sqlx::query("ALTER TABLE memories VALIDATE CONSTRAINT memories_metadata_is_object")
.execute(&mut *tx)
.await
.map_err(|e| to_store_err("validate memories_metadata_is_object constraint", e))?;
}
record_schema_version(&mut tx, 30).await?;
tx.commit()
.await
.map_err(|e| to_store_err("commit v30 migration", e))?;
tracing::info!(
target: TRACE_TARGET,
"schema migration v30 applied (memories_metadata_is_object CHECK)"
);
Ok(())
}
async fn migrate_v31(&self) -> StoreResult<()> {
let mut tx = self
.pool
.begin()
.await
.map_err(|e| to_store_err("begin v31 tx", e))?;
sqlx::raw_sql(MIGRATION_V31_REFLECTION_DEPTH)
.execute(&mut *tx)
.await
.map_err(|e| to_store_err("apply v31 reflection_depth", e))?;
record_schema_version(&mut tx, 31).await?;
tx.commit()
.await
.map_err(|e| to_store_err("commit v31 migration", e))?;
tracing::info!(
target: TRACE_TARGET,
"schema migration v31 applied (memories.reflection_depth column)"
);
Ok(())
}
async fn migrate_v32(&self) -> StoreResult<()> {
let mut tx = self
.pool
.begin()
.await
.map_err(|e| to_store_err("begin v32 tx", e))?;
sqlx::raw_sql(MIGRATION_V32_LINK_RELATION_CHECK)
.execute(&mut *tx)
.await
.map_err(|e| to_store_err("apply v32 memory_links.relation CHECK", e))?;
record_schema_version(&mut tx, 32).await?;
tx.commit()
.await
.map_err(|e| to_store_err("commit v32 migration", e))?;
tracing::info!(
target: TRACE_TARGET,
"schema migration v32 applied (memory_links.relation CHECK constraint)"
);
Ok(())
}
async fn migrate_v33(&self) -> StoreResult<()> {
use crate::signed_events::{ZERO_HASH, canonical_chain_bytes};
use sha2::{Digest, Sha256};
let mut tx = self
.pool
.begin()
.await
.map_err(|e| to_store_err("begin v33 tx", e))?;
sqlx::raw_sql(MIGRATION_V33_SIGNED_EVENTS_CHAIN)
.execute(&mut *tx)
.await
.map_err(|e| to_store_err("apply v33 signed_events chain", e))?;
let rows: Vec<(
String,
String,
String,
Vec<u8>,
Option<Vec<u8>>,
String,
chrono::DateTime<chrono::Utc>,
)> = sqlx::query_as(
"SELECT id, agent_id, event_type, payload_hash, signature, attest_level, \
timestamp \
FROM signed_events \
WHERE sequence IS NULL \
ORDER BY ctid ASC",
)
.fetch_all(&mut *tx)
.await
.map_err(|e| to_store_err("v33 backfill: select pending", e))?;
if !rows.is_empty() {
let mut next_seq: i64 =
sqlx::query_scalar("SELECT COALESCE(MAX(sequence), 0) FROM signed_events")
.fetch_one(&mut *tx)
.await
.map_err(|e| to_store_err("v33 backfill: read max sequence", e))?;
let mut prev_hash: [u8; 32] = ZERO_HASH;
for (id, agent_id, event_type, payload_hash, signature, attest_level, ts_dt) in rows {
next_seq += 1;
let event = crate::signed_events::SignedEvent {
id: id.clone(),
agent_id,
event_type,
payload_hash,
signature,
attest_level,
timestamp: ts_dt.to_rfc3339(),
prev_hash: Vec::new(),
sequence: next_seq,
};
sqlx::query("UPDATE signed_events SET prev_hash = $1, sequence = $2 WHERE id = $3")
.bind(prev_hash.to_vec())
.bind(next_seq)
.bind(&id)
.execute(&mut *tx)
.await
.map_err(|e| to_store_err("v33 backfill: UPDATE row", e))?;
let canon = canonical_chain_bytes(&event);
let mut hasher = Sha256::new();
hasher.update(&canon);
prev_hash.copy_from_slice(&hasher.finalize());
}
}
record_schema_version(&mut tx, 33).await?;
tx.commit()
.await
.map_err(|e| to_store_err("commit v33 migration", e))?;
tracing::info!(
target: TRACE_TARGET,
"schema migration v33 applied (signed_events prev_hash + sequence chain)"
);
Ok(())
}
async fn migrate_v34(&self) -> StoreResult<()> {
let mut tx = self
.pool
.begin()
.await
.map_err(|e| to_store_err("begin v34 tx", e))?;
sqlx::raw_sql(MIGRATION_V34_OFFLOADED_BLOBS)
.execute(&mut *tx)
.await
.map_err(|e| to_store_err("apply v34 offloaded_blobs", e))?;
record_schema_version(&mut tx, 34).await?;
tx.commit()
.await
.map_err(|e| to_store_err("commit v34 migration", e))?;
tracing::info!(
target: TRACE_TARGET,
"schema migration v34 applied (offloaded_blobs context-offload substrate)"
);
Ok(())
}
async fn migrate_v35(&self) -> StoreResult<()> {
let mut tx = self
.pool
.begin()
.await
.map_err(|e| to_store_err("begin v35 tx", e))?;
sqlx::raw_sql(MIGRATION_V35_ATOMISATION)
.execute(&mut *tx)
.await
.map_err(|e| to_store_err("apply v35 atomisation", e))?;
record_schema_version(&mut tx, 35).await?;
tx.commit()
.await
.map_err(|e| to_store_err("commit v35 migration", e))?;
tracing::info!(
target: TRACE_TARGET,
"schema migration v35 applied (memories.atomised_into + atom_of columns; \
memory_links.relation CHECK extended with derives_from)"
);
Ok(())
}
async fn migrate_v36(&self) -> StoreResult<()> {
let mut tx = self
.pool
.begin()
.await
.map_err(|e| to_store_err("begin v36 tx", e))?;
sqlx::raw_sql(MIGRATION_V36_PERSONA)
.execute(&mut *tx)
.await
.map_err(|e| to_store_err("apply v36 persona", e))?;
record_schema_version(&mut tx, 36).await?;
tx.commit()
.await
.map_err(|e| to_store_err("commit v36 migration", e))?;
tracing::info!(
target: TRACE_TARGET,
"schema migration v36 applied (persona-as-artifact entity_id + persona_version)"
);
Ok(())
}
async fn migrate_v37(&self) -> StoreResult<()> {
let mut tx = self
.pool
.begin()
.await
.map_err(|e| to_store_err("begin v37 tx", e))?;
sqlx::raw_sql(MIGRATION_V37_FORM4_PROVENANCE)
.execute(&mut *tx)
.await
.map_err(|e| to_store_err("apply v37 form4 provenance", e))?;
record_schema_version(&mut tx, 37).await?;
tx.commit()
.await
.map_err(|e| to_store_err("commit v37 migration", e))?;
tracing::info!(
target: TRACE_TARGET,
"schema migration v37 applied (form4 fact-provenance citations + source_uri + source_span)"
);
Ok(())
}
async fn migrate_v38(&self) -> StoreResult<()> {
let mut tx = self
.pool
.begin()
.await
.map_err(|e| to_store_err("begin v38 tx", e))?;
sqlx::raw_sql(MIGRATION_V38_FORM5_CONFIDENCE)
.execute(&mut *tx)
.await
.map_err(|e| to_store_err("apply v38 form5 confidence", e))?;
record_schema_version(&mut tx, 38).await?;
tx.commit()
.await
.map_err(|e| to_store_err("commit v38 migration", e))?;
tracing::info!(
target: TRACE_TARGET,
"schema migration v38 applied (form5 auto-confidence + shadow-mode + calibration)"
);
Ok(())
}
async fn migrate_v39(&self) -> StoreResult<()> {
let mut tx = self
.pool
.begin()
.await
.map_err(|e| to_store_err("begin v39 tx", e))?;
sqlx::raw_sql(MIGRATION_V39_SIGNED_EVENTS_DLQ)
.execute(&mut *tx)
.await
.map_err(|e| to_store_err("apply v39 signed_events_dlq", e))?;
record_schema_version(&mut tx, 39).await?;
tx.commit()
.await
.map_err(|e| to_store_err("commit v39 migration", e))?;
tracing::info!(
target: TRACE_TARGET,
"schema migration v39 applied (signed_events_dlq dead-letter queue)"
);
Ok(())
}
async fn migrate_v40(&self) -> StoreResult<()> {
let mut tx = self
.pool
.begin()
.await
.map_err(|e| to_store_err("begin v40 tx", e))?;
sqlx::raw_sql(MIGRATION_V40_SHADOW_RETENTION)
.execute(&mut *tx)
.await
.map_err(|e| to_store_err("apply v40 shadow retention", e))?;
record_schema_version(&mut tx, 40).await?;
tx.commit()
.await
.map_err(|e| to_store_err("commit v40 migration", e))?;
tracing::info!(
target: TRACE_TARGET,
"schema migration v40 applied (cluster-G shadow retention + denormalised source + compound index)"
);
Ok(())
}
async fn migrate_v41(&self) -> StoreResult<()> {
let mut tx = self
.pool
.begin()
.await
.map_err(|e| to_store_err("begin v41 tx", e))?;
sqlx::raw_sql(MIGRATION_V41_AUTO_PERSONA_ENTITY_ID)
.execute(&mut *tx)
.await
.map_err(|e| to_store_err("apply v41 auto_persona entity-id", e))?;
record_schema_version(&mut tx, 41).await?;
tx.commit()
.await
.map_err(|e| to_store_err("commit v41 migration", e))?;
tracing::info!(
target: TRACE_TARGET,
"schema migration v41 applied (PERF-8 auto_persona mentioned_entity_id + partial index)"
);
Ok(())
}
async fn migrate_v42(&self) -> StoreResult<()> {
let mut tx = self
.pool
.begin()
.await
.map_err(|e| to_store_err("begin v42 tx", e))?;
sqlx::raw_sql(MIGRATION_V42_MEMORY_VERSION)
.execute(&mut *tx)
.await
.map_err(|e| to_store_err("apply v42 memory_version", e))?;
record_schema_version(&mut tx, 42).await?;
tx.commit()
.await
.map_err(|e| to_store_err("commit v42 migration", e))?;
tracing::info!(
target: TRACE_TARGET,
"schema migration v42 applied (Provenance Gap 1: memories.version)"
);
Ok(())
}
async fn migrate_v43(&self) -> StoreResult<()> {
let mut tx = self
.pool
.begin()
.await
.map_err(|e| to_store_err("begin v43 tx", e))?;
sqlx::raw_sql(MIGRATION_V43_SOURCE_URI_UPGRADE)
.execute(&mut *tx)
.await
.map_err(|e| to_store_err("apply v43 source_uri upgrade", e))?;
record_schema_version(&mut tx, 43).await?;
tx.commit()
.await
.map_err(|e| to_store_err("commit v43 migration", e))?;
tracing::info!(
target: TRACE_TARGET,
"schema migration v43 applied (Provenance Gap 2: source_uri upgrade + backfill)"
);
Ok(())
}
async fn migrate_v44(&self) -> StoreResult<()> {
let mut tx = self
.pool
.begin()
.await
.map_err(|e| to_store_err("begin v44 tx", e))?;
sqlx::raw_sql(MIGRATION_V44_RECALL_OBSERVATIONS)
.execute(&mut *tx)
.await
.map_err(|e| to_store_err("apply v44 recall_observations", e))?;
record_schema_version(&mut tx, 44).await?;
tx.commit()
.await
.map_err(|e| to_store_err("commit v44 migration", e))?;
tracing::info!(
target: TRACE_TARGET,
"schema migration v44 applied (Gap 3: recall_observations ledger)"
);
Ok(())
}
async fn migrate_v45(&self) -> StoreResult<()> {
let mut tx = self
.pool
.begin()
.await
.map_err(|e| to_store_err("begin v45 tx", e))?;
sqlx::raw_sql(MIGRATION_V45_EDIT_SOURCE_ARCHIVE)
.execute(&mut *tx)
.await
.map_err(|e| to_store_err("apply v45 edit_source archive", e))?;
record_schema_version(&mut tx, 45).await?;
tx.commit()
.await
.map_err(|e| to_store_err("commit v45 migration", e))?;
tracing::info!(
target: TRACE_TARGET,
"schema migration v45 applied (Provenance Gap 5: archive_reason indexes)"
);
Ok(())
}
async fn migrate_v46(&self) -> StoreResult<()> {
let mut tx = self
.pool
.begin()
.await
.map_err(|e| to_store_err("begin v46 tx", e))?;
sqlx::raw_sql(MIGRATION_V46_LINKS_TEMPORAL_COLUMNS)
.execute(&mut *tx)
.await
.map_err(|e| to_store_err("apply v46 links temporal columns", e))?;
record_schema_version(&mut tx, 46).await?;
tx.commit()
.await
.map_err(|e| to_store_err("commit v46 migration", e))?;
tracing::info!(
target: TRACE_TARGET,
"schema migration v46 applied (#860: memory_links temporal + attest columns)"
);
Ok(())
}
async fn migrate_v47(&self) -> StoreResult<()> {
let mut tx = self
.pool
.begin()
.await
.map_err(|e| to_store_err("begin v47 tx", e))?;
sqlx::raw_sql(MIGRATION_V47_PERSONA_SIGNING_ATOMICITY)
.execute(&mut *tx)
.await
.map_err(|e| to_store_err("apply v47 persona signing atomicity", e))?;
record_schema_version(&mut tx, 47).await?;
tx.commit()
.await
.map_err(|e| to_store_err("commit v47 migration", e))?;
tracing::info!(
target: TRACE_TARGET,
"schema migration v47 applied (#902: memory_links attest+signature atomic CHECK)"
);
Ok(())
}
async fn migrate_v48(&self) -> StoreResult<()> {
let mut tx = self
.pool
.begin()
.await
.map_err(|e| to_store_err("begin v48 tx", e))?;
sqlx::raw_sql(MIGRATION_V48_FEDERATION_PUSH_DLQ)
.execute(&mut *tx)
.await
.map_err(|e| to_store_err("apply v48 federation push DLQ", e))?;
record_schema_version(&mut tx, 48).await?;
tx.commit()
.await
.map_err(|e| to_store_err("commit v48 migration", e))?;
tracing::info!(
target: TRACE_TARGET,
"schema migration v48 applied (#933: federation_push_dlq table)"
);
Ok(())
}
async fn migrate_v49(&self) -> StoreResult<()> {
let mut tx = self
.pool
.begin()
.await
.map_err(|e| to_store_err("begin v49 tx", e))?;
sqlx::raw_sql(
"ALTER TABLE archived_memories
ADD COLUMN IF NOT EXISTS reflection_depth INTEGER,
ADD COLUMN IF NOT EXISTS atomised_into INTEGER,
ADD COLUMN IF NOT EXISTS atom_of TEXT,
ADD COLUMN IF NOT EXISTS memory_kind TEXT,
ADD COLUMN IF NOT EXISTS entity_id TEXT,
ADD COLUMN IF NOT EXISTS persona_version INTEGER,
ADD COLUMN IF NOT EXISTS citations TEXT,
ADD COLUMN IF NOT EXISTS source_uri TEXT,
ADD COLUMN IF NOT EXISTS source_span TEXT,
ADD COLUMN IF NOT EXISTS confidence_source TEXT,
ADD COLUMN IF NOT EXISTS confidence_signals TEXT,
ADD COLUMN IF NOT EXISTS confidence_decayed_at TEXT,
ADD COLUMN IF NOT EXISTS mentioned_entity_id TEXT,
ADD COLUMN IF NOT EXISTS version BIGINT",
)
.execute(&mut *tx)
.await
.map_err(|e| to_store_err("apply v49 archived_memories column extension", e))?;
record_schema_version(&mut tx, 49).await?;
tx.commit()
.await
.map_err(|e| to_store_err("commit v49 migration", e))?;
tracing::info!(
target: TRACE_TARGET,
"schema migration v49 applied (#1025: archived_memories full v0.7.0 column carry)"
);
Ok(())
}
async fn migrate_v50(&self) -> StoreResult<()> {
let mut tx = self
.pool
.begin()
.await
.map_err(|e| to_store_err("begin v50 tx", e))?;
sqlx::raw_sql(
"ALTER TABLE agent_quotas
ADD COLUMN IF NOT EXISTS namespace TEXT NOT NULL DEFAULT '_global'",
)
.execute(&mut *tx)
.await
.map_err(|e| to_store_err("apply v50 add namespace column", e))?;
sqlx::raw_sql(
"DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'agent_quotas_pkey'
AND conrelid = 'agent_quotas'::regclass
AND pg_get_constraintdef(oid) NOT LIKE '%namespace%'
) THEN
ALTER TABLE agent_quotas DROP CONSTRAINT agent_quotas_pkey;
ALTER TABLE agent_quotas
ADD CONSTRAINT agent_quotas_pkey
PRIMARY KEY (agent_id, namespace);
END IF;
END $$",
)
.execute(&mut *tx)
.await
.map_err(|e| to_store_err("apply v50 swap PK to (agent_id, namespace)", e))?;
sqlx::raw_sql(
"CREATE INDEX IF NOT EXISTS idx_agent_quotas_namespace
ON agent_quotas (namespace, agent_id)",
)
.execute(&mut *tx)
.await
.map_err(|e| to_store_err("create idx_agent_quotas_namespace", e))?;
record_schema_version(&mut tx, 50).await?;
tx.commit()
.await
.map_err(|e| to_store_err("commit v50 migration", e))?;
tracing::info!(
target: TRACE_TARGET,
"schema migration v50 applied (#1156: agent_quotas per-namespace dimension)"
);
Ok(())
}
async fn migrate_v51(&self) -> StoreResult<()> {
let mut tx = self
.pool
.begin()
.await
.map_err(|e| to_store_err("begin v51 tx", e))?;
record_schema_version(&mut tx, 51).await?;
tx.commit()
.await
.map_err(|e| to_store_err("commit v51 migration", e))?;
tracing::info!(
target: TRACE_TARGET,
"schema migration v51 applied (#1255: federation_nonce_cache lives in sqlite; no-op postgres DDL)"
);
Ok(())
}
async fn migrate_v52(&self) -> StoreResult<()> {
let mut tx = self
.pool
.begin()
.await
.map_err(|e| to_store_err("begin v52 tx", e))?;
sqlx::query(
"CREATE TABLE IF NOT EXISTS transcript_line_dedup (\
sha256 BYTEA NOT NULL PRIMARY KEY,\
memory_id TEXT NOT NULL,\
host_kind TEXT NOT NULL,\
transcript_path TEXT,\
host_session_id TEXT,\
host_turn_index BIGINT,\
recovered_at BIGINT NOT NULL\
)",
)
.execute(&mut *tx)
.await
.map_err(|e| to_store_err("create transcript_line_dedup", e))?;
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_transcript_line_dedup_host_turn \
ON transcript_line_dedup(host_session_id, host_turn_index) \
WHERE host_session_id IS NOT NULL",
)
.execute(&mut *tx)
.await
.map_err(|e| to_store_err("create idx_transcript_line_dedup_host_turn", e))?;
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_transcript_line_dedup_recovered_at \
ON transcript_line_dedup(recovered_at, host_kind)",
)
.execute(&mut *tx)
.await
.map_err(|e| to_store_err("create idx_transcript_line_dedup_recovered_at", e))?;
record_schema_version(&mut tx, 52).await?;
tx.commit()
.await
.map_err(|e| to_store_err("commit v52 migration", e))?;
tracing::info!(
target: TRACE_TARGET,
"schema migration v52 applied (#1389: transcript_line_dedup table + indexes for layered-capture idempotency)"
);
Ok(())
}
async fn migrate_v53(&self) -> StoreResult<()> {
let mut tx = self
.pool
.begin()
.await
.map_err(|e| to_store_err("begin v53 tx", e))?;
record_schema_version(&mut tx, 53).await?;
tx.commit()
.await
.map_err(|e| to_store_err("commit v53 migration", e))?;
tracing::info!(
target: TRACE_TARGET,
"schema migration v53 applied (#1418: memories_au column-scope is sqlite-only; no-op postgres DDL)"
);
Ok(())
}
async fn migrate_v54(&self) -> StoreResult<()> {
let mut tx = self
.pool
.begin()
.await
.map_err(|e| to_store_err("begin v54 tx", e))?;
for tier in [Tier::Mid, Tier::Short, Tier::Long] {
if let Some(ttl_secs) = tier.default_ttl_secs() {
sqlx::query(
"UPDATE memories \
SET expires_at = created_at + ($1 || ' seconds')::interval \
WHERE expires_at IS NULL AND tier = $2",
)
.bind(ttl_secs.to_string())
.bind(tier.as_str())
.execute(&mut *tx)
.await
.map_err(|e| to_store_err("v54 backfill expires_at", e))?;
}
}
record_schema_version(&mut tx, 54).await?;
tx.commit()
.await
.map_err(|e| to_store_err("commit v54 migration", e))?;
tracing::info!(
target: TRACE_TARGET,
"schema migration v54 applied (#1466: backfilled tier-default expiry on NULL-expiry mid/short rows)"
);
Ok(())
}
async fn migrate_v55(&self) -> StoreResult<()> {
let mut tx = self
.pool
.begin()
.await
.map_err(|e| to_store_err("begin v55 tx", e))?;
record_schema_version(&mut tx, 55).await?;
tx.commit()
.await
.map_err(|e| to_store_err("commit v55 migration", e))?;
tracing::info!(
target: TRACE_TARGET,
"schema migration v55 applied (#1476: sargable federation-catchup rewrite; existing memories_updated_at_idx DESC serves the range scan — no new postgres index)"
);
Ok(())
}
async fn migrate_v56(&self) -> StoreResult<()> {
let mut tx = self
.pool
.begin()
.await
.map_err(|e| to_store_err("begin v56 tx", e))?;
record_schema_version(&mut tx, 56).await?;
tx.commit()
.await
.map_err(|e| to_store_err("commit v56 migration", e))?;
tracing::info!(
target: TRACE_TARGET,
"schema migration v56 applied (#1579: composite list/archive indexes are sqlite-only; no-op postgres DDL)"
);
Ok(())
}
async fn migrate_v57(&self) -> StoreResult<()> {
let mut tx = self
.pool
.begin()
.await
.map_err(|e| to_store_err("begin v57 tx", e))?;
sqlx::query(
"ALTER TABLE memories ADD COLUMN IF NOT EXISTS tsv tsvector \
GENERATED ALWAYS AS (to_tsvector('english', \
coalesce(title, '') || ' ' || coalesce(content, ''))) STORED",
)
.execute(&mut *tx)
.await
.map_err(|e| to_store_err("v57 add tsv generated column", e))?;
sqlx::query("CREATE INDEX IF NOT EXISTS memories_tsv_gin ON memories USING gin (tsv)")
.execute(&mut *tx)
.await
.map_err(|e| to_store_err("v57 create memories_tsv_gin", e))?;
sqlx::query("DROP INDEX IF EXISTS memories_content_fts")
.execute(&mut *tx)
.await
.map_err(|e| to_store_err("v57 drop memories_content_fts", e))?;
record_schema_version(&mut tx, CURRENT_SCHEMA_VERSION).await?;
tx.commit()
.await
.map_err(|e| to_store_err("commit v57 migration", e))?;
tracing::info!(
target: TRACE_TARGET,
"schema migration v57 applied (#1579 B2: stored generated tsv column + memories_tsv_gin; dropped expression index memories_content_fts)"
);
Ok(())
}
const SQL_SELECT_OWNER_AGENT_ID_BY_ID: &'static str =
"SELECT metadata->>'agent_id' FROM memories WHERE id = $1";
async fn assert_caller_owns_for_mutation(
&self,
ctx: &CallerContext,
id: &str,
action: &str,
unstamped_reason: &str,
) -> StoreResult<()> {
if ctx.bypass_visibility {
return Ok(());
}
let owner: Option<Option<String>> =
sqlx::query_scalar(Self::SQL_SELECT_OWNER_AGENT_ID_BY_ID)
.bind(id)
.fetch_optional(&self.pool)
.await
.map_err(|e| to_store_err(&format!("{action}: pre-fetch owner"), e))?;
match owner {
None => Err(StoreError::NotFound { id: id.to_string() }),
Some(None) => {
Err(StoreError::PermissionDenied {
action: action.to_string(),
target: id.to_string(),
reason: unstamped_reason.to_string(),
})
}
Some(Some(existing_owner)) if existing_owner != ctx.effective_principal() => {
Err(StoreError::PermissionDenied {
action: action.to_string(),
target: id.to_string(),
reason: format!(
"caller {:?} does not own memory (owner: {existing_owner:?})",
ctx.effective_principal()
),
})
}
Some(Some(_)) => Ok(()), }
}
pub async fn update_with_expected_version(
&self,
ctx: &CallerContext,
id: &str,
patch: UpdatePatch,
expected_version: Option<i64>,
) -> StoreResult<i64> {
const MAX_GATE_RETRIES: usize = 3;
let mut attempt = 0;
loop {
attempt += 1;
match self
.update_with_expected_version_once(ctx, id, patch.clone(), expected_version)
.await?
{
Some(new_version) => return Ok(new_version),
None => {
let observed: Option<(i64,)> =
sqlx::query_as("SELECT version FROM memories WHERE id = $1")
.bind(id)
.fetch_optional(&self.pool)
.await
.map_err(|e| to_store_err("re-read version on conflict", e))?;
let Some((cur,)) = observed else {
return Err(StoreError::NotFound { id: id.to_string() });
};
if let Some(exp) = expected_version {
return Err(StoreError::IntegrityFailed {
detail: format!(
"VersionConflict: memory {id} expected_version={exp} but stored version={cur}"
),
});
}
if attempt >= MAX_GATE_RETRIES {
return Err(StoreError::IntegrityFailed {
detail: format!(
"VersionConflict: memory {id} mutated concurrently on every gate-CAS attempt ({MAX_GATE_RETRIES}); last stored version={cur}"
),
});
}
}
}
}
}
async fn update_with_expected_version_once(
&self,
ctx: &CallerContext,
id: &str,
patch: UpdatePatch,
expected_version: Option<i64>,
) -> StoreResult<Option<i64>> {
self.assert_caller_owns_for_mutation(ctx, id, "update", REASON_UNSTAMPED_TENANT_WRITE)
.await?;
let current_row: Option<(i64, String, String, String, String, serde_json::Value)> =
sqlx::query_as(
"SELECT version, namespace, tier, title, memory_kind, metadata \
FROM memories WHERE id = $1",
)
.bind(id)
.fetch_optional(&self.pool)
.await
.map_err(|e| to_store_err("read memories row for update gate", e))?;
let Some((current, cur_ns, cur_tier, cur_title, cur_kind, cur_meta)) = current_row else {
return Err(StoreError::NotFound { id: id.to_string() });
};
if let Some(expected) = expected_version
&& expected != current
{
return Err(StoreError::IntegrityFailed {
detail: format!(
"VersionConflict: memory {id} expected_version={expected} but stored version={current}"
),
});
}
let existing_tier = Tier::from_str(&cur_tier).unwrap_or(Tier::Long);
let effective_tier = match (patch.tier.as_ref(), existing_tier) {
(Some(_), Tier::Long) => Tier::Long,
(Some(Tier::Short), Tier::Mid) => Tier::Mid,
(Some(req), _) => req.clone(),
(None, existing) => existing,
};
let governed = Memory {
namespace: patch.namespace.clone().unwrap_or(cur_ns),
tier: effective_tier,
title: patch.title.clone().unwrap_or(cur_title),
memory_kind: crate::models::MemoryKind::from_str(&cur_kind).unwrap_or_default(),
metadata: patch.metadata.clone().unwrap_or(cur_meta),
..Memory::default()
};
consult_governance_pre_write_pg(&governed)?;
let new_version = current + 1;
let rows_affected = sqlx::query(
"UPDATE memories SET
title = COALESCE($2, title),
content = COALESCE($3, content),
tier = CASE
WHEN $4::TEXT IS NULL THEN tier
WHEN tier_rank($4::TEXT) >= tier_rank(tier) THEN $4::TEXT
ELSE tier
END,
namespace = COALESCE($5, namespace),
tags = COALESCE($6, tags),
priority = COALESCE($7, priority),
confidence = COALESCE($8, confidence),
metadata = CASE
WHEN $9::JSONB IS NULL THEN metadata
WHEN metadata ? 'agent_id' THEN jsonb_set(
$9::JSONB, '{agent_id}', metadata -> 'agent_id'
)
ELSE $9::JSONB
END,
source_uri = COALESCE($10, source_uri),
-- #1628/#1626 — If-Match PUTs route through this method
-- on postgres, so it must mirror the trait `update`'s
-- expires_at semantics including the #1626 tier→long
-- coupling: when the patch tier IS long the clear wins
-- over an explicitly-supplied $12; when the patch tier
-- is NOT long an explicit $12 wins and an absent $12
-- leaves the stored value untouched.
expires_at = CASE
WHEN $4::TEXT = 'long' THEN NULL
ELSE COALESCE($12, expires_at)
END,
updated_at = NOW(),
version = version + 1
WHERE id = $1
AND ($11::BIGINT IS NULL OR version = $11::BIGINT)",
)
.bind(id)
.bind(patch.title)
.bind(patch.content)
.bind(patch.tier.as_ref().map(Tier::as_str))
.bind(patch.namespace)
.bind(
patch
.tags
.map(serde_json::to_value)
.transpose()
.map_err(|e| StoreError::IntegrityFailed {
detail: format!("serialize tags patch: {e}"),
})?,
)
.bind(patch.priority)
.bind(patch.confidence)
.bind(patch.metadata)
.bind(patch.source_uri)
.bind(expected_version.unwrap_or(current))
.bind(parse_rfc3339_opt(patch.expires_at.as_deref()))
.execute(&self.pool)
.await
.map_err(|e| to_store_err("update_with_expected_version", e))?
.rows_affected();
if rows_affected == 0 {
return Ok(None);
}
Ok(Some(new_version))
}
pub async fn update_with_archive_on_supersede(
&self,
id: &str,
patch: UpdatePatch,
expected_version: Option<i64>,
edit_source: crate::models::EditSource,
) -> StoreResult<(String, String)> {
let mut tx = self
.pool
.begin()
.await
.map_err(|e| to_store_err("begin supersede tx", e))?;
let existing_row = sqlx::query("SELECT * FROM memories WHERE id = $1 FOR UPDATE")
.bind(id)
.fetch_optional(&mut *tx)
.await
.map_err(|e| to_store_err("select existing for supersede", e))?;
let Some(existing_pg) = existing_row else {
return Err(StoreError::NotFound { id: id.to_string() });
};
let existing = Self::row_to_memory(&existing_pg)?;
if let Some(expected) = expected_version
&& expected != existing.version
{
return Err(StoreError::IntegrityFailed {
detail: format!(
"VersionConflict: memory {id} expected_version={expected} but stored version={}",
existing.version
),
});
}
let new_id = uuid::Uuid::new_v4().to_string();
let new_tier = match (patch.tier.as_ref(), &existing.tier) {
(Some(requested), existing_tier) => match (existing_tier, requested) {
(Tier::Long, _) => Tier::Long,
(Tier::Mid, Tier::Short) => Tier::Mid,
(_, r) => r.clone(),
},
(None, existing_tier) => existing_tier.clone(),
};
let new_title = patch
.title
.as_deref()
.unwrap_or(existing.title.as_str())
.to_string();
let new_content = patch
.content
.as_deref()
.unwrap_or(existing.content.as_str())
.to_string();
let new_namespace = patch
.namespace
.as_deref()
.unwrap_or(existing.namespace.as_str())
.to_string();
let new_tags = patch.tags.clone().unwrap_or_else(|| existing.tags.clone());
let new_priority = patch.priority.unwrap_or(existing.priority);
let new_confidence = patch.confidence.unwrap_or(existing.confidence);
let new_source_uri = patch
.source_uri
.clone()
.or_else(|| existing.source_uri.clone());
let new_expires = match patch.expires_at.as_deref() {
Some("" | "null") => None,
Some(v) => Some(v.to_string()),
None => existing.expires_at.clone(),
};
let mut new_metadata = patch
.metadata
.clone()
.unwrap_or_else(|| existing.metadata.clone());
if let serde_json::Value::Object(ref mut m) = new_metadata {
m.insert(
"edit_source".to_string(),
serde_json::Value::String(edit_source.as_str().to_string()),
);
m.insert(
field_names::SUPERSEDED_ID.to_string(),
serde_json::Value::String(existing.id.clone()),
);
}
let now_dt = Utc::now();
let now_rfc = now_dt.to_rfc3339();
let candidate = Memory {
id: new_id.clone(),
tier: new_tier.clone(),
namespace: new_namespace.clone(),
title: new_title.clone(),
content: new_content.clone(),
tags: new_tags.clone(),
priority: new_priority,
confidence: new_confidence,
source: existing.source.clone(),
access_count: 0,
created_at: now_rfc.clone(),
updated_at: now_rfc,
last_accessed_at: None,
expires_at: new_expires.clone(),
metadata: new_metadata.clone(),
reflection_depth: existing.reflection_depth,
memory_kind: existing.memory_kind.clone(),
entity_id: existing.entity_id.clone(),
persona_version: existing.persona_version,
citations: existing.citations.clone(),
source_uri: new_source_uri.clone(),
source_span: existing.source_span,
confidence_source: existing.confidence_source.clone(),
confidence_signals: existing.confidence_signals.clone(),
confidence_decayed_at: existing.confidence_decayed_at.clone(),
version: crate::models::default_memory_version(),
};
consult_governance_pre_write_pg(&candidate)?;
let archive_rows = sqlx::query(
"INSERT INTO archived_memories
(id, tier, namespace, title, content, tags, priority, confidence,
source, access_count, created_at, updated_at, last_accessed_at,
expires_at, archived_at, archive_reason, metadata,
embedding, embedding_dim, original_tier, original_expires_at,
reflection_depth, atomised_into, atom_of, memory_kind,
entity_id, persona_version, citations, source_uri, source_span,
confidence_source, confidence_signals, confidence_decayed_at,
mentioned_entity_id, version)
SELECT id, tier, namespace, title, content, tags, priority, confidence,
source, access_count, created_at, updated_at, last_accessed_at,
expires_at, NOW(), 'superseded', metadata,
embedding, embedding_dim, tier, expires_at,
reflection_depth, atomised_into, atom_of, memory_kind,
entity_id, persona_version, citations, source_uri, source_span,
confidence_source, confidence_signals, confidence_decayed_at,
mentioned_entity_id, version
FROM memories WHERE id = $1
ON CONFLICT (id) DO NOTHING",
)
.bind(id)
.execute(&mut *tx)
.await
.map_err(|e| to_store_err("archive on supersede", e))?
.rows_affected();
if archive_rows == 0 {
tx.rollback()
.await
.map_err(|e| to_store_err("rollback supersede", e))?;
return Err(StoreError::NotFound { id: id.to_string() });
}
sqlx::query(SQL_DELETE_MEMORY_BY_ID)
.bind(id)
.execute(&mut *tx)
.await
.map_err(|e| to_store_err("delete old on supersede", e))?;
let citations_json = serde_json::to_string(&candidate.citations).map_err(|e| {
StoreError::IntegrityFailed {
detail: serialize_err("citations", e),
}
})?;
let source_span_json = match candidate.source_span {
Some(span) => {
Some(
serde_json::to_string(&span).map_err(|e| StoreError::IntegrityFailed {
detail: serialize_err(COL_SOURCE_SPAN, e),
})?,
)
}
None => None,
};
let confidence_signals_json = match &candidate.confidence_signals {
Some(s) => Some(
serde_json::to_string(s).map_err(|e| StoreError::IntegrityFailed {
detail: serialize_err(COL_CONFIDENCE_SIGNALS, e),
})?,
),
None => None,
};
let mentioned_entity_id = crate::storage::extract_mentioned_entity_id(&candidate);
let new_expires_dt = parse_rfc3339_opt(candidate.effective_expires_at().as_deref());
sqlx::query(
"INSERT INTO memories
(id, tier, namespace, title, content, tags, priority, confidence,
source, access_count, created_at, updated_at, last_accessed_at,
expires_at, metadata, reflection_depth, memory_kind,
entity_id, persona_version, citations, source_uri, source_span,
confidence_source, confidence_signals, confidence_decayed_at,
mentioned_entity_id, version)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 0, NOW(), NOW(), NULL,
$10, $11, $12, $13,
$14, $15, $16, $17, $18,
$19, $20, $21,
$22, 1)",
)
.bind(&new_id)
.bind(new_tier.as_str())
.bind(&new_namespace)
.bind(&new_title)
.bind(&new_content)
.bind(
serde_json::to_value(&new_tags).map_err(|e| StoreError::IntegrityFailed {
detail: serialize_err("tags", e),
})?,
)
.bind(new_priority)
.bind(new_confidence)
.bind(&existing.source)
.bind(new_expires_dt)
.bind(&new_metadata)
.bind(candidate.reflection_depth)
.bind(candidate.memory_kind.as_str())
.bind(candidate.entity_id.as_deref())
.bind(candidate.persona_version)
.bind(&citations_json)
.bind(&new_source_uri)
.bind(source_span_json.as_deref())
.bind(candidate.confidence_source.as_str())
.bind(confidence_signals_json.as_deref())
.bind(candidate.confidence_decayed_at.as_deref())
.bind(mentioned_entity_id.as_deref())
.execute(&mut *tx)
.await
.map_err(|e| to_store_err("insert new on supersede", e))?;
tx.commit()
.await
.map_err(|e| to_store_err("commit supersede tx", e))?;
Ok((existing.id, new_id))
}
pub async fn search_with_source_uri(
&self,
query: &str,
filter: &Filter,
source_uri: Option<&str>,
) -> StoreResult<Vec<Memory>> {
let limit: i64 = filter
.limit
.clamp(1, crate::storage::LIST_MAX_LIMIT)
.try_into()
.unwrap_or(LIST_FALLBACK_LIMIT_I64);
let rows = sqlx::query(
"SELECT m.*,
ts_rank(m.tsv, plainto_tsquery('english', $1)) AS rank
FROM memories m
WHERE m.tsv @@ plainto_tsquery('english', $1)
AND ($2::text IS NULL OR m.namespace = $2)
AND ($3::text IS NULL OR m.tier = $3)
AND (m.expires_at IS NULL OR m.expires_at > NOW())
AND ($4::timestamptz IS NULL OR m.created_at >= $4)
AND ($5::timestamptz IS NULL OR m.created_at <= $5)
AND ($6::text IS NULL OR m.source_uri = $6)
ORDER BY rank DESC,
m.priority DESC,
m.updated_at DESC
LIMIT $7",
)
.bind(query)
.bind(filter.namespace.as_ref())
.bind(filter.tier.as_ref().map(Tier::as_str))
.bind(filter.since)
.bind(filter.until)
.bind(source_uri)
.bind(limit)
.fetch_all(&self.pool)
.await
.map_err(|e| to_store_err("search_with_source_uri", e))?;
rows.iter().map(Self::row_to_memory).collect()
}
pub async fn list_by_source_uri(
&self,
source_uri: &str,
namespace: Option<&str>,
limit: Option<usize>,
) -> StoreResult<Vec<Memory>> {
let cap_usize = limit
.unwrap_or(crate::storage::LIST_DEFAULT_CAP)
.min(crate::storage::LIST_MAX_LIMIT);
let cap: i64 = i64::try_from(cap_usize).unwrap_or(DEFAULT_LIST_CAP_I64);
let rows = sqlx::query(
"SELECT m.* FROM memories m
WHERE m.source_uri = $1
AND ($2::text IS NULL OR m.namespace = $2)
ORDER BY m.created_at ASC
LIMIT $3",
)
.bind(source_uri)
.bind(namespace)
.bind(cap)
.fetch_all(&self.pool)
.await
.map_err(|e| to_store_err("list_by_source_uri", e))?;
rows.iter().map(Self::row_to_memory).collect()
}
pub async fn recall_observation_insert(
&self,
recall_id: &str,
candidates: &[(String, String, i64, f64)],
) -> StoreResult<usize> {
if candidates.is_empty() {
return Ok(0);
}
let mut tx = self
.pool
.begin()
.await
.map_err(|e| to_store_err("begin recall_observation tx", e))?;
let mut written: usize = 0;
for (memory_id, retriever, rank, score) in candidates {
let n = sqlx::query(
"INSERT INTO recall_observations
(recall_id, memory_id, retriever, rank, score)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (recall_id, memory_id) DO NOTHING",
)
.bind(recall_id)
.bind(memory_id)
.bind(retriever)
.bind(rank)
.bind(score)
.execute(&mut *tx)
.await
.map_err(|e| to_store_err("insert recall_observation", e))?
.rows_affected();
written += usize::try_from(n).unwrap_or(0);
}
tx.commit()
.await
.map_err(|e| to_store_err("commit recall_observation tx", e))?;
Ok(written)
}
pub async fn recall_observation_gc(&self, ttl_days: i64) -> StoreResult<usize> {
let cutoff = chrono::Utc::now() - chrono::Duration::days(ttl_days.max(1));
let n = sqlx::query("DELETE FROM recall_observations WHERE observed_at < $1")
.bind(cutoff)
.execute(&self.pool)
.await
.map_err(|e| to_store_err("recall_observation_gc", e))?
.rows_affected();
Ok(usize::try_from(n).unwrap_or(0))
}
pub async fn get_links(&self, id: &str) -> StoreResult<Vec<MemoryLink>> {
let rows = sqlx::query(
"SELECT source_id, target_id, relation, created_at,
valid_from, valid_until, observed_by, attest_level
FROM memory_links
WHERE source_id = $1 OR target_id = $1
ORDER BY source_id, target_id, relation",
)
.bind(id)
.fetch_all(&self.pool)
.await
.map_err(|e| to_store_err("get_links", e))?;
rows.iter()
.map(|r| {
let created_at: DateTime<Utc> = r
.try_get(field_names::CREATED_AT)
.map_err(|e| to_store_err(READ_CREATED_AT, e))?;
let valid_from: Option<DateTime<Utc>> = r
.try_get(field_names::VALID_FROM)
.map_err(|e| to_store_err(READ_VALID_FROM, e))?;
let valid_until: Option<DateTime<Utc>> = r
.try_get(field_names::VALID_UNTIL)
.map_err(|e| to_store_err(READ_VALID_UNTIL, e))?;
let observed_by: Option<String> = r
.try_get(field_names::OBSERVED_BY)
.map_err(|e| to_store_err(READ_OBSERVED_BY, e))?;
let attest_level: Option<String> = r
.try_get(field_names::ATTEST_LEVEL)
.map_err(|e| to_store_err(READ_ATTEST_LEVEL, e))?;
let relation_str: String = r
.try_get("relation")
.map_err(|e| to_store_err(READ_RELATION, e))?;
Ok(MemoryLink {
source_id: r
.try_get("source_id")
.map_err(|e| to_store_err(READ_SOURCE_ID, e))?,
target_id: r
.try_get("target_id")
.map_err(|e| to_store_err(READ_TARGET_ID, e))?,
relation: crate::models::MemoryLinkRelation::from_str(&relation_str)
.unwrap_or_default(),
created_at: created_at.to_rfc3339(),
signature: None,
valid_from: valid_from.map(|t| t.to_rfc3339()),
valid_until: valid_until.map(|t| t.to_rfc3339()),
observed_by,
attest_level,
})
})
.collect()
}
pub const AGE_CYPHER_SUPERSEDE_EDGE: &'static str = "\
MERGE (a:Memory {id: $new_id})-[:SUPERSEDES {at: $ts, edit_source: $src}]->\
(b:Memory {id: $archived_id})";
pub const AGE_CYPHER_LATEST_LINK_ATTEST_LEVEL: &'static str = "\
MATCH (m:Memory {id: $id})-[r]->() \
RETURN max(coalesce(r.attest_level, 'unsigned'))";
async fn migrate_v29_stamp(&self) -> StoreResult<()> {
let mut tx = self
.pool
.begin()
.await
.map_err(|e| to_store_err("begin v29 stamp tx", e))?;
record_schema_version(&mut tx, 29).await?;
tx.commit()
.await
.map_err(|e| to_store_err("commit v29 stamp", e))?;
tracing::info!(
target: TRACE_TARGET,
"schema migration v29 stamped (operator-initiated vector(N) conversion available via ai-memory schema-init --embedding-dim)"
);
Ok(())
}
pub async fn current_embedding_dim(&self) -> StoreResult<Option<i32>> {
let row: Option<(i32,)> = sqlx::query_as(
"SELECT atttypmod FROM pg_attribute a
JOIN pg_class c ON c.oid = a.attrelid
JOIN pg_namespace n ON c.relnamespace = n.oid
WHERE c.relname = 'memories' AND a.attname = 'embedding'
AND n.nspname = 'public'",
)
.fetch_optional(&self.pool)
.await
.map_err(|e| to_store_err("read memories.embedding atttypmod", e))?;
Ok(row.map(|(t,)| t))
}
pub async fn migrate_embedding_dim(&self, target_dim: u32) -> StoreResult<bool> {
let target_i32 = i32::try_from(target_dim).map_err(|_| StoreError::InvalidInput {
detail: format!("target_dim {target_dim} out of i32 range"),
})?;
if !SUPPORTED_EMBEDDING_DIMS.contains(&target_i32) {
return Err(StoreError::InvalidInput {
detail: format!(
"unsupported target embedding dim {target_dim}: expected one of {SUPPORTED_EMBEDDING_DIMS:?}"
),
});
}
let current = self.current_embedding_dim().await?;
if let Some(cur) = current
&& cur == target_i32
{
tracing::info!(
target: TRACE_TARGET,
dim = target_i32,
"v29 embedding-dim migration: column already vector({target_i32}); no-op"
);
return Ok(false);
}
tracing::warn!(
target: TRACE_TARGET,
current = ?current,
target = target_i32,
"v29 embedding-dim migration: converting memories.embedding + archived_memories.embedding; existing embeddings will be NULLed — operators MUST re-run embeddings after this conversion completes"
);
let mut tx = self
.pool
.begin()
.await
.map_err(|e| to_store_err("begin v29 conversion tx", e))?;
sqlx::query("DROP INDEX IF EXISTS memories_embedding_hnsw")
.execute(&mut *tx)
.await
.map_err(|e| to_store_err("drop memories_embedding_hnsw", e))?;
sqlx::query("DROP INDEX IF EXISTS archived_memories_embedding_hnsw")
.execute(&mut *tx)
.await
.map_err(|e| to_store_err("drop archived_memories_embedding_hnsw", e))?;
sqlx::query("UPDATE memories SET embedding = NULL WHERE embedding IS NOT NULL")
.execute(&mut *tx)
.await
.map_err(|e| to_store_err("null memories.embedding", e))?;
sqlx::query("UPDATE archived_memories SET embedding = NULL WHERE embedding IS NOT NULL")
.execute(&mut *tx)
.await
.map_err(|e| to_store_err("null archived_memories.embedding", e))?;
let alter_memories = format!(
"ALTER TABLE memories ALTER COLUMN embedding TYPE vector({target_dim}) USING NULL"
);
sqlx::query(&alter_memories)
.execute(&mut *tx)
.await
.map_err(|e| to_store_err("alter memories.embedding type", e))?;
let alter_archived = format!(
"ALTER TABLE archived_memories ALTER COLUMN embedding TYPE vector({target_dim}) USING NULL"
);
sqlx::query(&alter_archived)
.execute(&mut *tx)
.await
.map_err(|e| to_store_err("alter archived_memories.embedding type", e))?;
sqlx::query(
"CREATE INDEX IF NOT EXISTS memories_embedding_hnsw ON memories
USING hnsw (embedding vector_cosine_ops)",
)
.execute(&mut *tx)
.await
.map_err(|e| to_store_err("recreate memories_embedding_hnsw", e))?;
record_schema_version(&mut tx, 29).await?;
tx.commit()
.await
.map_err(|e| to_store_err("commit v29 conversion", e))?;
tracing::warn!(
target: TRACE_TARGET,
target_dim = target_i32,
"v29 embedding-dim migration committed; re-run embeddings (e.g. via memory_store with the matching embedder configured)"
);
Ok(true)
}
async fn migrate_v23(&self) -> StoreResult<()> {
let mut tx = self
.pool
.begin()
.await
.map_err(|e| to_store_err("begin v23 tx", e))?;
let has_attest_level: bool = sqlx::query_scalar(
"SELECT EXISTS(
SELECT 1 FROM information_schema.columns
WHERE table_name='memory_links' AND column_name='attest_level'
)",
)
.fetch_one(&mut *tx)
.await
.map_err(|e| to_store_err("check attest_level column", e))?;
if !has_attest_level {
sqlx::query("ALTER TABLE memory_links ADD COLUMN attest_level TEXT")
.execute(&mut *tx)
.await
.map_err(|e| to_store_err("add attest_level column", e))?;
}
sqlx::query("UPDATE memory_links SET attest_level = 'unsigned' WHERE attest_level IS NULL")
.execute(&mut *tx)
.await
.map_err(|e| to_store_err("backfill attest_level", e))?;
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_memory_links_attest_level
ON memory_links (attest_level, created_at)",
)
.execute(&mut *tx)
.await
.map_err(|e| to_store_err("create idx_memory_links_attest_level", e))?;
record_schema_version(&mut tx, 23).await?;
tx.commit()
.await
.map_err(|e| to_store_err("commit v23 migration", e))?;
tracing::info!(target: TRACE_TARGET, "schema migration v23 applied");
Ok(())
}
async fn migrate_v24(&self) -> StoreResult<()> {
let mut tx = self
.pool
.begin()
.await
.map_err(|e| to_store_err("begin v24 tx", e))?;
sqlx::query(
"CREATE TABLE IF NOT EXISTS memory_transcript_links (
memory_id TEXT NOT NULL REFERENCES memories(id) ON DELETE CASCADE,
transcript_id TEXT NOT NULL REFERENCES memory_transcripts(id) ON DELETE CASCADE,
span_start BIGINT,
span_end BIGINT,
PRIMARY KEY (memory_id, transcript_id)
)",
)
.execute(&mut *tx)
.await
.map_err(|e| to_store_err("create memory_transcript_links table", e))?;
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_mtl_transcript
ON memory_transcript_links (transcript_id)",
)
.execute(&mut *tx)
.await
.map_err(|e| to_store_err("create idx_mtl_transcript", e))?;
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_mtl_memory
ON memory_transcript_links (memory_id)",
)
.execute(&mut *tx)
.await
.map_err(|e| to_store_err("create idx_mtl_memory", e))?;
sqlx::query(
"CREATE OR REPLACE VIEW kg_query_view AS
WITH RECURSIVE traversal(source_id, target_id, relation, depth, nodes) AS (
SELECT ml.source_id, ml.target_id, ml.relation, 1,
ARRAY[ml.source_id, ml.target_id]::TEXT[]
FROM memory_links ml
UNION ALL
SELECT t.source_id, ml.target_id, ml.relation, t.depth + 1,
t.nodes || ml.target_id
FROM memory_links ml
JOIN traversal t ON ml.source_id = t.target_id
WHERE t.depth < 5
AND NOT (ml.target_id = ANY(t.nodes))
)
SELECT source_id, target_id, relation, depth,
array_to_string(nodes, '->') AS path
FROM traversal",
)
.execute(&mut *tx)
.await
.map_err(|e| to_store_err("create kg_query_view", e))?;
sqlx::query(
"CREATE OR REPLACE VIEW kg_timeline_view AS
SELECT ml.source_id, ml.target_id, ml.relation,
ml.valid_from, ml.valid_until, ml.observed_by,
encode(ml.signature, 'hex') AS signature_hex
FROM memory_links ml
WHERE ml.valid_from IS NOT NULL
ORDER BY ml.valid_from DESC, ml.created_at DESC",
)
.execute(&mut *tx)
.await
.map_err(|e| to_store_err("create kg_timeline_view", e))?;
sqlx::query(
"CREATE OR REPLACE FUNCTION kg_find_paths(start_id TEXT, max_depth INTEGER)
RETURNS TABLE (path_id INTEGER, length INTEGER, nodes TEXT[], relations TEXT[])
LANGUAGE SQL STABLE PARALLEL SAFE AS $$
WITH RECURSIVE walk(current_id, depth, nodes, relations) AS (
SELECT start_id, 0, ARRAY[start_id], ARRAY[]::TEXT[]
UNION ALL
SELECT edges.next_id,
w.depth + 1,
w.nodes || edges.next_id,
w.relations || edges.relation
FROM walk w
JOIN (
SELECT source_id AS from_id, target_id AS next_id, relation FROM memory_links
UNION
SELECT target_id AS from_id, source_id AS next_id, relation FROM memory_links
) edges ON edges.from_id = w.current_id
WHERE w.depth < LEAST(max_depth, 7)
AND NOT (edges.next_id = ANY(w.nodes))
)
SELECT
ROW_NUMBER() OVER (ORDER BY depth ASC, nodes ASC)::INTEGER AS path_id,
depth AS length,
nodes,
relations
FROM walk
WHERE depth >= 1
$$",
)
.execute(&mut *tx)
.await
.map_err(|e| to_store_err("create kg_find_paths", e))?;
record_schema_version(&mut tx, 24).await?;
tx.commit()
.await
.map_err(|e| to_store_err("commit v24 migration", e))?;
tracing::info!(target: TRACE_TARGET, "schema migration v24 applied");
Ok(())
}
async fn migrate_v17(&self) -> StoreResult<()> {
let mut tx = self
.pool
.begin()
.await
.map_err(|e| to_store_err("begin v17 tx", e))?;
sqlx::query(
"UPDATE memories
SET metadata = jsonb_set(
metadata,
'{governance,inherit}',
'true'::jsonb,
true
)
WHERE jsonb_typeof(metadata -> 'governance') = 'object'
AND NOT (metadata -> 'governance' ? 'inherit')",
)
.execute(&mut *tx)
.await
.map_err(|e| to_store_err("backfill governance.inherit", e))?;
record_schema_version(&mut tx, 17).await?;
tx.commit()
.await
.map_err(|e| to_store_err("commit v17 migration", e))?;
tracing::info!(target: TRACE_TARGET, "schema migration v17 applied");
Ok(())
}
async fn migrate_v18(&self) -> StoreResult<()> {
let mut tx = self
.pool
.begin()
.await
.map_err(|e| to_store_err("begin v18 tx", e))?;
let existing_dim: Option<(i32,)> = sqlx::query_as(
"SELECT atttypmod FROM pg_attribute a
JOIN pg_class c ON c.oid = a.attrelid
JOIN pg_namespace n ON c.relnamespace = n.oid
WHERE c.relname = 'memories' AND a.attname = 'embedding'
AND n.nspname = 'public'",
)
.fetch_optional(&mut *tx)
.await
.map_err(|e| to_store_err("read memories.embedding dim in v18", e))?;
let dim_for_archive = existing_dim.map_or(DEFAULT_EMBEDDING_DIM, |(d,)| {
u32::try_from(d).unwrap_or(DEFAULT_EMBEDDING_DIM)
});
let archive_embedding_ddl =
format!("ALTER TABLE archived_memories ADD COLUMN embedding vector({dim_for_archive})");
for (table, column, ddl) in [
(
"memories",
"embedding_dim",
"ALTER TABLE memories ADD COLUMN embedding_dim INTEGER".to_string(),
),
(TABLE_ARCHIVED_MEMORIES, "embedding", archive_embedding_ddl),
(
TABLE_ARCHIVED_MEMORIES,
"embedding_dim",
"ALTER TABLE archived_memories ADD COLUMN embedding_dim INTEGER".to_string(),
),
(
TABLE_ARCHIVED_MEMORIES,
"original_tier",
"ALTER TABLE archived_memories ADD COLUMN original_tier TEXT".to_string(),
),
(
TABLE_ARCHIVED_MEMORIES,
"original_expires_at",
"ALTER TABLE archived_memories ADD COLUMN original_expires_at TIMESTAMPTZ"
.to_string(),
),
] {
add_column_if_missing(&mut tx, table, column, &ddl).await?;
}
sqlx::query(
"UPDATE archived_memories
SET original_tier = 'long'
WHERE original_tier IS NULL",
)
.execute(&mut *tx)
.await
.map_err(|e| to_store_err("backfill original_tier", e))?;
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_memories_embedding_dim
ON memories (embedding_dim)
WHERE embedding_dim IS NOT NULL",
)
.execute(&mut *tx)
.await
.map_err(|e| to_store_err("create idx_memories_embedding_dim", e))?;
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_memories_ns_dim
ON memories (namespace, embedding_dim)
WHERE embedding_dim IS NOT NULL",
)
.execute(&mut *tx)
.await
.map_err(|e| to_store_err("create idx_memories_ns_dim", e))?;
record_schema_version(&mut tx, 18).await?;
tx.commit()
.await
.map_err(|e| to_store_err("commit v18 migration", e))?;
tracing::info!(target: TRACE_TARGET, "schema migration v18 applied");
Ok(())
}
async fn migrate_v19(&self) -> StoreResult<()> {
let mut tx = self
.pool
.begin()
.await
.map_err(|e| to_store_err("begin v19 tx", e))?;
add_column_if_missing(
&mut tx,
field_names::SUBSCRIPTIONS,
field_names::EVENT_TYPES,
"ALTER TABLE subscriptions ADD COLUMN event_types JSONB",
)
.await?;
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_subscriptions_event_types
ON subscriptions (event_types)",
)
.execute(&mut *tx)
.await
.map_err(|e| to_store_err("create idx_subscriptions_event_types", e))?;
record_schema_version(&mut tx, 19).await?;
tx.commit()
.await
.map_err(|e| to_store_err("commit v19 migration", e))?;
tracing::info!(target: TRACE_TARGET, "schema migration v19 applied");
Ok(())
}
async fn migrate_v20(&self) -> StoreResult<()> {
let mut tx = self
.pool
.begin()
.await
.map_err(|e| to_store_err("begin v20 tx", e))?;
sqlx::query(
"CREATE TABLE IF NOT EXISTS audit_log (
id TEXT PRIMARY KEY,
agent_id TEXT,
event_type TEXT NOT NULL,
requested_family TEXT,
granted BOOLEAN NOT NULL,
attestation_tier TEXT,
timestamp TIMESTAMPTZ NOT NULL
)",
)
.execute(&mut *tx)
.await
.map_err(|e| to_store_err("create audit_log table", e))?;
for (name, ddl) in [
(
"idx_audit_log_agent_id",
"CREATE INDEX IF NOT EXISTS idx_audit_log_agent_id ON audit_log (agent_id)",
),
(
"idx_audit_log_timestamp",
"CREATE INDEX IF NOT EXISTS idx_audit_log_timestamp ON audit_log (timestamp)",
),
(
"idx_audit_log_event_type",
"CREATE INDEX IF NOT EXISTS idx_audit_log_event_type ON audit_log (event_type)",
),
] {
sqlx::query(ddl)
.execute(&mut *tx)
.await
.map_err(|e| to_store_err(&format!("create {name}"), e))?;
}
record_schema_version(&mut tx, 20).await?;
tx.commit()
.await
.map_err(|e| to_store_err("commit v20 migration", e))?;
tracing::info!(target: TRACE_TARGET, "schema migration v20 applied");
Ok(())
}
async fn migrate_v21(&self) -> StoreResult<()> {
let mut tx = self
.pool
.begin()
.await
.map_err(|e| to_store_err("begin v21 tx", e))?;
add_column_if_missing(
&mut tx,
"pending_actions",
field_names::DEFAULT_TIMEOUT_SECONDS,
"ALTER TABLE pending_actions ADD COLUMN default_timeout_seconds BIGINT",
)
.await?;
add_column_if_missing(
&mut tx,
"pending_actions",
field_names::EXPIRED_AT,
"ALTER TABLE pending_actions ADD COLUMN expired_at TIMESTAMPTZ",
)
.await?;
sqlx::query(
"CREATE INDEX IF NOT EXISTS pending_actions_status_requested_idx
ON pending_actions (status, requested_at)",
)
.execute(&mut *tx)
.await
.map_err(|e| to_store_err("create pending_actions_status_requested_idx", e))?;
record_schema_version(&mut tx, 21).await?;
tx.commit()
.await
.map_err(|e| to_store_err("commit v21 migration", e))?;
tracing::info!(target: TRACE_TARGET, "schema migration v21 applied");
Ok(())
}
async fn migrate_v22(&self) -> StoreResult<()> {
let mut tx = self
.pool
.begin()
.await
.map_err(|e| to_store_err("begin v22 tx", e))?;
sqlx::query(
"CREATE TABLE IF NOT EXISTS memory_transcripts (
id TEXT PRIMARY KEY,
namespace TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
expires_at TIMESTAMPTZ,
compressed_size BIGINT NOT NULL,
original_size BIGINT NOT NULL,
zstd_level INTEGER NOT NULL DEFAULT 3,
content_blob BYTEA NOT NULL
)",
)
.execute(&mut *tx)
.await
.map_err(|e| to_store_err("create memory_transcripts table", e))?;
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_memory_transcripts_namespace_created
ON memory_transcripts (namespace, created_at)",
)
.execute(&mut *tx)
.await
.map_err(|e| to_store_err("create idx_memory_transcripts_namespace_created", e))?;
record_schema_version(&mut tx, 22).await?;
tx.commit()
.await
.map_err(|e| to_store_err("commit v22 migration", e))?;
tracing::info!(target: TRACE_TARGET, "schema migration v22 applied");
Ok(())
}
async fn migrate_v25(&self) -> StoreResult<()> {
let mut tx = self
.pool
.begin()
.await
.map_err(|e| to_store_err("begin v25 tx", e))?;
add_column_if_missing(
&mut tx,
"memory_transcripts",
field_names::ARCHIVED_AT,
"ALTER TABLE memory_transcripts ADD COLUMN archived_at TIMESTAMPTZ",
)
.await?;
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_memory_transcripts_archived_at
ON memory_transcripts (archived_at)
WHERE archived_at IS NOT NULL",
)
.execute(&mut *tx)
.await
.map_err(|e| to_store_err("create idx_memory_transcripts_archived_at", e))?;
record_schema_version(&mut tx, 25).await?;
tx.commit()
.await
.map_err(|e| to_store_err("commit v25 migration", e))?;
tracing::info!(target: TRACE_TARGET, "schema migration v25 applied");
Ok(())
}
async fn migrate_v26(&self) -> StoreResult<()> {
let mut tx = self
.pool
.begin()
.await
.map_err(|e| to_store_err("begin v26 tx", e))?;
sqlx::query(
"CREATE TABLE IF NOT EXISTS signed_events (
id TEXT PRIMARY KEY,
agent_id TEXT NOT NULL,
event_type TEXT NOT NULL,
payload_hash BYTEA NOT NULL,
signature BYTEA,
attest_level TEXT NOT NULL DEFAULT 'unsigned',
timestamp TIMESTAMPTZ NOT NULL
)",
)
.execute(&mut *tx)
.await
.map_err(|e| to_store_err("create signed_events table", e))?;
for (name, ddl) in [
(
"idx_signed_events_agent",
"CREATE INDEX IF NOT EXISTS idx_signed_events_agent ON signed_events (agent_id)",
),
(
"idx_signed_events_type",
"CREATE INDEX IF NOT EXISTS idx_signed_events_type ON signed_events (event_type)",
),
(
"idx_signed_events_timestamp",
"CREATE INDEX IF NOT EXISTS idx_signed_events_timestamp ON signed_events (timestamp)",
),
] {
sqlx::query(ddl)
.execute(&mut *tx)
.await
.map_err(|e| to_store_err(&format!("create {name}"), e))?;
}
record_schema_version(&mut tx, 26).await?;
tx.commit()
.await
.map_err(|e| to_store_err("commit v26 migration", e))?;
tracing::info!(target: TRACE_TARGET, "schema migration v26 applied");
Ok(())
}
async fn migrate_v27(&self) -> StoreResult<()> {
let mut tx = self
.pool
.begin()
.await
.map_err(|e| to_store_err("begin v27 tx", e))?;
sqlx::query(
"CREATE TABLE IF NOT EXISTS subscription_events (
id BIGSERIAL PRIMARY KEY,
subscription_id TEXT NOT NULL,
correlation_id TEXT NOT NULL DEFAULT '',
event_type TEXT NOT NULL,
payload JSONB NOT NULL,
delivered_at TIMESTAMPTZ NOT NULL,
delivery_status TEXT NOT NULL DEFAULT 'pending'
)",
)
.execute(&mut *tx)
.await
.map_err(|e| to_store_err("create subscription_events table", e))?;
add_column_if_missing(
&mut tx,
"subscription_events",
"correlation_id",
"ALTER TABLE subscription_events ADD COLUMN correlation_id TEXT NOT NULL DEFAULT ''",
)
.await?;
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_subscription_events_correlation
ON subscription_events (correlation_id)",
)
.execute(&mut *tx)
.await
.map_err(|e| to_store_err("create idx_subscription_events_correlation", e))?;
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_subscription_events_subscription
ON subscription_events (subscription_id, delivered_at)",
)
.execute(&mut *tx)
.await
.map_err(|e| to_store_err("create idx_subscription_events_subscription", e))?;
sqlx::query(
"CREATE TABLE IF NOT EXISTS subscription_dlq (
id BIGSERIAL PRIMARY KEY,
subscription_id TEXT NOT NULL,
correlation_id TEXT NOT NULL,
event_type TEXT NOT NULL,
payload JSONB NOT NULL,
retry_count INTEGER NOT NULL,
last_error TEXT NOT NULL,
first_failed_at TIMESTAMPTZ NOT NULL,
last_failed_at TIMESTAMPTZ NOT NULL
)",
)
.execute(&mut *tx)
.await
.map_err(|e| to_store_err("create subscription_dlq table", e))?;
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_subscription_dlq_subscription
ON subscription_dlq (subscription_id, last_failed_at)",
)
.execute(&mut *tx)
.await
.map_err(|e| to_store_err("create idx_subscription_dlq_subscription", e))?;
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_subscription_dlq_correlation
ON subscription_dlq (correlation_id)",
)
.execute(&mut *tx)
.await
.map_err(|e| to_store_err("create idx_subscription_dlq_correlation", e))?;
record_schema_version(&mut tx, 27).await?;
tx.commit()
.await
.map_err(|e| to_store_err("commit v27 migration", e))?;
tracing::info!(target: TRACE_TARGET, "schema migration v27 applied");
Ok(())
}
async fn migrate_v28(&self) -> StoreResult<()> {
let mut tx = self
.pool
.begin()
.await
.map_err(|e| to_store_err("begin v28 tx", e))?;
sqlx::query(&format!(
"CREATE TABLE IF NOT EXISTS agent_quotas (
agent_id TEXT PRIMARY KEY,
max_memories_per_day BIGINT NOT NULL DEFAULT {memories},
max_storage_bytes BIGINT NOT NULL DEFAULT {storage},
max_links_per_day BIGINT NOT NULL DEFAULT {links},
current_memories_today BIGINT NOT NULL DEFAULT 0,
current_storage_bytes BIGINT NOT NULL DEFAULT 0,
current_links_today BIGINT NOT NULL DEFAULT 0,
day_started_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
)",
memories = crate::quotas::DEFAULT_MAX_MEMORIES_PER_DAY,
storage = crate::quotas::DEFAULT_MAX_STORAGE_BYTES,
links = crate::quotas::DEFAULT_MAX_LINKS_PER_DAY,
))
.execute(&mut *tx)
.await
.map_err(|e| to_store_err("create agent_quotas table", e))?;
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_agent_quotas_agent_id
ON agent_quotas (agent_id)",
)
.execute(&mut *tx)
.await
.map_err(|e| to_store_err("create idx_agent_quotas_agent_id", e))?;
record_schema_version(&mut tx, 28).await?;
tx.commit()
.await
.map_err(|e| to_store_err("commit v28 migration", e))?;
tracing::info!(target: TRACE_TARGET, "schema migration v28 applied");
Ok(())
}
async fn migrate_v15(&self) -> StoreResult<()> {
let mut tx = self
.pool
.begin()
.await
.map_err(|e| to_store_err("begin transaction", e))?;
let has_valid_from: bool = sqlx::query_scalar(
"SELECT EXISTS(
SELECT 1 FROM information_schema.columns
WHERE table_name='memory_links' AND column_name='valid_from'
)",
)
.fetch_one(&mut *tx)
.await
.map_err(|e| to_store_err("check valid_from column", e))?;
if !has_valid_from {
sqlx::query("ALTER TABLE memory_links ADD COLUMN valid_from TIMESTAMPTZ")
.execute(&mut *tx)
.await
.map_err(|e| to_store_err("add valid_from column", e))?;
}
let has_valid_until: bool = sqlx::query_scalar(
"SELECT EXISTS(
SELECT 1 FROM information_schema.columns
WHERE table_name='memory_links' AND column_name='valid_until'
)",
)
.fetch_one(&mut *tx)
.await
.map_err(|e| to_store_err("check valid_until column", e))?;
if !has_valid_until {
sqlx::query("ALTER TABLE memory_links ADD COLUMN valid_until TIMESTAMPTZ")
.execute(&mut *tx)
.await
.map_err(|e| to_store_err("add valid_until column", e))?;
}
let has_observed_by: bool = sqlx::query_scalar(
"SELECT EXISTS(
SELECT 1 FROM information_schema.columns
WHERE table_name='memory_links' AND column_name='observed_by'
)",
)
.fetch_one(&mut *tx)
.await
.map_err(|e| to_store_err("check observed_by column", e))?;
if !has_observed_by {
sqlx::query("ALTER TABLE memory_links ADD COLUMN observed_by TEXT")
.execute(&mut *tx)
.await
.map_err(|e| to_store_err("add observed_by column", e))?;
}
let has_signature: bool = sqlx::query_scalar(
"SELECT EXISTS(
SELECT 1 FROM information_schema.columns
WHERE table_name='memory_links' AND column_name='signature'
)",
)
.fetch_one(&mut *tx)
.await
.map_err(|e| to_store_err("check signature column", e))?;
if !has_signature {
sqlx::query("ALTER TABLE memory_links ADD COLUMN signature BYTEA")
.execute(&mut *tx)
.await
.map_err(|e| to_store_err("add signature column", e))?;
}
sqlx::query(
"UPDATE memory_links
SET valid_from = (SELECT created_at FROM memories WHERE id = memory_links.source_id)
WHERE valid_from IS NULL",
)
.execute(&mut *tx)
.await
.map_err(|e| to_store_err("backfill valid_from", e))?;
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_links_temporal_src
ON memory_links (source_id, valid_from, valid_until)",
)
.execute(&mut *tx)
.await
.map_err(|e| to_store_err("create idx_links_temporal_src", e))?;
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_links_temporal_tgt
ON memory_links (target_id, valid_from, valid_until)",
)
.execute(&mut *tx)
.await
.map_err(|e| to_store_err("create idx_links_temporal_tgt", e))?;
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_links_relation
ON memory_links (relation, valid_from)",
)
.execute(&mut *tx)
.await
.map_err(|e| to_store_err("create idx_links_relation", e))?;
sqlx::query(
"CREATE TABLE IF NOT EXISTS entity_aliases (
entity_id TEXT NOT NULL,
alias TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (entity_id, alias)
)",
)
.execute(&mut *tx)
.await
.map_err(|e| to_store_err("create entity_aliases table", e))?;
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_entity_aliases_alias
ON entity_aliases (alias)",
)
.execute(&mut *tx)
.await
.map_err(|e| to_store_err("create idx_entity_aliases_alias", e))?;
record_schema_version(&mut tx, 15).await?;
tx.commit()
.await
.map_err(|e| to_store_err("commit migration transaction", e))?;
tracing::info!(
target: TRACE_TARGET,
version = 15,
"schema migration v15 applied"
);
Ok(())
}
pub async fn kg_query(
&self,
source_id: &str,
max_depth: usize,
) -> StoreResult<Vec<KgQueryRow>> {
self.kg_query_with_history(source_id, max_depth, false)
.await
}
pub async fn kg_query_with_history(
&self,
source_id: &str,
max_depth: usize,
include_invalidated: bool,
) -> StoreResult<Vec<KgQueryRow>> {
match self.kg_backend {
KgBackend::Age if !include_invalidated => {
match self.kg_query_cypher(source_id, max_depth).await {
Ok(rows) => Ok(rows),
Err(err) if is_age_runtime_failure(&err) => {
warn_age_fallback("kg_query", source_id, &err);
self.kg_query_cte_filtered(source_id, max_depth, include_invalidated)
.await
}
Err(err) => Err(err),
}
}
_ => {
self.kg_query_cte_filtered(source_id, max_depth, include_invalidated)
.await
}
}
}
pub async fn kg_query_cypher(
&self,
source_id: &str,
max_depth: usize,
) -> StoreResult<Vec<KgQueryRow>> {
validate_depth(max_depth)?;
let mut tx = self
.pool
.begin()
.await
.map_err(|e| to_store_err(CTX_BEGIN_AGE_TX, e))?;
load_age_tolerated(&mut tx).await?;
sqlx::query(SQL_SET_AGE_SEARCH_PATH)
.execute(&mut *tx)
.await
.map_err(|e| to_store_err(CTX_SET_SEARCH_PATH, e))?;
let cypher = build_kg_query_current_view_cypher(max_depth);
let now_stamp = Utc::now().to_rfc3339();
let params_lit = age_params_literal(&[("start_id", source_id), ("now", &now_stamp)]);
let sql = format!(
"SELECT target_id, relation, depth, path FROM cypher('memory_graph', $$ {cypher} $$, \
{params_lit}) AS (target_id agtype, relation agtype, depth agtype, path agtype)"
);
let rows = sqlx::query(&sql)
.persistent(false)
.fetch_all(&mut *tx)
.await
.map_err(|e| to_store_err("cypher kg_query", e))?;
tx.commit()
.await
.map_err(|e| to_store_err(CTX_COMMIT_AGE_TX, e))?;
rows.iter()
.map(|r| {
let target_id: String = r
.try_get::<String, _>("target_id")
.map_err(|e| to_store_err(READ_TARGET_ID, e))?;
let relation: String = r
.try_get::<String, _>("relation")
.map_err(|e| to_store_err(READ_RELATION, e))?;
let depth_raw: String = r
.try_get::<String, _>("depth")
.map_err(|e| to_store_err("read depth", e))?;
let path: String = r
.try_get::<String, _>("path")
.map_err(|e| to_store_err("read path", e))?;
let depth: usize = strip_agtype_quotes(&depth_raw).parse().map_err(|_| {
StoreError::IntegrityFailed {
detail: format!("non-numeric AGE depth: {depth_raw}"),
}
})?;
Ok(KgQueryRow {
target_id: strip_agtype_quotes(&target_id).to_string(),
relation: strip_agtype_quotes(&relation).to_string(),
depth,
path: strip_agtype_quotes(&path).to_string(),
})
})
.collect()
}
pub async fn kg_query_cte(
&self,
source_id: &str,
max_depth: usize,
) -> StoreResult<Vec<KgQueryRow>> {
self.kg_query_cte_filtered(source_id, max_depth, false)
.await
}
pub async fn kg_query_cte_filtered(
&self,
source_id: &str,
max_depth: usize,
include_invalidated: bool,
) -> StoreResult<Vec<KgQueryRow>> {
validate_depth(max_depth)?;
let depth_cap = i32::try_from(max_depth).unwrap_or(i32::MAX);
let valid_filter = if include_invalidated {
"TRUE"
} else {
"(ml.valid_until IS NULL OR ml.valid_until > NOW())"
};
let sql = format!(
"WITH RECURSIVE traversal(target_id, relation, depth, nodes) AS (
SELECT ml.target_id, ml.relation, 1,
ARRAY[ml.source_id, ml.target_id]::TEXT[]
FROM memory_links ml
WHERE ml.source_id = $1
AND {valid_filter}
UNION ALL
SELECT ml.target_id, ml.relation, t.depth + 1,
t.nodes || ml.target_id
FROM memory_links ml
JOIN traversal t ON ml.source_id = t.target_id
WHERE t.depth < $2
AND NOT (ml.target_id = ANY(t.nodes))
AND {valid_filter}
)
SELECT target_id, relation, depth,
array_to_string(nodes, '->') AS path
FROM traversal
ORDER BY depth ASC, target_id ASC"
);
let rows = sqlx::query(&sql)
.bind(source_id)
.bind(depth_cap)
.fetch_all(&self.pool)
.await
.map_err(|e| to_store_err("cte kg_query", e))?;
rows.iter()
.map(|r| {
let target_id: String = r
.try_get::<String, _>("target_id")
.map_err(|e| to_store_err(READ_TARGET_ID, e))?;
let relation: String = r
.try_get::<String, _>("relation")
.map_err(|e| to_store_err(READ_RELATION, e))?;
let depth_i: i32 = r
.try_get::<i32, _>("depth")
.map_err(|e| to_store_err("read depth", e))?;
let path: String = r
.try_get::<String, _>("path")
.map_err(|e| to_store_err("read path", e))?;
Ok(KgQueryRow {
target_id,
relation,
depth: usize::try_from(depth_i).unwrap_or(0),
path,
})
})
.collect()
}
pub async fn kg_timeline(
&self,
source_id: &str,
since: Option<&str>,
until: Option<&str>,
limit: Option<usize>,
) -> StoreResult<Vec<KgTimelineRow>> {
match self.kg_backend {
KgBackend::Age => {
match self
.kg_timeline_cypher(source_id, since, until, limit)
.await
{
Ok(rows) => Ok(rows),
Err(err) if is_age_runtime_failure(&err) => {
warn_age_fallback("kg_timeline", source_id, &err);
self.kg_timeline_cte(source_id, since, until, limit).await
}
Err(err) => Err(err),
}
}
KgBackend::Cte => self.kg_timeline_cte(source_id, since, until, limit).await,
}
}
pub async fn kg_timeline_cypher(
&self,
source_id: &str,
since: Option<&str>,
until: Option<&str>,
limit: Option<usize>,
) -> StoreResult<Vec<KgTimelineRow>> {
let cap = clamp_timeline_limit(limit);
let mut tx = self
.pool
.begin()
.await
.map_err(|e| to_store_err(CTX_BEGIN_AGE_TX, e))?;
load_age_tolerated(&mut tx).await?;
sqlx::query(SQL_SET_AGE_SEARCH_PATH)
.execute(&mut *tx)
.await
.map_err(|e| to_store_err(CTX_SET_SEARCH_PATH, e))?;
let mut where_clauses: Vec<&str> = vec!["a.id = $start_id", "r.valid_from IS NOT NULL"];
if since.is_some() {
where_clauses.push("r.valid_from >= $since");
}
if until.is_some() {
where_clauses.push("r.valid_from <= $until");
}
let where_sql = where_clauses.join(" AND ");
let cypher = format!(
"MATCH (a)-[r:related_to]->(b) \
WHERE {where_sql} \
RETURN b.id AS target_id, \
r.relation AS relation, \
r.valid_from AS valid_from, \
r.valid_until AS valid_until, \
r.observed_by AS observed_by \
ORDER BY r.valid_from ASC, r.created_at ASC \
LIMIT {cap}"
);
let mut pairs: Vec<(&str, &str)> = vec![("start_id", source_id)];
if let Some(s) = since {
pairs.push(("since", s));
}
if let Some(u) = until {
pairs.push(("until", u));
}
let params_lit = age_params_literal(&pairs);
let sql = format!(
"SELECT target_id, relation, valid_from, valid_until, observed_by \
FROM cypher('memory_graph', $$ {cypher} $$, {params_lit}) AS \
(target_id agtype, relation agtype, valid_from agtype, \
valid_until agtype, observed_by agtype)"
);
let rows = sqlx::query(&sql)
.persistent(false)
.fetch_all(&mut *tx)
.await
.map_err(|e| to_store_err("cypher kg_timeline", e))?;
let mut decoded: Vec<KgTimelineRow> = Vec::with_capacity(rows.len());
for r in &rows {
let target_id_raw: String = r
.try_get::<String, _>("target_id")
.map_err(|e| to_store_err(READ_TARGET_ID, e))?;
let relation_raw: String = r
.try_get::<String, _>("relation")
.map_err(|e| to_store_err(READ_RELATION, e))?;
let valid_from_raw: String = r
.try_get::<String, _>(field_names::VALID_FROM)
.map_err(|e| to_store_err(READ_VALID_FROM, e))?;
let valid_until_raw: String = r
.try_get::<String, _>(field_names::VALID_UNTIL)
.map_err(|e| to_store_err(READ_VALID_UNTIL, e))?;
let observed_by_raw: String = r
.try_get::<String, _>(field_names::OBSERVED_BY)
.map_err(|e| to_store_err(READ_OBSERVED_BY, e))?;
decoded.push(KgTimelineRow {
target_id: strip_agtype_quotes(&target_id_raw).to_string(),
relation: strip_agtype_quotes(&relation_raw).to_string(),
valid_from: strip_agtype_quotes(&valid_from_raw).to_string(),
valid_until: agtype_optional_string(&valid_until_raw),
observed_by: agtype_optional_string(&observed_by_raw),
title: String::new(),
target_namespace: String::new(),
});
}
if !decoded.is_empty() {
let ids: Vec<String> = {
let mut seen: std::collections::BTreeSet<String> =
std::collections::BTreeSet::new();
for row in &decoded {
seen.insert(row.target_id.clone());
}
seen.into_iter().collect()
};
let display_rows =
sqlx::query("SELECT id, title, namespace FROM memories WHERE id = ANY($1)")
.bind(&ids)
.fetch_all(&mut *tx)
.await
.map_err(|e| to_store_err("fetch timeline display fields", e))?;
let mut display: std::collections::HashMap<String, (String, String)> =
std::collections::HashMap::with_capacity(display_rows.len());
for r in &display_rows {
let id: String = r
.try_get::<String, _>("id")
.map_err(|e| to_store_err("read id", e))?;
let title: String = r
.try_get::<String, _>("title")
.map_err(|e| to_store_err(READ_TITLE, e))?;
let namespace: String = r
.try_get::<String, _>("namespace")
.map_err(|e| to_store_err(READ_NAMESPACE, e))?;
display.insert(id, (title, namespace));
}
for row in &mut decoded {
if let Some((title, ns)) = display.get(&row.target_id) {
row.title.clone_from(title);
row.target_namespace.clone_from(ns);
}
}
}
tx.commit()
.await
.map_err(|e| to_store_err(CTX_COMMIT_AGE_TX, e))?;
Ok(decoded)
}
pub async fn kg_timeline_cte(
&self,
source_id: &str,
since: Option<&str>,
until: Option<&str>,
limit: Option<usize>,
) -> StoreResult<Vec<KgTimelineRow>> {
let cap = clamp_timeline_limit(limit);
let cap_i64 = i64::try_from(cap).unwrap_or(i64::MAX);
let mut sql = String::from(
"SELECT ml.target_id, ml.relation, ml.valid_from, ml.valid_until,
ml.observed_by, m.title, m.namespace, ml.created_at
FROM memory_links ml
JOIN memories m ON m.id = ml.target_id
WHERE ml.source_id = $1
AND ml.valid_from IS NOT NULL",
);
let mut next_placeholder = 2usize;
if since.is_some() {
sql.push_str(&format!(
" AND ml.valid_from >= ${next_placeholder}::TIMESTAMPTZ"
));
next_placeholder += 1;
}
if until.is_some() {
sql.push_str(&format!(
" AND ml.valid_from <= ${next_placeholder}::TIMESTAMPTZ"
));
next_placeholder += 1;
}
sql.push_str(&format!(
" ORDER BY ml.valid_from ASC, ml.created_at ASC LIMIT ${next_placeholder}"
));
let mut q = sqlx::query(&sql).bind(source_id);
if let Some(s) = since {
q = q.bind(s);
}
if let Some(u) = until {
q = q.bind(u);
}
q = q.bind(cap_i64);
let rows = q
.fetch_all(&self.pool)
.await
.map_err(|e| to_store_err("cte kg_timeline", e))?;
rows.iter()
.map(|r| {
let target_id: String = r
.try_get::<String, _>("target_id")
.map_err(|e| to_store_err(READ_TARGET_ID, e))?;
let relation: String = r
.try_get::<String, _>("relation")
.map_err(|e| to_store_err(READ_RELATION, e))?;
let valid_from: DateTime<Utc> = r
.try_get::<DateTime<Utc>, _>(field_names::VALID_FROM)
.map_err(|e| to_store_err(READ_VALID_FROM, e))?;
let valid_until: Option<DateTime<Utc>> = r
.try_get::<Option<DateTime<Utc>>, _>(field_names::VALID_UNTIL)
.map_err(|e| to_store_err(READ_VALID_UNTIL, e))?;
let observed_by: Option<String> = r
.try_get::<Option<String>, _>(field_names::OBSERVED_BY)
.map_err(|e| to_store_err(READ_OBSERVED_BY, e))?;
let title: String = r
.try_get::<String, _>("title")
.map_err(|e| to_store_err(READ_TITLE, e))?;
let target_namespace: String = r
.try_get::<String, _>("namespace")
.map_err(|e| to_store_err(READ_NAMESPACE, e))?;
Ok(KgTimelineRow {
target_id,
relation,
valid_from: valid_from.to_rfc3339(),
valid_until: valid_until.map(|t| t.to_rfc3339()),
observed_by,
title,
target_namespace,
})
})
.collect()
}
pub async fn kg_invalidate(
&self,
source_id: &str,
target_id: &str,
relation: &str,
valid_until: Option<&str>,
) -> StoreResult<KgInvalidateRow> {
match self.kg_backend {
KgBackend::Age => {
match self
.kg_invalidate_cypher(source_id, target_id, relation, valid_until)
.await
{
Ok(row) => Ok(row),
Err(err) if is_age_runtime_failure(&err) => {
warn_age_fallback(
crate::governance::action_labels::KG_INVALIDATE,
source_id,
&err,
);
self.kg_invalidate_cte(source_id, target_id, relation, valid_until)
.await
}
Err(err) => Err(err),
}
}
KgBackend::Cte => {
self.kg_invalidate_cte(source_id, target_id, relation, valid_until)
.await
}
}
}
pub async fn kg_invalidate_cypher(
&self,
source_id: &str,
target_id: &str,
relation: &str,
valid_until: Option<&str>,
) -> StoreResult<KgInvalidateRow> {
let stamp = valid_until.map_or_else(|| Utc::now().to_rfc3339(), str::to_string);
let mut tx = self
.pool
.begin()
.await
.map_err(|e| to_store_err(CTX_BEGIN_AGE_TX, e))?;
load_age_tolerated(&mut tx).await?;
sqlx::query(SQL_SET_AGE_SEARCH_PATH)
.execute(&mut *tx)
.await
.map_err(|e| to_store_err(CTX_SET_SEARCH_PATH, e))?;
let read_cypher = "MATCH (a)-[r:related_to]->(b) \
WHERE a.id = $src AND b.id = $dst AND r.relation = $rel \
RETURN r.valid_until AS prior";
let read_params_lit =
age_params_literal(&[("src", source_id), ("dst", target_id), ("rel", relation)]);
let read_sql = format!(
"SELECT prior FROM cypher('memory_graph', $$ {read_cypher} $$, {read_params_lit}) AS \
(prior agtype)"
);
let prior_rows = sqlx::query(&read_sql)
.fetch_all(&mut *tx)
.await
.map_err(|e| to_store_err("cypher kg_invalidate read", e))?;
if prior_rows.is_empty() {
tx.commit()
.await
.map_err(|e| to_store_err(CTX_COMMIT_AGE_TX, e))?;
return Ok(KgInvalidateRow {
found: false,
valid_until: String::new(),
previous_valid_until: None,
});
}
let prior_raw: String = prior_rows[0]
.try_get::<String, _>("prior")
.map_err(|e| to_store_err("read prior valid_until", e))?;
let previous_valid_until = agtype_optional_string(&prior_raw);
let write_cypher = "MATCH (a)-[r:related_to]->(b) \
WHERE a.id = $src AND b.id = $dst AND r.relation = $rel \
SET r.valid_until = $now \
RETURN count(r) AS affected";
let write_params_lit = age_params_literal(&[
("src", source_id),
("dst", target_id),
("rel", relation),
("now", &stamp),
]);
let write_sql = format!(
"SELECT affected FROM cypher('memory_graph', $$ {write_cypher} $$, {write_params_lit}) AS \
(affected agtype)"
);
let _ = sqlx::query(&write_sql)
.fetch_all(&mut *tx)
.await
.map_err(|e| to_store_err("cypher kg_invalidate set", e))?;
sqlx::query(
"UPDATE memory_links SET valid_until = $4 \
WHERE source_id = $1 AND target_id = $2 AND relation = $3",
)
.bind(source_id)
.bind(target_id)
.bind(relation)
.bind(&stamp)
.execute(&mut *tx)
.await
.map_err(|e| to_store_err("cypher kg_invalidate mirror", e))?;
tx.commit()
.await
.map_err(|e| to_store_err(CTX_COMMIT_AGE_TX, e))?;
Ok(KgInvalidateRow {
found: true,
valid_until: stamp,
previous_valid_until,
})
}
pub async fn kg_invalidate_cte(
&self,
source_id: &str,
target_id: &str,
relation: &str,
valid_until: Option<&str>,
) -> StoreResult<KgInvalidateRow> {
let stamp = valid_until.map_or_else(|| Utc::now().to_rfc3339(), str::to_string);
let sql = "WITH prev AS (
SELECT valid_until AS prior
FROM memory_links
WHERE source_id = $1 AND target_id = $2 AND relation = $3
FOR UPDATE
),
upd AS (
UPDATE memory_links
SET valid_until = $4::TIMESTAMPTZ
WHERE source_id = $1 AND target_id = $2 AND relation = $3
RETURNING valid_until AS now_until
)
SELECT prev.prior, upd.now_until
FROM prev FULL OUTER JOIN upd ON TRUE";
let rows = sqlx::query(sql)
.bind(source_id)
.bind(target_id)
.bind(relation)
.bind(&stamp)
.fetch_all(&self.pool)
.await
.map_err(|e| to_store_err("cte kg_invalidate", e))?;
if rows.is_empty() {
return Ok(KgInvalidateRow {
found: false,
valid_until: String::new(),
previous_valid_until: None,
});
}
let row = &rows[0];
let prior: Option<DateTime<Utc>> = row
.try_get::<Option<DateTime<Utc>>, _>("prior")
.map_err(|e| to_store_err("read prior valid_until", e))?;
let now_until: Option<DateTime<Utc>> = row
.try_get::<Option<DateTime<Utc>>, _>("now_until")
.map_err(|e| to_store_err("read new valid_until", e))?;
if now_until.is_none() {
return Ok(KgInvalidateRow {
found: false,
valid_until: String::new(),
previous_valid_until: None,
});
}
Ok(KgInvalidateRow {
found: true,
valid_until: stamp,
previous_valid_until: prior.map(|t| t.to_rfc3339()),
})
}
pub async fn find_paths(
&self,
source_id: &str,
target_id: &str,
max_depth: Option<usize>,
max_results: Option<usize>,
) -> StoreResult<Vec<Vec<String>>> {
match self.kg_backend {
KgBackend::Age => {
match self
.find_paths_cypher(source_id, target_id, max_depth, max_results)
.await
{
Ok(paths) => Ok(paths),
Err(err) if is_age_runtime_failure(&err) => {
warn_age_fallback_pair("find_paths", source_id, target_id, &err);
self.find_paths_cte(source_id, target_id, max_depth, max_results)
.await
}
Err(err) => Err(err),
}
}
KgBackend::Cte => {
self.find_paths_cte(source_id, target_id, max_depth, max_results)
.await
}
}
}
pub async fn find_paths_cte(
&self,
source_id: &str,
target_id: &str,
max_depth: Option<usize>,
max_results: Option<usize>,
) -> StoreResult<Vec<Vec<String>>> {
let depth = max_depth.unwrap_or(FIND_PATHS_DEFAULT_DEPTH_SAL);
validate_find_paths_depth(depth)?;
let cap = max_results
.unwrap_or(FIND_PATHS_DEFAULT_LIMIT_SAL)
.clamp(1, FIND_PATHS_MAX_LIMIT_SAL);
if source_id == target_id {
return Ok(vec![vec![source_id.to_string()]]);
}
let depth_i32 = i32::try_from(depth).unwrap_or(i32::MAX);
let cap_i64 = i64::try_from(cap).unwrap_or(i64::MAX);
let sql = "WITH RECURSIVE traversal(current_id, depth, path) AS (
SELECT $1::TEXT, 0, ARRAY[$1::TEXT]
UNION ALL
SELECT edges.next_id, t.depth + 1, t.path || edges.next_id
FROM traversal t
JOIN (
-- #1689 — exclude invalidated edges from traversal so a
-- retracted link no longer influences path-finding, matching
-- the sqlite find_paths current-view default and pg
-- kg_query_cte_filtered. Both UNION arms get the filter.
SELECT source_id AS from_id, target_id AS next_id FROM memory_links
WHERE valid_until IS NULL OR valid_until > NOW()
UNION
SELECT target_id AS from_id, source_id AS next_id FROM memory_links
WHERE valid_until IS NULL OR valid_until > NOW()
) edges ON edges.from_id = t.current_id
WHERE t.depth < $3
AND NOT (edges.next_id = ANY(t.path))
)
SELECT path
FROM traversal
WHERE current_id = $2 AND depth >= 1
ORDER BY depth ASC, path ASC
LIMIT $4";
let rows = sqlx::query(sql)
.bind(source_id)
.bind(target_id)
.bind(depth_i32)
.bind(cap_i64)
.fetch_all(&self.pool)
.await
.map_err(|e| to_store_err("cte find_paths", e))?;
rows.iter()
.map(|r| {
let path: Vec<String> = r
.try_get::<Vec<String>, _>("path")
.map_err(|e| to_store_err("read path", e))?;
Ok(path)
})
.collect()
}
pub async fn find_paths_cypher(
&self,
source_id: &str,
target_id: &str,
max_depth: Option<usize>,
max_results: Option<usize>,
) -> StoreResult<Vec<Vec<String>>> {
let depth = max_depth.unwrap_or(FIND_PATHS_DEFAULT_DEPTH_SAL);
validate_find_paths_depth(depth)?;
let cap = max_results
.unwrap_or(FIND_PATHS_DEFAULT_LIMIT_SAL)
.clamp(1, FIND_PATHS_MAX_LIMIT_SAL);
if source_id == target_id {
return Ok(vec![vec![source_id.to_string()]]);
}
let mut tx = self
.pool
.begin()
.await
.map_err(|e| to_store_err(CTX_BEGIN_AGE_TX, e))?;
load_age_tolerated(&mut tx).await?;
sqlx::query(SQL_SET_AGE_SEARCH_PATH)
.execute(&mut *tx)
.await
.map_err(|e| to_store_err(CTX_SET_SEARCH_PATH, e))?;
assert_age_id_safe(source_id).map_err(|detail| StoreError::InvalidInput { detail })?;
assert_age_id_safe(target_id).map_err(|detail| StoreError::InvalidInput { detail })?;
let now_stamp = Utc::now().to_rfc3339();
let sql = format!(
"SELECT path FROM cypher('memory_graph', $$ {} $$) AS (path agtype)",
build_find_paths_current_view_cypher(source_id, target_id, depth, cap, &now_stamp)
);
let rows = sqlx::query(&sql)
.persistent(false)
.fetch_all(&mut *tx)
.await
.map_err(|e| to_store_err("cypher find_paths", e))?;
tx.commit()
.await
.map_err(|e| to_store_err(CTX_COMMIT_AGE_TX, e))?;
rows.iter()
.map(|r| {
let raw: Agtype = r
.try_get::<Agtype, _>("path")
.map_err(|e| to_store_err("read path", e))?;
let json_payload = raw
.0
.replace("::vertex", "")
.replace("::edge", "")
.replace("::path", "");
let payload_preview = |s: &str| -> String {
if s.len() <= 200 {
s.to_string()
} else {
format!("{}…<{}b truncated>", &s[..200], s.len() - 200)
}
};
let arr: serde_json::Value = serde_json::from_str(&json_payload).map_err(|e| {
StoreError::IntegrityFailed {
detail: format!(
"non-JSON AGE path payload: {}: {e}",
payload_preview(&raw.0)
),
}
})?;
let nodes = arr.as_array().ok_or_else(|| StoreError::IntegrityFailed {
detail: format!("AGE path is not an array: {}", payload_preview(&raw.0)),
})?;
let ids: Vec<String> = nodes
.iter()
.filter_map(|v| {
v.get(field_names::PROPERTIES)
.and_then(|p| p.get("id"))
.and_then(|i| i.as_str())
.map(String::from)
})
.collect();
if ids.is_empty() {
return Err(StoreError::IntegrityFailed {
detail: format!(
"AGE path has no extractable ids: {}",
payload_preview(&raw.0)
),
});
}
Ok(ids)
})
.collect()
}
async fn validate_link_pre_create_pg(
&self,
link: &MemoryLink,
keypair: Option<&crate::identity::keypair::AgentKeypair>,
) -> StoreResult<()> {
let agent_id_for_eval = keypair.map_or("system", |kp| kp.agent_id.as_str());
let link_ns: Option<String> =
sqlx::query_scalar("SELECT namespace FROM memories WHERE id = $1")
.bind(&link.source_id)
.fetch_optional(&self.pool)
.await
.map_err(|e| to_store_err("resolve link source namespace", e))?;
let link_ns = link_ns.unwrap_or_else(|| crate::DEFAULT_NAMESPACE.to_string());
if link.relation == crate::models::MemoryLinkRelation::ReflectsOn {
let max_depth = super::MemoryStore::resolve_governance_policy(self, &link_ns)
.await?
.unwrap_or_default()
.effective_max_reflection_depth();
let bound = crate::kg::cycle_check::walk_bound(max_depth);
let rows: Vec<(String, String)> = sqlx::query_as(
"WITH RECURSIVE walk(source_id, target_id, depth) AS (
SELECT ml.source_id, ml.target_id, 1
FROM memory_links ml
WHERE ml.source_id = $1 AND ml.relation = $3
UNION
SELECT ml.source_id, ml.target_id, w.depth + 1
FROM memory_links ml
JOIN walk w ON ml.source_id = w.target_id
WHERE ml.relation = $3 AND w.depth < $2
)
SELECT source_id, target_id FROM walk",
)
.bind(&link.target_id)
.bind(i64::from(bound))
.bind(crate::models::MemoryLinkRelation::ReflectsOn.as_str())
.fetch_all(&self.pool)
.await
.map_err(|e| to_store_err("cycle-check subgraph fetch", e))?;
let mut adjacency: std::collections::HashMap<String, Vec<String>> =
std::collections::HashMap::new();
for (src, dst) in rows {
adjacency.entry(src).or_default().push(dst);
}
let result = crate::kg::cycle_check::would_create_reflection_cycle_with::<
std::convert::Infallible,
>(&link.source_id, &link.target_id, max_depth, &mut |node| {
Ok(adjacency.get(node).cloned().unwrap_or_default())
})
.unwrap_or_else(|never| match never {});
if result.would_cycle {
return Err(StoreError::LinkRefused {
detail: crate::storage::StorageError::LinkReflectionCycle {
source_id: link.source_id.clone(),
target_id: link.target_id.clone(),
}
.to_string(),
});
}
}
crate::storage::evaluate_link_permission(
&link_ns,
&link.source_id,
&link.target_id,
link.relation.as_str(),
agent_id_for_eval,
)
.map_err(|se| StoreError::PermissionDenied {
action: "memory_link".to_string(),
target: link_ns.clone(),
reason: se.to_string(),
})?;
Ok(())
}
async fn link_internal(
&self,
link: &MemoryLink,
keypair: Option<&crate::identity::keypair::AgentKeypair>,
) -> StoreResult<&'static str> {
self.validate_link_pre_create_pg(link, keypair).await?;
let source_exists: bool =
sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM memories WHERE id = $1)")
.bind(&link.source_id)
.fetch_one(&self.pool)
.await
.map_err(|e| to_store_err("check source memory", e))?;
if !source_exists {
return Err(StoreError::InvalidInput {
detail: format!("source memory not found: {}", link.source_id),
});
}
let target_exists: bool =
sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM memories WHERE id = $1)")
.bind(&link.target_id)
.fetch_one(&self.pool)
.await
.map_err(|e| to_store_err("check target memory", e))?;
if !target_exists {
return Err(StoreError::InvalidInput {
detail: format!("target memory not found: {}", link.target_id),
});
}
let now_utc = Utc::now();
let created_at_dt = if link.created_at.is_empty() {
now_utc
} else {
parse_rfc3339_required(&link.created_at)?
};
let valid_from_dt = match link.valid_from.as_deref() {
Some(s) if !s.is_empty() => parse_rfc3339_required(s)?,
_ => now_utc,
};
let valid_until_dt = match link.valid_until.as_deref() {
Some(s) if !s.is_empty() => Some(parse_rfc3339_required(s)?),
_ => None,
};
let valid_from_dt = truncate_to_microseconds(valid_from_dt);
let valid_until_dt = valid_until_dt.map(truncate_to_microseconds);
let valid_from_str = valid_from_dt.to_rfc3339();
let valid_until_str = valid_until_dt.map(|t| t.to_rfc3339());
let (signature, attest_level, observed_by_col): (
Option<Vec<u8>>,
&'static str,
Option<String>,
) = match keypair {
Some(kp) if kp.can_sign() => {
let signable = crate::identity::sign::SignableLink {
src_id: &link.source_id,
dst_id: &link.target_id,
relation: link.relation.as_str(),
observed_by: Some(kp.agent_id.as_str()),
valid_from: Some(valid_from_str.as_str()),
valid_until: valid_until_str.as_deref(),
};
let sig = crate::identity::sign::sign(kp, &signable).map_err(|e| {
StoreError::IntegrityFailed {
detail: format!("sign link: {e}"),
}
})?;
(
Some(sig),
crate::models::AttestLevel::SelfSigned.as_str(),
Some(kp.agent_id.clone()),
)
}
_ => (None, crate::models::AttestLevel::Unsigned.as_str(), None),
};
let mut tx = self
.pool
.begin()
.await
.map_err(|e| to_store_err("begin link tx", e))?;
sqlx::query(
"INSERT INTO memory_links
(source_id, target_id, relation, created_at, valid_from,
valid_until, signature, attest_level, observed_by)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
ON CONFLICT (source_id, target_id, relation) DO NOTHING",
)
.bind(&link.source_id)
.bind(&link.target_id)
.bind(link.relation.as_str())
.bind(created_at_dt)
.bind(valid_from_dt)
.bind(valid_until_dt)
.bind(signature)
.bind(attest_level)
.bind(observed_by_col)
.execute(&mut *tx)
.await
.map_err(|e| to_store_err("insert memory_link", e))?;
if matches!(self.kg_backend, KgBackend::Age) {
sqlx::query("SAVEPOINT age_link_projection")
.execute(&mut *tx)
.await
.map_err(|e| to_store_err("savepoint age_link_projection", e))?;
match project_link_into_age(
&mut tx,
&link.source_id,
&link.target_id,
link.relation.as_str(),
)
.await
{
Ok(()) => {
sqlx::query("RELEASE SAVEPOINT age_link_projection")
.execute(&mut *tx)
.await
.map_err(|e| to_store_err("release savepoint age_link_projection", e))?;
}
Err(e) if is_age_runtime_failure(&e) => {
sqlx::query("ROLLBACK TO SAVEPOINT age_link_projection")
.execute(&mut *tx)
.await
.map_err(|e2| to_store_err("rollback savepoint age_link_projection", e2))?;
tracing::warn!(
target: TRACE_TARGET_KG,
source_id = %link.source_id,
target_id = %link.target_id,
relation = link.relation.as_str(),
err = %e,
"AGE projection skipped on link insert — \
relational memory_links row still committed. \
find_paths_cypher will degrade to CTE fallback for \
queries that traverse this edge."
);
}
Err(e) => return Err(e),
}
}
tx.commit()
.await
.map_err(|e| to_store_err("commit link tx", e))?;
Ok(attest_level)
}
fn row_to_memory(row: &sqlx::postgres::PgRow) -> StoreResult<Memory> {
let created_at: DateTime<Utc> = row
.try_get(field_names::CREATED_AT)
.map_err(|e| to_store_err(READ_CREATED_AT, e))?;
let updated_at: DateTime<Utc> = row
.try_get(field_names::UPDATED_AT)
.map_err(|e| to_store_err("read updated_at", e))?;
let last_accessed_at: Option<DateTime<Utc>> = row
.try_get(field_names::LAST_ACCESSED_AT)
.map_err(|e| to_store_err("read last_accessed_at", e))?;
let expires_at: Option<DateTime<Utc>> = row
.try_get(field_names::EXPIRES_AT)
.map_err(|e| to_store_err("read expires_at", e))?;
let tier_str: String = row
.try_get("tier")
.map_err(|e| to_store_err("read tier", e))?;
let tier = Tier::from_str(&tier_str).ok_or_else(|| StoreError::IntegrityFailed {
detail: format!("invalid tier value: {tier_str}"),
})?;
let row_id: String = row.try_get("id").map_err(|e| to_store_err("read id", e))?;
let tags_json: serde_json::Value = row
.try_get("tags")
.map_err(|e| to_store_err("read tags", e))?;
let tags: Vec<String> = serde_json::from_value(tags_json).unwrap_or_default();
let metadata: serde_json::Value = row
.try_get("metadata")
.map_err(|e| to_store_err("read metadata", e))?;
let reflection_depth: i32 = row.try_get(field_names::REFLECTION_DEPTH).unwrap_or(0);
let memory_kind: crate::models::MemoryKind = row
.try_get::<String, _>(field_names::MEMORY_KIND)
.ok()
.and_then(|s| crate::models::MemoryKind::from_str(&s))
.unwrap_or_default();
Ok(Memory {
id: row_id.clone(),
tier,
namespace: row
.try_get("namespace")
.map_err(|e| to_store_err(READ_NAMESPACE, e))?,
title: row
.try_get("title")
.map_err(|e| to_store_err(READ_TITLE, e))?,
content: row
.try_get("content")
.map_err(|e| to_store_err("read content", e))?,
tags,
priority: row
.try_get("priority")
.map_err(|e| to_store_err("read priority", e))?,
confidence: row
.try_get(field_names::CONFIDENCE)
.map_err(|e| to_store_err("read confidence", e))?,
source: row
.try_get("source")
.map_err(|e| to_store_err("read source", e))?,
access_count: row
.try_get(field_names::ACCESS_COUNT)
.map_err(|e| to_store_err("read access_count", e))?,
created_at: created_at.to_rfc3339(),
updated_at: updated_at.to_rfc3339(),
last_accessed_at: last_accessed_at.map(|t| t.to_rfc3339()),
expires_at: expires_at.map(|t| t.to_rfc3339()),
metadata,
reflection_depth,
memory_kind,
entity_id: row
.try_get::<Option<String>, _>("entity_id")
.unwrap_or(None),
persona_version: row
.try_get::<Option<i32>, _>(field_names::PERSONA_VERSION)
.unwrap_or(None),
citations: match row.try_get::<String, _>("citations") {
Err(_) => Vec::new(),
Ok(s) => serde_json::from_str(&s).unwrap_or_else(|e| {
tracing::warn!(memory_id = %row_id, "corrupt citations JSON in postgres row: {e}");
crate::metrics::record_corrupt_provenance("citations");
Vec::new()
}),
},
source_uri: row
.try_get::<Option<String>, _>(field_names::SOURCE_URI)
.unwrap_or(None),
source_span: row
.try_get::<Option<String>, _>(COL_SOURCE_SPAN)
.unwrap_or(None)
.and_then(|s| match serde_json::from_str(&s) {
Ok(v) => Some(v),
Err(e) => {
tracing::warn!(memory_id = %row_id, "corrupt source_span JSON in postgres row: {e}");
crate::metrics::record_corrupt_provenance(field_names::SOURCE_SPAN);
None
}
}),
confidence_source: row
.try_get::<String, _>(field_names::CONFIDENCE_SOURCE)
.ok()
.and_then(|s| crate::models::ConfidenceSource::from_str(&s))
.unwrap_or_default(),
confidence_signals: row
.try_get::<Option<String>, _>(COL_CONFIDENCE_SIGNALS)
.unwrap_or(None)
.and_then(|s| match serde_json::from_str(&s) {
Ok(v) => Some(v),
Err(e) => {
tracing::warn!(memory_id = %row_id, "corrupt confidence_signals JSON in postgres row: {e}");
crate::metrics::record_corrupt_provenance(COL_CONFIDENCE_SIGNALS);
None
}
}),
confidence_decayed_at: row
.try_get::<Option<String>, _>(field_names::CONFIDENCE_DECAYED_AT)
.unwrap_or(None),
version: row
.try_get::<i64, _>("version")
.unwrap_or_else(|_| crate::models::default_memory_version()),
})
}
pub async fn reflect(
&self,
ctx: &super::CallerContext,
input: &crate::db::ReflectInput,
) -> std::result::Result<crate::db::ReflectOutcome, crate::db::ReflectError> {
self.reflect_with_hooks(ctx, input, &crate::db::ReflectHooks::empty())
.await
}
#[allow(clippy::too_many_lines)]
pub async fn reflect_with_hooks(
&self,
ctx: &super::CallerContext,
input: &crate::db::ReflectInput,
hooks: &crate::db::ReflectHooks<'_>,
) -> std::result::Result<crate::db::ReflectOutcome, crate::db::ReflectError> {
use crate::db::ReflectError;
use crate::validate;
validate::validate_title(&input.title)
.map_err(|e| ReflectError::Validation(e.to_string()))?;
validate::validate_content(&input.content)
.map_err(|e| ReflectError::Validation(e.to_string()))?;
validate::validate_tags(&input.tags)
.map_err(|e| ReflectError::Validation(e.to_string()))?;
validate::validate_priority(input.priority)
.map_err(|e| ReflectError::Validation(e.to_string()))?;
validate::validate_confidence(input.confidence)
.map_err(|e| ReflectError::Validation(e.to_string()))?;
validate::validate_source(&input.source)
.map_err(|e| ReflectError::Validation(e.to_string()))?;
validate::validate_agent_id(&input.agent_id)
.map_err(|e| ReflectError::Validation(e.to_string()))?;
if input.source_ids.is_empty() {
return Err(ReflectError::Validation(
"source_ids cannot be empty — a reflection must reflect on at least one source memory".into(),
));
}
let mut seen = std::collections::HashSet::new();
for (i, id) in input.source_ids.iter().enumerate() {
validate::validate_id(id)
.map_err(|e| ReflectError::Validation(format!("source_ids[{i}]: {e}")))?;
if !seen.insert(id.as_str()) {
return Err(ReflectError::Validation(format!(
"source_ids[{i}]: duplicate id '{id}'"
)));
}
}
if let Some(ref ns) = input.namespace {
validate::validate_namespace(ns)
.map_err(|e| ReflectError::Validation(e.to_string()))?;
}
validate::validate_metadata(&input.metadata)
.map_err(|e| ReflectError::Validation(e.to_string()))?;
let mut sources = Vec::with_capacity(input.source_ids.len());
for id in &input.source_ids {
match super::MemoryStore::get(self, ctx, id).await {
Ok(m) => sources.push(m),
Err(StoreError::NotFound { .. }) => {
return Err(ReflectError::SourceNotFound(id.clone()));
}
Err(e) => return Err(ReflectError::Database(e.to_string())),
}
}
let max_src_depth = sources
.iter()
.map(|m| m.reflection_depth)
.max()
.unwrap_or(0);
let new_depth_i32 = max_src_depth.max(0).saturating_add(1);
#[allow(clippy::cast_sign_loss)]
let new_depth_u32: u32 = new_depth_i32 as u32;
let target_namespace = match input.namespace {
Some(ref ns) => ns.clone(),
None => sources[0].namespace.clone(),
};
let policy = super::MemoryStore::resolve_governance_policy(self, &target_namespace)
.await
.map_err(|e| ReflectError::Database(e.to_string()))?
.unwrap_or_else(crate::models::GovernancePolicy::default);
let cap = policy.effective_max_reflection_depth();
if let Some(pre) = hooks.pre_reflect.as_ref() {
match (pre)(input) {
crate::db::ReflectHookDecision::Allow => {}
crate::db::ReflectHookDecision::Deny { reason, code } => {
return Err(ReflectError::HookVeto { reason, code });
}
}
}
if new_depth_u32 > cap {
let cross_peer_refusal =
crate::federation::reflection_bookkeeping::enforce_local_cap_on_derived(
new_depth_u32,
cap,
&sources,
);
let peer_origin: Option<String> = if let Err(ref r) = cross_peer_refusal {
if let Some(ref peer) = r.imported_peer {
tracing::warn!(
target: "federation::reflection_bookkeeping",
peer = %peer,
attempted = new_depth_u32,
local_cap = cap,
namespace = %target_namespace,
"L2-2 (pg): refusing derived reflection: {}",
r,
);
}
r.imported_peer.clone()
} else {
None
};
self.emit_reflection_depth_exceeded_audit(
&input.agent_id,
new_depth_u32,
cap,
&target_namespace,
&input.source_ids,
&input.title,
peer_origin.as_deref(),
)
.await;
return Err(ReflectError::DepthExceeded {
attempted: new_depth_u32,
cap,
namespace: target_namespace,
});
}
let now = Utc::now().to_rfc3339();
let mut metadata = match input.metadata.clone() {
serde_json::Value::Object(map) => map,
_ => serde_json::Map::new(),
};
metadata.insert(
"agent_id".to_string(),
serde_json::Value::String(input.agent_id.clone()),
);
if !metadata.contains_key(field_names::REFLECTION_METADATA) {
let reflection_meta = serde_json::json!({
"reflected_on_source_ids": input.source_ids,
(field_names::REFLECTION_DEPTH): new_depth_i32,
"reflection_created_at": now,
});
metadata.insert(
field_names::REFLECTION_METADATA.to_string(),
reflection_meta,
);
}
let metadata_value = serde_json::Value::Object(metadata);
validate::validate_metadata(&metadata_value)
.map_err(|e| ReflectError::Validation(e.to_string()))?;
let new_id = uuid::Uuid::new_v4().to_string();
let created_at_dt = chrono::DateTime::parse_from_rfc3339(&now)
.map_err(|e| ReflectError::Database(format!("parse now: {e}")))?
.with_timezone(&Utc);
let tags_json = serde_json::to_value(&input.tags)
.map_err(|e| ReflectError::Database(serialize_err("tags", e)))?;
let candidate = Memory {
id: new_id.clone(),
tier: input.tier.clone(),
namespace: target_namespace.clone(),
title: input.title.clone(),
content: input.content.clone(),
tags: input.tags.clone(),
priority: input.priority.clamp(1, 10),
confidence: input.confidence.clamp(0.0, 1.0),
source: input.source.clone(),
access_count: 0,
created_at: now.clone(),
updated_at: now.clone(),
last_accessed_at: None,
expires_at: None,
metadata: metadata_value.clone(),
reflection_depth: new_depth_i32,
memory_kind: crate::models::MemoryKind::Reflection,
entity_id: None,
persona_version: None,
citations: Vec::new(),
source_uri: None,
source_span: None,
confidence_source: crate::models::ConfidenceSource::CallerProvided,
confidence_signals: None,
confidence_decayed_at: None,
version: crate::models::default_memory_version(),
};
if let Err(e) = consult_governance_pre_write_pg(&candidate) {
let reason = match &e {
StoreError::PermissionDenied { reason, .. } => reason.clone(),
other => other.to_string(),
};
return Err(ReflectError::HookVeto { reason, code: 403 });
}
let mut tx = self
.pool
.begin()
.await
.map_err(|e| ReflectError::Database(format!("begin reflect tx: {e}")))?;
let mentioned_entity_id = crate::storage::extract_mentioned_entity_id(&candidate);
let actual_id: String = sqlx::query(
"INSERT INTO memories (
id, tier, namespace, title, content, tags, priority, confidence,
source, access_count, created_at, updated_at, last_accessed_at,
expires_at, metadata, reflection_depth, memory_kind, mentioned_entity_id
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, NULL, NULL, $13, $14, $15, $16)
ON CONFLICT (title, namespace) DO UPDATE SET
content = EXCLUDED.content,
tier = CASE
WHEN tier_rank(EXCLUDED.tier) >= tier_rank(memories.tier)
THEN EXCLUDED.tier
ELSE memories.tier
END,
tags = EXCLUDED.tags,
priority = EXCLUDED.priority,
confidence = EXCLUDED.confidence,
updated_at = EXCLUDED.updated_at,
metadata = CASE
WHEN memories.metadata ? 'agent_id'
THEN jsonb_set(
EXCLUDED.metadata,
'{agent_id}',
memories.metadata -> 'agent_id'
)
ELSE EXCLUDED.metadata
END,
reflection_depth = GREATEST(memories.reflection_depth, EXCLUDED.reflection_depth),
memory_kind = CASE WHEN memories.memory_kind = 'reflection' THEN 'reflection'
ELSE EXCLUDED.memory_kind END,
-- #1383 — mirror the sqlite ON CONFLICT clause: preserve
-- a previously-extracted attribution if EXCLUDED is NULL
-- (e.g. a re-store that lost the metadata key by accident).
mentioned_entity_id = COALESCE(EXCLUDED.mentioned_entity_id, memories.mentioned_entity_id)
RETURNING id",
)
.bind(&new_id)
.bind(input.tier.as_str())
.bind(&target_namespace)
.bind(&input.title)
.bind(&input.content)
.bind(&tags_json)
.bind(input.priority.clamp(1, 10))
.bind(input.confidence.clamp(0.0, 1.0))
.bind(&input.source)
.bind(0_i64)
.bind(created_at_dt)
.bind(created_at_dt)
.bind(&metadata_value)
.bind(new_depth_i32)
.bind(crate::models::MemoryKind::Reflection.as_str())
.bind(mentioned_entity_id.as_deref())
.fetch_one(&mut *tx)
.await
.map_err(|e| ReflectError::Database(format!("insert reflection memory: {e}")))?
.try_get::<String, _>("id")
.map_err(|e| ReflectError::Database(format!("read returned id: {e}")))?;
for src_id in &input.source_ids {
validate::validate_link(
&actual_id,
src_id,
crate::models::MemoryLinkRelation::ReflectsOn.as_str(),
)
.map_err(|e| ReflectError::Validation(e.to_string()))?;
sqlx::query(
"INSERT INTO memory_links \
(source_id, target_id, relation, created_at, valid_from, attest_level) \
VALUES ($1, $2, $3, $4, $4, 'unsigned') \
ON CONFLICT (source_id, target_id, relation) DO NOTHING",
)
.bind(&actual_id)
.bind(src_id)
.bind(crate::models::MemoryLinkRelation::ReflectsOn.as_str())
.bind(created_at_dt)
.execute(&mut *tx)
.await
.map_err(|e| ReflectError::Database(format!("insert reflects_on link: {e}")))?;
}
tx.commit()
.await
.map_err(|e| ReflectError::Database(format!("commit reflect tx: {e}")))?;
let outcome = crate::db::ReflectOutcome {
id: actual_id,
reflection_depth: new_depth_i32,
reflects_on: input.source_ids.clone(),
namespace: target_namespace,
};
if let Some(post) = hooks.post_reflect.as_ref() {
(post)(&outcome);
}
Ok(outcome)
}
async fn emit_reflection_depth_exceeded_audit(
&self,
agent_id: &str,
attempted: u32,
cap: u32,
namespace: &str,
source_ids: &[String],
proposed_title: &str,
peer_origin: Option<&str>,
) {
let created_at_dt = Utc::now();
let created_at = created_at_dt.to_rfc3339();
let cbor = match crate::db::canonical_cbor_reflection_depth_exceeded(
agent_id,
attempted,
cap,
namespace,
source_ids,
proposed_title,
&created_at,
peer_origin,
) {
Ok(b) => b,
Err(e) => {
tracing::warn!(
target: crate::signed_events::SIGNED_EVENTS_TRACE_TARGET,
agent_id, attempted, cap, namespace,
"failed to encode canonical CBOR for reflection_depth_exceeded audit: {e}"
);
return;
}
};
let id = uuid::Uuid::new_v4().to_string();
let payload_hash = crate::signed_events::payload_hash(&cbor);
let event_type = if peer_origin.is_some() {
"reflection.depth_exceeded.cross_peer"
} else {
"reflection.depth_exceeded"
};
let insert_row = PgSignedEventInsert {
id: &id,
agent_id,
event_type,
payload_hash: &payload_hash,
signature: None,
attest_level: crate::models::AttestLevel::Unsigned.as_str(),
timestamp: created_at_dt,
};
if let Err(e) = pg_append_signed_event_with_chain(&self.pool, insert_row).await {
tracing::warn!(
target: crate::signed_events::SIGNED_EVENTS_TRACE_TARGET,
agent_id, attempted, cap, namespace,
"failed to append reflection_depth_exceeded audit row: {e}"
);
}
}
}
struct PgSignedEventInsert<'a> {
id: &'a str,
agent_id: &'a str,
event_type: &'a str,
payload_hash: &'a [u8],
signature: Option<&'a [u8]>,
attest_level: &'a str,
timestamp: chrono::DateTime<chrono::Utc>,
}
async fn pg_append_signed_event_with_chain(
pool: &PgPool,
row: PgSignedEventInsert<'_>,
) -> Result<(), sqlx::Error> {
let mut tx = pool.begin().await?;
pg_append_signed_event_with_chain_in_tx(&mut tx, row).await?;
tx.commit().await?;
Ok(())
}
async fn pg_append_signed_event_with_chain_in_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
row: PgSignedEventInsert<'_>,
) -> Result<(), sqlx::Error> {
let PgSignedEventInsert {
id,
agent_id,
event_type,
payload_hash,
signature,
attest_level,
timestamp,
} = row;
use crate::signed_events::{ZERO_HASH, canonical_chain_bytes};
use sha2::{Digest, Sha256};
let head: Option<(
String,
String,
String,
Vec<u8>,
Option<Vec<u8>>,
String,
chrono::DateTime<chrono::Utc>,
Option<i64>,
)> = sqlx::query_as(
"SELECT id, agent_id, event_type, payload_hash, signature, attest_level, timestamp, \
sequence \
FROM signed_events \
ORDER BY COALESCE(sequence, 0) DESC, ctid DESC \
LIMIT 1",
)
.fetch_optional(&mut **tx)
.await?;
let (next_seq, prev_hash) = match head {
None => (1_i64, ZERO_HASH.to_vec()),
Some((h_id, h_agent, h_type, h_payload, h_sig, h_attest, h_ts, h_seq)) => {
let seq = h_seq.unwrap_or(0);
let event = crate::signed_events::SignedEvent {
id: h_id,
agent_id: h_agent,
event_type: h_type,
payload_hash: h_payload,
signature: h_sig,
attest_level: h_attest,
timestamp: h_ts.to_rfc3339(),
prev_hash: Vec::new(),
sequence: seq,
};
let canon = canonical_chain_bytes(&event);
let mut hasher = Sha256::new();
hasher.update(&canon);
let mut digest = [0u8; 32];
digest.copy_from_slice(&hasher.finalize());
(seq + 1, digest.to_vec())
}
};
sqlx::query(
"INSERT INTO signed_events \
(id, agent_id, event_type, payload_hash, signature, attest_level, timestamp, \
prev_hash, sequence) \
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)",
)
.bind(id)
.bind(agent_id)
.bind(event_type)
.bind(payload_hash)
.bind(signature.map(<[u8]>::to_vec))
.bind(attest_level)
.bind(timestamp)
.bind(&prev_hash)
.bind(next_seq)
.execute(&mut **tx)
.await?;
Ok(())
}
const READ_NAMESPACE: &str = "read namespace";
const READ_RELATION: &str = "read relation";
const READ_TARGET_ID: &str = "read target_id";
const READ_VALID_FROM: &str = "read valid_from";
const READ_VALID_UNTIL: &str = "read valid_until";
const READ_OBSERVED_BY: &str = "read observed_by";
const READ_CREATED_AT: &str = "read created_at";
const READ_TITLE: &str = "read title";
const READ_SOURCE_ID: &str = "read source_id";
const READ_ATTEST_LEVEL: &str = "read attest_level";
const READ_RETURNED_ID: &str = "read returned id";
const COL_SOURCE_SPAN: &str = "source_span";
const COL_CONFIDENCE_SIGNALS: &str = "confidence_signals";
fn serialize_err(field: &str, e: impl std::fmt::Display) -> String {
format!("serialize {field}: {e}")
}
#[allow(clippy::needless_pass_by_value)]
fn to_store_err(what: &str, e: sqlx::Error) -> StoreError {
StoreError::BackendUnavailable {
backend: "postgres".to_string(),
detail: crate::logging::redact_urls_in_message(&format!("{what}: {e}")),
}
}
fn consult_governance_pre_write_pg(memory: &Memory) -> StoreResult<()> {
match crate::storage::consult_governance_pre_write(memory) {
Ok(()) => Ok(()),
Err(e) => {
let reason = e
.downcast_ref::<crate::storage::GovernanceRefusal>()
.map_or_else(|| e.to_string(), |r| r.reason.clone());
Err(StoreError::PermissionDenied {
action: "memory_write".to_string(),
target: memory.namespace.clone(),
reason,
})
}
}
}
impl PostgresStore {
async fn load_archived_as_memory_pg(
executor: &mut sqlx::PgConnection,
id: &str,
) -> StoreResult<Memory> {
let row = sqlx::query(
"SELECT id, COALESCE(original_tier, tier) AS tier, namespace, title, content,
tags, priority, confidence, source, access_count, created_at,
updated_at, last_accessed_at,
COALESCE(original_expires_at, expires_at) AS expires_at, metadata,
embedding, embedding_dim,
COALESCE(reflection_depth, 0) AS reflection_depth,
atomised_into, atom_of,
COALESCE(memory_kind, 'observation') AS memory_kind,
entity_id, persona_version,
COALESCE(citations, '[]') AS citations,
source_uri, source_span,
COALESCE(confidence_source, 'caller_provided') AS confidence_source,
confidence_signals, confidence_decayed_at,
mentioned_entity_id,
COALESCE(version, 1) AS version
FROM archived_memories WHERE id = $1",
)
.bind(id)
.fetch_optional(executor)
.await
.map_err(|e| to_store_err("load archived as memory", e))?;
let row = row.ok_or_else(|| StoreError::NotFound { id: id.to_string() })?;
Self::row_to_memory(&row)
}
}
const NS_FILTER_SARGABLE: &str = "namespace = $1";
const NS_FILTER_OPTIONAL: &str = "($1::text IS NULL OR namespace = $1)";
fn prefix_upper_bound(prefix: &str) -> Option<String> {
let mut bytes = prefix.as_bytes().to_vec();
while let Some(&last) = bytes.last() {
if last < 0xFF {
*bytes.last_mut().unwrap() = last + 1;
return String::from_utf8(bytes).ok();
}
bytes.pop();
}
None
}
fn is_age_runtime_failure(err: &StoreError) -> bool {
matches!(err, StoreError::BackendUnavailable { .. })
}
fn warn_age_fallback(op: &str, source_id: &str, err: &StoreError) {
tracing::warn!(
target: TRACE_TARGET_KG,
op = op,
source_id = source_id,
backend = "age",
fallback = "cte",
error = %err,
"AGE backend unreachable; falling back to CTE for kg_{op}=<{source_id}>"
);
}
fn warn_age_fallback_pair(op: &str, source_id: &str, target_id: &str, err: &StoreError) {
tracing::warn!(
target: TRACE_TARGET_KG,
op = op,
source_id = source_id,
target_id = target_id,
backend = "age",
fallback = "cte",
error = %err,
"AGE backend unreachable; falling back to CTE for kg_{op}=<{source_id}->{target_id}>"
);
}
async fn add_column_if_missing(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
table: &str,
column: &str,
ddl: &str,
) -> StoreResult<()> {
let exists: bool = sqlx::query_scalar(
"SELECT EXISTS(
SELECT 1 FROM information_schema.columns
WHERE table_name = $1 AND column_name = $2
)",
)
.bind(table)
.bind(column)
.fetch_one(&mut **tx)
.await
.map_err(|e| to_store_err(&format!("check {table}.{column} column"), e))?;
if !exists {
sqlx::query(ddl)
.execute(&mut **tx)
.await
.map_err(|e| to_store_err(&format!("add {table}.{column} column"), e))?;
}
Ok(())
}
async fn record_schema_version(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
version: i32,
) -> StoreResult<()> {
sqlx::query(
"INSERT INTO schema_version (version) VALUES ($1) ON CONFLICT (version) DO NOTHING",
)
.bind(version)
.execute(&mut **tx)
.await
.map_err(|e| to_store_err("insert schema_version", e))?;
Ok(())
}
async fn build_namespace_chain_in_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
namespace: &str,
) -> StoreResult<Vec<String>> {
let mut chain: Vec<String> = Vec::new();
if namespace == "*" {
chain.push("*".to_string());
return Ok(chain);
}
chain.push("*".to_string());
let mut hierarchy_chain: Vec<String> = crate::models::namespace_ancestors(namespace)
.into_iter()
.rev()
.collect();
if let Some(root) = hierarchy_chain.first().cloned() {
let mut explicit_above: Vec<String> = Vec::new();
let mut current = root;
for _ in 0..GOVERNANCE_INHERITANCE_DEPTH_CAP {
let row: Option<(Option<String>,)> =
sqlx::query_as("SELECT parent_namespace FROM namespace_meta WHERE namespace = $1")
.bind(¤t)
.fetch_optional(&mut **tx)
.await
.map_err(|e| to_store_err("build_namespace_chain_in_tx parent lookup", e))?;
let next = row.and_then(|(p,)| p);
match next {
Some(p)
if p != "*"
&& !explicit_above.contains(&p)
&& !hierarchy_chain.contains(&p) =>
{
explicit_above.push(p.clone());
current = p;
}
_ => break,
}
}
for p in explicit_above.into_iter().rev() {
if !chain.contains(&p) {
chain.push(p);
}
}
}
let drained: Vec<String> = hierarchy_chain.drain(..).collect();
let drained_len = drained.len();
let kept: Vec<String> = if drained_len > GOVERNANCE_INHERITANCE_DEPTH_CAP {
drained
.into_iter()
.skip(drained_len - GOVERNANCE_INHERITANCE_DEPTH_CAP)
.collect()
} else {
drained
};
for entry in kept {
if !chain.contains(&entry) {
chain.push(entry);
}
}
Ok(chain)
}
pub const GOVERNANCE_INHERITANCE_DEPTH_CAP: usize = 5;
const KG_QUERY_MAX_SUPPORTED_DEPTH: usize = 5;
fn validate_depth(max_depth: usize) -> StoreResult<()> {
if max_depth == 0 {
return Err(StoreError::InvalidInput {
detail: crate::errors::msg::MAX_DEPTH_MIN.to_string(),
});
}
if max_depth > KG_QUERY_MAX_SUPPORTED_DEPTH {
return Err(StoreError::InvalidInput {
detail: format!(
"max_depth={max_depth} exceeds supported depth={KG_QUERY_MAX_SUPPORTED_DEPTH}"
),
});
}
Ok(())
}
const FIND_PATHS_DEFAULT_DEPTH_SAL: usize = 4;
const FIND_PATHS_MAX_DEPTH_SAL: usize = 7;
const FIND_PATHS_DEFAULT_LIMIT_SAL: usize = 10;
const FIND_PATHS_MAX_LIMIT_SAL: usize = 50;
fn validate_find_paths_depth(max_depth: usize) -> StoreResult<()> {
if max_depth == 0 {
return Err(StoreError::InvalidInput {
detail: crate::errors::msg::MAX_DEPTH_MIN.to_string(),
});
}
if max_depth > FIND_PATHS_MAX_DEPTH_SAL {
return Err(StoreError::InvalidInput {
detail: format!(
"max_depth={max_depth} exceeds supported depth={FIND_PATHS_MAX_DEPTH_SAL}"
),
});
}
Ok(())
}
const KG_TIMELINE_DEFAULT_LIMIT_SAL: usize = 200;
const KG_TIMELINE_MAX_LIMIT_SAL: usize = 1000;
const STORE_LIST_MAX_LIMIT_SAL: i64 = 10_000;
const DEFAULT_LIST_CAP_I64: i64 = 200;
const LIST_FALLBACK_LIMIT_I64: i64 = 100;
const ARCHIVED_LIST_FALLBACK_I64: i64 = 50;
const RECALL_FALLBACK_LIMIT_I64: i64 = 10;
fn clamp_timeline_limit(limit: Option<usize>) -> usize {
limit
.unwrap_or(KG_TIMELINE_DEFAULT_LIMIT_SAL)
.clamp(1, KG_TIMELINE_MAX_LIMIT_SAL)
}
fn assert_age_id_safe(id: &str) -> Result<(), String> {
if id.is_empty() || id.len() > 128 {
return Err(format!(
"id length {} out of bounds for cypher inline (1..=128)",
id.len()
));
}
if !id
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
{
return Err(format!(
"id contains characters unsafe for cypher inline (allowed: [A-Za-z0-9_-]): {id:?}"
));
}
Ok(())
}
fn build_kg_query_current_view_cypher(max_depth: usize) -> String {
format!(
"MATCH p = (a)-[r:related_to*1..{max_depth}]->(b) \
WHERE a.id = $start_id \
AND ALL(e IN relationships(p) WHERE e.valid_until IS NULL OR e.valid_until > $now) \
RETURN b.id AS target_id, \
last(r).relation AS relation, \
length(r) AS depth, \
reduce(s = a.id, n IN nodes(p)[1..] | s + '->' + n.id) AS path"
)
}
fn build_find_paths_current_view_cypher(
source_id: &str,
target_id: &str,
depth: usize,
cap: usize,
now_stamp: &str,
) -> String {
format!(
"MATCH p = (a)-[*1..{depth}]-(b) \
WHERE a.id = '{source_id}' AND b.id = '{target_id}' \
AND ALL(e IN relationships(p) WHERE e.valid_until IS NULL OR e.valid_until > '{now_stamp}') \
RETURN nodes(p) AS path \
ORDER BY length(p) ASC \
LIMIT {cap}"
)
}
fn age_params_literal(pairs: &[(&str, &str)]) -> String {
let mut map = serde_json::Map::with_capacity(pairs.len());
for (k, v) in pairs {
map.insert(
(*k).to_string(),
serde_json::Value::String((*v).to_string()),
);
}
let json = serde_json::Value::Object(map).to_string();
let escaped = json.replace('\'', "''");
format!("'{escaped}'::agtype")
}
struct Agtype(String);
impl sqlx::Type<sqlx::Postgres> for Agtype {
fn type_info() -> sqlx::postgres::PgTypeInfo {
sqlx::postgres::PgTypeInfo::with_name("agtype")
}
fn compatible(_ty: &sqlx::postgres::PgTypeInfo) -> bool {
true
}
}
impl<'q> sqlx::Encode<'q, sqlx::Postgres> for Agtype {
fn encode_by_ref(
&self,
buf: &mut sqlx::postgres::PgArgumentBuffer,
) -> Result<sqlx::encode::IsNull, sqlx::error::BoxDynError> {
buf.push(1);
buf.extend_from_slice(self.0.as_bytes());
Ok(sqlx::encode::IsNull::No)
}
}
impl<'r> sqlx::Decode<'r, sqlx::Postgres> for Agtype {
fn decode(value: sqlx::postgres::PgValueRef<'r>) -> Result<Self, sqlx::error::BoxDynError> {
match value.format() {
sqlx::postgres::PgValueFormat::Binary => {
let bytes = value.as_bytes()?;
if bytes.is_empty() {
return Err("empty agtype payload".into());
}
let version = bytes[0];
if version != 1 {
return Err(format!("unsupported agtype version: {version}").into());
}
let text = std::str::from_utf8(&bytes[1..])?.to_string();
Ok(Agtype(text))
}
sqlx::postgres::PgValueFormat::Text => {
let text = value.as_str()?.to_string();
Ok(Agtype(text))
}
}
}
}
fn build_or_tsquery(query: &str) -> String {
const MAX_TOKENS: usize = 16;
let tokens: Vec<String> = query
.split_whitespace()
.map(|raw| {
raw.chars()
.filter(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == '-')
.collect::<String>()
})
.filter(|t| t.len() >= 2)
.take(MAX_TOKENS)
.map(|t| format!("'{}'", t.to_lowercase()))
.collect();
if tokens.is_empty() {
return "'_empty_'".to_string();
}
tokens.join(" | ")
}
fn age_params_jsonb(pairs: &[(&str, &str)]) -> String {
let mut map = serde_json::Map::with_capacity(pairs.len());
for (k, v) in pairs {
map.insert(
(*k).to_string(),
serde_json::Value::String((*v).to_string()),
);
}
serde_json::Value::Object(map).to_string()
}
async fn ensure_memory_graph(pool: &PgPool) -> StoreResult<()> {
let mut tx = pool
.begin()
.await
.map_err(|e| to_store_err("begin ensure_memory_graph tx", e))?;
load_age_tolerated(&mut tx).await?;
sqlx::query(SQL_SET_AGE_SEARCH_PATH)
.execute(&mut *tx)
.await
.map_err(|e| to_store_err("set search_path (ensure_memory_graph)", e))?;
sqlx::query("SAVEPOINT create_age_graph")
.execute(&mut *tx)
.await
.map_err(|e| to_store_err("savepoint create_age_graph", e))?;
match sqlx::query(SQL_CREATE_AGE_GRAPH).execute(&mut *tx).await {
Ok(_) => {
sqlx::query("RELEASE SAVEPOINT create_age_graph")
.execute(&mut *tx)
.await
.map_err(|e| to_store_err("release create_age_graph", e))?;
}
Err(e) => {
let msg = e.to_string();
if !msg.contains(PG_ERR_ALREADY_EXISTS) {
return Err(to_store_err("create_graph memory_graph", e));
}
sqlx::query("ROLLBACK TO SAVEPOINT create_age_graph")
.execute(&mut *tx)
.await
.map_err(|e| to_store_err("rollback create_age_graph savepoint", e))?;
}
}
tx.commit()
.await
.map_err(|e| to_store_err("commit ensure_memory_graph tx", e))?;
Ok(())
}
async fn load_age_tolerated(tx: &mut sqlx::Transaction<'_, sqlx::Postgres>) -> StoreResult<()> {
sqlx::query("SAVEPOINT load_age_lib")
.execute(&mut **tx)
.await
.map_err(|e| to_store_err("savepoint load_age_lib", e))?;
match sqlx::query(SQL_LOAD_AGE).execute(&mut **tx).await {
Ok(_) => {
sqlx::query("RELEASE SAVEPOINT load_age_lib")
.execute(&mut **tx)
.await
.map_err(|e| to_store_err("release savepoint load_age_lib", e))?;
}
Err(e) => {
sqlx::query("ROLLBACK TO SAVEPOINT load_age_lib")
.execute(&mut **tx)
.await
.map_err(|e2| to_store_err("rollback savepoint load_age_lib", e2))?;
tracing::debug!(
target: TRACE_TARGET_KG,
err = %e,
"LOAD 'age' refused for this role — proceeding on the \
assumption shared_preload_libraries provides it (#1542/#1640)"
);
}
}
Ok(())
}
async fn project_link_into_age(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
source_id: &str,
target_id: &str,
relation: &str,
) -> StoreResult<()> {
if relation.is_empty()
|| !relation
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
{
return Err(StoreError::InvalidInput {
detail: format!(
"invalid relation for AGE projection: {relation:?} (must be [a-z0-9_]+)"
),
});
}
load_age_tolerated(tx).await?;
sqlx::query(SQL_SET_AGE_SEARCH_PATH)
.execute(&mut **tx)
.await
.map_err(|e| to_store_err("set search_path (project_link)", e))?;
sqlx::query("SAVEPOINT bootstrap_memory_graph")
.execute(&mut **tx)
.await
.map_err(|e| to_store_err("savepoint bootstrap_memory_graph", e))?;
match sqlx::query(SQL_CREATE_AGE_GRAPH).execute(&mut **tx).await {
Ok(_) => {
sqlx::query("RELEASE SAVEPOINT bootstrap_memory_graph")
.execute(&mut **tx)
.await
.map_err(|e| to_store_err("release savepoint bootstrap_memory_graph", e))?;
}
Err(e) => {
let msg = e.to_string();
sqlx::query("ROLLBACK TO SAVEPOINT bootstrap_memory_graph")
.execute(&mut **tx)
.await
.map_err(|err| to_store_err("rollback savepoint bootstrap_memory_graph", err))?;
sqlx::query("RELEASE SAVEPOINT bootstrap_memory_graph")
.execute(&mut **tx)
.await
.map_err(|err| to_store_err("release savepoint bootstrap_memory_graph", err))?;
if !msg.contains(PG_ERR_ALREADY_EXISTS) {
return Err(to_store_err("create_graph memory_graph (project_link)", e));
}
}
}
let node_sql = "SELECT n FROM cypher('memory_graph', $$ MERGE (n:Memory {id: $id}) RETURN n $$, $1) \
AS (n agtype)";
for id in [source_id, target_id] {
let params = age_params_jsonb(&[("id", id)]);
sqlx::query(node_sql)
.bind(Agtype(params))
.fetch_all(&mut **tx)
.await
.map_err(|e| to_store_err("project memory node into AGE", e))?;
}
let edge_cypher = format!(
"MATCH (a:Memory {{id: $src}}), (b:Memory {{id: $dst}}) \
MERGE (a)-[r:{relation} {{relation: $rel}}]->(b) RETURN r"
);
let edge_sql =
format!("SELECT r FROM cypher('memory_graph', $$ {edge_cypher} $$, $1) AS (r agtype)");
let edge_params =
age_params_jsonb(&[("src", source_id), ("dst", target_id), ("rel", relation)]);
sqlx::query(&edge_sql)
.bind(Agtype(edge_params))
.fetch_all(&mut **tx)
.await
.map_err(|e| to_store_err("project memory edge into AGE", e))?;
Ok(())
}
fn agtype_optional_string(s: &str) -> Option<String> {
let trimmed = s.trim();
if trimmed.eq_ignore_ascii_case("null") {
return None;
}
Some(strip_agtype_quotes(trimmed).to_string())
}
fn strip_agtype_quotes(s: &str) -> &str {
let trimmed = s.trim();
if trimmed.len() >= 2 && trimmed.starts_with('"') && trimmed.ends_with('"') {
&trimmed[1..trimmed.len() - 1]
} else {
trimmed
}
}
pub(crate) async fn detect_kg_backend(pool: &PgPool) -> KgBackend {
match sqlx::query_scalar::<_, i32>("SELECT 1 FROM pg_extension WHERE extname = 'age'")
.fetch_optional(pool)
.await
{
Ok(Some(_)) => KgBackend::Age,
Ok(None) => KgBackend::Cte,
Err(e) => {
tracing::debug!(
target: TRACE_TARGET,
error = %e,
"AGE detection probe failed; defaulting to CTE backend"
);
KgBackend::Cte
}
}
}
fn parse_rfc3339_opt(s: Option<&str>) -> Option<DateTime<Utc>> {
s.and_then(|raw| DateTime::parse_from_rfc3339(raw).ok().map(Into::into))
}
fn parse_rfc3339_required(s: &str) -> StoreResult<DateTime<Utc>> {
DateTime::parse_from_rfc3339(s)
.map(Into::into)
.map_err(|e| StoreError::IntegrityFailed {
detail: format!("invalid rfc3339 timestamp {s}: {e}"),
})
}
fn truncate_to_microseconds(t: DateTime<Utc>) -> DateTime<Utc> {
use chrono::Timelike;
let micros = t.nanosecond() / 1_000;
t.with_nanosecond(micros * 1_000).unwrap_or(t)
}
fn resolve_quota_agent_id(ctx: &CallerContext, metadata: &serde_json::Value) -> String {
metadata
.get("agent_id")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(str::to_string)
.unwrap_or_else(|| ctx.agent_id.clone())
}
fn memory_storage_bytes(memory: &Memory) -> i64 {
let raw = memory.title.len().saturating_add(memory.content.len());
i64::try_from(raw).unwrap_or(i64::MAX)
}
async fn record_memory_quota_in_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
agent_id: &str,
namespace: &str,
bytes_added: i64,
) -> StoreResult<()> {
let now = Utc::now();
sqlx::query(
"INSERT INTO agent_quotas (
agent_id, namespace,
max_memories_per_day, max_storage_bytes, max_links_per_day,
current_memories_today, current_storage_bytes, current_links_today,
day_started_at, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, 1, $6, 0, $7, $7, $7)
ON CONFLICT (agent_id, namespace) DO UPDATE SET
current_memories_today = CASE
WHEN date_trunc('day', agent_quotas.day_started_at) = date_trunc('day', $7)
THEN agent_quotas.current_memories_today + 1
ELSE 1
END,
current_links_today = CASE
WHEN date_trunc('day', agent_quotas.day_started_at) = date_trunc('day', $7)
THEN agent_quotas.current_links_today
ELSE 0
END,
current_storage_bytes = agent_quotas.current_storage_bytes + EXCLUDED.current_storage_bytes,
day_started_at = CASE
WHEN date_trunc('day', agent_quotas.day_started_at) = date_trunc('day', $7)
THEN agent_quotas.day_started_at
ELSE $7
END,
updated_at = $7",
)
.bind(agent_id)
.bind(namespace)
.bind(quota_defaults().max_memories_per_day)
.bind(quota_defaults().max_storage_bytes)
.bind(quota_defaults().max_links_per_day)
.bind(bytes_added)
.bind(now)
.execute(&mut **tx)
.await
.map_err(|e| to_store_err("record agent_quotas memory increment", e))?;
Ok(())
}
async fn record_memory_quota_batch_in_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
agent_id: &str,
namespace: &str,
memories_added: i64,
bytes_added: i64,
) -> StoreResult<()> {
let now = Utc::now();
sqlx::query(
"INSERT INTO agent_quotas (
agent_id, namespace,
max_memories_per_day, max_storage_bytes, max_links_per_day,
current_memories_today, current_storage_bytes, current_links_today,
day_started_at, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, $8, $6, 0, $7, $7, $7)
ON CONFLICT (agent_id, namespace) DO UPDATE SET
current_memories_today = CASE
WHEN date_trunc('day', agent_quotas.day_started_at) = date_trunc('day', $7)
THEN agent_quotas.current_memories_today + $8
ELSE $8
END,
current_links_today = CASE
WHEN date_trunc('day', agent_quotas.day_started_at) = date_trunc('day', $7)
THEN agent_quotas.current_links_today
ELSE 0
END,
current_storage_bytes = agent_quotas.current_storage_bytes + EXCLUDED.current_storage_bytes,
day_started_at = CASE
WHEN date_trunc('day', agent_quotas.day_started_at) = date_trunc('day', $7)
THEN agent_quotas.day_started_at
ELSE $7
END,
updated_at = $7",
)
.bind(agent_id)
.bind(namespace)
.bind(quota_defaults().max_memories_per_day)
.bind(quota_defaults().max_storage_bytes)
.bind(quota_defaults().max_links_per_day)
.bind(bytes_added)
.bind(now)
.bind(memories_added)
.execute(&mut **tx)
.await
.map_err(|e| to_store_err("record agent_quotas batch memory increment", e))?;
Ok(())
}
#[async_trait]
impl MemoryStore for PostgresStore {
fn capabilities(&self) -> Capabilities {
Capabilities::TRANSACTIONS
| Capabilities::NATIVE_VECTOR
| Capabilities::FULLTEXT
| Capabilities::DURABLE
| Capabilities::STRONG_CONSISTENCY
| Capabilities::ATOMIC_MULTI_WRITE
}
async fn schema_version(&self) -> StoreResult<i64> {
let v: Option<i32> = sqlx::query_scalar("SELECT MAX(version) FROM schema_version")
.fetch_one(&self.pool)
.await
.map_err(|e| to_store_err("read schema_version max", e))?;
Ok(i64::from(v.unwrap_or(0)))
}
async fn store(&self, ctx: &CallerContext, memory: &Memory) -> StoreResult<String> {
consult_governance_pre_write_pg(memory)?;
let created_at = parse_rfc3339_required(&memory.created_at)?;
let updated_at = parse_rfc3339_required(&memory.updated_at)?;
let last_accessed_at = parse_rfc3339_opt(memory.last_accessed_at.as_deref());
let expires_at = parse_rfc3339_opt(memory.effective_expires_at().as_deref());
let tags_json =
serde_json::to_value(&memory.tags).map_err(|e| StoreError::IntegrityFailed {
detail: serialize_err("tags", e),
})?;
let mut tx = self
.pool
.begin()
.await
.map_err(|e| to_store_err("begin store tx", e))?;
let citations_json =
serde_json::to_string(&memory.citations).map_err(|e| StoreError::IntegrityFailed {
detail: serialize_err("citations", e),
})?;
let source_span_json = match memory.source_span {
Some(span) => {
Some(
serde_json::to_string(&span).map_err(|e| StoreError::IntegrityFailed {
detail: serialize_err(COL_SOURCE_SPAN, e),
})?,
)
}
None => None,
};
let confidence_signals_json = match &memory.confidence_signals {
Some(s) => Some(
serde_json::to_string(s).map_err(|e| StoreError::IntegrityFailed {
detail: serialize_err(COL_CONFIDENCE_SIGNALS, e),
})?,
),
None => None,
};
let mentioned_entity_id = crate::storage::extract_mentioned_entity_id(memory);
let id: String = sqlx::query(
"INSERT INTO memories (
id, tier, namespace, title, content, tags, priority, confidence,
source, access_count, created_at, updated_at, last_accessed_at,
expires_at, metadata, reflection_depth, memory_kind,
citations, source_uri, source_span,
confidence_source, confidence_signals, confidence_decayed_at,
entity_id, persona_version,
mentioned_entity_id
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17,
$18, $19, $20,
$21, $22, $23,
$24, $25,
$26)
ON CONFLICT (title, namespace) DO UPDATE SET
content = EXCLUDED.content,
tier = CASE
WHEN tier_rank(EXCLUDED.tier) >= tier_rank(memories.tier)
THEN EXCLUDED.tier
ELSE memories.tier
END,
tags = EXCLUDED.tags,
-- #1629 — sqlite `storage::insert` parity: priority +
-- confidence take the MAX across upsert so a re-store can
-- never downgrade either signal.
priority = GREATEST(memories.priority, EXCLUDED.priority),
confidence = GREATEST(memories.confidence, EXCLUDED.confidence),
-- #1629 — sqlite parity: source follows the incoming row.
source = EXCLUDED.source,
updated_at = EXCLUDED.updated_at,
-- #1629 — sqlite parity: long-tier rows pin expiry to NULL;
-- otherwise an incoming row that omits expiry keeps the
-- stored one rather than blanking it out.
expires_at = CASE
WHEN EXCLUDED.tier = 'long' OR memories.tier = 'long' THEN NULL
ELSE COALESCE(EXCLUDED.expires_at, memories.expires_at)
END,
metadata = CASE
WHEN memories.metadata ? 'agent_id'
THEN jsonb_set(
EXCLUDED.metadata,
'{agent_id}',
memories.metadata -> 'agent_id'
)
ELSE EXCLUDED.metadata
END,
-- v0.7.0 Task 1/8 — recursion depth takes max on upsert so a
-- newer reflection at higher depth doesn't lose its provenance
-- signal when re-stored at the same (title, namespace).
reflection_depth = GREATEST(memories.reflection_depth, EXCLUDED.reflection_depth),
-- L1-1 — kind is sticky: once Reflection, always Reflection.
-- #1629 / QW-2 — Persona is also sticky (sqlite parity).
memory_kind = CASE WHEN memories.memory_kind = 'reflection' THEN 'reflection'
WHEN memories.memory_kind = 'persona' THEN 'persona'
ELSE EXCLUDED.memory_kind END,
-- v0.7.0 #900 / #1629 — Form-4 fact-provenance follows the
-- sqlite CASE: a non-empty incoming citations array replaces
-- (caller re-asserted provenance); an empty one preserves the
-- stored evidence instead of silently wiping it.
citations = CASE WHEN EXCLUDED.citations = '[]'
THEN memories.citations
ELSE EXCLUDED.citations END,
source_uri = COALESCE(EXCLUDED.source_uri, memories.source_uri),
source_span = COALESCE(EXCLUDED.source_span, memories.source_span),
-- #1629 — Form-5 sqlite parity: an explicit non-default
-- provenance replaces; the caller_provided default keeps the
-- stored (auto-derived / calibrated) signal.
confidence_source = CASE WHEN EXCLUDED.confidence_source != 'caller_provided'
THEN EXCLUDED.confidence_source
ELSE memories.confidence_source END,
confidence_signals = COALESCE(EXCLUDED.confidence_signals, memories.confidence_signals),
confidence_decayed_at = COALESCE(EXCLUDED.confidence_decayed_at, memories.confidence_decayed_at),
-- #1608 / #1629 — QW-2 persona-artifact columns stay sticky:
-- the sqlite COALESCE order keeps the value the row was
-- minted with (old wins), so match it.
entity_id = COALESCE(memories.entity_id, EXCLUDED.entity_id),
persona_version = COALESCE(memories.persona_version, EXCLUDED.persona_version),
-- #1383 — preserve a previously-extracted attribution if
-- EXCLUDED is NULL (matches the sqlite ON CONFLICT clause
-- at `src/storage/mod.rs:689`).
mentioned_entity_id = COALESCE(EXCLUDED.mentioned_entity_id, memories.mentioned_entity_id),
-- #1632 (pg twin) — upsert-merge IS a mutation, so the Gap-1
-- optimistic-concurrency counter bumps exactly like
-- db::update (sqlite landed in 27b45dc2).
version = memories.version + 1
RETURNING id",
)
.bind(&memory.id)
.bind(memory.tier.as_str())
.bind(&memory.namespace)
.bind(&memory.title)
.bind(&memory.content)
.bind(&tags_json)
.bind(memory.priority)
.bind(memory.confidence)
.bind(&memory.source)
.bind(memory.access_count)
.bind(created_at)
.bind(updated_at)
.bind(last_accessed_at)
.bind(expires_at)
.bind(&memory.metadata)
.bind(memory.reflection_depth)
.bind(memory.memory_kind.as_str())
.bind(&citations_json)
.bind(memory.source_uri.as_deref())
.bind(source_span_json.as_deref())
.bind(memory.confidence_source.as_str())
.bind(confidence_signals_json.as_deref())
.bind(memory.confidence_decayed_at.as_deref())
.bind(memory.entity_id.as_deref())
.bind(memory.persona_version)
.bind(mentioned_entity_id.as_deref())
.fetch_one(&mut *tx)
.await
.map_err(|e| to_store_err("insert memory", e))?
.try_get::<String, _>("id")
.map_err(|e| to_store_err(READ_RETURNED_ID, e))?;
let quota_agent_id = resolve_quota_agent_id(ctx, &memory.metadata);
let bytes_added = memory_storage_bytes(memory);
record_memory_quota_in_tx(&mut tx, "a_agent_id, &memory.namespace, bytes_added).await?;
tx.commit()
.await
.map_err(|e| to_store_err("commit store tx", e))?;
Ok(id)
}
async fn store_batch(
&self,
ctx: &CallerContext,
memories: &[Memory],
) -> StoreResult<Vec<String>> {
if memories.is_empty() {
return Ok(Vec::new());
}
for memory in memories {
consult_governance_pre_write_pg(memory)?;
}
let mut last_index: std::collections::HashMap<(&str, &str), usize> =
std::collections::HashMap::with_capacity(memories.len());
for (idx, memory) in memories.iter().enumerate() {
last_index.insert((memory.title.as_str(), memory.namespace.as_str()), idx);
}
let mut keep: Vec<usize> = (0..memories.len())
.filter(|idx| {
last_index[&(
memories[*idx].title.as_str(),
memories[*idx].namespace.as_str(),
)] == *idx
})
.collect();
keep.sort_unstable();
let mut tx = self
.pool
.begin()
.await
.map_err(|e| to_store_err("begin store_batch tx", e))?;
let mut builder: sqlx::QueryBuilder<sqlx::Postgres> = sqlx::QueryBuilder::new(
"INSERT INTO memories (
id, tier, namespace, title, content, tags, priority, confidence,
source, access_count, created_at, updated_at, last_accessed_at,
expires_at, metadata, reflection_depth, memory_kind,
citations, source_uri, source_span,
confidence_source, confidence_signals, confidence_decayed_at,
entity_id, persona_version,
mentioned_entity_id
) ",
);
let mut push_err: Option<StoreError> = None;
builder.push_values(keep.iter().copied(), |mut row, idx| {
let memory = &memories[idx];
let created_at = match parse_rfc3339_required(&memory.created_at) {
Ok(v) => v,
Err(e) => {
push_err.get_or_insert(e);
return;
}
};
let updated_at = match parse_rfc3339_required(&memory.updated_at) {
Ok(v) => v,
Err(e) => {
push_err.get_or_insert(e);
return;
}
};
let last_accessed_at = parse_rfc3339_opt(memory.last_accessed_at.as_deref());
let expires_at = parse_rfc3339_opt(memory.effective_expires_at().as_deref());
let tags_json = match serde_json::to_value(&memory.tags) {
Ok(v) => v,
Err(e) => {
push_err.get_or_insert(StoreError::IntegrityFailed {
detail: serialize_err("tags", e),
});
return;
}
};
let citations_json = match serde_json::to_string(&memory.citations) {
Ok(v) => v,
Err(e) => {
push_err.get_or_insert(StoreError::IntegrityFailed {
detail: serialize_err("citations", e),
});
return;
}
};
let source_span_json = match &memory.source_span {
Some(span) => match serde_json::to_string(span) {
Ok(v) => Some(v),
Err(e) => {
push_err.get_or_insert(StoreError::IntegrityFailed {
detail: serialize_err(COL_SOURCE_SPAN, e),
});
return;
}
},
None => None,
};
let confidence_signals_json = match &memory.confidence_signals {
Some(s) => match serde_json::to_string(s) {
Ok(v) => Some(v),
Err(e) => {
push_err.get_or_insert(StoreError::IntegrityFailed {
detail: serialize_err(COL_CONFIDENCE_SIGNALS, e),
});
return;
}
},
None => None,
};
let mentioned_entity_id = crate::storage::extract_mentioned_entity_id(memory);
row.push_bind(memory.id.clone())
.push_bind(memory.tier.as_str().to_string())
.push_bind(memory.namespace.clone())
.push_bind(memory.title.clone())
.push_bind(memory.content.clone())
.push_bind(tags_json)
.push_bind(memory.priority)
.push_bind(memory.confidence)
.push_bind(memory.source.clone())
.push_bind(memory.access_count)
.push_bind(created_at)
.push_bind(updated_at)
.push_bind(last_accessed_at)
.push_bind(expires_at)
.push_bind(memory.metadata.clone())
.push_bind(memory.reflection_depth)
.push_bind(memory.memory_kind.as_str().to_string())
.push_bind(citations_json)
.push_bind(memory.source_uri.clone())
.push_bind(source_span_json)
.push_bind(memory.confidence_source.as_str().to_string())
.push_bind(confidence_signals_json)
.push_bind(memory.confidence_decayed_at.clone())
.push_bind(memory.entity_id.clone())
.push_bind(memory.persona_version)
.push_bind(mentioned_entity_id);
});
if let Some(e) = push_err {
return Err(e);
}
builder.push(
" ON CONFLICT (title, namespace) DO UPDATE SET
content = EXCLUDED.content,
tier = CASE
WHEN tier_rank(EXCLUDED.tier) >= tier_rank(memories.tier)
THEN EXCLUDED.tier
ELSE memories.tier
END,
tags = EXCLUDED.tags,
-- #1629 — sqlite parity: MAX-merge, source follows incoming,
-- expiry long→NULL + COALESCE (see `store()` for rationale).
priority = GREATEST(memories.priority, EXCLUDED.priority),
confidence = GREATEST(memories.confidence, EXCLUDED.confidence),
source = EXCLUDED.source,
updated_at = EXCLUDED.updated_at,
expires_at = CASE
WHEN EXCLUDED.tier = 'long' OR memories.tier = 'long' THEN NULL
ELSE COALESCE(EXCLUDED.expires_at, memories.expires_at)
END,
metadata = CASE
WHEN memories.metadata ? 'agent_id'
THEN jsonb_set(
EXCLUDED.metadata,
'{agent_id}',
memories.metadata -> 'agent_id'
)
ELSE EXCLUDED.metadata
END,
reflection_depth = GREATEST(memories.reflection_depth, EXCLUDED.reflection_depth),
memory_kind = CASE WHEN memories.memory_kind = 'reflection' THEN 'reflection'
WHEN memories.memory_kind = 'persona' THEN 'persona'
ELSE EXCLUDED.memory_kind END,
citations = CASE WHEN EXCLUDED.citations = '[]'
THEN memories.citations
ELSE EXCLUDED.citations END,
source_uri = COALESCE(EXCLUDED.source_uri, memories.source_uri),
source_span = COALESCE(EXCLUDED.source_span, memories.source_span),
confidence_source = CASE WHEN EXCLUDED.confidence_source != 'caller_provided'
THEN EXCLUDED.confidence_source
ELSE memories.confidence_source END,
confidence_signals = COALESCE(EXCLUDED.confidence_signals, memories.confidence_signals),
confidence_decayed_at = COALESCE(EXCLUDED.confidence_decayed_at, memories.confidence_decayed_at),
entity_id = COALESCE(memories.entity_id, EXCLUDED.entity_id),
persona_version = COALESCE(memories.persona_version, EXCLUDED.persona_version),
mentioned_entity_id = COALESCE(EXCLUDED.mentioned_entity_id, memories.mentioned_entity_id),
-- #1632 (pg twin) — upsert-merge bumps the Gap-1 counter.
version = memories.version + 1
RETURNING id, title, namespace",
);
let rows = builder
.build()
.fetch_all(&mut *tx)
.await
.map_err(|e| to_store_err("store_batch multi-row insert", e))?;
let mut id_by_key: std::collections::HashMap<(String, String), String> =
std::collections::HashMap::with_capacity(rows.len());
for row in &rows {
let id: String = row
.try_get("id")
.map_err(|e| to_store_err("store_batch read returned id", e))?;
let title: String = row
.try_get("title")
.map_err(|e| to_store_err("store_batch read returned title", e))?;
let namespace: String = row
.try_get("namespace")
.map_err(|e| to_store_err("store_batch read returned namespace", e))?;
id_by_key.insert((title, namespace), id);
}
let mut quota_groups: std::collections::HashMap<(String, String), (i64, i64)> =
std::collections::HashMap::new();
for memory in memories {
let quota_agent_id = resolve_quota_agent_id(ctx, &memory.metadata);
let entry = quota_groups
.entry((quota_agent_id, memory.namespace.clone()))
.or_insert((0, 0));
entry.0 += 1;
entry.1 = entry.1.saturating_add(memory_storage_bytes(memory));
}
for ((agent_id, namespace), (count, bytes)) in "a_groups {
record_memory_quota_batch_in_tx(&mut tx, agent_id, namespace, *count, *bytes).await?;
}
tx.commit()
.await
.map_err(|e| to_store_err("commit store_batch tx", e))?;
let mut ids = Vec::with_capacity(memories.len());
for memory in memories {
let key = (memory.title.clone(), memory.namespace.clone());
let id = id_by_key
.get(&key)
.cloned()
.ok_or_else(|| StoreError::IntegrityFailed {
detail: format!(
"store_batch: no RETURNING id for (title, namespace) of memory {}",
memory.id
),
})?;
ids.push(id);
}
Ok(ids)
}
async fn capture_turn_idempotent(
&self,
_ctx: &CallerContext,
write: &CaptureTurnWrite,
) -> StoreResult<CaptureTurnResult> {
let existing: Option<String> = sqlx::query_scalar(
"SELECT memory_id FROM transcript_line_dedup \
WHERE host_session_id IS NOT NULL \
AND host_session_id = $1 \
AND host_turn_index = $2",
)
.bind(&write.host_session_id)
.bind(write.host_turn_index)
.fetch_optional(&self.pool)
.await
.map_err(|e| to_store_err("capture_turn dedup query", e))?;
if let Some(memory_id) = existing {
return Ok(CaptureTurnResult {
memory_id,
dedup_hit: true,
});
}
let memory = &write.memory;
consult_governance_pre_write_pg(memory)?;
let created_at = parse_rfc3339_required(&memory.created_at)?;
let updated_at = parse_rfc3339_required(&memory.updated_at)?;
let last_accessed_at = parse_rfc3339_opt(memory.last_accessed_at.as_deref());
let expires_at = parse_rfc3339_opt(memory.effective_expires_at().as_deref());
let tags_json =
serde_json::to_value(&memory.tags).map_err(|e| StoreError::IntegrityFailed {
detail: serialize_err("tags", e),
})?;
let citations_json =
serde_json::to_string(&memory.citations).map_err(|e| StoreError::IntegrityFailed {
detail: serialize_err("citations", e),
})?;
let source_span_json = match memory.source_span {
Some(span) => {
Some(
serde_json::to_string(&span).map_err(|e| StoreError::IntegrityFailed {
detail: serialize_err(COL_SOURCE_SPAN, e),
})?,
)
}
None => None,
};
let confidence_signals_json = match &memory.confidence_signals {
Some(s) => Some(
serde_json::to_string(s).map_err(|e| StoreError::IntegrityFailed {
detail: serialize_err(COL_CONFIDENCE_SIGNALS, e),
})?,
),
None => None,
};
let mentioned_entity_id = crate::storage::extract_mentioned_entity_id(memory);
let event_ts = parse_rfc3339_required(&write.signed_event.timestamp)?;
let mut tx = self
.pool
.begin()
.await
.map_err(|e| to_store_err("begin capture_turn tx", e))?;
let inserted_id: String = sqlx::query(
"INSERT INTO memories (
id, tier, namespace, title, content, tags, priority, confidence,
source, access_count, created_at, updated_at, last_accessed_at,
expires_at, metadata, reflection_depth, memory_kind,
citations, source_uri, source_span,
confidence_source, confidence_signals, confidence_decayed_at,
mentioned_entity_id
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17,
$18, $19, $20,
$21, $22, $23,
$24)
ON CONFLICT (title, namespace) DO UPDATE SET
content = EXCLUDED.content,
tier = CASE
WHEN tier_rank(EXCLUDED.tier) >= tier_rank(memories.tier)
THEN EXCLUDED.tier
ELSE memories.tier
END,
tags = EXCLUDED.tags,
-- #1629 — sqlite parity: MAX-merge, source follows incoming,
-- expiry long→NULL + COALESCE (see `store()` for rationale).
priority = GREATEST(memories.priority, EXCLUDED.priority),
confidence = GREATEST(memories.confidence, EXCLUDED.confidence),
source = EXCLUDED.source,
updated_at = EXCLUDED.updated_at,
expires_at = CASE
WHEN EXCLUDED.tier = 'long' OR memories.tier = 'long' THEN NULL
ELSE COALESCE(EXCLUDED.expires_at, memories.expires_at)
END,
metadata = CASE
WHEN memories.metadata ? 'agent_id'
THEN jsonb_set(
EXCLUDED.metadata,
'{agent_id}',
memories.metadata -> 'agent_id'
)
ELSE EXCLUDED.metadata
END,
reflection_depth = GREATEST(memories.reflection_depth, EXCLUDED.reflection_depth),
memory_kind = CASE WHEN memories.memory_kind = 'reflection' THEN 'reflection'
WHEN memories.memory_kind = 'persona' THEN 'persona'
ELSE EXCLUDED.memory_kind END,
citations = CASE WHEN EXCLUDED.citations = '[]'
THEN memories.citations
ELSE EXCLUDED.citations END,
source_uri = COALESCE(EXCLUDED.source_uri, memories.source_uri),
source_span = COALESCE(EXCLUDED.source_span, memories.source_span),
confidence_source = CASE WHEN EXCLUDED.confidence_source != 'caller_provided'
THEN EXCLUDED.confidence_source
ELSE memories.confidence_source END,
confidence_signals = COALESCE(EXCLUDED.confidence_signals, memories.confidence_signals),
confidence_decayed_at = COALESCE(EXCLUDED.confidence_decayed_at, memories.confidence_decayed_at),
mentioned_entity_id = COALESCE(EXCLUDED.mentioned_entity_id, memories.mentioned_entity_id),
-- #1632 (pg twin) — upsert-merge bumps the Gap-1 counter.
version = memories.version + 1
RETURNING id",
)
.bind(&memory.id)
.bind(memory.tier.as_str())
.bind(&memory.namespace)
.bind(&memory.title)
.bind(&memory.content)
.bind(&tags_json)
.bind(memory.priority)
.bind(memory.confidence)
.bind(&memory.source)
.bind(memory.access_count)
.bind(created_at)
.bind(updated_at)
.bind(last_accessed_at)
.bind(expires_at)
.bind(&memory.metadata)
.bind(memory.reflection_depth)
.bind(memory.memory_kind.as_str())
.bind(&citations_json)
.bind(memory.source_uri.as_deref())
.bind(source_span_json.as_deref())
.bind(memory.confidence_source.as_str())
.bind(confidence_signals_json.as_deref())
.bind(memory.confidence_decayed_at.as_deref())
.bind(mentioned_entity_id.as_deref())
.fetch_one(&mut *tx)
.await
.map_err(|e| to_store_err("capture_turn insert memory", e))?
.try_get::<String, _>("id")
.map_err(|e| to_store_err("capture_turn read returned id", e))?;
sqlx::query(
"INSERT INTO transcript_line_dedup \
(sha256, memory_id, host_kind, transcript_path, \
host_session_id, host_turn_index, recovered_at) \
VALUES ($1, $2, $3, NULL, $4, $5, $6) \
ON CONFLICT (sha256) DO NOTHING",
)
.bind(&write.sha256)
.bind(&inserted_id)
.bind(&write.host_kind)
.bind(&write.host_session_id)
.bind(write.host_turn_index)
.bind(write.recovered_at_ms)
.execute(&mut *tx)
.await
.map_err(|e| to_store_err("capture_turn insert dedup row", e))?;
let ev = &write.signed_event;
pg_append_signed_event_with_chain_in_tx(
&mut tx,
PgSignedEventInsert {
id: &ev.id,
agent_id: &ev.agent_id,
event_type: &ev.event_type,
payload_hash: &ev.payload_hash,
signature: ev.signature.as_deref(),
attest_level: &ev.attest_level,
timestamp: event_ts,
},
)
.await
.map_err(|e| to_store_err("capture_turn append signed_event", e))?;
tx.commit()
.await
.map_err(|e| to_store_err("commit capture_turn tx", e))?;
Ok(CaptureTurnResult {
memory_id: inserted_id,
dedup_hit: false,
})
}
async fn store_with_embedding(
&self,
ctx: &CallerContext,
memory: &Memory,
embedding: Option<&[f32]>,
) -> StoreResult<String> {
consult_governance_pre_write_pg(memory)?;
let created_at = parse_rfc3339_required(&memory.created_at)?;
let updated_at = parse_rfc3339_required(&memory.updated_at)?;
let last_accessed_at = parse_rfc3339_opt(memory.last_accessed_at.as_deref());
let expires_at = parse_rfc3339_opt(memory.effective_expires_at().as_deref());
let tags_json =
serde_json::to_value(&memory.tags).map_err(|e| StoreError::IntegrityFailed {
detail: serialize_err("tags", e),
})?;
let emb_pgvec = embedding.map(|v| pgvector::Vector::from(v.to_vec()));
let citations_json =
serde_json::to_string(&memory.citations).map_err(|e| StoreError::IntegrityFailed {
detail: serialize_err("citations", e),
})?;
let source_span_json = match &memory.source_span {
Some(span) => {
Some(
serde_json::to_string(span).map_err(|e| StoreError::IntegrityFailed {
detail: serialize_err(COL_SOURCE_SPAN, e),
})?,
)
}
None => None,
};
let confidence_signals_json = match &memory.confidence_signals {
Some(s) => Some(
serde_json::to_string(s).map_err(|e| StoreError::IntegrityFailed {
detail: serialize_err(COL_CONFIDENCE_SIGNALS, e),
})?,
),
None => None,
};
let mut tx = self
.pool
.begin()
.await
.map_err(|e| to_store_err("begin store tx", e))?;
let mentioned_entity_id = crate::storage::extract_mentioned_entity_id(memory);
let id: String = sqlx::query(
"INSERT INTO memories (
id, tier, namespace, title, content, tags, priority, confidence,
source, access_count, created_at, updated_at, last_accessed_at,
expires_at, metadata, reflection_depth, memory_kind,
citations, source_uri, source_span,
confidence_source, confidence_signals, confidence_decayed_at,
entity_id, persona_version, embedding,
mentioned_entity_id
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17,
$18, $19, $20,
$21, $22, $23,
$24, $25, $26,
$27)
ON CONFLICT (title, namespace) DO UPDATE SET
content = EXCLUDED.content,
tier = CASE
WHEN tier_rank(EXCLUDED.tier) >= tier_rank(memories.tier)
THEN EXCLUDED.tier
ELSE memories.tier
END,
tags = EXCLUDED.tags,
-- #1629 — sqlite parity: MAX-merge, source follows incoming,
-- expiry long→NULL + COALESCE (see `store()` for rationale).
priority = GREATEST(memories.priority, EXCLUDED.priority),
confidence = GREATEST(memories.confidence, EXCLUDED.confidence),
source = EXCLUDED.source,
updated_at = EXCLUDED.updated_at,
expires_at = CASE
WHEN EXCLUDED.tier = 'long' OR memories.tier = 'long' THEN NULL
ELSE COALESCE(EXCLUDED.expires_at, memories.expires_at)
END,
metadata = CASE
WHEN memories.metadata ? 'agent_id'
THEN jsonb_set(
EXCLUDED.metadata,
'{agent_id}',
memories.metadata -> 'agent_id'
)
ELSE EXCLUDED.metadata
END,
-- v0.7.0 Task 1/8 — recursion depth takes max on upsert.
reflection_depth = GREATEST(memories.reflection_depth, EXCLUDED.reflection_depth),
-- L1-1 — kind is sticky (reflection AND persona, #1629).
memory_kind = CASE WHEN memories.memory_kind = 'reflection' THEN 'reflection'
WHEN memories.memory_kind = 'persona' THEN 'persona'
ELSE EXCLUDED.memory_kind END,
-- #1608 / #1629 — Form-4 / Form-5 / QW-2 arms mirror `store()`
-- (sqlite shape): re-store doesn't blank out provenance.
citations = CASE WHEN EXCLUDED.citations = '[]'
THEN memories.citations
ELSE EXCLUDED.citations END,
source_uri = COALESCE(EXCLUDED.source_uri, memories.source_uri),
source_span = COALESCE(EXCLUDED.source_span, memories.source_span),
confidence_source = CASE WHEN EXCLUDED.confidence_source != 'caller_provided'
THEN EXCLUDED.confidence_source
ELSE memories.confidence_source END,
confidence_signals = COALESCE(EXCLUDED.confidence_signals, memories.confidence_signals),
confidence_decayed_at = COALESCE(EXCLUDED.confidence_decayed_at, memories.confidence_decayed_at),
entity_id = COALESCE(memories.entity_id, EXCLUDED.entity_id),
persona_version = COALESCE(memories.persona_version, EXCLUDED.persona_version),
embedding = COALESCE(EXCLUDED.embedding, memories.embedding),
-- #1383 — preserve a previously-extracted attribution
-- if EXCLUDED is NULL (sqlite parity).
mentioned_entity_id = COALESCE(EXCLUDED.mentioned_entity_id, memories.mentioned_entity_id),
-- #1632 (pg twin) — upsert-merge bumps the Gap-1 counter.
version = memories.version + 1
RETURNING id",
)
.bind(&memory.id)
.bind(memory.tier.as_str())
.bind(&memory.namespace)
.bind(&memory.title)
.bind(&memory.content)
.bind(&tags_json)
.bind(memory.priority)
.bind(memory.confidence)
.bind(&memory.source)
.bind(memory.access_count)
.bind(created_at)
.bind(updated_at)
.bind(last_accessed_at)
.bind(expires_at)
.bind(&memory.metadata)
.bind(memory.reflection_depth)
.bind(memory.memory_kind.as_str())
.bind(&citations_json)
.bind(memory.source_uri.as_deref())
.bind(source_span_json.as_deref())
.bind(memory.confidence_source.as_str())
.bind(confidence_signals_json.as_deref())
.bind(memory.confidence_decayed_at.as_deref())
.bind(memory.entity_id.as_deref())
.bind(memory.persona_version)
.bind(emb_pgvec)
.bind(mentioned_entity_id.as_deref())
.fetch_one(&mut *tx)
.await
.map_err(|e| to_store_err("insert memory_with_embedding", e))?
.try_get::<String, _>("id")
.map_err(|e| to_store_err(READ_RETURNED_ID, e))?;
let quota_agent_id = resolve_quota_agent_id(ctx, &memory.metadata);
let bytes_added = memory_storage_bytes(memory);
record_memory_quota_in_tx(&mut tx, "a_agent_id, &memory.namespace, bytes_added).await?;
tx.commit()
.await
.map_err(|e| to_store_err("commit store tx", e))?;
Ok(id)
}
async fn update_embedding(
&self,
_ctx: &CallerContext,
id: &str,
embedding: Option<&[f32]>,
) -> StoreResult<()> {
let emb_pgvec = embedding.map(|v| pgvector::Vector::from(v.to_vec()));
sqlx::query("UPDATE memories SET embedding = $2 WHERE id = $1")
.bind(id)
.bind(emb_pgvec)
.execute(&self.pool)
.await
.map_err(|e| to_store_err("update_embedding", e))?;
Ok(())
}
async fn list_unembedded(
&self,
ctx: &CallerContext,
limit: usize,
) -> StoreResult<Vec<(String, String, String)>> {
if !ctx.bypass_visibility {
return Ok(Vec::new());
}
let cap: i64 = i64::try_from(limit).unwrap_or(LIST_FALLBACK_LIMIT_I64);
let rows = sqlx::query(
"SELECT id, title, content FROM memories \
WHERE embedding IS NULL \
ORDER BY created_at ASC \
LIMIT $1",
)
.bind(cap)
.fetch_all(&self.pool)
.await
.map_err(|e| to_store_err("list_unembedded", e))?;
rows.iter()
.map(|r| {
Ok((
r.try_get::<String, _>("id")
.map_err(|e| to_store_err("read unembedded id", e))?,
r.try_get::<String, _>("title")
.map_err(|e| to_store_err("read unembedded title", e))?,
r.try_get::<String, _>("content")
.map_err(|e| to_store_err("read unembedded content", e))?,
))
})
.collect()
}
async fn set_embeddings_batch(
&self,
_ctx: &CallerContext,
entries: &[(String, Vec<f32>)],
) -> StoreResult<usize> {
if entries.is_empty() {
return Ok(0);
}
let mut tx = self
.pool
.begin()
.await
.map_err(|e| to_store_err("begin set_embeddings_batch tx", e))?;
let mut written = 0usize;
for (id, vec) in entries {
let emb_pgvec = pgvector::Vector::from(vec.clone());
let res = sqlx::query("UPDATE memories SET embedding = $2 WHERE id = $1")
.bind(id)
.bind(emb_pgvec)
.execute(&mut *tx)
.await
.map_err(|e| to_store_err("set_embeddings_batch update", e))?;
written += usize::try_from(res.rows_affected()).unwrap_or(0);
}
tx.commit()
.await
.map_err(|e| to_store_err("commit set_embeddings_batch tx", e))?;
Ok(written)
}
async fn get(&self, ctx: &CallerContext, id: &str) -> StoreResult<Memory> {
let row = sqlx::query("SELECT * FROM memories WHERE id = $1")
.bind(id)
.fetch_optional(&self.pool)
.await
.map_err(|e| to_store_err("select by id", e))?;
match row {
Some(r) => {
let mem = Self::row_to_memory(&r)?;
if ctx.bypass_visibility || is_visible_to_caller(&mem, ctx.effective_principal()) {
Ok(mem)
} else {
Err(StoreError::NotFound { id: id.to_string() })
}
}
None => Err(StoreError::NotFound { id: id.to_string() }),
}
}
async fn update(&self, ctx: &CallerContext, id: &str, patch: UpdatePatch) -> StoreResult<()> {
self.assert_caller_owns_for_mutation(ctx, id, "update", REASON_UNSTAMPED_TENANT_WRITE)
.await?;
let rows_affected = sqlx::query(
"UPDATE memories SET
title = COALESCE($2, title),
content = COALESCE($3, content),
tier = CASE
WHEN $4::TEXT IS NULL THEN tier
WHEN tier_rank($4::TEXT) >= tier_rank(tier) THEN $4::TEXT
ELSE tier
END,
namespace = COALESCE($5, namespace),
tags = COALESCE($6, tags),
priority = COALESCE($7, priority),
confidence = COALESCE($8, confidence),
metadata = CASE
WHEN $9::JSONB IS NULL THEN metadata
WHEN metadata ? 'agent_id' THEN jsonb_set(
$9::JSONB,
'{agent_id}',
metadata -> 'agent_id'
)
ELSE $9::JSONB
END,
source_uri = COALESCE($10, source_uri),
-- #1626 — tier→long ⇒ expires_at = NULL. A patch that
-- promotes to 'long' always wins the tier ladder above,
-- so the row must shed its expiry or GC reaps the
-- promoted row at the stale mid/short deadline. Rule:
-- when the patch tier IS long the clear wins over any
-- explicitly-supplied $11 (matching the sqlite upsert
-- semantics: expiry is only cleared when the new tier
-- is long, and the sqlite promote handler's explicit
-- `UPDATE memories SET expires_at = NULL`); when the
-- patch tier is NOT long an explicit $11 wins and an
-- absent $11 leaves the stored value untouched.
expires_at = CASE
WHEN $4::TEXT = 'long' THEN NULL
ELSE COALESCE($11, expires_at)
END,
version = version + 1,
updated_at = NOW()
WHERE id = $1",
)
.bind(id)
.bind(patch.title)
.bind(patch.content)
.bind(patch.tier.as_ref().map(Tier::as_str))
.bind(patch.namespace)
.bind(
patch
.tags
.map(serde_json::to_value)
.transpose()
.map_err(|e| StoreError::IntegrityFailed {
detail: format!("serialize tags patch: {e}"),
})?,
)
.bind(patch.priority)
.bind(patch.confidence)
.bind(patch.metadata)
.bind(patch.source_uri)
.bind(parse_rfc3339_opt(patch.expires_at.as_deref()))
.execute(&self.pool)
.await
.map_err(|e| to_store_err("update", e))?
.rows_affected();
if rows_affected == 0 {
Err(StoreError::NotFound { id: id.to_string() })
} else {
Ok(())
}
}
async fn delete(&self, ctx: &CallerContext, id: &str) -> StoreResult<()> {
self.assert_caller_owns_for_mutation(ctx, id, "delete", REASON_UNSTAMPED_TENANT_DELETE)
.await?;
sqlx::query(SQL_DELETE_NAMESPACE_META_BY_STANDARD_ID)
.bind(id)
.execute(&self.pool)
.await
.map_err(|e| to_store_err("delete: namespace_meta cleanup", e))?;
let rows_affected = sqlx::query(SQL_DELETE_MEMORY_BY_ID)
.bind(id)
.execute(&self.pool)
.await
.map_err(|e| to_store_err("delete", e))?
.rows_affected();
if rows_affected == 0 {
Err(StoreError::NotFound { id: id.to_string() })
} else {
Ok(())
}
}
async fn list(&self, ctx: &CallerContext, filter: &Filter) -> StoreResult<Vec<Memory>> {
let limit: i64 = filter
.limit
.clamp(1, crate::storage::LIST_MAX_LIMIT)
.try_into()
.unwrap_or(LIST_FALLBACK_LIMIT_I64);
let caller = ctx.effective_principal();
let caller_opt: Option<&str> = if ctx.bypass_visibility {
None
} else {
Some(caller)
};
let ns_predicate = if filter.namespace.is_some() {
NS_FILTER_SARGABLE
} else {
NS_FILTER_OPTIONAL
};
let list_sql = format!(
"SELECT * FROM memories
WHERE {ns_predicate}
AND ($2::text IS NULL OR tier = $2)
AND (expires_at IS NULL OR expires_at > NOW())
AND ($3::timestamptz IS NULL OR created_at >= $3)
AND ($4::timestamptz IS NULL OR created_at <= $4)
AND (
$6::text IS NULL
OR COALESCE(metadata->>'scope', 'private') <> 'private'
OR metadata->>'agent_id' = $6
OR metadata->>'target_agent_id' = $6
)
AND ($7::text IS NULL OR metadata->>'agent_id' = $7)
ORDER BY priority DESC, updated_at DESC
LIMIT $5"
);
let rows = sqlx::query(&list_sql)
.bind(filter.namespace.as_ref())
.bind(filter.tier.as_ref().map(Tier::as_str))
.bind(filter.since)
.bind(filter.until)
.bind(limit)
.bind(caller_opt)
.bind(filter.agent_id.as_ref())
.fetch_all(&self.pool)
.await
.map_err(|e| to_store_err("list", e))?;
let mems: Vec<Memory> = rows
.iter()
.map(Self::row_to_memory)
.collect::<StoreResult<Vec<_>>>()?;
if ctx.bypass_visibility {
return Ok(mems);
}
Ok(mems
.into_iter()
.filter(|m| is_visible_to_caller(m, caller))
.collect())
}
async fn list_by_namespace_prefix(
&self,
_ctx: &CallerContext,
prefix: &str,
limit: usize,
) -> StoreResult<Vec<Memory>> {
let limit: i64 = (limit as i64).clamp(1, STORE_LIST_MAX_LIMIT_SAL);
let upper = prefix_upper_bound(prefix);
let rows = match upper {
Some(ref upper) => {
sqlx::query(
"SELECT * FROM memories
WHERE namespace >= $1 AND namespace < $2
ORDER BY namespace
LIMIT $3",
)
.bind(prefix)
.bind(upper)
.bind(limit)
.fetch_all(&self.pool)
.await
}
None => {
sqlx::query(
"SELECT * FROM memories
WHERE namespace >= $1
ORDER BY namespace
LIMIT $2",
)
.bind(prefix)
.bind(limit)
.fetch_all(&self.pool)
.await
}
}
.map_err(|e| to_store_err("list_by_namespace_prefix", e))?;
rows.iter()
.map(Self::row_to_memory)
.collect::<StoreResult<Vec<_>>>()
.map(|mems| {
mems.into_iter()
.filter(|m| m.namespace.starts_with(prefix))
.collect()
})
}
async fn search(
&self,
ctx: &CallerContext,
query: &str,
filter: &Filter,
) -> StoreResult<Vec<Memory>> {
let limit: i64 = filter
.limit
.clamp(1, crate::storage::LIST_MAX_LIMIT)
.try_into()
.unwrap_or(LIST_FALLBACK_LIMIT_I64);
let caller = ctx.effective_principal();
let tags_first: Option<&str> = filter.tags_any.first().map(String::as_str);
let or_tsquery = build_or_tsquery(query);
let caller_opt: Option<&str> = if ctx.bypass_visibility {
None
} else {
Some(caller)
};
let rows = sqlx::query(
"SELECT *,
ts_rank(tsv, to_tsquery('english', $1))
+ (priority * 0.5)
+ (LEAST(access_count, 50) * 0.1)
+ (confidence * 2.0)
+ CASE tier
WHEN 'long' THEN 3.0
WHEN 'mid' THEN 1.0
ELSE 0.0
END
+ (1.0 / (1.0 +
EXTRACT(EPOCH FROM (NOW() - updated_at)) / 86400.0 * 0.1))
AS rank
FROM memories
WHERE tsv @@ to_tsquery('english', $1)
AND ($2::text IS NULL OR namespace = $2)
AND ($3::text IS NULL OR tier = $3)
AND ($4::text IS NULL OR tags @> to_jsonb(ARRAY[$4]))
AND ($5::text IS NULL OR metadata ->> 'agent_id' = $5)
AND (expires_at IS NULL OR expires_at > NOW())
AND (
$7::text IS NULL
OR COALESCE(metadata->>'scope', 'private') <> 'private'
OR metadata->>'agent_id' = $7
OR metadata->>'target_agent_id' = $7
)
ORDER BY rank DESC, priority DESC
LIMIT $6",
)
.bind(&or_tsquery)
.bind(filter.namespace.as_ref())
.bind(filter.tier.as_ref().map(Tier::as_str))
.bind(tags_first)
.bind(filter.agent_id.as_ref())
.bind(limit)
.bind(caller_opt)
.fetch_all(&self.pool)
.await
.map_err(|e| to_store_err("search", e))?;
let mems: Vec<Memory> = rows
.iter()
.map(Self::row_to_memory)
.collect::<StoreResult<Vec<_>>>()?;
if ctx.bypass_visibility {
return Ok(mems);
}
Ok(mems
.into_iter()
.filter(|m| is_visible_to_caller(m, caller))
.collect())
}
async fn verify(&self, _ctx: &CallerContext, id: &str) -> StoreResult<VerifyReport> {
let mem = self.get(_ctx, id).await?;
let findings = super::integrity_findings(&mem);
Ok(VerifyReport {
memory_id: id.to_string(),
integrity_ok: findings.is_empty(),
findings,
signature_verified: false,
})
}
async fn link(&self, _ctx: &CallerContext, link: &MemoryLink) -> StoreResult<()> {
self.link_internal(link, None).await.map(|_| ())
}
async fn link_signed(
&self,
_ctx: &CallerContext,
link: &MemoryLink,
keypair: Option<&crate::identity::keypair::AgentKeypair>,
) -> StoreResult<&'static str> {
self.link_internal(link, keypair).await
}
async fn list_links(&self, namespace: Option<&str>) -> StoreResult<Vec<MemoryLink>> {
let rows = sqlx::query(
"SELECT ml.source_id, ml.target_id, ml.relation, ml.created_at,
ml.valid_from, ml.valid_until, ml.observed_by, ml.signature,
ml.attest_level
FROM memory_links ml
WHERE ($1::text IS NULL
OR EXISTS (SELECT 1 FROM memories m
WHERE m.id = ml.source_id AND m.namespace = $1))
ORDER BY ml.source_id, ml.target_id, ml.relation",
)
.bind(namespace)
.fetch_all(&self.pool)
.await
.map_err(|e| to_store_err("list_links", e))?;
rows.iter()
.map(|r| {
let created_at: DateTime<Utc> = r
.try_get::<DateTime<Utc>, _>(field_names::CREATED_AT)
.map_err(|e| to_store_err(READ_CREATED_AT, e))?;
let valid_from: Option<DateTime<Utc>> = r
.try_get::<Option<DateTime<Utc>>, _>(field_names::VALID_FROM)
.map_err(|e| to_store_err(READ_VALID_FROM, e))?;
let valid_until: Option<DateTime<Utc>> = r
.try_get::<Option<DateTime<Utc>>, _>(field_names::VALID_UNTIL)
.map_err(|e| to_store_err(READ_VALID_UNTIL, e))?;
let observed_by: Option<String> = r
.try_get::<Option<String>, _>(field_names::OBSERVED_BY)
.map_err(|e| to_store_err(READ_OBSERVED_BY, e))?;
let signature: Option<Vec<u8>> = r
.try_get::<Option<Vec<u8>>, _>("signature")
.map_err(|e| to_store_err("read signature", e))?;
let relation_str: String = r
.try_get::<String, _>("relation")
.map_err(|e| to_store_err(READ_RELATION, e))?;
let attest_level: Option<String> = r
.try_get::<Option<String>, _>(field_names::ATTEST_LEVEL)
.map_err(|e| to_store_err(READ_ATTEST_LEVEL, e))?;
Ok(MemoryLink {
source_id: r
.try_get::<String, _>("source_id")
.map_err(|e| to_store_err(READ_SOURCE_ID, e))?,
target_id: r
.try_get::<String, _>("target_id")
.map_err(|e| to_store_err(READ_TARGET_ID, e))?,
relation: crate::models::MemoryLinkRelation::from_str(&relation_str)
.unwrap_or_default(),
created_at: created_at.to_rfc3339(),
signature,
observed_by,
valid_from: valid_from.map(|t| t.to_rfc3339()),
valid_until: valid_until.map(|t| t.to_rfc3339()),
attest_level,
})
})
.collect()
}
async fn get_links_for_anchor(&self, anchor_id: &str) -> StoreResult<Vec<MemoryLink>> {
let rows = sqlx::query(
"SELECT source_id, target_id, relation, created_at,
valid_from, valid_until, observed_by, attest_level
FROM memory_links
WHERE source_id = $1 OR target_id = $1
ORDER BY created_at DESC",
)
.bind(anchor_id)
.fetch_all(&self.pool)
.await
.map_err(|e| to_store_err("get_links_for_anchor", e))?;
rows.iter()
.map(|r| {
let created_at: DateTime<Utc> = r
.try_get::<DateTime<Utc>, _>(field_names::CREATED_AT)
.map_err(|e| to_store_err(READ_CREATED_AT, e))?;
let valid_from: Option<DateTime<Utc>> = r
.try_get::<Option<DateTime<Utc>>, _>(field_names::VALID_FROM)
.map_err(|e| to_store_err(READ_VALID_FROM, e))?;
let valid_until: Option<DateTime<Utc>> = r
.try_get::<Option<DateTime<Utc>>, _>(field_names::VALID_UNTIL)
.map_err(|e| to_store_err(READ_VALID_UNTIL, e))?;
let observed_by: Option<String> = r
.try_get::<Option<String>, _>(field_names::OBSERVED_BY)
.map_err(|e| to_store_err(READ_OBSERVED_BY, e))?;
let relation_str: String = r
.try_get::<String, _>("relation")
.map_err(|e| to_store_err(READ_RELATION, e))?;
let attest_level: Option<String> = r
.try_get::<Option<String>, _>(field_names::ATTEST_LEVEL)
.map_err(|e| to_store_err(READ_ATTEST_LEVEL, e))?;
Ok(MemoryLink {
source_id: r
.try_get::<String, _>("source_id")
.map_err(|e| to_store_err(READ_SOURCE_ID, e))?,
target_id: r
.try_get::<String, _>("target_id")
.map_err(|e| to_store_err(READ_TARGET_ID, e))?,
relation: crate::models::MemoryLinkRelation::from_str(&relation_str)
.unwrap_or_default(),
created_at: created_at.to_rfc3339(),
signature: None,
observed_by,
valid_from: valid_from.map(|t| t.to_rfc3339()),
valid_until: valid_until.map(|t| t.to_rfc3339()),
attest_level,
})
})
.collect()
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
async fn list_memories_updated_since(
&self,
since: Option<&str>,
limit: usize,
) -> StoreResult<Vec<Memory>> {
let limit_i: i64 = (limit as i64).clamp(1, STORE_LIST_MAX_LIMIT_SAL);
let since_dt = match since {
None => None,
Some(s) => Some(parse_rfc3339_required(s)?),
};
let rows = match since_dt {
None => sqlx::query(
"SELECT * FROM memories \
ORDER BY updated_at ASC \
LIMIT $1",
)
.bind(limit_i)
.fetch_all(&self.pool)
.await
.map_err(|e| to_store_err("list memories updated since", e))?,
Some(dt) => sqlx::query(
"SELECT * FROM memories \
WHERE updated_at > $1 \
ORDER BY updated_at ASC \
LIMIT $2",
)
.bind(dt)
.bind(limit_i)
.fetch_all(&self.pool)
.await
.map_err(|e| to_store_err("list memories updated since", e))?,
};
rows.iter().map(Self::row_to_memory).collect()
}
async fn apply_remote_memory(
&self,
_ctx: &CallerContext,
memory: &Memory,
) -> StoreResult<String> {
consult_governance_pre_write_pg(memory)?;
let created_at = parse_rfc3339_required(&memory.created_at)?;
let updated_at = parse_rfc3339_required(&memory.updated_at)?;
let last_accessed_at = parse_rfc3339_opt(memory.last_accessed_at.as_deref());
let expires_at = parse_rfc3339_opt(memory.effective_expires_at().as_deref());
let tags_json =
serde_json::to_value(&memory.tags).map_err(|e| StoreError::IntegrityFailed {
detail: serialize_err("tags", e),
})?;
let citations_json =
serde_json::to_string(&memory.citations).map_err(|e| StoreError::IntegrityFailed {
detail: serialize_err("citations", e),
})?;
let source_span_json = match &memory.source_span {
Some(span) => {
Some(
serde_json::to_string(span).map_err(|e| StoreError::IntegrityFailed {
detail: serialize_err(COL_SOURCE_SPAN, e),
})?,
)
}
None => None,
};
let confidence_signals_json = match &memory.confidence_signals {
Some(s) => Some(
serde_json::to_string(s).map_err(|e| StoreError::IntegrityFailed {
detail: serialize_err(COL_CONFIDENCE_SIGNALS, e),
})?,
),
None => None,
};
let confidence_decayed_at = parse_rfc3339_opt(memory.confidence_decayed_at.as_deref());
let mentioned_entity_id = crate::storage::extract_mentioned_entity_id(memory);
let row = sqlx::query(
"INSERT INTO memories (
id, tier, namespace, title, content, tags, priority, confidence,
source, access_count, created_at, updated_at, last_accessed_at,
expires_at, metadata, reflection_depth, memory_kind,
citations, source_uri, source_span,
confidence_source, confidence_signals, confidence_decayed_at,
entity_id, persona_version, version,
mentioned_entity_id
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17,
$18, $19, $20, $21, $22, $23, $24, $25, $26,
$27
)
ON CONFLICT (title, namespace) DO UPDATE SET
-- #1631 — every newer-wins arm carries the sqlite
-- `insert_if_newer` equal-timestamp id tiebreak
-- (src/storage/mod.rs:7320-7321) so two peers that wrote
-- in the same millisecond converge on ONE deterministic
-- winner (total order: updated_at, then id) instead of
-- diverging per receive order.
content = CASE
WHEN EXCLUDED.updated_at > memories.updated_at
OR (EXCLUDED.updated_at = memories.updated_at
AND EXCLUDED.id > memories.id)
THEN EXCLUDED.content
ELSE memories.content
END,
-- #1631 — tier upgrades are monotone REGARDLESS of the
-- timestamp (sqlite parity): a stale peer that promoted
-- a row to long must not lose the promotion.
tier = CASE WHEN EXCLUDED.tier = 'long' THEN 'long'
WHEN memories.tier = 'long' THEN 'long'
WHEN EXCLUDED.tier = 'mid' THEN 'mid'
ELSE memories.tier END,
tags = CASE
WHEN EXCLUDED.updated_at > memories.updated_at
OR (EXCLUDED.updated_at = memories.updated_at
AND EXCLUDED.id > memories.id)
THEN EXCLUDED.tags
ELSE memories.tags
END,
-- #1631 — sqlite parity: priority + confidence MAX-merge.
priority = GREATEST(memories.priority, EXCLUDED.priority),
confidence = GREATEST(memories.confidence, EXCLUDED.confidence),
-- #1631 — sqlite parity: source rides the winning row.
source = CASE
WHEN EXCLUDED.updated_at > memories.updated_at
OR (EXCLUDED.updated_at = memories.updated_at
AND EXCLUDED.id > memories.id)
THEN EXCLUDED.source
ELSE memories.source
END,
updated_at = GREATEST(memories.updated_at, EXCLUDED.updated_at),
-- #1631 — sqlite parity: access_count converges on MAX.
access_count = GREATEST(memories.access_count, EXCLUDED.access_count),
-- #1631 — sqlite parity: long-tier pins expiry to NULL;
-- otherwise a peer push that omits expiry keeps the local
-- one rather than blanking it out.
expires_at = CASE
WHEN EXCLUDED.tier = 'long' OR memories.tier = 'long' THEN NULL
ELSE COALESCE(EXCLUDED.expires_at, memories.expires_at)
END,
metadata = CASE
WHEN EXCLUDED.updated_at > memories.updated_at
OR (EXCLUDED.updated_at = memories.updated_at
AND EXCLUDED.id > memories.id) THEN
CASE
WHEN memories.metadata ? 'agent_id'
THEN jsonb_set(
EXCLUDED.metadata,
'{agent_id}',
memories.metadata -> 'agent_id'
)
ELSE EXCLUDED.metadata
END
ELSE memories.metadata
END,
-- v0.7.0 Task 1/8 — recursion depth takes max so the reflection
-- signal isn't lost on newer-wins federation merges.
reflection_depth = GREATEST(memories.reflection_depth, EXCLUDED.reflection_depth),
-- L1-1 — kind is sticky across federation merges.
-- #1631 / QW-2 — Persona is also sticky (sqlite parity).
memory_kind = CASE WHEN memories.memory_kind = 'reflection' THEN 'reflection'
WHEN memories.memory_kind = 'persona' THEN 'persona'
ELSE EXCLUDED.memory_kind END,
-- #1029 / #1631 — Form-4 provenance + Form-5 calibration:
-- newer-wins WITH the id tiebreak; source_uri / source_span
-- follow COALESCE so a merge lacking provenance does not
-- blank out a value the local row already had (sqlite
-- parity, src/storage/mod.rs:7376-7395).
citations = CASE
WHEN EXCLUDED.updated_at > memories.updated_at
OR (EXCLUDED.updated_at = memories.updated_at
AND EXCLUDED.id > memories.id)
THEN EXCLUDED.citations
ELSE memories.citations
END,
source_uri = COALESCE(EXCLUDED.source_uri, memories.source_uri),
source_span = COALESCE(EXCLUDED.source_span, memories.source_span),
confidence_source = CASE
WHEN EXCLUDED.updated_at > memories.updated_at
OR (EXCLUDED.updated_at = memories.updated_at
AND EXCLUDED.id > memories.id)
THEN EXCLUDED.confidence_source
ELSE memories.confidence_source
END,
confidence_signals = CASE
WHEN EXCLUDED.updated_at > memories.updated_at
OR (EXCLUDED.updated_at = memories.updated_at
AND EXCLUDED.id > memories.id)
THEN EXCLUDED.confidence_signals
ELSE memories.confidence_signals
END,
confidence_decayed_at = CASE
WHEN EXCLUDED.updated_at > memories.updated_at
OR (EXCLUDED.updated_at = memories.updated_at
AND EXCLUDED.id > memories.id)
THEN EXCLUDED.confidence_decayed_at
ELSE memories.confidence_decayed_at
END,
-- #1631 / QW-2 — entity_id + persona_version are immutable
-- once set (sqlite COALESCE order: old wins) so a federation
-- merge can't drop the persona discriminator off a
-- `memory_kind = 'persona'` row.
entity_id = COALESCE(memories.entity_id, EXCLUDED.entity_id),
persona_version = COALESCE(memories.persona_version, EXCLUDED.persona_version),
-- version IS replicated state (#1029 contract, decide-once):
-- advance to MAX so an out-of-order federation push doesn't
-- roll a peer back to a stale version.
version = GREATEST(memories.version, EXCLUDED.version),
-- #1383 — newer-wins for the denormalised attribution
-- column. Matches the metadata / citations / etc.
-- merge shape above (a newer remote write that drops
-- the attribution shouldn't clobber the local one;
-- a newer remote write that ADDS one replaces it).
mentioned_entity_id = CASE
WHEN EXCLUDED.updated_at > memories.updated_at
OR (EXCLUDED.updated_at = memories.updated_at
AND EXCLUDED.id > memories.id)
THEN COALESCE(EXCLUDED.mentioned_entity_id, memories.mentioned_entity_id)
ELSE memories.mentioned_entity_id
END
RETURNING id",
)
.bind(&memory.id)
.bind(memory.tier.as_str())
.bind(&memory.namespace)
.bind(&memory.title)
.bind(&memory.content)
.bind(&tags_json)
.bind(memory.priority)
.bind(memory.confidence)
.bind(&memory.source)
.bind(memory.access_count)
.bind(created_at)
.bind(updated_at)
.bind(last_accessed_at)
.bind(expires_at)
.bind(&memory.metadata)
.bind(memory.reflection_depth)
.bind(memory.memory_kind.as_str())
.bind(&citations_json)
.bind(memory.source_uri.as_ref())
.bind(source_span_json.as_deref())
.bind(memory.confidence_source.as_str())
.bind(confidence_signals_json.as_deref())
.bind(confidence_decayed_at)
.bind(memory.entity_id.as_ref())
.bind(memory.persona_version)
.bind(memory.version)
.bind(mentioned_entity_id.as_deref())
.fetch_one(&self.pool)
.await
.map_err(|e| to_store_err("apply_remote_memory upsert", e))?;
row.try_get::<String, _>("id")
.map_err(|e| to_store_err(READ_RETURNED_ID, e))
}
async fn apply_remote_link(
&self,
_ctx: &CallerContext,
link: &MemoryLink,
attest_level: &str,
) -> StoreResult<()> {
let created_at = parse_rfc3339_required(&link.created_at)?;
let valid_from = parse_rfc3339_opt(link.valid_from.as_deref());
let valid_until = parse_rfc3339_opt(link.valid_until.as_deref());
let mut tx = self
.pool
.begin()
.await
.map_err(|e| to_store_err("begin apply_remote_link tx", e))?;
sqlx::query(
"INSERT INTO memory_links (
source_id, target_id, relation, created_at,
valid_from, valid_until, observed_by, signature, attest_level
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
ON CONFLICT (source_id, target_id, relation) DO NOTHING",
)
.bind(&link.source_id)
.bind(&link.target_id)
.bind(link.relation.as_str())
.bind(created_at)
.bind(valid_from)
.bind(valid_until)
.bind(link.observed_by.as_ref())
.bind(link.signature.as_ref())
.bind(attest_level)
.execute(&mut *tx)
.await
.map_err(|e| to_store_err("apply_remote_link", e))?;
if matches!(self.kg_backend, KgBackend::Age) {
sqlx::query("SAVEPOINT age_link_projection")
.execute(&mut *tx)
.await
.map_err(|e| to_store_err("savepoint age_link_projection", e))?;
match project_link_into_age(
&mut tx,
&link.source_id,
&link.target_id,
link.relation.as_str(),
)
.await
{
Ok(()) => {
sqlx::query("RELEASE SAVEPOINT age_link_projection")
.execute(&mut *tx)
.await
.map_err(|e| to_store_err("release savepoint age_link_projection", e))?;
}
Err(e) if is_age_runtime_failure(&e) => {
sqlx::query("ROLLBACK TO SAVEPOINT age_link_projection")
.execute(&mut *tx)
.await
.map_err(|e2| to_store_err("rollback savepoint age_link_projection", e2))?;
warn_age_fallback("apply_remote_link", &link.source_id, &e);
}
Err(e) => return Err(e),
}
}
tx.commit()
.await
.map_err(|e| to_store_err("commit apply_remote_link tx", e))?;
Ok(())
}
async fn apply_remote_deletion(&self, _ctx: &CallerContext, id: &str) -> StoreResult<bool> {
let rows_affected = sqlx::query(SQL_DELETE_MEMORY_BY_ID)
.bind(id)
.execute(&self.pool)
.await
.map_err(|e| to_store_err("apply_remote_deletion", e))?
.rows_affected();
Ok(rows_affected > 0)
}
async fn recall_hybrid(
&self,
ctx: &CallerContext,
query: &str,
query_embedding: Option<&[f32]>,
filter: &Filter,
) -> StoreResult<Vec<(Memory, f64)>> {
let limit_eff: i64 = if filter.limit == 0 {
RECALL_FALLBACK_LIMIT_I64
} else {
i64::try_from(filter.limit.min(crate::storage::LIST_MAX_LIMIT))
.unwrap_or(RECALL_FALLBACK_LIMIT_I64)
};
let fts_pool: i64 = (limit_eff * 3).max(30);
let tags_first: Option<&str> = filter.tags_any.first().map(String::as_str);
let or_tsquery = build_or_tsquery(query);
let caller = ctx.effective_principal();
let caller_opt: Option<&str> = if ctx.bypass_visibility {
None
} else {
Some(caller)
};
let fts_rows = sqlx::query(
"SELECT *,
ts_rank(tsv, to_tsquery('english', $1))
+ (priority * 0.5)
+ (LEAST(access_count, 50) * 0.1)
+ (confidence * 2.0)
+ CASE tier
WHEN 'long' THEN 3.0
WHEN 'mid' THEN 1.0
ELSE 0.0
END
+ (1.0 / (1.0 +
EXTRACT(EPOCH FROM (NOW() - updated_at)) / 86400.0 * 0.1))
AS fts_score,
octet_length(content) AS content_len
FROM memories
WHERE tsv @@ to_tsquery('english', $1)
AND ($2::text IS NULL OR namespace = $2)
AND ($3::text IS NULL OR tier = $3)
AND ($4::text IS NULL OR tags @> to_jsonb(ARRAY[$4]))
AND ($5::text IS NULL OR metadata ->> 'agent_id' = $5)
AND ($6::timestamptz IS NULL OR created_at >= $6)
AND ($7::timestamptz IS NULL OR created_at <= $7)
AND (expires_at IS NULL OR expires_at > NOW())
AND (
$9::text IS NULL
OR COALESCE(metadata->>'scope', 'private') <> 'private'
OR metadata->>'agent_id' = $9
OR metadata->>'target_agent_id' = $9
)
ORDER BY fts_score DESC
LIMIT $8",
)
.bind(&or_tsquery)
.bind(filter.namespace.as_ref())
.bind(filter.tier.as_ref().map(Tier::as_str))
.bind(tags_first)
.bind(filter.agent_id.as_ref().or(ctx.as_agent.as_ref()))
.bind(filter.since)
.bind(filter.until)
.bind(fts_pool)
.bind(caller_opt)
.fetch_all(&self.pool)
.await
.map_err(|e| to_store_err("recall_hybrid fts pool", e))?;
let mut max_fts: f64 = 1.0;
let mut scored: std::collections::HashMap<String, (Memory, f64, f64, i64)> =
std::collections::HashMap::new();
for r in &fts_rows {
let mem = Self::row_to_memory(r)?;
let fts_score: f64 = r.try_get("fts_score").unwrap_or(0.0);
let content_len: i64 = r.try_get::<i32, _>(COL_CONTENT_LEN).map_or_else(
|_| {
r.try_get::<i64, _>(COL_CONTENT_LEN)
.unwrap_or_else(|_| i64::try_from(mem.content.len()).unwrap_or(0))
},
i64::from,
);
if fts_score > max_fts {
max_fts = fts_score;
}
scored.insert(mem.id.clone(), (mem, fts_score, 0.0, content_len));
}
if let Some(qe) = query_embedding {
let ann_pool: i64 = (limit_eff * 5).max(50);
let qvec = pgvector::Vector::from(qe.to_vec());
let sem_rows = sqlx::query(
"SELECT *, (1.0 - (embedding <=> $1)) AS cosine_sim,
octet_length(content) AS content_len
FROM memories
WHERE embedding IS NOT NULL
AND ($2::text IS NULL OR namespace = $2)
AND ($3::text IS NULL OR tier = $3)
AND ($4::text IS NULL OR tags @> to_jsonb(ARRAY[$4]))
AND ($5::text IS NULL OR metadata ->> 'agent_id' = $5)
AND ($6::timestamptz IS NULL OR created_at >= $6)
AND ($7::timestamptz IS NULL OR created_at <= $7)
AND (expires_at IS NULL OR expires_at > NOW())
AND (1.0 - (embedding <=> $1)) > 0.2
AND (
$9::text IS NULL
OR COALESCE(metadata->>'scope', 'private') <> 'private'
OR metadata->>'agent_id' = $9
)
ORDER BY embedding <=> $1
LIMIT $8",
)
.bind(&qvec)
.bind(filter.namespace.as_ref())
.bind(filter.tier.as_ref().map(Tier::as_str))
.bind(tags_first)
.bind(filter.agent_id.as_ref().or(ctx.as_agent.as_ref()))
.bind(filter.since)
.bind(filter.until)
.bind(ann_pool)
.bind(caller_opt)
.fetch_all(&self.pool)
.await
.map_err(|e| to_store_err("recall_hybrid semantic pool", e))?;
for r in &sem_rows {
let mem = Self::row_to_memory(r)?;
let cosine: f64 = r.try_get("cosine_sim").unwrap_or(0.0);
let content_len: i64 = r.try_get::<i32, _>(COL_CONTENT_LEN).map_or_else(
|_| {
r.try_get::<i64, _>(COL_CONTENT_LEN)
.unwrap_or_else(|_| i64::try_from(mem.content.len()).unwrap_or(0))
},
i64::from,
);
scored
.entry(mem.id.clone())
.and_modify(|entry| {
if cosine > entry.2 {
entry.2 = cosine;
}
})
.or_insert((mem, 0.0, cosine, content_len));
}
}
let mut results: Vec<(Memory, f64)> = scored
.into_values()
.map(|(mem, fts_score, cosine, content_len)| {
let norm_fts = if max_fts > 0.0 {
fts_score / max_fts
} else {
0.0
};
#[allow(clippy::cast_precision_loss)]
let cl = content_len as f64;
let semantic_weight = if cl <= 500.0 {
0.50
} else if cl >= 5000.0 {
0.15
} else {
0.50 - 0.35 * ((cl - 500.0) / 4500.0)
};
let blended = semantic_weight * cosine + (1.0 - semantic_weight) * norm_fts;
(mem, blended)
})
.collect();
results.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
if !ctx.bypass_visibility {
results.retain(|(m, _)| is_visible_to_caller(m, caller));
}
results.truncate(filter.limit.max(1));
Ok(results)
}
async fn pending_decide(
&self,
_ctx: &CallerContext,
id: &str,
approve: bool,
decided_by: &str,
) -> StoreResult<bool> {
let new_status = if approve { "approved" } else { "rejected" };
let rows_affected = sqlx::query(
"UPDATE pending_actions SET status = $1, decided_by = $2, decided_at = NOW()
WHERE id = $3 AND status = 'pending'",
)
.bind(new_status)
.bind(decided_by)
.bind(id)
.execute(&self.pool)
.await
.map_err(|e| to_store_err("pending_decide", e))?
.rows_affected();
Ok(rows_affected > 0)
}
async fn get_pending(
&self,
_ctx: &CallerContext,
id: &str,
) -> StoreResult<Option<crate::models::PendingAction>> {
let row = sqlx::query(
"SELECT id, action_type, memory_id, namespace, payload, requested_by,
requested_at, status, decided_by, decided_at, approvals
FROM pending_actions WHERE id = $1",
)
.bind(id)
.fetch_optional(&self.pool)
.await
.map_err(|e| to_store_err("get_pending", e))?;
let Some(r) = row else {
return Ok(None);
};
let requested_at: DateTime<Utc> = r
.try_get(field_names::REQUESTED_AT)
.map_err(|e| to_store_err("read requested_at", e))?;
let decided_at: Option<DateTime<Utc>> = r
.try_get(field_names::DECIDED_AT)
.map_err(|e| to_store_err("read decided_at", e))?;
let approvals_v: serde_json::Value = r
.try_get("approvals")
.unwrap_or(serde_json::Value::Array(vec![]));
let approvals: Vec<crate::models::Approval> =
serde_json::from_value(approvals_v).unwrap_or_default();
Ok(Some(crate::models::PendingAction {
id: r
.try_get::<String, _>("id")
.map_err(|e| to_store_err("read id", e))?,
action_type: r
.try_get::<String, _>(field_names::ACTION_TYPE)
.map_err(|e| to_store_err("read action_type", e))?,
memory_id: r.try_get::<Option<String>, _>("memory_id").unwrap_or(None),
namespace: r
.try_get::<String, _>("namespace")
.map_err(|e| to_store_err(READ_NAMESPACE, e))?,
payload: r
.try_get::<serde_json::Value, _>("payload")
.unwrap_or(serde_json::Value::Null),
requested_by: r
.try_get::<String, _>(field_names::REQUESTED_BY)
.map_err(|e| to_store_err("read requested_by", e))?,
requested_at: requested_at.to_rfc3339(),
status: r
.try_get::<String, _>("status")
.map_err(|e| to_store_err("read status", e))?,
decided_by: r
.try_get::<Option<String>, _>(field_names::DECIDED_BY)
.unwrap_or(None),
decided_at: decided_at.map(|d| d.to_rfc3339()),
approvals,
}))
}
async fn set_namespace_standard(
&self,
_ctx: &CallerContext,
namespace: &str,
standard_id: &str,
parent: Option<&str>,
) -> StoreResult<()> {
let exists: Option<(String,)> = sqlx::query_as(SQL_SELECT_MEMORY_ID_BY_ID)
.bind(standard_id)
.fetch_optional(&self.pool)
.await
.map_err(|e| to_store_err("set_namespace_standard verify memory", e))?;
if exists.is_none() {
return Err(StoreError::NotFound {
id: standard_id.to_string(),
});
}
if parent.is_some_and(|p| p == namespace) {
return Err(StoreError::InvalidInput {
detail: "namespace cannot be its own parent".to_string(),
});
}
sqlx::query(
"INSERT INTO namespace_meta (namespace, standard_id, updated_at, parent_namespace)
VALUES ($1, $2, NOW(), $3)
ON CONFLICT (namespace) DO UPDATE
SET standard_id = EXCLUDED.standard_id,
updated_at = EXCLUDED.updated_at,
parent_namespace = EXCLUDED.parent_namespace",
)
.bind(namespace)
.bind(standard_id)
.bind(parent)
.execute(&self.pool)
.await
.map_err(|e| to_store_err("set_namespace_standard", e))?;
Ok(())
}
async fn clear_namespace_standard(
&self,
_ctx: &CallerContext,
namespace: &str,
) -> StoreResult<bool> {
let rows_affected = sqlx::query("DELETE FROM namespace_meta WHERE namespace = $1")
.bind(namespace)
.execute(&self.pool)
.await
.map_err(|e| to_store_err("clear_namespace_standard", e))?
.rows_affected();
Ok(rows_affected > 0)
}
async fn get_namespace_standard(
&self,
_ctx: &CallerContext,
namespace: &str,
) -> StoreResult<Option<(String, Option<String>)>> {
let row: Option<(String, Option<String>)> = sqlx::query_as(
"SELECT standard_id, parent_namespace FROM namespace_meta WHERE namespace = $1",
)
.bind(namespace)
.fetch_optional(&self.pool)
.await
.map_err(|e| to_store_err("get_namespace_standard", e))?;
Ok(row)
}
async fn touch_after_recall(&self, ids: &[String]) -> StoreResult<()> {
if ids.is_empty() {
return Ok(());
}
sqlx::query(
"UPDATE memories SET
access_count = LEAST(access_count + 1, 1000000),
last_accessed_at = NOW(),
expires_at = CASE
WHEN tier = 'mid' AND access_count + 1 >= 5 THEN NULL
WHEN tier = 'long' THEN expires_at
WHEN tier = 'short' AND expires_at IS NOT NULL
THEN GREATEST(expires_at, NOW() + INTERVAL '1 hour')
WHEN tier = 'mid' AND expires_at IS NOT NULL
THEN GREATEST(expires_at, NOW() + INTERVAL '1 day')
ELSE expires_at
END,
tier = CASE
WHEN tier = 'mid' AND access_count + 1 >= 5 THEN 'long'
ELSE tier
END,
updated_at = CASE
WHEN tier = 'mid' AND access_count + 1 >= 5 THEN NOW()
ELSE updated_at
END,
priority = CASE
WHEN LEAST(access_count + 1, 1000000) > 0
AND LEAST(access_count + 1, 1000000) % 10 = 0
AND priority < 10
THEN LEAST(priority + 1, 10)
ELSE priority
END
WHERE id = ANY($1)",
)
.bind(ids)
.execute(&self.pool)
.await
.map_err(|e| to_store_err("touch_after_recall", e))?;
if crate::confidence::decay::decay_enabled() {
#[allow(clippy::cast_precision_loss)]
let secs_per_day = crate::SECS_PER_DAY as f64;
let now_stamp = Utc::now().to_rfc3339();
let source = crate::models::ConfidenceSource::Decayed.as_str();
let result = if let Some(overrides) =
crate::confidence::decay::namespace_half_life_overrides()
{
let mut qb = sqlx::QueryBuilder::new(
"UPDATE memories SET confidence = LEAST(GREATEST(confidence * POWER(2.0, -(\
GREATEST(EXTRACT(EPOCH FROM (NOW() - COALESCE(confidence_decayed_at::timestamptz, \
created_at))), 0.0) / ",
);
qb.push_bind(secs_per_day);
qb.push(") / (CASE namespace");
for (ns, hl) in overrides {
qb.push(" WHEN ");
qb.push_bind(ns.clone());
qb.push(" THEN ");
qb.push_bind(*hl);
}
qb.push(" ELSE ");
qb.push_bind(crate::confidence::DEFAULT_HALF_LIFE_DAYS);
qb.push(" END))), 0.0), 1.0), confidence_source = ");
qb.push_bind(source);
qb.push(", confidence_decayed_at = ");
qb.push_bind(now_stamp.clone());
qb.push(" WHERE id = ANY(");
qb.push_bind(ids);
qb.push(")");
qb.build().execute(&self.pool).await
} else {
sqlx::query(
"UPDATE memories SET
confidence = LEAST(GREATEST(
confidence * POWER(2.0, -(
GREATEST(EXTRACT(EPOCH FROM
(NOW() - COALESCE(confidence_decayed_at::timestamptz,
created_at))), 0.0)
/ $2) / $3),
0.0), 1.0),
confidence_source = $4,
confidence_decayed_at = $5
WHERE id = ANY($1)",
)
.bind(ids)
.bind(secs_per_day)
.bind(crate::confidence::DEFAULT_HALF_LIFE_DAYS)
.bind(source)
.bind(now_stamp)
.execute(&self.pool)
.await
};
if let Err(e) = result {
tracing::warn!(
target: TRACE_TARGET,
"confidence decay touch failed for {} memories: {e}",
ids.len()
);
}
}
Ok(())
}
async fn register_agent(
&self,
ctx: &CallerContext,
agent: &AgentRegistration,
) -> StoreResult<()> {
use crate::models::AGENTS_NAMESPACE;
let title = crate::models::agent_registration_title(&agent.agent_id);
let now_rfc = Utc::now().to_rfc3339();
let existing: Option<(serde_json::Value,)> =
sqlx::query_as(SQL_SELECT_METADATA_BY_NS_TITLE)
.bind(AGENTS_NAMESPACE)
.bind(&title)
.fetch_optional(&self.pool)
.await
.map_err(|e| to_store_err("read existing agent metadata", e))?;
let registered_at = existing
.as_ref()
.and_then(|(m,)| m.get(field_names::REGISTERED_AT))
.and_then(serde_json::Value::as_str)
.map_or_else(|| now_rfc.clone(), str::to_string);
let metadata = serde_json::json!({
"agent_id": agent.agent_id,
(field_names::AGENT_TYPE): agent.agent_type,
(field_names::CAPABILITIES): agent.capabilities,
(field_names::REGISTERED_AT): registered_at,
(field_names::LAST_SEEN_AT): now_rfc,
"scope": crate::models::MemoryScope::Collective.as_str(),
});
let content =
serde_json::to_string(&metadata).map_err(|e| StoreError::IntegrityFailed {
detail: format!("serialize agent registration: {e}"),
})?;
let mem = Memory {
id: uuid::Uuid::new_v4().to_string(),
tier: Tier::Long,
namespace: AGENTS_NAMESPACE.to_string(),
title,
content,
tags: vec!["agent-registration".to_string()],
priority: 5,
confidence: 1.0,
source: "system".to_string(),
access_count: 0,
created_at: now_rfc.clone(),
updated_at: now_rfc,
last_accessed_at: None,
expires_at: None,
metadata,
reflection_depth: 0,
memory_kind: crate::models::MemoryKind::Observation,
entity_id: None,
persona_version: None,
citations: Vec::new(),
source_uri: None,
source_span: None,
confidence_source: ConfidenceSource::CallerProvided,
confidence_signals: None,
confidence_decayed_at: None,
version: 1,
};
self.store(ctx, &mem).await.map(|_| ())
}
async fn bind_agent_pubkey(
&self,
_ctx: &CallerContext,
agent_id: &str,
pubkey_b64: &str,
) -> StoreResult<()> {
use crate::models::AGENTS_NAMESPACE;
let title = crate::models::agent_registration_title(agent_id);
let now = Utc::now().to_rfc3339();
let existing: Option<(serde_json::Value,)> =
sqlx::query_as(SQL_SELECT_METADATA_BY_NS_TITLE)
.bind(AGENTS_NAMESPACE)
.bind(&title)
.fetch_optional(&self.pool)
.await
.map_err(|e| to_store_err("read agent metadata for pubkey bind", e))?;
let Some((mut meta,)) = existing else {
return Err(StoreError::InvalidInput {
detail: format!(
"cannot bind pubkey: agent '{agent_id}' is not registered (register it first)"
),
});
};
if let Some(obj) = meta.as_object_mut() {
obj.insert(
field_names::AGENT_PUBKEY.to_string(),
serde_json::Value::String(pubkey_b64.to_string()),
);
obj.insert(
"pubkey_bound_at".to_string(),
serde_json::Value::String(now.clone()),
);
}
let content = serde_json::to_string(&meta).map_err(|e| StoreError::IntegrityFailed {
detail: format!("serialize agent metadata for pubkey bind: {e}"),
})?;
sqlx::query(
"UPDATE memories SET metadata = $3, content = $4, updated_at = $5::timestamptz
WHERE namespace = $1 AND title = $2",
)
.bind(AGENTS_NAMESPACE)
.bind(&title)
.bind(&meta)
.bind(&content)
.bind(&now)
.execute(&self.pool)
.await
.map_err(|e| to_store_err(crate::handlers::BIND_AGENT_PUBKEY_ACTION, e))?;
Ok(())
}
async fn agent_pubkey(&self, agent_id: &str) -> StoreResult<Option<String>> {
use crate::models::AGENTS_NAMESPACE;
let title = crate::models::agent_registration_title(agent_id);
let row: Option<(Option<String>,)> = sqlx::query_as(
"SELECT metadata->>'agent_pubkey' FROM memories
WHERE namespace = $1 AND title = $2",
)
.bind(AGENTS_NAMESPACE)
.bind(&title)
.fetch_optional(&self.pool)
.await
.map_err(|e| to_store_err(field_names::AGENT_PUBKEY, e))?;
Ok(row.and_then(|(pk,)| pk))
}
async fn revoke_agent_pubkey(&self, _ctx: &CallerContext, agent_id: &str) -> StoreResult<()> {
use crate::models::AGENTS_NAMESPACE;
let title = crate::models::agent_registration_title(agent_id);
let now = Utc::now().to_rfc3339();
let existing: Option<(serde_json::Value,)> =
sqlx::query_as(SQL_SELECT_METADATA_BY_NS_TITLE)
.bind(AGENTS_NAMESPACE)
.bind(&title)
.fetch_optional(&self.pool)
.await
.map_err(|e| to_store_err("read agent metadata for pubkey revoke", e))?;
let Some((mut meta,)) = existing else {
return Err(StoreError::InvalidInput {
detail: format!(
"cannot revoke pubkey: agent '{agent_id}' is not registered (register it first)"
),
});
};
if let Some(obj) = meta.as_object_mut() {
obj.remove(field_names::AGENT_PUBKEY);
obj.remove("pubkey_bound_at");
obj.insert(
"pubkey_revoked_at".to_string(),
serde_json::Value::String(now.clone()),
);
}
let content = serde_json::to_string(&meta).map_err(|e| StoreError::IntegrityFailed {
detail: format!("serialize agent metadata for pubkey revoke: {e}"),
})?;
sqlx::query(
"UPDATE memories SET metadata = $3, content = $4, updated_at = $5::timestamptz
WHERE namespace = $1 AND title = $2",
)
.bind(AGENTS_NAMESPACE)
.bind(&title)
.bind(&meta)
.bind(&content)
.bind(&now)
.execute(&self.pool)
.await
.map_err(|e| to_store_err("revoke_agent_pubkey", e))?;
Ok(())
}
async fn forget(
&self,
_ctx: &CallerContext,
namespace: Option<&str>,
pattern: Option<&str>,
tier: Option<&Tier>,
archive: bool,
) -> StoreResult<usize> {
if namespace.is_none() && pattern.is_none() && tier.is_none() {
return Err(StoreError::InvalidInput {
detail: crate::errors::msg::FORGET_FILTER_REQUIRED.to_string(),
});
}
let tier_str = tier.map(|t| t.as_str().to_string());
let pattern_like = pattern.map(|p| format!("%{p}%"));
let now = chrono::Utc::now().to_rfc3339();
if archive {
sqlx::query(
"INSERT INTO archived_memories (
id, tier, namespace, title, content, tags, priority, confidence,
source, access_count, created_at, updated_at, last_accessed_at,
expires_at, archived_at, archive_reason, metadata,
embedding, embedding_dim, original_tier, original_expires_at,
-- #1025 (CRITICAL, 2026-05-21) — full v0.7.0 column carry.
reflection_depth, atomised_into, atom_of, memory_kind,
entity_id, persona_version, citations, source_uri, source_span,
confidence_source, confidence_signals, confidence_decayed_at,
mentioned_entity_id, version
)
SELECT id, tier, namespace, title, content, tags, priority, confidence,
source, access_count, created_at, updated_at, last_accessed_at,
expires_at, $4::timestamptz, 'forget', metadata,
embedding, embedding_dim, tier, expires_at,
reflection_depth, atomised_into, atom_of, memory_kind,
entity_id, persona_version, citations, source_uri, source_span,
confidence_source, confidence_signals, confidence_decayed_at,
mentioned_entity_id, version
FROM memories
WHERE ($1::text IS NULL OR namespace = $1)
AND ($2::text IS NULL OR tier = $2)
AND ($3::text IS NULL
OR title ILIKE $3
OR content ILIKE $3)
ON CONFLICT (id) DO UPDATE SET
archived_at = EXCLUDED.archived_at,
archive_reason = EXCLUDED.archive_reason",
)
.bind(namespace)
.bind(tier_str.as_deref())
.bind(pattern_like.as_deref())
.bind(parse_rfc3339_required(&now)?)
.execute(&self.pool)
.await
.map_err(|e| to_store_err("forget archive copy", e))?;
}
let res = sqlx::query(
"DELETE FROM memories
WHERE ($1::text IS NULL OR namespace = $1)
AND ($2::text IS NULL OR tier = $2)
AND ($3::text IS NULL
OR title ILIKE $3
OR content ILIKE $3)",
)
.bind(namespace)
.bind(tier_str.as_deref())
.bind(pattern_like.as_deref())
.execute(&self.pool)
.await
.map_err(|e| to_store_err("forget delete", e))?;
Ok(usize::try_from(res.rows_affected()).unwrap_or(0))
}
async fn consolidate(
&self,
_ctx: &CallerContext,
ids: &[String],
title: &str,
summary: &str,
namespace: &str,
tier: &Tier,
source: &str,
consolidator_agent_id: &str,
) -> StoreResult<String> {
if ids.is_empty() {
return Err(StoreError::InvalidInput {
detail: "consolidate requires at least one source id".to_string(),
});
}
let mut tx = self
.pool
.begin()
.await
.map_err(|e| to_store_err("begin consolidate tx", e))?;
let mut max_priority: i32 = 5;
let mut all_tags: Vec<String> = Vec::new();
let mut total_access: i64 = 0;
let mut merged_metadata = serde_json::Map::new();
let mut source_agent_ids: Vec<String> = Vec::new();
for id in ids {
use sqlx::Row;
let row = sqlx::query(
"SELECT tags, priority, access_count, metadata FROM memories WHERE id = $1",
)
.bind(id)
.fetch_optional(&mut *tx)
.await
.map_err(|e| to_store_err("consolidate fetch source", e))?;
let Some(row) = row else {
return Err(StoreError::NotFound { id: id.clone() });
};
let priority: i32 = row.try_get("priority").unwrap_or(5);
max_priority = max_priority.max(priority);
let access_count: i64 = row.try_get(field_names::ACCESS_COUNT).unwrap_or(0);
total_access = total_access.saturating_add(access_count);
let tags_json: serde_json::Value = row.try_get("tags").unwrap_or(serde_json::json!([]));
if let Some(arr) = tags_json.as_array() {
for t in arr {
if let Some(s) = t.as_str() {
all_tags.push(s.to_string());
}
}
}
let metadata: serde_json::Value =
row.try_get("metadata").unwrap_or(serde_json::json!({}));
if let serde_json::Value::Object(map) = metadata {
for (k, v) in map {
if k == "agent_id" {
if let serde_json::Value::String(aid) = &v
&& !source_agent_ids.contains(aid)
{
source_agent_ids.push(aid.clone());
}
continue;
}
merged_metadata.insert(k, v);
}
}
}
all_tags.sort();
all_tags.dedup();
merged_metadata.insert(
crate::models::MemoryLinkRelation::DerivedFrom
.as_str()
.to_string(),
serde_json::Value::Array(
ids.iter()
.map(|id| serde_json::Value::String(id.clone()))
.collect(),
),
);
merged_metadata.insert(
"agent_id".to_string(),
serde_json::Value::String(consolidator_agent_id.to_string()),
);
if !source_agent_ids.is_empty() {
merged_metadata.insert(
"consolidated_from_agents".to_string(),
serde_json::Value::Array(
source_agent_ids
.into_iter()
.map(serde_json::Value::String)
.collect(),
),
);
}
let merged_metadata_value = serde_json::Value::Object(merged_metadata);
let new_id = uuid::Uuid::new_v4().to_string();
let now = chrono::Utc::now();
let tags_value =
serde_json::to_value(&all_tags).map_err(|e| StoreError::IntegrityFailed {
detail: format!("serialize consolidated tags: {e}"),
})?;
let now_rfc = now.to_rfc3339();
let candidate = Memory {
id: new_id.clone(),
tier: tier.clone(),
namespace: namespace.to_string(),
title: title.to_string(),
content: summary.to_string(),
tags: all_tags.clone(),
priority: max_priority,
confidence: 1.0,
source: source.to_string(),
access_count: total_access,
created_at: now_rfc.clone(),
updated_at: now_rfc,
last_accessed_at: None,
expires_at: None,
metadata: merged_metadata_value.clone(),
reflection_depth: 0,
memory_kind: crate::models::MemoryKind::Observation,
entity_id: None,
persona_version: None,
citations: Vec::new(),
source_uri: None,
source_span: None,
confidence_source: crate::models::ConfidenceSource::CuratorDerived,
confidence_signals: None,
confidence_decayed_at: None,
version: crate::models::default_memory_version(),
};
consult_governance_pre_write_pg(&candidate)?;
let inserted_id: String = sqlx::query_scalar(
"INSERT INTO memories (
id, tier, namespace, title, content, tags, priority, confidence,
source, access_count, created_at, updated_at, expires_at, metadata,
confidence_source
) VALUES ($1, $2, $3, $4, $5, $6, $7, 1.0, $8, $9, $10, $10, $11, $12, $13)
ON CONFLICT (title, namespace) DO UPDATE SET
tier = CASE
WHEN tier_rank(EXCLUDED.tier) >= tier_rank(memories.tier)
THEN EXCLUDED.tier
ELSE memories.tier
END,
content = EXCLUDED.content,
tags = EXCLUDED.tags,
priority = EXCLUDED.priority,
confidence = EXCLUDED.confidence,
source = EXCLUDED.source,
access_count = EXCLUDED.access_count,
updated_at = EXCLUDED.updated_at,
metadata = CASE
WHEN memories.metadata ? 'agent_id'
THEN jsonb_set(
EXCLUDED.metadata,
'{agent_id}',
memories.metadata -> 'agent_id'
)
ELSE EXCLUDED.metadata
END
-- reflection_depth intentionally not surfaced here: the
-- consolidate path mints a fresh memory and the DB column
-- DEFAULT 0 applies. The UPSERT branch preserves the
-- existing row's reflection_depth (no SET clause = keep).
RETURNING id",
)
.bind(&new_id)
.bind(tier.as_str())
.bind(namespace)
.bind(title)
.bind(summary)
.bind(&tags_value)
.bind(max_priority)
.bind(source)
.bind(total_access)
.bind(now)
.bind(parse_rfc3339_opt(
candidate.effective_expires_at().as_deref(),
))
.bind(&merged_metadata_value)
.bind(candidate.confidence_source.as_str())
.fetch_one(&mut *tx)
.await
.map_err(|e| to_store_err("consolidate upsert", e))?;
let new_id = inserted_id;
for id in ids {
if id == &new_id {
continue;
}
sqlx::query(SQL_DELETE_MEMORY_BY_ID)
.bind(id)
.execute(&mut *tx)
.await
.map_err(|e| to_store_err("consolidate delete source", e))?;
}
tx.commit()
.await
.map_err(|e| to_store_err("consolidate commit", e))?;
Ok(new_id)
}
async fn reflect(
&self,
ctx: &CallerContext,
input: &crate::storage::reflect::ReflectInput,
signing_key: Option<&crate::identity::keypair::AgentKeypair>,
) -> Result<crate::storage::reflect::ReflectOutcome, crate::storage::reflect::ReflectError>
{
let mut hooks = crate::db::ReflectHooks::empty();
hooks.active_keypair = signing_key;
self.reflect_with_hooks(ctx, input, &hooks).await
}
async fn get_reflection_origin(
&self,
id: &str,
) -> StoreResult<Option<crate::federation::reflection_bookkeeping::ReflectionOrigin>> {
let row = sqlx::query("SELECT * FROM memories WHERE id = $1")
.bind(id)
.fetch_optional(&self.pool)
.await
.map_err(|e| to_store_err("select for reflection_origin", e))?;
match row {
Some(r) => {
let mem = Self::row_to_memory(&r)?;
Ok(Some(
crate::federation::reflection_bookkeeping::reflection_origin_from_memory(&mem),
))
}
None => Ok(None),
}
}
async fn list_recall_observations(
&self,
recall_id: Option<&str>,
consumed: Option<bool>,
since: Option<&str>,
until: Option<&str>,
limit: usize,
) -> StoreResult<Vec<crate::observations::Observation>> {
use sqlx::Row;
let mut qb: sqlx::QueryBuilder<sqlx::Postgres> = sqlx::QueryBuilder::new(
"SELECT recall_id, memory_id, retriever, rank, score, consumed, \
observed_at, consumed_at, consumed_by_memory_id \
FROM recall_observations WHERE TRUE",
);
if let Some(rid) = recall_id {
qb.push(" AND recall_id = ").push_bind(rid.to_string());
}
if let Some(c) = consumed {
qb.push(" AND consumed = ").push_bind(c);
}
if let Some(s) = since {
qb.push(" AND observed_at >= ").push_bind(s.to_string());
}
if let Some(u) = until {
qb.push(" AND observed_at <= ").push_bind(u.to_string());
}
let lim_i64 = i64::try_from(limit).unwrap_or(i64::MAX);
qb.push(" ORDER BY observed_at DESC LIMIT ")
.push_bind(lim_i64);
let rows = qb
.build()
.fetch_all(&self.pool)
.await
.map_err(|e| to_store_err("list_recall_observations", e))?;
let mut out = Vec::with_capacity(rows.len());
for r in &rows {
let observed_at: chrono::DateTime<chrono::Utc> = r
.try_get("observed_at")
.map_err(|e| to_store_err("obs.observed_at", e))?;
let consumed_at: Option<chrono::DateTime<chrono::Utc>> = r
.try_get("consumed_at")
.map_err(|e| to_store_err("obs.consumed_at", e))?;
out.push(crate::observations::Observation {
recall_id: r
.try_get("recall_id")
.map_err(|e| to_store_err("obs.recall_id", e))?,
memory_id: r
.try_get("memory_id")
.map_err(|e| to_store_err("obs.memory_id", e))?,
retriever: r
.try_get("retriever")
.map_err(|e| to_store_err("obs.retriever", e))?,
rank: r.try_get("rank").map_err(|e| to_store_err("obs.rank", e))?,
score: r
.try_get("score")
.map_err(|e| to_store_err("obs.score", e))?,
consumed: r
.try_get("consumed")
.map_err(|e| to_store_err("obs.consumed", e))?,
observed_at: observed_at.to_rfc3339(),
consumed_at: consumed_at.map(|t| t.to_rfc3339()),
consumed_by_memory_id: r.try_get("consumed_by_memory_id").ok(),
});
}
Ok(out)
}
async fn run_gc(&self, archive: bool) -> StoreResult<usize> {
let now = chrono::Utc::now();
let mut tx = self
.pool
.begin()
.await
.map_err(|e| to_store_err("gc begin tx", e))?;
if archive {
sqlx::query(
"INSERT INTO archived_memories (
id, tier, namespace, title, content, tags, priority, confidence,
source, access_count, created_at, updated_at, last_accessed_at,
expires_at, archived_at, archive_reason, metadata,
embedding, embedding_dim, original_tier, original_expires_at,
-- #1025 (CRITICAL, 2026-05-21) — full v0.7.0 column carry.
reflection_depth, atomised_into, atom_of, memory_kind,
entity_id, persona_version, citations, source_uri, source_span,
confidence_source, confidence_signals, confidence_decayed_at,
mentioned_entity_id, version
)
SELECT id, tier, namespace, title, content, tags, priority, confidence,
source, access_count, created_at, updated_at, last_accessed_at,
expires_at, $1::timestamptz, 'ttl_expired', metadata,
embedding, embedding_dim, tier, expires_at,
reflection_depth, atomised_into, atom_of, memory_kind,
entity_id, persona_version, citations, source_uri, source_span,
confidence_source, confidence_signals, confidence_decayed_at,
mentioned_entity_id, version
FROM memories
WHERE expires_at IS NOT NULL AND expires_at < $1
ON CONFLICT (id) DO UPDATE SET
archived_at = EXCLUDED.archived_at,
archive_reason = EXCLUDED.archive_reason",
)
.bind(now)
.execute(&mut *tx)
.await
.map_err(|e| to_store_err("gc archive copy", e))?;
}
let res =
sqlx::query("DELETE FROM memories WHERE expires_at IS NOT NULL AND expires_at < $1")
.bind(now)
.execute(&mut *tx)
.await
.map_err(|e| to_store_err("gc delete", e))?;
tx.commit()
.await
.map_err(|e| to_store_err("gc commit", e))?;
let _ = sqlx::query(
"DELETE FROM namespace_meta \
WHERE standard_id NOT IN (SELECT id FROM memories)",
)
.execute(&self.pool)
.await;
Ok(usize::try_from(res.rows_affected()).unwrap_or(0))
}
async fn archive_restore(&self, _ctx: &CallerContext, id: &str) -> StoreResult<bool> {
let mut tx = self
.pool
.begin()
.await
.map_err(|e| to_store_err("begin archive_restore tx", e))?;
let exists: Option<(String,)> =
sqlx::query_as("SELECT id FROM archived_memories WHERE id = $1")
.bind(id)
.fetch_optional(&mut *tx)
.await
.map_err(|e| to_store_err("archive_restore lookup", e))?;
if exists.is_none() {
return Ok(false);
}
let active: Option<(String,)> = sqlx::query_as(SQL_SELECT_MEMORY_ID_BY_ID)
.bind(id)
.fetch_optional(&mut *tx)
.await
.map_err(|e| to_store_err("archive_restore active lookup", e))?;
if active.is_some() {
return Err(StoreError::Conflict { id: id.to_string() });
}
let candidate = Self::load_archived_as_memory_pg(&mut *tx, id).await?;
consult_governance_pre_write_pg(&candidate)?;
let now = chrono::Utc::now();
sqlx::query(
"INSERT INTO memories (
id, tier, namespace, title, content, tags, priority, confidence,
source, access_count, created_at, updated_at, last_accessed_at,
expires_at, metadata, embedding, embedding_dim,
reflection_depth, atomised_into, atom_of, memory_kind,
entity_id, persona_version, citations, source_uri, source_span,
confidence_source, confidence_signals, confidence_decayed_at,
mentioned_entity_id, version
)
SELECT id, COALESCE(original_tier, 'long'), namespace, title, content,
tags, priority, confidence, source, access_count, created_at,
$1::timestamptz, last_accessed_at, original_expires_at, metadata,
embedding, embedding_dim,
COALESCE(reflection_depth, 0),
atomised_into,
atom_of,
COALESCE(memory_kind, 'observation'),
entity_id, persona_version,
COALESCE(citations, '[]'),
source_uri, source_span,
COALESCE(confidence_source, 'caller_provided'),
confidence_signals, confidence_decayed_at,
mentioned_entity_id,
COALESCE(version, 1)
FROM archived_memories WHERE id = $2",
)
.bind(now)
.bind(id)
.execute(&mut *tx)
.await
.map_err(|e| to_store_err("archive_restore insert", e))?;
sqlx::query("DELETE FROM archived_memories WHERE id = $1")
.bind(id)
.execute(&mut *tx)
.await
.map_err(|e| to_store_err("archive_restore delete", e))?;
tx.commit()
.await
.map_err(|e| to_store_err("archive_restore commit", e))?;
Ok(true)
}
async fn archive_purge(
&self,
ctx: &CallerContext,
older_than_days: Option<i64>,
) -> StoreResult<usize> {
if let Some(days) = older_than_days {
if days < 0 {
return Err(StoreError::InvalidInput {
detail: crate::errors::msg::older_than_days_negative(days),
});
}
}
let caller = ctx.effective_principal();
let bypass = ctx.bypass_visibility;
let res = match (older_than_days, bypass) {
(Some(days), true) => {
let cutoff = chrono::Utc::now() - chrono::Duration::days(days);
sqlx::query("DELETE FROM archived_memories WHERE archived_at < $1")
.bind(cutoff)
.execute(&self.pool)
.await
.map_err(|e| to_store_err("archive_purge admin", e))?
}
(None, true) => sqlx::query("DELETE FROM archived_memories")
.execute(&self.pool)
.await
.map_err(|e| to_store_err("archive_purge admin all", e))?,
(Some(days), false) => {
let cutoff = chrono::Utc::now() - chrono::Duration::days(days);
sqlx::query(
"DELETE FROM archived_memories \
WHERE archived_at < $1 \
AND ( \
(metadata->>'agent_id') = $2 OR \
(metadata->>'target_agent_id') = $2 \
)",
)
.bind(cutoff)
.bind(caller)
.execute(&self.pool)
.await
.map_err(|e| to_store_err("archive_purge owner", e))?
}
(None, false) => sqlx::query(
"DELETE FROM archived_memories \
WHERE \
(metadata->>'agent_id') = $1 OR \
(metadata->>'target_agent_id') = $1",
)
.bind(caller)
.execute(&self.pool)
.await
.map_err(|e| to_store_err("archive_purge owner all", e))?,
};
Ok(usize::try_from(res.rows_affected()).unwrap_or(0))
}
async fn archive_by_ids(
&self,
_ctx: &CallerContext,
ids: &[String],
reason: Option<&str>,
) -> StoreResult<usize> {
if ids.is_empty() {
return Ok(0);
}
let mut moved = 0usize;
let now = chrono::Utc::now();
let archive_reason = reason.unwrap_or("manual");
let mut tx = self
.pool
.begin()
.await
.map_err(|e| to_store_err("begin archive_by_ids tx", e))?;
for id in ids {
let exists: Option<(String,)> = sqlx::query_as(SQL_SELECT_MEMORY_ID_BY_ID)
.bind(id)
.fetch_optional(&mut *tx)
.await
.map_err(|e| to_store_err("archive_by_ids exists", e))?;
if exists.is_none() {
continue;
}
sqlx::query(
"INSERT INTO archived_memories (
id, tier, namespace, title, content, tags, priority, confidence,
source, access_count, created_at, updated_at, last_accessed_at,
expires_at, archived_at, archive_reason, metadata,
embedding, embedding_dim, original_tier, original_expires_at,
-- #1025 (CRITICAL, 2026-05-21) — full v0.7.0 column carry.
reflection_depth, atomised_into, atom_of, memory_kind,
entity_id, persona_version, citations, source_uri, source_span,
confidence_source, confidence_signals, confidence_decayed_at,
mentioned_entity_id, version
)
SELECT id, tier, namespace, title, content, tags, priority, confidence,
source, access_count, created_at, updated_at, last_accessed_at,
expires_at, $1::timestamptz, $2::text, metadata,
embedding, embedding_dim, tier, expires_at,
reflection_depth, atomised_into, atom_of, memory_kind,
entity_id, persona_version, citations, source_uri, source_span,
confidence_source, confidence_signals, confidence_decayed_at,
mentioned_entity_id, version
FROM memories WHERE id = $3
ON CONFLICT (id) DO UPDATE SET
archived_at = EXCLUDED.archived_at,
archive_reason = EXCLUDED.archive_reason",
)
.bind(now)
.bind(archive_reason)
.bind(id)
.execute(&mut *tx)
.await
.map_err(|e| to_store_err("archive_by_ids insert", e))?;
sqlx::query(SQL_DELETE_MEMORY_BY_ID)
.bind(id)
.execute(&mut *tx)
.await
.map_err(|e| to_store_err("archive_by_ids delete", e))?;
moved += 1;
}
tx.commit()
.await
.map_err(|e| to_store_err("archive_by_ids commit", e))?;
Ok(moved)
}
async fn export_memories(&self) -> StoreResult<Vec<Memory>> {
let ctx = CallerContext::for_admin(crate::identity::sentinels::EXPORT_INTERNAL);
let filter = Filter {
limit: 100_000,
..Filter::default()
};
self.list(&ctx, &filter).await
}
async fn export_links(&self) -> StoreResult<Vec<MemoryLink>> {
self.list_links(None).await
}
async fn notify(
&self,
ctx: &CallerContext,
target_agent: &str,
title: &str,
payload: &str,
priority: Option<i32>,
tier: Option<&Tier>,
) -> StoreResult<String> {
let now = chrono::Utc::now().to_rfc3339();
let resolved_tier = tier.cloned().unwrap_or(Tier::Short);
let priority = priority.unwrap_or(5);
let metadata = serde_json::json!({
"agent_id": &ctx.agent_id,
(field_names::TARGET_AGENT_ID): target_agent,
"notify": true,
});
let mem = Memory {
id: uuid::Uuid::new_v4().to_string(),
tier: resolved_tier,
namespace: crate::inbox_namespace(target_agent),
title: title.to_string(),
content: payload.to_string(),
tags: vec!["notify".to_string()],
priority,
confidence: 1.0,
source: "notify".to_string(),
access_count: 0,
created_at: now.clone(),
updated_at: now,
last_accessed_at: None,
expires_at: None,
metadata,
reflection_depth: 0,
memory_kind: crate::models::MemoryKind::Observation,
entity_id: None,
persona_version: None,
citations: Vec::new(),
source_uri: None,
source_span: None,
confidence_source: ConfidenceSource::CallerProvided,
confidence_signals: None,
confidence_decayed_at: None,
version: 1,
};
self.store(ctx, &mem).await
}
async fn build_namespace_chain(&self, namespace: &str) -> StoreResult<Vec<String>> {
let mut chain: Vec<String> = Vec::new();
if namespace == "*" {
chain.push("*".to_string());
return Ok(chain);
}
chain.push("*".to_string());
let mut hierarchy_chain: Vec<String> = crate::models::namespace_ancestors(namespace)
.into_iter()
.rev()
.collect();
if let Some(root) = hierarchy_chain.first().cloned() {
let mut explicit_above: Vec<String> = Vec::new();
let mut current = root;
for _ in 0..GOVERNANCE_INHERITANCE_DEPTH_CAP {
let row: Option<(Option<String>,)> = sqlx::query_as(
"SELECT parent_namespace FROM namespace_meta WHERE namespace = $1",
)
.bind(¤t)
.fetch_optional(&self.pool)
.await
.map_err(|e| to_store_err("build_namespace_chain parent lookup", e))?;
let next = row.and_then(|(p,)| p);
match next {
Some(p)
if p != "*"
&& !explicit_above.contains(&p)
&& !hierarchy_chain.contains(&p) =>
{
explicit_above.push(p.clone());
current = p;
}
_ => break,
}
}
for p in explicit_above.into_iter().rev() {
if !chain.contains(&p) {
chain.push(p);
}
}
}
let drained: Vec<String> = hierarchy_chain.drain(..).collect();
let drained_len = drained.len();
let kept: Vec<String> = if drained_len > GOVERNANCE_INHERITANCE_DEPTH_CAP {
drained
.into_iter()
.skip(drained_len - GOVERNANCE_INHERITANCE_DEPTH_CAP)
.collect()
} else {
drained
};
for entry in kept {
if !chain.contains(&entry) {
chain.push(entry);
}
}
Ok(chain)
}
async fn resolve_governance_policy(
&self,
namespace: &str,
) -> StoreResult<Option<crate::models::GovernancePolicy>> {
let chain = self.build_namespace_chain(namespace).await?;
for ns in chain.into_iter().rev() {
let row: Option<(Option<String>,)> =
sqlx::query_as("SELECT standard_id FROM namespace_meta WHERE namespace = $1")
.bind(&ns)
.fetch_optional(&self.pool)
.await
.map_err(|e| to_store_err("resolve_governance_policy lookup", e))?;
let Some((Some(standard_id),)) = row else {
continue;
};
let ctx = CallerContext::for_admin(crate::identity::sentinels::GOVERNANCE_INTERNAL);
let mem = match self.get(&ctx, &standard_id).await {
Ok(m) => m,
Err(StoreError::NotFound { .. }) => continue,
Err(e) => return Err(e),
};
if let Some(Ok(p)) = crate::models::GovernancePolicy::from_metadata(&mem.metadata) {
return Ok(Some(p));
}
}
Ok(None)
}
async fn governance_approve_with_consensus(
&self,
ctx: &CallerContext,
pending_id: &str,
approver_agent_id: &str,
) -> StoreResult<super::ApproveOutcome> {
let pa = self
.get_pending(ctx, pending_id)
.await?
.ok_or_else(|| StoreError::NotFound {
id: pending_id.to_string(),
})?;
if pa.status != "pending" {
return Ok(super::ApproveOutcome::Rejected(format!(
"already decided: status={}",
pa.status
)));
}
let approver = self
.resolve_governance_policy(&pa.namespace)
.await?
.map_or(crate::models::ApproverType::Human, |p| p.core.approver);
match approver {
crate::models::ApproverType::Human => {
let ok = self
.pending_decide(ctx, pending_id, true, approver_agent_id)
.await?;
if ok {
Ok(super::ApproveOutcome::Approved)
} else {
Ok(super::ApproveOutcome::Rejected(
crate::errors::msg::DECISION_WRITE_FAILED.to_string(),
))
}
}
crate::models::ApproverType::Agent(required) => {
if approver_agent_id != required {
return Ok(super::ApproveOutcome::Rejected(format!(
"designated approver is '{required}'; got '{approver_agent_id}'"
)));
}
let ok = self
.pending_decide(ctx, pending_id, true, approver_agent_id)
.await?;
if ok {
Ok(super::ApproveOutcome::Approved)
} else {
Ok(super::ApproveOutcome::Rejected(
crate::errors::msg::DECISION_WRITE_FAILED.to_string(),
))
}
}
crate::models::ApproverType::Consensus(quorum) => {
if !self.is_registered_agent(approver_agent_id).await? {
return Ok(super::ApproveOutcome::Rejected(format!(
"consensus voter '{approver_agent_id}' is not a registered agent"
)));
}
let canonical_id = approver_agent_id.to_ascii_lowercase();
let mut approvals = pa.approvals.clone();
if approvals
.iter()
.any(|a| a.agent_id.eq_ignore_ascii_case(&canonical_id))
{
return Ok(super::ApproveOutcome::Pending {
votes: approvals.len(),
quorum,
});
}
approvals.push(crate::models::Approval {
agent_id: canonical_id.clone(),
approved_at: chrono::Utc::now().to_rfc3339(),
});
let approvals_json =
serde_json::to_value(&approvals).map_err(|e| StoreError::IntegrityFailed {
detail: format!("serialize approvals: {e}"),
})?;
sqlx::query(
"UPDATE pending_actions SET approvals = $1 \
WHERE id = $2 AND status = 'pending'",
)
.bind(&approvals_json)
.bind(pending_id)
.execute(&self.pool)
.await
.map_err(|e| to_store_err("update consensus approvals", e))?;
let votes = approvals.len();
if u32::try_from(votes).unwrap_or(u32::MAX) >= quorum {
let ok = self
.pending_decide(ctx, pending_id, true, &canonical_id)
.await?;
if ok {
return Ok(super::ApproveOutcome::Approved);
}
return Ok(super::ApproveOutcome::Rejected(
"decision write failed at consensus threshold".to_string(),
));
}
Ok(super::ApproveOutcome::Pending { votes, quorum })
}
}
}
async fn execute_pending_action(
&self,
ctx: &CallerContext,
pending_id: &str,
) -> StoreResult<Option<String>> {
let pa = match self.get_pending(ctx, pending_id).await? {
Some(p) => p,
None => {
return Err(StoreError::InvalidInput {
detail: crate::errors::msg::pending_action_not_found(pending_id),
});
}
};
if pa.status != "approved" {
return Err(StoreError::InvalidInput {
detail: format!("cannot execute non-approved action (status={})", pa.status),
});
}
match pa.action_type.as_str() {
"store" => {
let mut mem: Memory = match serde_json::from_value(pa.payload.clone()) {
Ok(m) => m,
Err(e) => {
return Err(StoreError::IntegrityFailed {
detail: format!("invalid store payload: {e}"),
});
}
};
mem.id = uuid::Uuid::new_v4().to_string();
let now = Utc::now().to_rfc3339();
mem.created_at.clone_from(&now);
mem.updated_at = now;
mem.access_count = 0;
let id = self.store(ctx, &mem).await?;
Ok(Some(id))
}
"delete" => {
if let Some(mid) = pa.memory_id.clone() {
self.delete(ctx, &mid).await?;
Ok(Some(mid))
} else {
Ok(None)
}
}
"promote" => {
if let Some(mid) = pa.memory_id.clone() {
let patch = crate::store::UpdatePatch {
tier: Some(Tier::Long),
..Default::default()
};
self.update(ctx, &mid, patch).await?;
Ok(Some(mid))
} else {
Ok(None)
}
}
other => Err(StoreError::InvalidInput {
detail: format!("unsupported action_type: {other}"),
}),
}
}
async fn is_registered_agent(&self, agent_id: &str) -> StoreResult<bool> {
use crate::models::AGENTS_NAMESPACE;
let title = crate::models::agent_registration_title(agent_id);
let row: Option<(String,)> =
sqlx::query_as("SELECT id FROM memories WHERE namespace = $1 AND title = $2")
.bind(AGENTS_NAMESPACE)
.bind(&title)
.fetch_optional(&self.pool)
.await
.map_err(|e| to_store_err("is_registered_agent", e))?;
Ok(row.is_some())
}
async fn enforce_governance_action(
&self,
action: super::GovernedAction,
namespace: &str,
agent_id: &str,
memory_id: Option<&str>,
memory_owner: Option<&str>,
payload: &serde_json::Value,
) -> StoreResult<crate::models::GovernanceDecision> {
use crate::config::{
PermissionsMode, active_permissions_mode, record_permissions_decision,
};
use crate::models::{GovernanceDecision, GovernanceLevel};
let mode = active_permissions_mode();
record_permissions_decision(mode);
if mode == PermissionsMode::Off {
return Ok(GovernanceDecision::Allow);
}
let mut tx = self
.pool
.begin()
.await
.map_err(|e| to_store_err("begin enforce_governance_action tx", e))?;
let chain = build_namespace_chain_in_tx(&mut tx, namespace).await?;
let mut resolved_policy: Option<crate::models::GovernancePolicy> = None;
for ns in chain.iter().rev() {
let row: Option<(Option<String>,)> =
sqlx::query_as("SELECT standard_id FROM namespace_meta WHERE namespace = $1")
.bind(ns)
.fetch_optional(&mut *tx)
.await
.map_err(|e| to_store_err("resolve_governance_policy lookup (tx)", e))?;
let Some((Some(standard_id),)) = row else {
continue;
};
let meta: Option<(serde_json::Value,)> =
sqlx::query_as("SELECT metadata FROM memories WHERE id = $1")
.bind(&standard_id)
.fetch_optional(&mut *tx)
.await
.map_err(|e| to_store_err("read governance standard metadata", e))?;
if let Some((m,)) = meta
&& let Some(Ok(p)) = crate::models::GovernancePolicy::from_metadata(&m)
{
resolved_policy = Some(p);
break;
}
}
let Some(policy) = resolved_policy else {
return Ok(GovernanceDecision::Allow);
};
let level = match action {
super::GovernedAction::Store => &policy.core.write,
super::GovernedAction::Delete => &policy.core.delete,
super::GovernedAction::Promote => &policy.core.promote,
super::GovernedAction::Reflect => &policy.core.write,
};
let ns_owner = if matches!(action, super::GovernedAction::Store) {
let mut found: Option<String> = None;
for ns in chain.iter().rev() {
let row: Option<(Option<String>,)> = sqlx::query_as(
"SELECT m.metadata->>'agent_id' AS agent_id \
FROM namespace_meta nm \
JOIN memories m ON m.id = nm.standard_id \
WHERE nm.namespace = $1",
)
.bind(ns)
.fetch_optional(&mut *tx)
.await
.map_err(|e| to_store_err("namespace_owner chain lookup (tx)", e))?;
if let Some((Some(o),)) = row {
found = Some(o);
break;
}
}
found
} else {
None
};
let registered_agent_check = if matches!(level, GovernanceLevel::Registered) {
use crate::models::AGENTS_NAMESPACE;
let title = crate::models::agent_registration_title(agent_id);
let row: Option<(String,)> =
sqlx::query_as("SELECT id FROM memories WHERE namespace = $1 AND title = $2")
.bind(AGENTS_NAMESPACE)
.bind(&title)
.fetch_optional(&mut *tx)
.await
.map_err(|e| to_store_err("is_registered_agent (tx)", e))?;
row.is_some()
} else {
false
};
let model_action = match action {
super::GovernedAction::Store => crate::models::GovernedAction::Store,
super::GovernedAction::Delete => crate::models::GovernedAction::Delete,
super::GovernedAction::Promote => crate::models::GovernedAction::Promote,
super::GovernedAction::Reflect => crate::models::GovernedAction::Reflect,
};
let decision = match level {
GovernanceLevel::Any => GovernanceDecision::Allow,
GovernanceLevel::Registered => {
if registered_agent_check {
GovernanceDecision::Allow
} else {
GovernanceDecision::Deny(
crate::governance::GovernanceRefusal::new(
model_action,
GovernanceLevel::Registered,
agent_id,
format!(
"agent '{agent_id}' is not registered for namespace '{namespace}'"
),
)
.with_namespace(namespace),
)
}
}
GovernanceLevel::Owner => {
let owner_to_compare = match action {
super::GovernedAction::Store => ns_owner.as_deref(),
_ => memory_owner,
};
match owner_to_compare {
Some(o) if o == agent_id => GovernanceDecision::Allow,
Some(o) => GovernanceDecision::Deny(
crate::governance::GovernanceRefusal::new(
model_action,
GovernanceLevel::Owner,
agent_id,
format!(
"owner-only namespace '{namespace}': caller '{agent_id}' is not '{o}'"
),
)
.with_namespace(namespace)
.with_owner(o),
),
None => GovernanceDecision::Allow,
}
}
GovernanceLevel::Approve => {
let owner_to_compare = match action {
super::GovernedAction::Store => ns_owner.as_deref(),
_ => memory_owner,
};
if matches!(owner_to_compare, Some(o) if o == agent_id) {
GovernanceDecision::Allow
} else {
GovernanceDecision::Pending(String::new())
}
}
};
if mode == PermissionsMode::Advisory {
return Ok(GovernanceDecision::Allow);
}
if let GovernanceDecision::Pending(_) = decision {
let pending_id = uuid::Uuid::new_v4().to_string();
let now = chrono::Utc::now();
let action_str = match action {
super::GovernedAction::Store => "store",
super::GovernedAction::Delete => "delete",
super::GovernedAction::Promote => "promote",
super::GovernedAction::Reflect => "reflect",
};
sqlx::query(
"INSERT INTO pending_actions \
(id, action_type, memory_id, namespace, payload, requested_by, requested_at, status) \
VALUES ($1, $2, $3, $4, $5, $6, $7, 'pending')",
)
.bind(&pending_id)
.bind(action_str)
.bind(memory_id)
.bind(namespace)
.bind(payload)
.bind(agent_id)
.bind(now)
.execute(&mut *tx)
.await
.map_err(|e| to_store_err("queue_pending_action (tx)", e))?;
tx.commit()
.await
.map_err(|e| to_store_err("commit enforce_governance_action tx", e))?;
return Ok(GovernanceDecision::Pending(pending_id));
}
tx.commit()
.await
.map_err(|e| to_store_err("commit enforce_governance_action tx (read-only)", e))?;
Ok(decision)
}
async fn quota_status(&self, agent_id: &str) -> StoreResult<QuotaStatus> {
let now = Utc::now();
sqlx::query(
"INSERT INTO agent_quotas (
agent_id, namespace,
max_memories_per_day, max_storage_bytes, max_links_per_day,
current_memories_today, current_storage_bytes, current_links_today,
day_started_at, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, 0, 0, 0, $6, $6, $6)
ON CONFLICT (agent_id, namespace) DO NOTHING",
)
.bind(agent_id)
.bind(crate::quotas::GLOBAL_NAMESPACE)
.bind(quota_defaults().max_memories_per_day)
.bind(quota_defaults().max_storage_bytes)
.bind(quota_defaults().max_links_per_day)
.bind(now)
.execute(&self.pool)
.await
.map_err(|e| to_store_err("ensure aggregate _global agent_quotas row", e))?;
let row = sqlx::query(
"SELECT
$1::text AS agent_id,
'_global'::text AS namespace,
COALESCE(MAX(max_memories_per_day), 0) AS max_memories_per_day,
COALESCE(MAX(max_storage_bytes), 0) AS max_storage_bytes,
COALESCE(MAX(max_links_per_day), 0) AS max_links_per_day,
COALESCE(SUM(current_memories_today), 0)::BIGINT AS current_memories_today,
COALESCE(SUM(current_storage_bytes), 0)::BIGINT AS current_storage_bytes,
COALESCE(SUM(current_links_today), 0)::BIGINT AS current_links_today,
COALESCE(MIN(day_started_at), $2) AS day_started_at,
COALESCE(MIN(created_at), $2) AS created_at,
COALESCE(MAX(updated_at), $2) AS updated_at
FROM agent_quotas WHERE agent_id = $1",
)
.bind(agent_id)
.bind(now)
.fetch_one(&self.pool)
.await
.map_err(|e| to_store_err("read aggregate agent_quotas row", e))?;
row_to_quota_status(&row)
}
async fn quota_status_ns(&self, agent_id: &str, namespace: &str) -> StoreResult<QuotaStatus> {
let now = Utc::now();
sqlx::query(
"INSERT INTO agent_quotas (
agent_id, namespace,
max_memories_per_day, max_storage_bytes, max_links_per_day,
current_memories_today, current_storage_bytes, current_links_today,
day_started_at, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, 0, 0, 0, $6, $6, $6)
ON CONFLICT (agent_id, namespace) DO NOTHING",
)
.bind(agent_id)
.bind(namespace)
.bind(quota_defaults().max_memories_per_day)
.bind(quota_defaults().max_storage_bytes)
.bind(quota_defaults().max_links_per_day)
.bind(now)
.execute(&self.pool)
.await
.map_err(|e| to_store_err("ensure (agent, namespace) agent_quotas row", e))?;
let row = sqlx::query(
"SELECT agent_id, namespace,
max_memories_per_day, max_storage_bytes, max_links_per_day,
current_memories_today, current_storage_bytes, current_links_today,
day_started_at, created_at, updated_at
FROM agent_quotas WHERE agent_id = $1 AND namespace = $2",
)
.bind(agent_id)
.bind(namespace)
.fetch_one(&self.pool)
.await
.map_err(|e| to_store_err("read (agent, namespace) agent_quotas row", e))?;
row_to_quota_status(&row)
}
async fn quota_status_list(&self) -> StoreResult<Vec<QuotaStatus>> {
let rows = sqlx::query(
"SELECT agent_id, namespace,
max_memories_per_day, max_storage_bytes, max_links_per_day,
current_memories_today, current_storage_bytes, current_links_today,
day_started_at, created_at, updated_at
FROM agent_quotas ORDER BY agent_id ASC, namespace ASC",
)
.fetch_all(&self.pool)
.await
.map_err(|e| to_store_err("list agent_quotas rows", e))?;
rows.iter().map(row_to_quota_status).collect()
}
async fn quota_status_list_ns(&self, namespace: &str) -> StoreResult<Vec<QuotaStatus>> {
let rows = sqlx::query(
"SELECT agent_id, namespace,
max_memories_per_day, max_storage_bytes, max_links_per_day,
current_memories_today, current_storage_bytes, current_links_today,
day_started_at, created_at, updated_at
FROM agent_quotas
WHERE namespace = $1
ORDER BY agent_id ASC, namespace ASC",
)
.bind(namespace)
.fetch_all(&self.pool)
.await
.map_err(|e| to_store_err("list (namespace) agent_quotas rows", e))?;
rows.iter().map(row_to_quota_status).collect()
}
async fn verify_link(&self, filter: VerifyFilter) -> StoreResult<VerifyLinkReport> {
if filter.source_id.is_none() && filter.link_id.is_none() {
return Err(StoreError::InvalidInput {
detail: crate::errors::msg::VERIFY_LINK_ARGS_REQUIRED.to_string(),
});
}
let (source_id, target_id, relation_filter) = if let Some(link_id) =
filter.link_id.as_deref()
{
let parts: Vec<&str> = link_id.split('|').collect();
if parts.len() != 3 {
return Err(StoreError::InvalidInput {
detail: format!(
"link_id must be canonical source_id|target_id|relation triple, got {link_id}"
),
});
}
(
parts[0].to_string(),
Some(parts[1].to_string()),
Some(parts[2].to_string()),
)
} else {
(filter.source_id.unwrap_or_default(), filter.target_id, None)
};
let row_opt = match (target_id.as_deref(), relation_filter.as_deref()) {
(Some(t), Some(r)) => sqlx::query(
"SELECT source_id, target_id, relation, valid_from, valid_until, \
observed_by, signature, attest_level
FROM memory_links \
WHERE source_id = $1 AND target_id = $2 AND relation = $3 \
LIMIT 1",
)
.bind(&source_id)
.bind(t)
.bind(r)
.fetch_optional(&self.pool)
.await
.map_err(|e| to_store_err(CTX_VERIFY_LINK_SELECT, e))?,
(Some(t), None) => sqlx::query(
"SELECT source_id, target_id, relation, valid_from, valid_until, \
observed_by, signature, attest_level
FROM memory_links \
WHERE source_id = $1 AND target_id = $2 \
ORDER BY created_at ASC LIMIT 1",
)
.bind(&source_id)
.bind(t)
.fetch_optional(&self.pool)
.await
.map_err(|e| to_store_err(CTX_VERIFY_LINK_SELECT, e))?,
(None, _) => sqlx::query(
"SELECT source_id, target_id, relation, valid_from, valid_until, \
observed_by, signature, attest_level
FROM memory_links \
WHERE source_id = $1 \
ORDER BY created_at ASC LIMIT 1",
)
.bind(&source_id)
.fetch_optional(&self.pool)
.await
.map_err(|e| to_store_err(CTX_VERIFY_LINK_SELECT, e))?,
};
let Some(row) = row_opt else {
return Err(StoreError::NotFound {
id: format!(
"link {source_id} -> {} {}",
target_id.as_deref().unwrap_or("?"),
relation_filter.as_deref().unwrap_or("?")
),
});
};
let src: String = row
.try_get("source_id")
.map_err(|e| to_store_err(READ_SOURCE_ID, e))?;
let tgt: String = row
.try_get("target_id")
.map_err(|e| to_store_err(READ_TARGET_ID, e))?;
let rel: String = row
.try_get("relation")
.map_err(|e| to_store_err(READ_RELATION, e))?;
let vf: Option<DateTime<Utc>> = row
.try_get(field_names::VALID_FROM)
.map_err(|e| to_store_err(READ_VALID_FROM, e))?;
let vu: Option<DateTime<Utc>> = row
.try_get(field_names::VALID_UNTIL)
.map_err(|e| to_store_err(READ_VALID_UNTIL, e))?;
let obs: Option<String> = row
.try_get(field_names::OBSERVED_BY)
.map_err(|e| to_store_err(READ_OBSERVED_BY, e))?;
let sig: Option<Vec<u8>> = row
.try_get("signature")
.map_err(|e| to_store_err("read signature", e))?;
let attest: Option<String> = row
.try_get(field_names::ATTEST_LEVEL)
.map_err(|e| to_store_err(READ_ATTEST_LEVEL, e))?;
let attest_level =
attest.unwrap_or_else(|| crate::models::AttestLevel::Unsigned.as_str().to_string());
let signature_present = sig.is_some();
let mut findings: Vec<String> = Vec::new();
let vf_str = vf.map(|t| truncate_to_microseconds(t).to_rfc3339());
let vu_str = vu.map(|t| truncate_to_microseconds(t).to_rfc3339());
let verified = if signature_present {
let observed = obs.as_deref().unwrap_or("");
match crate::identity::verify::lookup_peer_public_key(observed) {
None => {
findings.push(format!(
"signature present but no enrolled public key for observed_by={observed}"
));
false
}
Some(pubkey) => {
let signable = crate::identity::sign::SignableLink {
src_id: &src,
dst_id: &tgt,
relation: &rel,
observed_by: obs.as_deref(),
valid_from: vf_str.as_deref(),
valid_until: vu_str.as_deref(),
};
let sig_bytes = sig.as_deref().unwrap_or(&[]);
match crate::identity::verify::verify(&pubkey, &signable, sig_bytes) {
Ok(()) => true,
Err(e) => {
findings.push(crate::errors::msg::signature_verify_failed(e));
false
}
}
}
}
} else {
true
};
Ok(VerifyLinkReport {
source_id: src,
target_id: tgt,
relation: rel,
verified,
attest_level,
signature_present,
observed_by: obs,
findings,
})
}
async fn find_paths(
&self,
ctx: &CallerContext,
source_id: &str,
target_id: &str,
max_depth: Option<usize>,
max_results: Option<usize>,
) -> StoreResult<Vec<Vec<String>>> {
let paths =
PostgresStore::find_paths(self, source_id, target_id, max_depth, max_results).await?;
if ctx.bypass_visibility {
return Ok(paths);
}
let caller = ctx.effective_principal();
let mut visible_cache: std::collections::HashMap<String, bool> =
std::collections::HashMap::new();
let mut filtered: Vec<Vec<String>> = Vec::with_capacity(paths.len());
'outer: for path in paths {
for node in &path {
let visible = if let Some(v) = visible_cache.get(node) {
*v
} else {
let row = sqlx::query("SELECT metadata FROM memories WHERE id = $1")
.bind(node)
.fetch_optional(&self.pool)
.await
.map_err(|e| to_store_err("find_paths visibility fetch", e))?;
let v = match row {
Some(r) => {
let meta: serde_json::Value = r
.try_get("metadata")
.map_err(|e| to_store_err("read metadata", e))?;
let scope = meta
.get(crate::META_KEY_SCOPE)
.and_then(serde_json::Value::as_str)
.unwrap_or(crate::models::namespace::MemoryScope::Private.as_str());
if scope != crate::models::namespace::MemoryScope::Private.as_str() {
true
} else {
let owner = meta
.get(crate::META_KEY_AGENT_ID)
.and_then(serde_json::Value::as_str)
.unwrap_or("");
owner == caller
}
}
None => false,
};
visible_cache.insert(node.clone(), v);
v
};
if !visible {
continue 'outer;
}
}
filtered.push(path);
}
Ok(filtered)
}
async fn list_namespaces(&self) -> StoreResult<Vec<crate::models::NamespaceCount>> {
let now_dt = Utc::now();
let rows = sqlx::query(
"SELECT namespace, COUNT(*)::BIGINT AS c FROM memories
WHERE expires_at IS NULL OR expires_at > $1
GROUP BY namespace
ORDER BY c DESC, namespace ASC",
)
.bind(now_dt)
.fetch_all(&self.pool)
.await
.map_err(|e| to_store_err("list_namespaces", e))?;
rows.iter()
.map(|r| {
let namespace: String = r
.try_get::<String, _>("namespace")
.map_err(|e| to_store_err(READ_NAMESPACE, e))?;
let c: i64 = r
.try_get::<i64, _>("c")
.map_err(|e| to_store_err("read count", e))?;
Ok(crate::models::NamespaceCount {
namespace,
count: usize::try_from(c).unwrap_or(0),
})
})
.collect()
}
async fn get_taxonomy(
&self,
namespace_prefix: Option<&str>,
max_depth: usize,
limit: usize,
) -> StoreResult<crate::models::Taxonomy> {
let effective_limit = limit.min(crate::storage::TAXONOMY_MAX_LIMIT);
let effective_depth = max_depth.min(crate::models::MAX_NAMESPACE_DEPTH);
let prefix = namespace_prefix.unwrap_or("");
let descendant_pattern = format!(
"{}/%",
prefix
.replace('\\', "\\\\")
.replace('%', "\\%")
.replace('_', "\\_")
);
let now_dt = Utc::now();
let total_count: i64 = if prefix.is_empty() {
sqlx::query_scalar(
"SELECT COUNT(*)::BIGINT FROM memories
WHERE expires_at IS NULL OR expires_at > $1",
)
.bind(now_dt)
.fetch_one(&self.pool)
.await
.map_err(|e| to_store_err("get_taxonomy total", e))?
} else {
sqlx::query_scalar(
"SELECT COUNT(*)::BIGINT FROM memories
WHERE (expires_at IS NULL OR expires_at > $1)
AND (namespace = $2 OR namespace LIKE $3 ESCAPE '\\')",
)
.bind(now_dt)
.bind(prefix)
.bind(&descendant_pattern)
.fetch_one(&self.pool)
.await
.map_err(|e| to_store_err("get_taxonomy prefix total", e))?
};
let limit_i64: i64 = i64::try_from(effective_limit).unwrap_or(i64::MAX);
let groups: Vec<(String, i64)> = if prefix.is_empty() {
sqlx::query_as(
"SELECT namespace, COUNT(*)::BIGINT FROM memories
WHERE expires_at IS NULL OR expires_at > $1
GROUP BY namespace
ORDER BY COUNT(*) DESC, namespace ASC
LIMIT $2",
)
.bind(now_dt)
.bind(limit_i64)
.fetch_all(&self.pool)
.await
.map_err(|e| to_store_err("get_taxonomy groups", e))?
} else {
sqlx::query_as(
"SELECT namespace, COUNT(*)::BIGINT FROM memories
WHERE (expires_at IS NULL OR expires_at > $1)
AND (namespace = $2 OR namespace LIKE $3 ESCAPE '\\')
GROUP BY namespace
ORDER BY COUNT(*) DESC, namespace ASC
LIMIT $4",
)
.bind(now_dt)
.bind(prefix)
.bind(&descendant_pattern)
.bind(limit_i64)
.fetch_all(&self.pool)
.await
.map_err(|e| to_store_err("get_taxonomy prefix groups", e))?
};
let total_usize = usize::try_from(total_count).unwrap_or(0);
let group_rows: Vec<(String, usize)> = groups
.into_iter()
.map(|(ns, c)| (ns, usize::try_from(c).unwrap_or(0)))
.collect();
let walked_count: usize = group_rows.iter().map(|(_, c)| *c).sum();
let truncated = walked_count < total_usize;
let _ = effective_limit;
Ok(crate::storage::fold_taxonomy_groups(
prefix,
effective_depth,
total_usize,
truncated,
group_rows,
))
}
async fn list_agents(&self) -> StoreResult<Vec<AgentRegistration>> {
use crate::models::AGENTS_NAMESPACE;
let now_dt = Utc::now();
let rows = sqlx::query(
"SELECT metadata FROM memories
WHERE namespace = $1
AND (expires_at IS NULL OR expires_at > $2)
ORDER BY (metadata->>'registered_at') ASC NULLS LAST",
)
.bind(AGENTS_NAMESPACE)
.bind(now_dt)
.fetch_all(&self.pool)
.await
.map_err(|e| to_store_err("list_agents", e))?;
let mut agents = Vec::with_capacity(rows.len());
for r in &rows {
let meta: serde_json::Value = r
.try_get::<serde_json::Value, _>("metadata")
.map_err(|e| to_store_err("read agent metadata", e))?;
let agent_id = meta
.get("agent_id")
.and_then(serde_json::Value::as_str)
.unwrap_or_default()
.to_string();
let agent_type = meta
.get(field_names::AGENT_TYPE)
.and_then(serde_json::Value::as_str)
.unwrap_or_default()
.to_string();
let capabilities: Vec<String> = meta
.get(field_names::CAPABILITIES)
.and_then(serde_json::Value::as_array)
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default();
let registered_at = meta
.get(field_names::REGISTERED_AT)
.and_then(serde_json::Value::as_str)
.unwrap_or_default()
.to_string();
let last_seen_at = meta
.get(field_names::LAST_SEEN_AT)
.and_then(serde_json::Value::as_str)
.unwrap_or_default()
.to_string();
agents.push(AgentRegistration {
agent_id,
agent_type,
capabilities,
registered_at,
last_seen_at,
});
}
Ok(agents)
}
async fn list_pending_actions(
&self,
status: Option<&str>,
limit: usize,
) -> StoreResult<Vec<crate::models::PendingAction>> {
let limit_i64: i64 = i64::try_from(limit).unwrap_or(i64::MAX);
let rows = sqlx::query(
"SELECT id, action_type, memory_id, namespace, payload, requested_by,
requested_at, status, decided_by, decided_at, approvals
FROM pending_actions
WHERE ($1::TEXT IS NULL OR status = $1)
ORDER BY requested_at DESC
LIMIT $2",
)
.bind(status)
.bind(limit_i64)
.fetch_all(&self.pool)
.await
.map_err(|e| to_store_err("list_pending_actions", e))?;
let mut out = Vec::with_capacity(rows.len());
for r in &rows {
let requested_at: DateTime<Utc> = r
.try_get(field_names::REQUESTED_AT)
.map_err(|e| to_store_err("read requested_at", e))?;
let decided_at: Option<DateTime<Utc>> = r
.try_get(field_names::DECIDED_AT)
.map_err(|e| to_store_err("read decided_at", e))?;
let approvals_v: serde_json::Value = r
.try_get("approvals")
.unwrap_or(serde_json::Value::Array(vec![]));
let approvals: Vec<crate::models::Approval> =
serde_json::from_value(approvals_v).unwrap_or_default();
out.push(crate::models::PendingAction {
id: r
.try_get::<String, _>("id")
.map_err(|e| to_store_err("read id", e))?,
action_type: r
.try_get::<String, _>(field_names::ACTION_TYPE)
.map_err(|e| to_store_err("read action_type", e))?,
memory_id: r.try_get::<Option<String>, _>("memory_id").unwrap_or(None),
namespace: r
.try_get::<String, _>("namespace")
.map_err(|e| to_store_err(READ_NAMESPACE, e))?,
payload: r
.try_get::<serde_json::Value, _>("payload")
.unwrap_or(serde_json::Value::Null),
requested_by: r
.try_get::<String, _>(field_names::REQUESTED_BY)
.map_err(|e| to_store_err("read requested_by", e))?,
requested_at: requested_at.to_rfc3339(),
status: r
.try_get::<String, _>("status")
.map_err(|e| to_store_err("read status", e))?,
decided_by: r
.try_get::<Option<String>, _>(field_names::DECIDED_BY)
.unwrap_or(None),
decided_at: decided_at.map(|d| d.to_rfc3339()),
approvals,
});
}
Ok(out)
}
async fn entity_get_by_alias(
&self,
alias: &str,
namespace: Option<&str>,
) -> StoreResult<Option<crate::models::EntityRecord>> {
use crate::models::{ENTITY_KIND, EntityRecord};
let trimmed = alias.trim();
if trimmed.is_empty() {
return Ok(None);
}
let row = if let Some(ns) = namespace {
sqlx::query(
"SELECT m.id, m.title, m.namespace
FROM entity_aliases ea
JOIN memories m ON m.id = ea.entity_id
WHERE ea.alias = $1
AND m.namespace = $2
AND COALESCE(m.metadata->>'kind', '') = $3
ORDER BY m.created_at DESC
LIMIT 1",
)
.bind(trimmed)
.bind(ns)
.bind(ENTITY_KIND)
.fetch_optional(&self.pool)
.await
.map_err(|e| to_store_err("entity_get_by_alias ns", e))?
} else {
sqlx::query(
"SELECT m.id, m.title, m.namespace
FROM entity_aliases ea
JOIN memories m ON m.id = ea.entity_id
WHERE ea.alias = $1
AND COALESCE(m.metadata->>'kind', '') = $2
ORDER BY m.created_at DESC
LIMIT 1",
)
.bind(trimmed)
.bind(ENTITY_KIND)
.fetch_optional(&self.pool)
.await
.map_err(|e| to_store_err("entity_get_by_alias", e))?
};
let Some(r) = row else {
return Ok(None);
};
let entity_id: String = r
.try_get::<String, _>("id")
.map_err(|e| to_store_err("read entity id", e))?;
let canonical_name: String = r
.try_get::<String, _>("title")
.map_err(|e| to_store_err("read entity title", e))?;
let ns_resolved: String = r
.try_get::<String, _>("namespace")
.map_err(|e| to_store_err("read entity namespace", e))?;
let alias_rows =
sqlx::query("SELECT alias FROM entity_aliases WHERE entity_id = $1 ORDER BY alias")
.bind(&entity_id)
.fetch_all(&self.pool)
.await
.map_err(|e| to_store_err("list aliases for entity", e))?;
let aliases: Vec<String> = alias_rows
.iter()
.filter_map(|r| r.try_get::<String, _>("alias").ok())
.collect();
Ok(Some(EntityRecord {
entity_id,
canonical_name,
namespace: ns_resolved,
aliases,
}))
}
async fn health_check(&self) -> StoreResult<bool> {
let _: i64 = sqlx::query_scalar("SELECT COUNT(*)::BIGINT FROM memories")
.fetch_one(&self.pool)
.await
.map_err(|e| to_store_err("health_check count", e))?;
let one: i32 = sqlx::query_scalar("SELECT 1")
.fetch_one(&self.pool)
.await
.map_err(|e| to_store_err("health_check ping", e))?;
Ok(one == 1)
}
async fn stats(&self) -> StoreResult<crate::models::Stats> {
let total: i64 = sqlx::query_scalar("SELECT COUNT(*)::BIGINT FROM memories")
.fetch_one(&self.pool)
.await
.map_err(|e| to_store_err("stats total", e))?;
let total_usize = usize::try_from(total).unwrap_or(0);
let tier_rows = sqlx::query(
"SELECT tier, COUNT(*)::BIGINT AS c FROM memories
GROUP BY tier ORDER BY c DESC",
)
.fetch_all(&self.pool)
.await
.map_err(|e| to_store_err("stats by_tier", e))?;
let by_tier: Vec<crate::models::TierCount> = tier_rows
.iter()
.map(|r| {
let tier: String = r
.try_get::<String, _>("tier")
.map_err(|e| to_store_err("read tier", e))?;
let c: i64 = r
.try_get::<i64, _>("c")
.map_err(|e| to_store_err("read tier count", e))?;
Ok(crate::models::TierCount {
tier,
count: usize::try_from(c).unwrap_or(0),
})
})
.collect::<StoreResult<Vec<_>>>()?;
let ns_rows = sqlx::query(
"SELECT namespace, COUNT(*)::BIGINT AS c FROM memories
GROUP BY namespace ORDER BY c DESC",
)
.fetch_all(&self.pool)
.await
.map_err(|e| to_store_err("stats by_namespace", e))?;
let by_namespace: Vec<crate::models::NamespaceCount> = ns_rows
.iter()
.map(|r| {
let namespace: String = r
.try_get::<String, _>("namespace")
.map_err(|e| to_store_err(READ_NAMESPACE, e))?;
let c: i64 = r
.try_get::<i64, _>("c")
.map_err(|e| to_store_err("read namespace count", e))?;
Ok(crate::models::NamespaceCount {
namespace,
count: usize::try_from(c).unwrap_or(0),
})
})
.collect::<StoreResult<Vec<_>>>()?;
let now_dt = Utc::now();
let one_hour = now_dt + chrono::Duration::hours(1);
let expiring_soon: i64 = sqlx::query_scalar(
"SELECT COUNT(*)::BIGINT FROM memories
WHERE expires_at IS NOT NULL AND expires_at > $1 AND expires_at <= $2",
)
.bind(now_dt)
.bind(one_hour)
.fetch_one(&self.pool)
.await
.map_err(|e| to_store_err("stats expiring_soon", e))?;
let links_count: i64 = sqlx::query_scalar("SELECT COUNT(*)::BIGINT FROM memory_links")
.fetch_one(&self.pool)
.await
.unwrap_or(0);
let db_size_bytes: i64 =
sqlx::query_scalar("SELECT COALESCE(pg_total_relation_size('memories'), 0)::BIGINT")
.fetch_one(&self.pool)
.await
.unwrap_or(0);
Ok(crate::models::Stats {
total: total_usize,
by_tier,
by_namespace,
expiring_soon: usize::try_from(expiring_soon).unwrap_or(0),
links_count: usize::try_from(links_count).unwrap_or(0),
db_size_bytes: u64::try_from(db_size_bytes).unwrap_or(0),
dim_violations: 0,
index_evictions_total: 0,
})
}
async fn find_by_title_namespace(
&self,
title: &str,
namespace: &str,
) -> StoreResult<Option<String>> {
let id: Option<String> = sqlx::query_scalar(
"SELECT id FROM memories WHERE title = $1 AND namespace = $2 LIMIT 1",
)
.bind(title)
.bind(namespace)
.fetch_optional(&self.pool)
.await
.map_err(|e| to_store_err("find_by_title_namespace", e))?;
Ok(id)
}
async fn next_versioned_title(&self, base_title: &str, namespace: &str) -> StoreResult<String> {
const MAX_VERSION_SUFFIX: u32 = 1024;
if self
.find_by_title_namespace(base_title, namespace)
.await?
.is_none()
{
return Ok(base_title.to_string());
}
for n in 2..=MAX_VERSION_SUFFIX {
let candidate = format!("{base_title} ({n})");
if self
.find_by_title_namespace(&candidate, namespace)
.await?
.is_none()
{
return Ok(candidate);
}
}
Err(StoreError::IntegrityFailed {
detail: format!(
"could not find a free versioned title for '{base_title}' in namespace '{namespace}' within {MAX_VERSION_SUFFIX} attempts"
),
})
}
async fn find_contradictions(&self, title: &str, namespace: &str) -> StoreResult<Vec<Memory>> {
let rows = sqlx::query(
"SELECT m.* FROM memories m
WHERE m.namespace = $2
AND m.tsv @@ plainto_tsquery('english', $1)
ORDER BY ts_rank(m.tsv, plainto_tsquery('english', $1)) DESC
LIMIT 5",
)
.bind(title)
.bind(namespace)
.fetch_all(&self.pool)
.await
.map_err(|e| to_store_err("find_contradictions", e))?;
let mut out = Vec::with_capacity(rows.len());
for r in &rows {
out.push(Self::row_to_memory(r)?);
}
Ok(out)
}
async fn invalidate_link(
&self,
source_id: &str,
target_id: &str,
relation: &str,
valid_until: Option<&str>,
) -> StoreResult<KgInvalidateRow> {
self.kg_invalidate(source_id, target_id, relation, valid_until)
.await
}
async fn check_duplicate_with_text(
&self,
query_embedding: &[f32],
query_text: &str,
namespace: Option<&str>,
threshold: f32,
) -> StoreResult<crate::models::DuplicateCheck> {
use sha2::{Digest, Sha256};
let effective_threshold = threshold.max(0.5_f32); let now_dt = Utc::now();
let rows = if let Some(ns) = namespace {
sqlx::query(
"SELECT id, title, namespace, content FROM memories
WHERE (expires_at IS NULL OR expires_at > $1)
AND namespace = $2",
)
.bind(now_dt)
.bind(ns)
.fetch_all(&self.pool)
.await
.map_err(|e| to_store_err("check_duplicate_with_text scan", e))?
} else {
sqlx::query(
"SELECT id, title, namespace, content FROM memories
WHERE (expires_at IS NULL OR expires_at > $1)",
)
.bind(now_dt)
.fetch_all(&self.pool)
.await
.map_err(|e| to_store_err("check_duplicate_with_text scan", e))?
};
let mut query_hasher = Sha256::new();
query_hasher.update(query_text.as_bytes());
let query_hash = query_hasher.finalize();
let candidates_scanned = rows.len();
for r in &rows {
let id: String = r.try_get("id").map_err(|e| to_store_err("read id", e))?;
let title: String = r
.try_get("title")
.map_err(|e| to_store_err(READ_TITLE, e))?;
let ns_v: String = r
.try_get("namespace")
.map_err(|e| to_store_err(READ_NAMESPACE, e))?;
let content: String = r
.try_get("content")
.map_err(|e| to_store_err("read content", e))?;
let row_text = crate::embeddings::embedding_document(&title, &content);
let mut row_hasher = Sha256::new();
row_hasher.update(row_text.as_bytes());
let row_hash = row_hasher.finalize();
if row_hash == query_hash {
return Ok(crate::models::DuplicateCheck {
is_duplicate: true,
threshold: effective_threshold,
nearest: Some(crate::models::DuplicateMatch {
id,
title,
namespace: ns_v,
similarity: 1.0,
}),
candidates_scanned,
});
}
}
if query_embedding.is_empty() {
return Ok(crate::models::DuplicateCheck {
is_duplicate: false,
threshold: effective_threshold,
nearest: None,
candidates_scanned,
});
}
let emb_pgvec = pgvector::Vector::from(query_embedding.to_vec());
let nearest_row = if let Some(ns) = namespace {
sqlx::query(
"SELECT id, title, namespace,
1 - (embedding <=> $1) AS sim
FROM memories
WHERE embedding IS NOT NULL
AND namespace = $2
AND (expires_at IS NULL OR expires_at > $3)
ORDER BY embedding <=> $1
LIMIT 1",
)
.bind(&emb_pgvec)
.bind(ns)
.bind(now_dt)
.fetch_optional(&self.pool)
.await
.map_err(|e| to_store_err("check_duplicate_with_text cosine", e))?
} else {
sqlx::query(
"SELECT id, title, namespace,
1 - (embedding <=> $1) AS sim
FROM memories
WHERE embedding IS NOT NULL
AND (expires_at IS NULL OR expires_at > $2)
ORDER BY embedding <=> $1
LIMIT 1",
)
.bind(&emb_pgvec)
.bind(now_dt)
.fetch_optional(&self.pool)
.await
.map_err(|e| to_store_err("check_duplicate_with_text cosine", e))?
};
let nearest = match nearest_row {
Some(r) => {
let id: String = r.try_get("id").map_err(|e| to_store_err("read id", e))?;
let title: String = r
.try_get("title")
.map_err(|e| to_store_err(READ_TITLE, e))?;
let ns_v: String = r
.try_get("namespace")
.map_err(|e| to_store_err(READ_NAMESPACE, e))?;
let sim: f64 = r.try_get("sim").map_err(|e| to_store_err("read sim", e))?;
#[allow(clippy::cast_possible_truncation)]
let sim_f32 = sim as f32;
Some(crate::models::DuplicateMatch {
id,
title,
namespace: ns_v,
similarity: sim_f32,
})
}
None => None,
};
let is_duplicate = nearest
.as_ref()
.is_some_and(|n| n.similarity >= effective_threshold);
Ok(crate::models::DuplicateCheck {
is_duplicate,
threshold: effective_threshold,
nearest,
candidates_scanned,
})
}
async fn kg_query(
&self,
source_id: &str,
max_depth: usize,
include_invalidated: bool,
) -> StoreResult<Vec<super::KgQueryRow>> {
self.kg_query_with_history(source_id, max_depth, include_invalidated)
.await
}
async fn kg_timeline(
&self,
source_id: &str,
since: Option<&str>,
until: Option<&str>,
limit: Option<usize>,
) -> StoreResult<Vec<super::KgTimelineRow>> {
match self.kg_backend {
KgBackend::Age => {
match self
.kg_timeline_cypher(source_id, since, until, limit)
.await
{
Ok(rows) => Ok(rows),
Err(err) if is_age_runtime_failure(&err) => {
warn_age_fallback("kg_timeline", source_id, &err);
self.kg_timeline_cte(source_id, since, until, limit).await
}
Err(err) => Err(err),
}
}
KgBackend::Cte => self.kg_timeline_cte(source_id, since, until, limit).await,
}
}
async fn entity_register(
&self,
ctx: &CallerContext,
canonical_name: &str,
namespace: &str,
aliases: &[String],
extra_metadata: &serde_json::Value,
agent_id: Option<&str>,
) -> StoreResult<crate::models::EntityRegistration> {
use crate::models::{ConfidenceSource, ENTITY_KIND, EntityRegistration, MemoryKind, Tier};
let resolved_agent = agent_id
.map(str::to_string)
.unwrap_or_else(|| ctx.agent_id.clone());
let filter = super::Filter {
namespace: Some(namespace.to_string()),
limit: 10_000,
..Default::default()
};
let candidates = self.list(ctx, &filter).await?;
let prior = candidates.into_iter().find(|m| {
m.title == canonical_name
&& m.metadata.get("kind").and_then(serde_json::Value::as_str) == Some(ENTITY_KIND)
});
if prior.is_none() {
if let Some(existing_id) = self
.find_by_title_namespace(canonical_name, namespace)
.await?
{
return Err(StoreError::Conflict { id: existing_id });
}
}
let prior_aliases: Vec<String> = prior
.as_ref()
.and_then(|m| {
m.metadata
.get("aliases")
.and_then(|v| v.as_array())
.map(|a| {
a.iter()
.filter_map(|x| x.as_str().map(str::to_string))
.collect()
})
})
.unwrap_or_default();
let mut union: Vec<String> = Vec::new();
for a in prior_aliases.iter().chain(aliases.iter()) {
if !a.trim().is_empty() && !union.iter().any(|x| x == a) {
union.push(a.clone());
}
}
let mut metadata = match extra_metadata {
serde_json::Value::Object(m) => serde_json::Value::Object(m.clone()),
_ => serde_json::json!({}),
};
let meta_map = metadata.as_object_mut().expect("metadata is an object");
meta_map.insert(
"kind".to_string(),
serde_json::Value::String(ENTITY_KIND.to_string()),
);
meta_map.insert("aliases".to_string(), serde_json::json!(union.clone()));
meta_map
.entry("agent_id".to_string())
.or_insert(serde_json::Value::String(resolved_agent.clone()));
let now = chrono::Utc::now().to_rfc3339();
let resolved_id = prior
.as_ref()
.map(|m| m.id.clone())
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
let mem = Memory {
id: resolved_id.clone(),
tier: Tier::Long,
namespace: namespace.to_string(),
title: canonical_name.to_string(),
content: canonical_name.to_string(),
tags: vec!["entity".to_string()],
priority: 7,
confidence: 1.0,
source: "api".to_string(),
access_count: 0,
created_at: now.clone(),
updated_at: now,
last_accessed_at: None,
expires_at: None,
metadata,
reflection_depth: 0,
memory_kind: MemoryKind::Observation,
entity_id: None,
persona_version: None,
citations: Vec::new(),
source_uri: None,
source_span: None,
confidence_source: ConfidenceSource::CallerProvided,
confidence_signals: None,
confidence_decayed_at: None,
version: 1,
};
let written_id = self.store(ctx, &mem).await?;
let created = prior.is_none();
for alias in &union {
sqlx::query(
"INSERT INTO entity_aliases (entity_id, alias) VALUES ($1, $2)
ON CONFLICT (entity_id, alias) DO NOTHING",
)
.bind(&written_id)
.bind(alias)
.execute(&self.pool)
.await
.map_err(|e| to_store_err("entity_register populate entity_aliases", e))?;
}
Ok(EntityRegistration {
entity_id: written_id,
canonical_name: canonical_name.to_string(),
namespace: namespace.to_string(),
aliases: union,
created,
})
}
async fn list_archived(
&self,
namespace: Option<&str>,
limit: usize,
offset: usize,
) -> StoreResult<Vec<serde_json::Value>> {
self.list_archived_pg(namespace, limit, offset).await
}
}
fn row_to_quota_status(row: &sqlx::postgres::PgRow) -> StoreResult<QuotaStatus> {
let agent_id: String = row
.try_get("agent_id")
.map_err(|e| to_store_err("read quota agent_id", e))?;
let namespace: String = row
.try_get("namespace")
.unwrap_or_else(|_| crate::quotas::GLOBAL_NAMESPACE.to_string());
let max_memories_per_day: i64 = row
.try_get("max_memories_per_day")
.map_err(|e| to_store_err("read max_memories_per_day", e))?;
let max_storage_bytes: i64 = row
.try_get("max_storage_bytes")
.map_err(|e| to_store_err("read max_storage_bytes", e))?;
let max_links_per_day: i64 = row
.try_get("max_links_per_day")
.map_err(|e| to_store_err("read max_links_per_day", e))?;
let current_memories_today: i64 = row
.try_get("current_memories_today")
.map_err(|e| to_store_err("read current_memories_today", e))?;
let current_storage_bytes: i64 = row
.try_get("current_storage_bytes")
.map_err(|e| to_store_err("read current_storage_bytes", e))?;
let current_links_today: i64 = row
.try_get("current_links_today")
.map_err(|e| to_store_err("read current_links_today", e))?;
let day_started_at: DateTime<Utc> = row
.try_get("day_started_at")
.map_err(|e| to_store_err("read day_started_at", e))?;
let created_at: DateTime<Utc> = row
.try_get(field_names::CREATED_AT)
.map_err(|e| to_store_err(READ_CREATED_AT, e))?;
let updated_at: DateTime<Utc> = row
.try_get(field_names::UPDATED_AT)
.map_err(|e| to_store_err("read updated_at", e))?;
Ok(QuotaStatus {
agent_id,
namespace,
max_memories_per_day,
max_storage_bytes,
max_links_per_day,
current_memories_today,
current_storage_bytes,
current_links_today,
day_started_at: day_started_at.to_rfc3339(),
created_at: created_at.to_rfc3339(),
updated_at: updated_at.to_rfc3339(),
})
}
pub async fn list_archived_via_store(
store: &std::sync::Arc<dyn MemoryStore>,
namespace: Option<&str>,
limit: usize,
offset: usize,
) -> StoreResult<Vec<serde_json::Value>> {
let pg = downcast_postgres(store)?;
pg.list_archived_pg(namespace, limit, offset).await
}
pub async fn kg_query_via_store(
store: &std::sync::Arc<dyn MemoryStore>,
source_id: &str,
max_depth: usize,
include_invalidated: bool,
) -> StoreResult<Vec<crate::store::KgQueryRow>> {
let pg = downcast_postgres(store)?;
pg.kg_query_with_history(source_id, max_depth, include_invalidated)
.await
}
pub async fn kg_timeline_via_store(
store: &std::sync::Arc<dyn MemoryStore>,
source_id: &str,
since: Option<&str>,
until: Option<&str>,
limit: Option<usize>,
) -> StoreResult<Vec<crate::store::KgTimelineRow>> {
let pg = downcast_postgres(store)?;
pg.kg_timeline(source_id, since, until, limit).await
}
pub async fn kg_invalidate_via_store(
store: &std::sync::Arc<dyn MemoryStore>,
source_id: &str,
target_id: &str,
relation: &str,
valid_until: Option<&str>,
) -> StoreResult<crate::store::KgInvalidateRow> {
let pg = downcast_postgres(store)?;
pg.kg_invalidate(source_id, target_id, relation, valid_until)
.await
}
pub async fn archive_stats_via_store(
store: &std::sync::Arc<dyn MemoryStore>,
) -> StoreResult<serde_json::Value> {
let pg = downcast_postgres(store)?;
pg.archive_stats().await
}
pub async fn taxonomy_namespaces_via_store(
store: &std::sync::Arc<dyn MemoryStore>,
prefix: Option<&str>,
) -> StoreResult<Vec<(String, i64)>> {
let pg = downcast_postgres(store)?;
pg.taxonomy_namespaces(prefix).await
}
pub async fn list_pending_actions_via_store(
store: &std::sync::Arc<dyn MemoryStore>,
status: Option<&str>,
namespace: Option<&str>,
limit: usize,
) -> StoreResult<Vec<serde_json::Value>> {
let pg = downcast_postgres(store)?;
pg.list_pending_actions(status, namespace, limit).await
}
fn downcast_postgres(store: &std::sync::Arc<dyn MemoryStore>) -> StoreResult<&PostgresStore> {
let any = store.as_any();
any.downcast_ref::<PostgresStore>()
.ok_or_else(|| StoreError::BackendUnavailable {
backend: "postgres".to_string(),
detail: "active store is not a PostgresStore".to_string(),
})
}
impl PostgresStore {
pub(super) async fn list_archived_pg(
&self,
namespace: Option<&str>,
limit: usize,
offset: usize,
) -> StoreResult<Vec<serde_json::Value>> {
let limit_i: i64 = limit
.clamp(1, crate::storage::LIST_MAX_LIMIT)
.try_into()
.unwrap_or(ARCHIVED_LIST_FALLBACK_I64);
let offset_i: i64 = offset.try_into().unwrap_or(0);
let rows = sqlx::query(
"SELECT id, tier, namespace, title, content, tags, priority, confidence, \
source, access_count, created_at, updated_at, last_accessed_at, \
expires_at, archived_at, archive_reason, metadata, \
reflection_depth, memory_kind, entity_id, persona_version, \
citations, source_uri, source_span, confidence_source, \
confidence_signals, confidence_decayed_at, version, \
atomised_into, atom_of, mentioned_entity_id \
FROM archived_memories \
WHERE ($1::text IS NULL OR namespace = $1) \
ORDER BY archived_at DESC \
LIMIT $2 OFFSET $3",
)
.bind(namespace)
.bind(limit_i)
.bind(offset_i)
.fetch_all(&self.pool)
.await
.map_err(|e| to_store_err("list archived memories", e))?;
let mut out = Vec::with_capacity(rows.len());
for row in rows {
use sqlx::Row;
let tags_jsonb: serde_json::Value = row
.try_get("tags")
.map_err(|e| to_store_err("list_archived tags decode", e))?;
let tags_string =
serde_json::to_string(&tags_jsonb).map_err(|e| StoreError::InvalidInput {
detail: format!("list_archived tags serialise: {e}"),
})?;
let metadata: serde_json::Value = row
.try_get("metadata")
.map_err(|e| to_store_err("list_archived metadata decode", e))?;
let last_accessed_at: Option<DateTime<Utc>> = row
.try_get(field_names::LAST_ACCESSED_AT)
.map_err(|e| to_store_err("list_archived last_accessed_at decode", e))?;
let expires_at: Option<DateTime<Utc>> = row
.try_get(field_names::EXPIRES_AT)
.map_err(|e| to_store_err("list_archived expires_at decode", e))?;
let archived_at: DateTime<Utc> = row
.try_get(field_names::ARCHIVED_AT)
.map_err(|e| to_store_err("list_archived archived_at decode", e))?;
let created_at: DateTime<Utc> = row
.try_get(field_names::CREATED_AT)
.map_err(|e| to_store_err("list_archived created_at decode", e))?;
let updated_at: DateTime<Utc> = row
.try_get(field_names::UPDATED_AT)
.map_err(|e| to_store_err("list_archived updated_at decode", e))?;
let id: String = row
.try_get("id")
.map_err(|e| to_store_err("list_archived id decode", e))?;
let tier: String = row
.try_get("tier")
.map_err(|e| to_store_err("list_archived tier decode", e))?;
let namespace: String = row
.try_get("namespace")
.map_err(|e| to_store_err("list_archived namespace decode", e))?;
let title: String = row
.try_get("title")
.map_err(|e| to_store_err("list_archived title decode", e))?;
let content: String = row
.try_get("content")
.map_err(|e| to_store_err("list_archived content decode", e))?;
let priority: i32 = row
.try_get("priority")
.map_err(|e| to_store_err("list_archived priority decode", e))?;
let confidence: f64 = row
.try_get(field_names::CONFIDENCE)
.map_err(|e| to_store_err("list_archived confidence decode", e))?;
let source: String = row
.try_get("source")
.map_err(|e| to_store_err("list_archived source decode", e))?;
let access_count: i64 = row
.try_get(field_names::ACCESS_COUNT)
.map_err(|e| to_store_err("list_archived access_count decode", e))?;
let archive_reason: String = row
.try_get(field_names::ARCHIVE_REASON)
.map_err(|e| to_store_err("list_archived archive_reason decode", e))?;
let reflection_depth: Option<i32> = row
.try_get(field_names::REFLECTION_DEPTH)
.map_err(|e| to_store_err("list_archived reflection_depth decode", e))?;
let memory_kind: Option<String> = row
.try_get(field_names::MEMORY_KIND)
.map_err(|e| to_store_err("list_archived memory_kind decode", e))?;
let entity_id: Option<String> = row
.try_get("entity_id")
.map_err(|e| to_store_err("list_archived entity_id decode", e))?;
let persona_version: Option<i32> = row
.try_get(field_names::PERSONA_VERSION)
.map_err(|e| to_store_err("list_archived persona_version decode", e))?;
let citations: Option<String> = row
.try_get("citations")
.map_err(|e| to_store_err("list_archived citations decode", e))?;
let source_uri: Option<String> = row
.try_get(field_names::SOURCE_URI)
.map_err(|e| to_store_err("list_archived source_uri decode", e))?;
let source_span: Option<String> = row
.try_get(field_names::SOURCE_SPAN)
.map_err(|e| to_store_err("list_archived source_span decode", e))?;
let confidence_source: Option<String> = row
.try_get(field_names::CONFIDENCE_SOURCE)
.map_err(|e| to_store_err("list_archived confidence_source decode", e))?;
let confidence_signals: Option<String> =
row.try_get(field_names::CONFIDENCE_SIGNALS)
.map_err(|e| to_store_err("list_archived confidence_signals decode", e))?;
let confidence_decayed_at: Option<String> = row
.try_get(field_names::CONFIDENCE_DECAYED_AT)
.map_err(|e| to_store_err("list_archived confidence_decayed_at decode", e))?;
let version: Option<i64> = row
.try_get("version")
.map_err(|e| to_store_err("list_archived version decode", e))?;
let atomised_into: Option<i32> = row
.try_get(field_names::ATOMISED_INTO)
.map_err(|e| to_store_err("list_archived atomised_into decode", e))?;
let atom_of: Option<String> = row
.try_get(field_names::ATOM_OF)
.map_err(|e| to_store_err("list_archived atom_of decode", e))?;
let mentioned_entity_id: Option<String> = row
.try_get(field_names::MENTIONED_ENTITY_ID)
.map_err(|e| to_store_err("list_archived mentioned_entity_id decode", e))?;
out.push(serde_json::json!({
"id": id,
"tier": tier,
"namespace": namespace,
"title": title,
"content": content,
"tags": tags_string,
"priority": priority,
(field_names::CONFIDENCE): confidence,
"source": source,
(field_names::ACCESS_COUNT): access_count,
(field_names::CREATED_AT): created_at.to_rfc3339(),
(field_names::UPDATED_AT): updated_at.to_rfc3339(),
(field_names::LAST_ACCESSED_AT): last_accessed_at.map(|d| d.to_rfc3339()),
(field_names::EXPIRES_AT): expires_at.map(|d| d.to_rfc3339()),
(field_names::ARCHIVED_AT): archived_at.to_rfc3339(),
(field_names::ARCHIVE_REASON): archive_reason,
"metadata": metadata,
(field_names::REFLECTION_DEPTH): reflection_depth.unwrap_or(0),
(field_names::MEMORY_KIND): memory_kind,
"entity_id": entity_id,
(field_names::PERSONA_VERSION): persona_version,
"citations": citations
.and_then(|c| serde_json::from_str::<serde_json::Value>(&c).ok())
.unwrap_or_else(|| serde_json::json!([])),
(field_names::SOURCE_URI): source_uri,
(field_names::SOURCE_SPAN): source_span
.and_then(|c| serde_json::from_str::<serde_json::Value>(&c).ok()),
(field_names::CONFIDENCE_SOURCE): confidence_source,
(field_names::CONFIDENCE_SIGNALS): confidence_signals
.and_then(|c| serde_json::from_str::<serde_json::Value>(&c).ok()),
(field_names::CONFIDENCE_DECAYED_AT): confidence_decayed_at,
"version": version.unwrap_or(1),
(field_names::ATOMISED_INTO): atomised_into,
(field_names::ATOM_OF): atom_of,
(field_names::MENTIONED_ENTITY_ID): mentioned_entity_id,
}));
}
Ok(out)
}
pub async fn taxonomy_namespaces(
&self,
prefix: Option<&str>,
) -> StoreResult<Vec<(String, i64)>> {
use sqlx::Row;
let rows = if let Some(p) = prefix {
let escaped = p
.replace('\\', "\\\\")
.replace('%', "\\%")
.replace('_', "\\_");
let descendant = format!("{escaped}/%");
sqlx::query(
"SELECT namespace, COUNT(*) AS cnt
FROM memories
WHERE (expires_at IS NULL OR expires_at > NOW())
AND (namespace = $1 OR namespace LIKE $2 ESCAPE '\\')
GROUP BY namespace
ORDER BY cnt DESC, namespace ASC",
)
.bind(p)
.bind(descendant)
.fetch_all(&self.pool)
.await
.map_err(|e| to_store_err("taxonomy_namespaces prefix", e))?
} else {
sqlx::query(
"SELECT namespace, COUNT(*) AS cnt
FROM memories
WHERE (expires_at IS NULL OR expires_at > NOW())
GROUP BY namespace
ORDER BY cnt DESC, namespace ASC",
)
.fetch_all(&self.pool)
.await
.map_err(|e| to_store_err("taxonomy_namespaces all", e))?
};
let mut out: Vec<(String, i64)> = Vec::with_capacity(rows.len());
for r in rows {
let ns: String = r.try_get("namespace").unwrap_or_default();
let cnt: i64 = r.try_get("cnt").unwrap_or(0);
out.push((ns, cnt));
}
Ok(out)
}
pub async fn list_pending_actions(
&self,
status: Option<&str>,
namespace: Option<&str>,
limit: usize,
) -> StoreResult<Vec<serde_json::Value>> {
use sqlx::Row;
let limit_i: i64 = limit
.clamp(1, crate::storage::LIST_MAX_LIMIT)
.try_into()
.unwrap_or(LIST_FALLBACK_LIMIT_I64);
let rows = sqlx::query(
"SELECT id, action_type, memory_id, namespace, payload, requested_by, \
requested_at, status, decided_by, decided_at, approvals, \
default_timeout_seconds, expired_at \
FROM pending_actions \
WHERE ($1::text IS NULL OR status = $1) \
AND ($2::text IS NULL OR namespace = $2) \
ORDER BY requested_at DESC \
LIMIT $3",
)
.bind(status)
.bind(namespace)
.bind(limit_i)
.fetch_all(&self.pool)
.await
.map_err(|e| to_store_err("list_pending_actions", e))?;
let mut out: Vec<serde_json::Value> = Vec::with_capacity(rows.len());
for r in &rows {
let id: String = r.try_get("id").unwrap_or_default();
let action_type: String = r.try_get(field_names::ACTION_TYPE).unwrap_or_default();
let memory_id: Option<String> = r.try_get("memory_id").ok();
let ns: String = r.try_get("namespace").unwrap_or_default();
let payload: serde_json::Value =
r.try_get("payload").unwrap_or(serde_json::Value::Null);
let requested_by: String = r.try_get(field_names::REQUESTED_BY).unwrap_or_default();
let requested_at: chrono::DateTime<chrono::Utc> = r
.try_get(field_names::REQUESTED_AT)
.unwrap_or_else(|_| chrono::Utc::now());
let status_v: String = r.try_get("status").unwrap_or_default();
let decided_by: Option<String> = r.try_get(field_names::DECIDED_BY).ok();
let decided_at: Option<chrono::DateTime<chrono::Utc>> =
r.try_get(field_names::DECIDED_AT).ok();
let approvals: serde_json::Value = r
.try_get("approvals")
.unwrap_or(serde_json::Value::Array(Vec::new()));
let default_timeout_seconds: Option<i64> =
r.try_get(field_names::DEFAULT_TIMEOUT_SECONDS).ok();
let expired_at: Option<chrono::DateTime<chrono::Utc>> =
r.try_get(field_names::EXPIRED_AT).ok();
out.push(serde_json::json!({
"id": id,
(field_names::ACTION_TYPE): action_type,
"memory_id": memory_id,
"namespace": ns,
"payload": payload,
(field_names::REQUESTED_BY): requested_by,
(field_names::REQUESTED_AT): requested_at.to_rfc3339(),
"status": status_v,
(field_names::DECIDED_BY): decided_by,
(field_names::DECIDED_AT): decided_at.map(|t| t.to_rfc3339()),
"approvals": approvals,
(field_names::DEFAULT_TIMEOUT_SECONDS): default_timeout_seconds,
(field_names::EXPIRED_AT): expired_at.map(|t| t.to_rfc3339()),
}));
}
Ok(out)
}
async fn archive_stats(&self) -> StoreResult<serde_json::Value> {
use sqlx::Row;
let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM archived_memories")
.fetch_one(&self.pool)
.await
.map_err(|e| to_store_err("archive stats total", e))?;
let by_reason_rows = sqlx::query(
"SELECT archive_reason, COUNT(*) AS cnt FROM archived_memories \
GROUP BY archive_reason ORDER BY cnt DESC",
)
.fetch_all(&self.pool)
.await
.map_err(|e| to_store_err("archive stats by_reason", e))?;
let mut by_reason: serde_json::Map<String, serde_json::Value> = serde_json::Map::new();
for r in by_reason_rows {
let reason: String = r.try_get(field_names::ARCHIVE_REASON).unwrap_or_default();
let cnt: i64 = r.try_get("cnt").unwrap_or(0);
by_reason.insert(reason, serde_json::json!(cnt));
}
let by_namespace_rows = sqlx::query(
"SELECT namespace, COUNT(*) AS cnt FROM archived_memories \
GROUP BY namespace ORDER BY cnt DESC LIMIT 100",
)
.fetch_all(&self.pool)
.await
.map_err(|e| to_store_err("archive stats by_namespace", e))?;
let mut by_namespace: serde_json::Map<String, serde_json::Value> = serde_json::Map::new();
for r in by_namespace_rows {
let ns: String = r.try_get("namespace").unwrap_or_default();
let cnt: i64 = r.try_get("cnt").unwrap_or(0);
by_namespace.insert(ns, serde_json::json!(cnt));
}
Ok(serde_json::json!({
"total_archived": total,
"by_reason": by_reason,
(field_names::BY_NAMESPACE): by_namespace,
}))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn prefix_upper_bound_increments_last_byte() {
assert_eq!(
prefix_upper_bound("_subscriptions/").as_deref(),
Some("_subscriptions0")
);
assert_eq!(prefix_upper_bound("a").as_deref(), Some("b"));
assert_eq!(prefix_upper_bound("ns/").as_deref(), Some("ns0"));
}
#[test]
fn prefix_upper_bound_bounds_exactly_the_prefix_band() {
let lo = "_subscriptions/";
let hi = prefix_upper_bound(lo).unwrap();
for inside in [
"_subscriptions/",
"_subscriptions/alice",
"_subscriptions/zzz",
"_subscriptions/\u{10FFFF}",
] {
assert!(inside >= lo && inside < hi.as_str(), "{inside}");
}
for outside in [
"_subscriptions", "_subscriptions.", "_subscriptions0", "_subscriptions0a", "_subscriptionz", ] {
assert!(
!(outside >= lo && outside < hi.as_str()),
"{outside} must fall outside [{lo}, {hi})"
);
}
}
#[test]
fn prefix_upper_bound_empty_and_non_ascii_degrade_to_none() {
assert_eq!(prefix_upper_bound(""), None);
assert_eq!(prefix_upper_bound("\u{00ff}"), None);
assert_eq!(prefix_upper_bound("_inbox/").as_deref(), Some("_inbox0"));
}
#[test]
fn assert_age_id_safe_accepts_canonical_uuid() {
assert!(assert_age_id_safe("a0a15bb5-5aa2-4219-96bd-d15f27618eb8").is_ok());
}
#[test]
fn assert_age_id_safe_accepts_alphanumeric_dash_underscore() {
assert!(assert_age_id_safe("AaZz09-_").is_ok());
assert!(assert_age_id_safe("single").is_ok());
}
#[test]
fn assert_age_id_safe_rejects_empty() {
assert!(assert_age_id_safe("").is_err());
}
#[test]
fn assert_age_id_safe_rejects_over_max_length() {
let s = "a".repeat(129);
assert!(assert_age_id_safe(&s).is_err());
let s = "a".repeat(128);
assert!(assert_age_id_safe(&s).is_ok(), "boundary 128 is inclusive");
}
#[test]
fn assert_age_id_safe_rejects_single_quote() {
assert!(assert_age_id_safe("a'b").is_err());
assert!(assert_age_id_safe("a''b").is_err());
}
#[test]
fn assert_age_id_safe_rejects_double_quote_and_backtick() {
assert!(assert_age_id_safe("a\"b").is_err());
assert!(assert_age_id_safe("a`b").is_err());
}
#[test]
fn assert_age_id_safe_rejects_dollar_sign() {
assert!(assert_age_id_safe("$start").is_err());
assert!(assert_age_id_safe("a$$b").is_err());
}
#[test]
fn assert_age_id_safe_rejects_braces_brackets_parens() {
assert!(assert_age_id_safe("a{b").is_err());
assert!(assert_age_id_safe("a}b").is_err());
assert!(assert_age_id_safe("a[b").is_err());
assert!(assert_age_id_safe("a]b").is_err());
assert!(assert_age_id_safe("a(b").is_err());
assert!(assert_age_id_safe("a)b").is_err());
}
#[test]
fn assert_age_id_safe_rejects_semicolon_and_comma() {
assert!(assert_age_id_safe("a;b").is_err());
assert!(assert_age_id_safe("a,b").is_err());
}
#[test]
fn assert_age_id_safe_rejects_comment_chars() {
assert!(assert_age_id_safe("a/b").is_err());
assert!(assert_age_id_safe("a*b").is_err());
assert!(assert_age_id_safe("a--b").is_ok());
}
#[test]
fn assert_age_id_safe_rejects_whitespace() {
assert!(assert_age_id_safe("a b").is_err());
assert!(assert_age_id_safe("a\tb").is_err());
assert!(assert_age_id_safe("a\nb").is_err());
assert!(assert_age_id_safe("a\rb").is_err());
}
#[test]
fn assert_age_id_safe_rejects_age_type_tags() {
assert!(assert_age_id_safe("a::vertex").is_err());
assert!(assert_age_id_safe("a:b").is_err());
}
#[test]
fn assert_age_id_safe_rejects_unicode_homoglyphs() {
assert!(assert_age_id_safe("аbc").is_err()); assert!(assert_age_id_safe("a\u{200B}b").is_err()); assert!(assert_age_id_safe("⟨admin⟩").is_err());
}
#[test]
fn assert_age_id_safe_rejects_null_byte() {
assert!(assert_age_id_safe("a\0b").is_err());
}
#[test]
fn capabilities_advertise_native_vector() {
let caps = Capabilities::TRANSACTIONS
| Capabilities::NATIVE_VECTOR
| Capabilities::FULLTEXT
| Capabilities::DURABLE
| Capabilities::STRONG_CONSISTENCY
| Capabilities::ATOMIC_MULTI_WRITE;
assert!(caps.contains(Capabilities::NATIVE_VECTOR));
assert!(caps.contains(Capabilities::FULLTEXT));
assert!(caps.contains(Capabilities::STRONG_CONSISTENCY));
assert!(!caps.contains(Capabilities::TTL_NATIVE));
}
#[test]
fn parse_rfc3339_opt_handles_some_and_none() {
assert!(parse_rfc3339_opt(None).is_none());
assert!(parse_rfc3339_opt(Some("not a date")).is_none());
let parsed = parse_rfc3339_opt(Some("2026-04-19T16:00:00Z"));
assert!(parsed.is_some());
}
#[test]
fn parse_rfc3339_required_rejects_garbage() {
assert!(parse_rfc3339_required("garbage").is_err());
assert!(parse_rfc3339_required("2026-04-19T16:00:00Z").is_ok());
}
#[test]
fn init_schema_contains_vector_extension_and_indexes() {
assert!(INIT_SCHEMA.contains("CREATE EXTENSION IF NOT EXISTS vector"));
assert!(INIT_SCHEMA.contains("memories_embedding_hnsw"));
assert!(INIT_SCHEMA.contains("to_tsvector"));
}
#[test]
fn init_schema_contains_schema_version_table() {
assert!(INIT_SCHEMA.contains("CREATE TABLE IF NOT EXISTS schema_version"));
assert!(INIT_SCHEMA.contains("version INTEGER PRIMARY KEY"));
}
#[test]
fn governance_inheritance_depth_cap_is_five() {
assert_eq!(super::GOVERNANCE_INHERITANCE_DEPTH_CAP, 5);
}
#[test]
fn namespace_ancestors_within_cap_pass_through() {
let ancestors: Vec<String> = crate::models::namespace_ancestors("a/b/c")
.into_iter()
.rev()
.collect();
assert_eq!(ancestors.len(), 3);
assert_eq!(ancestors[0], "a");
assert_eq!(ancestors[2], "a/b/c");
}
#[test]
fn namespace_ancestors_at_max_namespace_depth() {
let deep = "l1/l2/l3/l4/l5/l6/l7/l8";
let ancestors: Vec<String> = crate::models::namespace_ancestors(deep)
.into_iter()
.rev()
.collect();
assert_eq!(ancestors.len(), 8);
let cap = super::GOVERNANCE_INHERITANCE_DEPTH_CAP;
let kept: Vec<String> = if ancestors.len() > cap {
ancestors
.iter()
.skip(ancestors.len() - cap)
.cloned()
.collect()
} else {
ancestors
};
assert_eq!(kept.len(), cap);
assert_eq!(kept.last().map(String::as_str), Some(deep));
assert_eq!(kept.first().map(String::as_str), Some("l1/l2/l3/l4"));
}
#[test]
fn namespace_ancestors_star_short_circuits() {
let ancestors = crate::models::namespace_ancestors("*");
assert_eq!(ancestors, vec!["*".to_string()]);
}
#[test]
fn schema_template_carries_embedding_dim_placeholder() {
assert!(
INIT_SCHEMA.contains(EMBEDDING_DIM_PLACEHOLDER),
"postgres_schema.sql must contain {EMBEDDING_DIM_PLACEHOLDER}"
);
assert!(
!INIT_SCHEMA.contains("vector(384)"),
"postgres_schema.sql must not contain hardcoded vector(384)"
);
assert!(
!INIT_SCHEMA.contains("vector(768)"),
"postgres_schema.sql must not contain hardcoded vector(768)"
);
}
#[test]
fn render_schema_sql_substitutes_768() {
let rendered = render_schema_sql(INIT_SCHEMA, 768);
assert!(rendered.contains("vector(768)"), "missing vector(768)");
assert!(!rendered.contains("vector(384)"), "stray vector(384)");
assert!(
!rendered.contains(EMBEDDING_DIM_PLACEHOLDER),
"placeholder not substituted: {rendered}"
);
}
#[test]
fn render_schema_sql_substitutes_384() {
let rendered = render_schema_sql(INIT_SCHEMA, 384);
assert!(rendered.contains("vector(384)"), "missing vector(384)");
assert!(
!rendered.contains(EMBEDDING_DIM_PLACEHOLDER),
"placeholder not substituted: {rendered}"
);
}
#[test]
fn render_schema_sql_handles_arbitrary_dim() {
let rendered = render_schema_sql("vector({EMBEDDING_DIM})", 1024);
assert_eq!(rendered, "vector(1024)");
}
#[test]
fn supported_embedding_dims_match_compiled_embedders() {
assert_eq!(SUPPORTED_EMBEDDING_DIMS, &[384, 768]);
}
fn age_url() -> Option<String> {
std::env::var("AI_MEMORY_TEST_AGE_URL").ok()
}
#[test]
fn kg_backend_default_tag_is_cte() {
assert_eq!(KgBackend::Cte.as_str(), "cte");
assert_eq!(KgBackend::Age.as_str(), "age");
}
#[tokio::test]
async fn live_kg_backend_resolves_to_age_when_extension_present() {
let Some(url) = age_url() else {
eprintln!("skip: AI_MEMORY_TEST_AGE_URL not set");
return;
};
let store = PostgresStore::connect(&url).await.expect("connect");
assert_eq!(
store.kg_backend(),
KgBackend::Age,
"AGE-enabled Postgres must resolve to KgBackend::Age"
);
}
#[tokio::test]
async fn live_kg_backend_resolves_to_cte_without_age() {
let Some(url) = postgres_url() else {
eprintln!("skip: AI_MEMORY_TEST_POSTGRES_URL not set");
return;
};
let store = PostgresStore::connect(&url).await.expect("connect");
if store.kg_backend() == KgBackend::Age {
eprintln!(
"skip: connected DB has AGE installed; this test exercises the CTE-fallback path (#1272)"
);
return;
}
assert_eq!(
store.kg_backend(),
KgBackend::Cte,
"Postgres without AGE must resolve to KgBackend::Cte"
);
}
#[tokio::test]
async fn live_detect_kg_backend_returns_cte_on_missing_extension() {
let Some(url) = postgres_url() else {
return;
};
let store = PostgresStore::connect(&url).await.expect("connect");
let probed = detect_kg_backend(&store.pool).await;
if probed == KgBackend::Age {
eprintln!(
"skip: connected DB has AGE installed; this test exercises the CTE-fallback path (#1272)"
);
return;
}
assert_eq!(probed, KgBackend::Cte);
}
#[test]
fn validate_depth_rejects_zero_and_overflow() {
assert!(matches!(
validate_depth(0),
Err(StoreError::InvalidInput { .. })
));
assert!(matches!(
validate_depth(KG_QUERY_MAX_SUPPORTED_DEPTH + 1),
Err(StoreError::InvalidInput { .. })
));
assert!(validate_depth(1).is_ok());
assert!(validate_depth(KG_QUERY_MAX_SUPPORTED_DEPTH).is_ok());
}
#[test]
fn strip_agtype_quotes_recovers_scalar_payload() {
assert_eq!(strip_agtype_quotes("\"mem-1\""), "mem-1");
assert_eq!(strip_agtype_quotes("3"), "3");
assert_eq!(strip_agtype_quotes(" \"mem-1\" "), "mem-1");
assert_eq!(strip_agtype_quotes("42"), "42");
assert_eq!(strip_agtype_quotes("\""), "\"");
}
fn age_kg_url() -> Option<String> {
std::env::var("AI_MEMORY_TEST_AGE_URL").ok()
}
#[tokio::test]
async fn live_kg_query_dispatches_to_cypher_under_age() {
let Some(url) = age_kg_url() else {
eprintln!("skip: AI_MEMORY_TEST_AGE_URL not set");
return;
};
let store = PostgresStore::connect(&url).await.expect("connect");
assert_eq!(store.kg_backend(), KgBackend::Age);
match store.kg_query("nonexistent-source", 1).await {
Ok(rows) => {
assert!(
rows.is_empty() || rows.iter().all(|r| !r.target_id.is_empty()),
"rows must have non-empty target_ids when present"
);
}
Err(StoreError::BackendUnavailable { detail, .. }) => {
eprintln!(
"AGE graph projection appears unbootstrapped on this URL: {detail}; \
run the J1 graph-prep script before re-running this test"
);
}
Err(other) => panic!("unexpected error from AGE kg_query: {other:?}"),
}
}
#[tokio::test]
async fn live_kg_query_routes_to_cte_without_age() {
let Some(url) = postgres_url() else {
eprintln!("skip: AI_MEMORY_TEST_POSTGRES_URL not set");
return;
};
let store = PostgresStore::connect(&url).await.expect("connect");
if store.kg_backend() == KgBackend::Age {
eprintln!(
"skip: connected DB has AGE installed; this test exercises the CTE-fallback path (#1272)"
);
return;
}
assert_eq!(
store.kg_backend(),
KgBackend::Cte,
"vanilla Postgres must resolve to KgBackend::Cte"
);
let direct = store
.kg_query_cte("nonexistent-source", 1)
.await
.expect("cte direct");
let dispatched = store
.kg_query("nonexistent-source", 1)
.await
.expect("cte via dispatcher");
assert!(direct.is_empty(), "no rows expected for synthetic id");
assert_eq!(
direct, dispatched,
"dispatcher must return the same shape as the direct CTE call"
);
}
#[tokio::test]
async fn live_kg_query_rejects_out_of_range_depth() {
let Some(url) = age_kg_url().or_else(postgres_url) else {
eprintln!("skip: no Postgres test URL set");
return;
};
let store = PostgresStore::connect(&url).await.expect("connect");
let zero = store.kg_query("any", 0).await;
let over = store
.kg_query("any", KG_QUERY_MAX_SUPPORTED_DEPTH + 1)
.await;
assert!(matches!(zero, Err(StoreError::InvalidInput { .. })));
assert!(matches!(over, Err(StoreError::InvalidInput { .. })));
}
#[test]
fn clamp_timeline_limit_applies_default_and_ceiling() {
assert_eq!(clamp_timeline_limit(None), KG_TIMELINE_DEFAULT_LIMIT_SAL);
assert_eq!(clamp_timeline_limit(Some(0)), 1);
assert_eq!(clamp_timeline_limit(Some(50)), 50);
assert_eq!(
clamp_timeline_limit(Some(KG_TIMELINE_MAX_LIMIT_SAL)),
KG_TIMELINE_MAX_LIMIT_SAL
);
assert_eq!(
clamp_timeline_limit(Some(KG_TIMELINE_MAX_LIMIT_SAL + 999)),
KG_TIMELINE_MAX_LIMIT_SAL
);
}
#[test]
fn age_params_literal_renders_json_dict() {
assert_eq!(age_params_literal(&[("k", "v")]), "'{\"k\":\"v\"}'::agtype");
assert_eq!(
age_params_literal(&[("a", "1"), ("b", "two")]),
"'{\"a\":\"1\",\"b\":\"two\"}'::agtype"
);
assert_eq!(
age_params_literal(&[("name", "O'Reilly")]),
"'{\"name\":\"O''Reilly\"}'::agtype"
);
assert_eq!(age_params_literal(&[]), "'{}'::agtype");
}
#[test]
fn issue_1689_kg_query_cypher_excludes_invalidated_edges() {
let depth = 3usize;
let cypher = build_kg_query_current_view_cypher(depth);
assert!(
cypher.contains(
"ALL(e IN relationships(p) WHERE e.valid_until IS NULL OR e.valid_until > $now)"
),
"current-view kg_query Cypher missing the valid_until guard: {cypher}"
);
assert!(
cypher.contains(&format!("related_to*1..{depth}")),
"depth not interpolated: {cypher}"
);
assert!(
cypher.contains("a.id = $start_id"),
"start id not parameterised: {cypher}"
);
}
#[test]
fn issue_1689_find_paths_cypher_excludes_invalidated_edges() {
let (src, dst, depth, cap, now) = (
"11111111-1111-1111-1111-111111111111",
"22222222-2222-2222-2222-222222222222",
4usize,
10usize,
"2026-06-15T00:00:00+00:00",
);
let cypher = build_find_paths_current_view_cypher(src, dst, depth, cap, now);
assert!(
cypher.contains(&format!(
"ALL(e IN relationships(p) WHERE e.valid_until IS NULL OR e.valid_until > '{now}')"
)),
"find_paths Cypher missing the valid_until guard: {cypher}"
);
assert!(
cypher.contains(&format!("[*1..{depth}]")),
"depth not interpolated: {cypher}"
);
assert!(
cypher.contains(&format!("a.id = '{src}'")),
"source id not inlined: {cypher}"
);
assert!(
cypher.contains(&format!("b.id = '{dst}'")),
"target id not inlined: {cypher}"
);
assert!(
cypher.contains(&format!("LIMIT {cap}")),
"cap not applied: {cypher}"
);
}
#[test]
fn build_or_tsquery_or_joins_lexemes() {
assert_eq!(build_or_tsquery("rust ownership"), "'rust' | 'ownership'");
assert_eq!(build_or_tsquery("dog field"), "'dog' | 'field'");
assert_eq!(build_or_tsquery("Rust Ownership"), "'rust' | 'ownership'");
assert_eq!(build_or_tsquery("dog"), "'dog'");
}
#[test]
fn build_or_tsquery_strips_tsquery_operators() {
let got = build_or_tsquery("cat | drop ' table");
assert!(
!got.contains('&') && !got.contains('!') && !got.contains('('),
"build_or_tsquery must drop tsquery operators: got {got}"
);
assert!(got.contains("'cat'"), "must keep the cat lexeme: {got}");
assert!(got.contains("'drop'"), "must keep the drop lexeme: {got}");
assert!(got.contains("'table'"), "must keep the table lexeme: {got}");
}
#[test]
fn build_or_tsquery_drops_short_tokens() {
assert_eq!(build_or_tsquery("a brown dog"), "'brown' | 'dog'");
assert_eq!(build_or_tsquery("x y z"), "'_empty_'");
}
#[test]
fn build_or_tsquery_falls_back_to_sentinel_for_empty_input() {
assert_eq!(build_or_tsquery(""), "'_empty_'");
assert_eq!(build_or_tsquery(" "), "'_empty_'");
assert_eq!(build_or_tsquery("!@# $%^"), "'_empty_'");
}
#[test]
fn build_or_tsquery_caps_token_count() {
let long = (0..50)
.map(|i| format!("tok{i:02}"))
.collect::<Vec<_>>()
.join(" ");
let got = build_or_tsquery(&long);
let lexeme_count = got.matches('|').count() + 1;
assert_eq!(
lexeme_count, 16,
"build_or_tsquery must cap to 16 tokens: got {lexeme_count} from {got}"
);
}
#[test]
fn agtype_optional_string_decodes_null_and_quoted() {
assert_eq!(agtype_optional_string("null"), None);
assert_eq!(agtype_optional_string("NULL"), None);
assert_eq!(
agtype_optional_string("\"agent-1\""),
Some("agent-1".to_string())
);
assert_eq!(
agtype_optional_string(" \"agent-1\" "),
Some("agent-1".to_string())
);
assert_eq!(agtype_optional_string("\"\""), Some(String::new()));
}
#[tokio::test]
async fn live_kg_timeline_dispatches_to_cypher_under_age() {
let Some(url) = age_kg_url() else {
eprintln!("skip: AI_MEMORY_TEST_AGE_URL not set");
return;
};
let store = PostgresStore::connect(&url).await.expect("connect");
assert_eq!(store.kg_backend(), KgBackend::Age);
match store
.kg_timeline("nonexistent-source", None, None, Some(10))
.await
{
Ok(rows) => {
assert!(
rows.is_empty() || rows.iter().all(|r| !r.target_id.is_empty()),
"rows must have non-empty target_ids when present"
);
}
Err(StoreError::BackendUnavailable { detail, .. }) => {
eprintln!(
"AGE graph projection appears unbootstrapped on this URL: {detail}; \
run the J1 graph-prep script before re-running this test"
);
}
Err(other) => panic!("unexpected error from AGE kg_timeline: {other:?}"),
}
}
#[tokio::test]
async fn live_kg_timeline_routes_to_cte_without_age() {
let Some(url) = postgres_url() else {
eprintln!("skip: AI_MEMORY_TEST_POSTGRES_URL not set");
return;
};
let store = PostgresStore::connect(&url).await.expect("connect");
if store.kg_backend() == KgBackend::Age {
eprintln!(
"skip: connected DB has AGE installed; this test exercises the CTE-fallback path (#1272)"
);
return;
}
assert_eq!(
store.kg_backend(),
KgBackend::Cte,
"vanilla Postgres must resolve to KgBackend::Cte"
);
let direct = store
.kg_timeline_cte("nonexistent-source", None, None, None)
.await
.expect("cte direct");
let dispatched = store
.kg_timeline("nonexistent-source", None, None, None)
.await
.expect("cte via dispatcher");
assert!(direct.is_empty(), "no rows expected for synthetic id");
assert_eq!(
direct, dispatched,
"dispatcher must return the same shape as the direct CTE call"
);
}
#[test]
fn kg_invalidate_row_default_no_match_shape() {
let row = KgInvalidateRow {
found: false,
valid_until: String::new(),
previous_valid_until: None,
};
assert!(!row.found);
assert!(row.valid_until.is_empty());
assert!(row.previous_valid_until.is_none());
}
#[test]
fn kg_invalidate_row_serialises_to_stable_json_keys() {
let row = KgInvalidateRow {
found: true,
valid_until: "2026-05-05T12:00:00+00:00".to_string(),
previous_valid_until: Some("2026-05-04T11:00:00+00:00".to_string()),
};
let v = serde_json::to_value(&row).expect("serialise");
assert_eq!(v["found"], serde_json::Value::Bool(true));
assert_eq!(v["valid_until"], "2026-05-05T12:00:00+00:00");
assert_eq!(v["previous_valid_until"], "2026-05-04T11:00:00+00:00");
}
#[tokio::test]
async fn live_kg_invalidate_dispatches_to_cypher_under_age() {
let Some(url) = age_kg_url() else {
eprintln!("skip: AI_MEMORY_TEST_AGE_URL not set");
return;
};
let store = PostgresStore::connect(&url).await.expect("connect");
assert_eq!(store.kg_backend(), KgBackend::Age);
match store
.kg_invalidate(
"nonexistent-source",
"nonexistent-target",
"related_to",
None,
)
.await
{
Ok(row) => {
assert!(!row.found, "synthetic ids must not match an existing edge");
assert!(row.valid_until.is_empty());
assert!(row.previous_valid_until.is_none());
}
Err(StoreError::BackendUnavailable { detail, .. }) => {
eprintln!(
"AGE graph projection appears unbootstrapped on this URL: {detail}; \
run the J1 graph-prep script before re-running this test"
);
}
Err(other) => panic!("unexpected error from AGE kg_invalidate: {other:?}"),
}
}
#[tokio::test]
async fn live_kg_invalidate_routes_to_cte_without_age() {
let Some(url) = postgres_url() else {
eprintln!("skip: AI_MEMORY_TEST_POSTGRES_URL not set");
return;
};
let store = PostgresStore::connect(&url).await.expect("connect");
if store.kg_backend() == KgBackend::Age {
eprintln!(
"skip: connected DB has AGE installed; this test exercises the CTE-fallback path (#1272)"
);
return;
}
assert_eq!(
store.kg_backend(),
KgBackend::Cte,
"vanilla Postgres must resolve to KgBackend::Cte"
);
let direct = store
.kg_invalidate_cte(
"nonexistent-source",
"nonexistent-target",
"related_to",
None,
)
.await
.expect("cte direct");
let dispatched = store
.kg_invalidate(
"nonexistent-source",
"nonexistent-target",
"related_to",
None,
)
.await
.expect("cte via dispatcher");
assert!(!direct.found, "synthetic triple must not match");
assert_eq!(
direct, dispatched,
"dispatcher must return the same shape as the direct CTE call"
);
}
fn postgres_url() -> Option<String> {
std::env::var("AI_MEMORY_TEST_POSTGRES_URL").ok()
}
fn sample_memory(id: &str, ns: &str, title: &str, content: &str) -> Memory {
let now = chrono::Utc::now().to_rfc3339();
Memory {
id: id.to_string(),
tier: Tier::Mid,
namespace: ns.to_string(),
title: title.to_string(),
content: content.to_string(),
tags: vec!["test".to_string()],
priority: 5,
confidence: 1.0,
source: "sal-integration".to_string(),
access_count: 0,
created_at: now.clone(),
updated_at: now,
last_accessed_at: None,
expires_at: None,
metadata: serde_json::json!({"agent_id":"ai:sal-test"}),
reflection_depth: 0,
memory_kind: crate::models::MemoryKind::Observation,
entity_id: None,
persona_version: None,
citations: Vec::new(),
source_uri: None,
source_span: None,
confidence_source: ConfidenceSource::CallerProvided,
confidence_signals: None,
confidence_decayed_at: None,
version: 1,
}
}
#[tokio::test]
async fn live_store_get_roundtrip() {
let Some(url) = postgres_url() else {
eprintln!("skip: AI_MEMORY_TEST_POSTGRES_URL not set");
return;
};
let store = PostgresStore::connect(&url).await.expect("connect");
let ctx = CallerContext::for_agent("ai:sal-test");
let ns = format!("sal-test-{}", uuid::Uuid::new_v4());
let mem = sample_memory(
&format!("test-{}", uuid::Uuid::new_v4()),
&ns,
"hello",
"quick brown fox jumps over the lazy dog",
);
let returned = store.store(&ctx, &mem).await.expect("store");
assert_eq!(returned, mem.id);
let fetched = store.get(&ctx, &mem.id).await.expect("get");
assert_eq!(fetched.title, "hello");
assert_eq!(fetched.namespace, ns);
}
#[tokio::test]
async fn live_search_finds_fts_match() {
let Some(url) = postgres_url() else {
return;
};
let store = PostgresStore::connect(&url).await.unwrap();
let ctx = CallerContext::for_agent("ai:sal-test");
let ns = format!("sal-search-{}", uuid::Uuid::new_v4());
let id = format!("search-test-{}", uuid::Uuid::new_v4());
let mem = sample_memory(
&id,
&ns,
"uniquephrase xyzzy42",
"body containing uniquephrase xyzzy42 as a distinctive token",
);
store.store(&ctx, &mem).await.unwrap();
let filter = Filter {
namespace: Some(ns.clone()),
limit: 5,
..Filter::default()
};
let hits = store
.search(&ctx, "xyzzy42", &filter)
.await
.expect("search");
assert!(hits.iter().any(|m| m.id == id));
}
#[tokio::test]
async fn live_delete_removes_row() {
let Some(url) = postgres_url() else {
return;
};
let store = PostgresStore::connect(&url).await.unwrap();
let ctx = CallerContext::for_agent("ai:sal-test");
let ns = format!("sal-del-{}", uuid::Uuid::new_v4());
let id = format!("del-test-{}", uuid::Uuid::new_v4());
let mem = sample_memory(&id, &ns, "to be deleted", "soon gone");
store.store(&ctx, &mem).await.unwrap();
store.delete(&ctx, &id).await.expect("delete");
let err = store.get(&ctx, &id).await.unwrap_err();
match err {
StoreError::NotFound { id: missing } => assert_eq!(missing, id),
other => panic!("expected NotFound, got {other:?}"),
}
}
#[tokio::test]
async fn upserts_by_title_namespace_not_id() {
let Some(url) = postgres_url() else {
return;
};
let store = PostgresStore::connect(&url).await.unwrap();
let ctx = CallerContext::for_agent("ai:sal-test");
let ns = format!("sal-upsert-{}", uuid::Uuid::new_v4());
let id_a = format!("upsert-a-{}", uuid::Uuid::new_v4());
let id_b = format!("upsert-b-{}", uuid::Uuid::new_v4());
let first = sample_memory(&id_a, &ns, "shared title", "first body");
let second = sample_memory(&id_b, &ns, "shared title", "second body");
let id_first = store.store(&ctx, &first).await.unwrap();
let id_second = store.store(&ctx, &second).await.unwrap();
assert_eq!(id_first, id_second, "upsert should return the same id");
let filter = Filter {
namespace: Some(ns.clone()),
limit: 10,
..Filter::default()
};
let listed = store.list(&ctx, &filter).await.unwrap();
assert_eq!(listed.len(), 1, "expected single upserted row");
assert_eq!(listed[0].content, "second body");
}
#[tokio::test]
async fn upsert_preserves_agent_id() {
let Some(url) = postgres_url() else {
return;
};
let store = PostgresStore::connect(&url).await.unwrap();
let ctx = CallerContext::for_admin("ai:sal-test");
let ns = format!("sal-agent-{}", uuid::Uuid::new_v4());
let id_a = format!("agent-1-{}", uuid::Uuid::new_v4());
let id_b = format!("agent-2-{}", uuid::Uuid::new_v4());
let mut first = sample_memory(&id_a, &ns, "owned-by-alice", "original");
first.metadata = serde_json::json!({"agent_id": "ai:alice"});
store.store(&ctx, &first).await.unwrap();
let mut second = sample_memory(&id_b, &ns, "owned-by-alice", "replayed");
second.metadata = serde_json::json!({"agent_id": "ai:attacker"});
store.store(&ctx, &second).await.unwrap();
let got = store
.get(&ctx, &store.store(&ctx, &second).await.unwrap())
.await
.unwrap();
assert_eq!(
got.metadata.get("agent_id").and_then(|v| v.as_str()),
Some("ai:alice"),
"agent_id must be pinned to the original writer"
);
}
#[tokio::test]
async fn update_refuses_tier_downgrade() {
let Some(url) = postgres_url() else {
return;
};
let store = PostgresStore::connect(&url).await.unwrap();
let ctx = CallerContext::for_agent("ai:sal-test");
let ns = format!("sal-tier-{}", uuid::Uuid::new_v4());
let id = format!("tier-test-{}", uuid::Uuid::new_v4());
let mut mem = sample_memory(&id, &ns, "long-pinned", "must not downgrade");
mem.tier = Tier::Long;
store.store(&ctx, &mem).await.unwrap();
let patch = UpdatePatch {
tier: Some(Tier::Short),
..UpdatePatch::default()
};
store.update(&ctx, &id, patch).await.unwrap();
let got = store.get(&ctx, &id).await.unwrap();
assert!(
matches!(got.tier, Tier::Long),
"tier must remain Long (got {:?})",
got.tier
);
}
#[tokio::test]
async fn migration_v15_is_idempotent() {
let Some(url) = postgres_url() else {
eprintln!("skipping: no AI_MEMORY_TEST_POSTGRES_URL");
return;
};
let store = PostgresStore::connect(&url).await.expect("connect");
let first_version: Option<i32> =
sqlx::query_scalar("SELECT COALESCE(MAX(version), 0) FROM schema_version")
.fetch_optional(&store.pool)
.await
.expect("read version after first connect");
store.migrate().await.expect("migrate again");
let second_version: Option<i32> =
sqlx::query_scalar("SELECT COALESCE(MAX(version), 0) FROM schema_version")
.fetch_optional(&store.pool)
.await
.expect("read version after second migrate");
assert_eq!(
first_version, second_version,
"schema version must be stable across repeated migrations"
);
let has_valid_from: Option<(i32,)> = sqlx::query_as(
"SELECT 1 FROM information_schema.columns
WHERE table_name='memory_links' AND column_name='valid_from'",
)
.fetch_optional(&store.pool)
.await
.expect("check valid_from column");
assert!(has_valid_from.is_some(), "valid_from column must exist");
let has_entity_aliases_idx: Option<(String,)> = sqlx::query_as(
"SELECT indexname FROM pg_indexes WHERE indexname='idx_entity_aliases_alias'",
)
.fetch_optional(&store.pool)
.await
.expect("check entity_aliases index");
assert!(
has_entity_aliases_idx.is_some(),
"idx_entity_aliases_alias must exist"
);
}
#[test]
fn init_schema_advertises_full_v28_shape() {
for needle in [
"embedding_dim",
"original_tier",
"original_expires_at",
"event_types",
"idx_subscriptions_event_types",
"CREATE TABLE IF NOT EXISTS audit_log",
"idx_audit_log_agent_id",
"default_timeout_seconds",
"expired_at",
"pending_actions_status_requested_idx",
"CREATE TABLE IF NOT EXISTS memory_transcripts",
"idx_memory_transcripts_namespace_created",
"idx_memory_links_attest_level",
"CREATE TABLE IF NOT EXISTS memory_transcript_links",
"idx_mtl_transcript",
"idx_mtl_memory",
"idx_memory_transcripts_archived_at",
"CREATE TABLE IF NOT EXISTS signed_events",
"idx_signed_events_agent",
"CREATE TABLE IF NOT EXISTS subscription_events",
"CREATE TABLE IF NOT EXISTS subscription_dlq",
"idx_subscription_events_correlation",
"idx_subscription_dlq_correlation",
"CREATE TABLE IF NOT EXISTS agent_quotas",
"idx_agent_quotas_agent_id",
] {
assert!(
INIT_SCHEMA.contains(needle),
"postgres_schema.sql missing expected v17-v28 fragment: {needle}"
);
}
}
#[test]
fn current_schema_version_matches_sqlite_ladder() {
assert_eq!(
i64::from(CURRENT_SCHEMA_VERSION),
crate::storage::migrations::current_schema_version(),
"postgres CURRENT_SCHEMA_VERSION must track the sqlite SSOT \
(storage::migrations::CURRENT_SCHEMA_VERSION). A bump on either \
side without the corresponding port re-trips this assertion."
);
}
#[tokio::test]
async fn live_migration_reaches_current_schema_version() {
let Some(url) = postgres_url() else {
eprintln!("skip: AI_MEMORY_TEST_POSTGRES_URL not set");
return;
};
let store = PostgresStore::connect(&url).await.expect("connect");
let stamped: Option<i32> = sqlx::query_scalar("SELECT MAX(version) FROM schema_version")
.fetch_optional(&store.pool)
.await
.expect("read max schema_version");
assert_eq!(
stamped,
Some(CURRENT_SCHEMA_VERSION),
"schema_version must reach CURRENT_SCHEMA_VERSION"
);
}
#[tokio::test]
async fn live_migration_v17_to_current_is_idempotent() {
let Some(url) = postgres_url() else {
eprintln!("skip: AI_MEMORY_TEST_POSTGRES_URL not set");
return;
};
let store = PostgresStore::connect(&url).await.expect("connect");
let first: Option<i32> = sqlx::query_scalar("SELECT MAX(version) FROM schema_version")
.fetch_optional(&store.pool)
.await
.expect("read first version");
store.migrate().await.expect("migrate again");
let second: Option<i32> = sqlx::query_scalar("SELECT MAX(version) FROM schema_version")
.fetch_optional(&store.pool)
.await
.expect("read second version");
assert_eq!(first, second, "migrate() must be idempotent");
assert_eq!(second, Some(CURRENT_SCHEMA_VERSION));
}
async fn assert_relation_exists(pool: &PgPool, relname: &str) {
let exists: Option<i32> = sqlx::query_scalar(
"SELECT 1 FROM pg_class WHERE relname = $1 AND relkind IN ('r','v')",
)
.bind(relname)
.fetch_optional(pool)
.await
.expect("query pg_class");
assert!(exists.is_some(), "expected relation {relname} to exist");
}
async fn assert_index_exists(pool: &PgPool, indexname: &str) {
let exists: Option<String> =
sqlx::query_scalar("SELECT indexname FROM pg_indexes WHERE indexname = $1")
.bind(indexname)
.fetch_optional(pool)
.await
.expect("query pg_indexes");
assert!(exists.is_some(), "expected index {indexname} to exist");
}
async fn assert_column_exists(pool: &PgPool, table: &str, column: &str) {
let exists: Option<i32> = sqlx::query_scalar(
"SELECT 1 FROM information_schema.columns
WHERE table_name = $1 AND column_name = $2",
)
.bind(table)
.bind(column)
.fetch_optional(pool)
.await
.expect("query information_schema");
assert!(
exists.is_some(),
"expected column {table}.{column} to exist"
);
}
#[tokio::test]
async fn live_v18_data_integrity_columns_present() {
let Some(url) = postgres_url() else {
eprintln!("skip: AI_MEMORY_TEST_POSTGRES_URL not set");
return;
};
let store = PostgresStore::connect(&url).await.expect("connect");
for (table, column) in [
("memories", "embedding_dim"),
("archived_memories", "embedding"),
("archived_memories", "embedding_dim"),
("archived_memories", "original_tier"),
("archived_memories", "original_expires_at"),
] {
assert_column_exists(&store.pool, table, column).await;
}
assert_index_exists(&store.pool, "idx_memories_embedding_dim").await;
assert_index_exists(&store.pool, "idx_memories_ns_dim").await;
}
#[tokio::test]
async fn live_v19_webhook_event_types_present() {
let Some(url) = postgres_url() else {
eprintln!("skip: AI_MEMORY_TEST_POSTGRES_URL not set");
return;
};
let store = PostgresStore::connect(&url).await.expect("connect");
assert_column_exists(&store.pool, "subscriptions", "event_types").await;
assert_index_exists(&store.pool, "idx_subscriptions_event_types").await;
}
#[tokio::test]
async fn live_v20_audit_log_table_present() {
let Some(url) = postgres_url() else {
eprintln!("skip: AI_MEMORY_TEST_POSTGRES_URL not set");
return;
};
let store = PostgresStore::connect(&url).await.expect("connect");
assert_relation_exists(&store.pool, "audit_log").await;
assert_index_exists(&store.pool, "idx_audit_log_agent_id").await;
assert_index_exists(&store.pool, "idx_audit_log_timestamp").await;
assert_index_exists(&store.pool, "idx_audit_log_event_type").await;
let now = chrono::Utc::now();
let id = format!("audit-{}", uuid::Uuid::new_v4());
sqlx::query(
"INSERT INTO audit_log
(id, agent_id, event_type, requested_family, granted, attestation_tier, timestamp)
VALUES ($1, $2, $3, $4, $5, $6, $7)",
)
.bind(&id)
.bind("ai:test")
.bind("capability_expansion")
.bind("kg")
.bind(true)
.bind(Option::<&str>::None)
.bind(now)
.execute(&store.pool)
.await
.expect("insert audit_log row");
let granted: bool = sqlx::query_scalar("SELECT granted FROM audit_log WHERE id = $1")
.bind(&id)
.fetch_one(&store.pool)
.await
.expect("read audit_log row");
assert!(granted, "round-trip should preserve granted=true");
sqlx::query("DELETE FROM audit_log WHERE id = $1")
.bind(&id)
.execute(&store.pool)
.await
.expect("delete audit_log row");
}
#[tokio::test]
async fn live_v21_pending_actions_timeout_columns_present() {
let Some(url) = postgres_url() else {
eprintln!("skip: AI_MEMORY_TEST_POSTGRES_URL not set");
return;
};
let store = PostgresStore::connect(&url).await.expect("connect");
assert_column_exists(&store.pool, "pending_actions", "default_timeout_seconds").await;
assert_column_exists(&store.pool, "pending_actions", "expired_at").await;
assert_index_exists(&store.pool, "pending_actions_status_requested_idx").await;
}
#[tokio::test]
async fn live_v22_memory_transcripts_table_present() {
let Some(url) = postgres_url() else {
eprintln!("skip: AI_MEMORY_TEST_POSTGRES_URL not set");
return;
};
let store = PostgresStore::connect(&url).await.expect("connect");
assert_relation_exists(&store.pool, "memory_transcripts").await;
assert_index_exists(&store.pool, "idx_memory_transcripts_namespace_created").await;
}
#[tokio::test]
async fn live_v24_transcript_links_and_kg_views_present() {
let Some(url) = postgres_url() else {
eprintln!("skip: AI_MEMORY_TEST_POSTGRES_URL not set");
return;
};
let store = PostgresStore::connect(&url).await.expect("connect");
assert_relation_exists(&store.pool, "memory_transcript_links").await;
assert_index_exists(&store.pool, "idx_mtl_transcript").await;
assert_index_exists(&store.pool, "idx_mtl_memory").await;
assert_relation_exists(&store.pool, "kg_query_view").await;
assert_relation_exists(&store.pool, "kg_timeline_view").await;
let fn_exists: Option<i32> =
sqlx::query_scalar("SELECT 1 FROM pg_proc WHERE proname = 'kg_find_paths'")
.fetch_optional(&store.pool)
.await
.expect("query pg_proc for kg_find_paths");
assert!(fn_exists.is_some(), "kg_find_paths function must exist");
}
#[tokio::test]
async fn live_v25_transcript_archive_lifecycle_present() {
let Some(url) = postgres_url() else {
eprintln!("skip: AI_MEMORY_TEST_POSTGRES_URL not set");
return;
};
let store = PostgresStore::connect(&url).await.expect("connect");
assert_column_exists(&store.pool, "memory_transcripts", "archived_at").await;
assert_index_exists(&store.pool, "idx_memory_transcripts_archived_at").await;
}
#[tokio::test]
async fn live_v26_signed_events_table_present() {
let Some(url) = postgres_url() else {
eprintln!("skip: AI_MEMORY_TEST_POSTGRES_URL not set");
return;
};
let store = PostgresStore::connect(&url).await.expect("connect");
assert_relation_exists(&store.pool, "signed_events").await;
for idx in [
"idx_signed_events_agent",
"idx_signed_events_type",
"idx_signed_events_timestamp",
] {
assert_index_exists(&store.pool, idx).await;
}
let id = format!("se-{}", uuid::Uuid::new_v4());
let payload_hash = vec![0u8; 32];
let now = chrono::Utc::now();
sqlx::query(
"INSERT INTO signed_events
(id, agent_id, event_type, payload_hash, signature, attest_level, timestamp)
VALUES ($1, $2, $3, $4, $5, $6, $7)",
)
.bind(&id)
.bind("ai:test")
.bind("memory_link.created")
.bind(&payload_hash)
.bind(Option::<Vec<u8>>::None)
.bind("unsigned")
.bind(now)
.execute(&store.pool)
.await
.expect("insert signed_events row");
let level: String =
sqlx::query_scalar("SELECT attest_level FROM signed_events WHERE id = $1")
.bind(&id)
.fetch_one(&store.pool)
.await
.expect("read signed_events row");
assert_eq!(level, "unsigned");
}
#[tokio::test]
async fn live_v27_subscription_events_and_dlq_present() {
let Some(url) = postgres_url() else {
eprintln!("skip: AI_MEMORY_TEST_POSTGRES_URL not set");
return;
};
let store = PostgresStore::connect(&url).await.expect("connect");
assert_relation_exists(&store.pool, "subscription_events").await;
assert_relation_exists(&store.pool, "subscription_dlq").await;
for idx in [
"idx_subscription_events_correlation",
"idx_subscription_events_subscription",
"idx_subscription_dlq_subscription",
"idx_subscription_dlq_correlation",
] {
assert_index_exists(&store.pool, idx).await;
}
assert_column_exists(&store.pool, "subscription_events", "correlation_id").await;
}
#[tokio::test]
async fn live_v28_agent_quotas_table_present_and_writable() {
let Some(url) = postgres_url() else {
eprintln!("skip: AI_MEMORY_TEST_POSTGRES_URL not set");
return;
};
let store = PostgresStore::connect(&url).await.expect("connect");
assert_relation_exists(&store.pool, "agent_quotas").await;
assert_index_exists(&store.pool, "idx_agent_quotas_agent_id").await;
let agent = format!("ai:quota-test-{}", uuid::Uuid::new_v4());
let now = chrono::Utc::now();
sqlx::query(
"INSERT INTO agent_quotas
(agent_id, day_started_at, created_at, updated_at)
VALUES ($1, $2, $3, $4)",
)
.bind(&agent)
.bind(now)
.bind(now)
.bind(now)
.execute(&store.pool)
.await
.expect("insert agent_quotas row");
let (max_mem, max_bytes, max_links): (i64, i64, i64) = sqlx::query_as(
"SELECT max_memories_per_day, max_storage_bytes, max_links_per_day
FROM agent_quotas WHERE agent_id = $1",
)
.bind(&agent)
.fetch_one(&store.pool)
.await
.expect("read agent_quotas row");
assert_eq!(max_mem, 1000);
assert_eq!(max_bytes, 104_857_600);
assert_eq!(max_links, 5000);
sqlx::query("DELETE FROM agent_quotas WHERE agent_id = $1")
.bind(&agent)
.execute(&store.pool)
.await
.expect("delete agent_quotas row");
}
async fn seed_governance_standard(
pool: &sqlx::PgPool,
namespace: &str,
owner: &str,
policy_json: serde_json::Value,
) -> String {
let standard_id = uuid::Uuid::new_v4().to_string();
let now = chrono::Utc::now();
let metadata = serde_json::json!({
"agent_id": owner,
"governance": policy_json,
});
sqlx::query(
"INSERT INTO memories (
id, tier, namespace, title, content, tags, priority, confidence,
source, access_count, created_at, updated_at, metadata
) VALUES ($1, 'long', $2, $3, 'standard', '[]'::jsonb, 5, 1.0,
'test', 0, $4, $4, $5)",
)
.bind(&standard_id)
.bind(namespace)
.bind(format!("standard:{namespace}"))
.bind(now)
.bind(&metadata)
.execute(pool)
.await
.expect("seed standard memory");
sqlx::query(
"INSERT INTO namespace_meta (namespace, standard_id, parent_namespace) \
VALUES ($1, $2, NULL) \
ON CONFLICT (namespace) DO UPDATE SET standard_id = EXCLUDED.standard_id",
)
.bind(namespace)
.bind(&standard_id)
.execute(pool)
.await
.expect("seed namespace_meta");
standard_id
}
async fn cleanup_governance_ns(pool: &sqlx::PgPool, namespace: &str) {
let _ = sqlx::query("DELETE FROM pending_actions WHERE namespace LIKE $1")
.bind(format!("{namespace}%"))
.execute(pool)
.await;
let _ = sqlx::query("DELETE FROM namespace_meta WHERE namespace LIKE $1")
.bind(format!("{namespace}%"))
.execute(pool)
.await;
let _ = sqlx::query("DELETE FROM memories WHERE namespace LIKE $1")
.bind(format!("{namespace}%"))
.execute(pool)
.await;
}
#[tokio::test]
async fn live_governance_allow_owner_at_leaf() {
let Some(url) = postgres_url() else {
eprintln!("skip: AI_MEMORY_TEST_POSTGRES_URL not set");
return;
};
crate::config::override_active_permissions_mode_for_test(
crate::config::PermissionsMode::Enforce,
);
let store = PostgresStore::connect(&url).await.expect("connect");
let pool = store.pool.clone();
let owner = format!("ai:gov-owner-{}", uuid::Uuid::new_v4());
let ns = format!("fa2a12-allow-{}", &uuid::Uuid::new_v4().to_string()[..8]);
seed_governance_standard(
&pool,
&ns,
&owner,
serde_json::json!({"write": "owner", "promote": "any", "delete": "owner"}),
)
.await;
let payload = serde_json::json!({"title": "owner write"});
let decision = store
.enforce_governance_action(
crate::store::GovernedAction::Store,
&ns,
&owner,
None,
None,
&payload,
)
.await
.expect("enforce_governance_action");
assert!(
matches!(decision, crate::models::GovernanceDecision::Allow),
"owner write to own ns must Allow; got {decision:?}"
);
cleanup_governance_ns(&pool, &ns).await;
}
#[tokio::test]
async fn live_governance_deny_non_owner_inherited() {
let Some(url) = postgres_url() else {
eprintln!("skip: AI_MEMORY_TEST_POSTGRES_URL not set");
return;
};
crate::config::override_active_permissions_mode_for_test(
crate::config::PermissionsMode::Enforce,
);
let store = PostgresStore::connect(&url).await.expect("connect");
let pool = store.pool.clone();
let owner = format!("ai:gov-owner-{}", uuid::Uuid::new_v4());
let intruder = format!("ai:gov-intruder-{}", uuid::Uuid::new_v4());
let parent = format!("fa2a12-deny-{}", &uuid::Uuid::new_v4().to_string()[..8]);
let deep_child = format!("{parent}/sub/level/deep");
seed_governance_standard(
&pool,
&parent,
&owner,
serde_json::json!({
"write": "owner",
"promote": "any",
"delete": "owner",
"inherit": true,
}),
)
.await;
let payload = serde_json::json!({"title": "intruder"});
let decision = store
.enforce_governance_action(
crate::store::GovernedAction::Store,
&deep_child,
&intruder,
None,
None,
&payload,
)
.await
.expect("enforce_governance_action");
match decision {
crate::models::GovernanceDecision::Deny(refusal) => {
assert!(
refusal.reason.contains("owner-only namespace")
|| refusal.reason.to_lowercase().contains("owner"),
"deny reason should reference owner-only policy; got: {refusal:?}"
);
assert_eq!(
refusal.denied_level,
crate::models::GovernanceLevel::Owner,
"owner-level deny must carry GovernanceLevel::Owner; got {refusal:?}",
);
}
other => panic!("intruder write to deep child must Deny; got {other:?}"),
}
let owner_decision = store
.enforce_governance_action(
crate::store::GovernedAction::Store,
&deep_child,
&owner,
None,
None,
&payload,
)
.await
.expect("enforce_governance_action owner");
assert!(
matches!(owner_decision, crate::models::GovernanceDecision::Allow),
"owner write to inherited deep child must Allow; got {owner_decision:?}"
);
cleanup_governance_ns(&pool, &parent).await;
}
#[tokio::test]
async fn live_governance_pending_on_approve_level() {
let Some(url) = postgres_url() else {
eprintln!("skip: AI_MEMORY_TEST_POSTGRES_URL not set");
return;
};
crate::config::override_active_permissions_mode_for_test(
crate::config::PermissionsMode::Enforce,
);
let store = PostgresStore::connect(&url).await.expect("connect");
let pool = store.pool.clone();
let owner = format!("ai:gov-owner-{}", uuid::Uuid::new_v4());
let requester = format!("ai:gov-requester-{}", uuid::Uuid::new_v4());
let ns = format!("fa2a12-pending-{}", &uuid::Uuid::new_v4().to_string()[..8]);
seed_governance_standard(
&pool,
&ns,
&owner,
serde_json::json!({"write": "approve", "promote": "any", "delete": "owner"}),
)
.await;
let payload = serde_json::json!({"title": "needs approval"});
let decision = store
.enforce_governance_action(
crate::store::GovernedAction::Store,
&ns,
&requester,
None,
None,
&payload,
)
.await
.expect("enforce_governance_action");
let pending_id = match decision {
crate::models::GovernanceDecision::Pending(id) => id,
other => panic!("approve-level non-owner write must Pending; got {other:?}"),
};
assert!(!pending_id.is_empty(), "Pending id must be non-empty");
let row: (String, String, String) = sqlx::query_as(
"SELECT action_type, namespace, status FROM pending_actions WHERE id = $1",
)
.bind(&pending_id)
.fetch_one(&pool)
.await
.expect("read pending_actions row");
assert_eq!(row.0, "store", "action_type must be 'store'");
assert_eq!(row.1, ns, "namespace must match");
assert_eq!(row.2, "pending", "status must be 'pending'");
cleanup_governance_ns(&pool, &ns).await;
}
#[tokio::test]
async fn live_governance_inheritance_cap_at_five() {
let Some(url) = postgres_url() else {
eprintln!("skip: AI_MEMORY_TEST_POSTGRES_URL not set");
return;
};
crate::config::override_active_permissions_mode_for_test(
crate::config::PermissionsMode::Enforce,
);
let store = PostgresStore::connect(&url).await.expect("connect");
let pool = store.pool.clone();
let owner = format!("ai:cap-owner-{}", uuid::Uuid::new_v4());
let intruder = format!("ai:cap-intruder-{}", uuid::Uuid::new_v4());
let suffix = &uuid::Uuid::new_v4().to_string()[..6];
let policy_ns = format!("fa2a12-cap-{suffix}/a/b/c");
let leaf_ns = format!("{policy_ns}/d/e");
seed_governance_standard(
&pool,
&policy_ns,
&owner,
serde_json::json!({
"write": "owner",
"promote": "any",
"delete": "owner",
"inherit": true,
}),
)
.await;
let payload = serde_json::json!({"leaf": leaf_ns});
let decision = store
.enforce_governance_action(
crate::store::GovernedAction::Store,
&leaf_ns,
&intruder,
None,
None,
&payload,
)
.await
.expect("enforce_governance_action");
match decision {
crate::models::GovernanceDecision::Deny(_) => {}
other => panic!(
"policy at depth 4 must deny intruder write at depth 6 (within cap); got \
{other:?}"
),
}
cleanup_governance_ns(&pool, &format!("fa2a12-cap-{suffix}")).await;
}
async fn fxc2_seed_two_memories(store: &PostgresStore, ns: &str) -> (String, String) {
let ctx = CallerContext::for_agent("ai:sal-test");
let a_id = format!("fxc2-a-{}", uuid::Uuid::new_v4());
let b_id = format!("fxc2-b-{}", uuid::Uuid::new_v4());
let a = sample_memory(&a_id, ns, "fxc2-anchor", "anchor content here");
let b = sample_memory(&b_id, ns, "fxc2-target", "target content here");
store.store(&ctx, &a).await.expect("store anchor");
store.store(&ctx, &b).await.expect("store target");
(a_id, b_id)
}
#[tokio::test]
async fn live_get_links_for_anchor_returns_inbound_and_outbound() {
let Some(url) = postgres_url() else {
eprintln!("skip: AI_MEMORY_TEST_POSTGRES_URL not set");
return;
};
let store = PostgresStore::connect(&url).await.expect("connect");
let ctx = CallerContext::for_agent("ai:sal-test");
let ns = format!("fxc2-getlinks-{}", uuid::Uuid::new_v4());
let (a_id, b_id) = fxc2_seed_two_memories(&store, &ns).await;
let c_id = format!("fxc2-c-{}", uuid::Uuid::new_v4());
let c = sample_memory(&c_id, &ns, "fxc2-upstream", "upstream content");
store.store(&ctx, &c).await.expect("store upstream");
let now = chrono::Utc::now().to_rfc3339();
store
.link(
&ctx,
&MemoryLink {
source_id: a_id.clone(),
target_id: b_id.clone(),
relation: crate::models::MemoryLinkRelation::RelatedTo,
created_at: now.clone(),
valid_from: None,
valid_until: None,
observed_by: None,
signature: None,
attest_level: None,
},
)
.await
.expect("link a->b");
store
.link(
&ctx,
&MemoryLink {
source_id: c_id.clone(),
target_id: a_id.clone(),
relation: crate::models::MemoryLinkRelation::Contradicts,
created_at: now,
valid_from: None,
valid_until: None,
observed_by: None,
signature: None,
attest_level: None,
},
)
.await
.expect("link c->a");
let edges = store
.get_links_for_anchor(&a_id)
.await
.expect("get_links_for_anchor");
assert!(
edges
.iter()
.any(|l| l.source_id == a_id && l.target_id == b_id),
"missing outbound edge in postgres parity"
);
assert!(
edges
.iter()
.any(|l| l.source_id == c_id && l.target_id == a_id),
"missing inbound edge in postgres parity"
);
}
#[tokio::test]
async fn live_get_links_for_anchor_empty_for_unlinked_id() {
let Some(url) = postgres_url() else {
eprintln!("skip: AI_MEMORY_TEST_POSTGRES_URL not set");
return;
};
let store = PostgresStore::connect(&url).await.expect("connect");
let ctx = CallerContext::for_agent("ai:sal-test");
let ns = format!("fxc2-empty-{}", uuid::Uuid::new_v4());
let alone_id = format!("fxc2-alone-{}", uuid::Uuid::new_v4());
let alone = sample_memory(&alone_id, &ns, "fxc2-alone", "no edges here");
store.store(&ctx, &alone).await.expect("store alone");
let edges = store
.get_links_for_anchor(&alone_id)
.await
.expect("get_links_for_anchor on unlinked id");
assert!(
edges.is_empty(),
"unlinked id must yield empty vec across both backends"
);
}
#[tokio::test]
async fn live_get_links_for_anchor_projects_attest_level_and_temporal() {
let Some(url) = postgres_url() else {
eprintln!("skip: AI_MEMORY_TEST_POSTGRES_URL not set");
return;
};
let store = PostgresStore::connect(&url).await.expect("connect");
let ns = format!("fxc2-temporal-{}", uuid::Uuid::new_v4());
let (a_id, b_id) = fxc2_seed_two_memories(&store, &ns).await;
let vf = chrono::DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z")
.expect("parse vf")
.with_timezone(&chrono::Utc);
let vu = chrono::DateTime::parse_from_rfc3339("2026-12-31T23:59:59Z")
.expect("parse vu")
.with_timezone(&chrono::Utc);
sqlx::query(
"INSERT INTO memory_links
(source_id, target_id, relation, created_at,
valid_from, valid_until, observed_by, attest_level)
VALUES ($1, $2, $3, NOW(), $4, $5, $6, $7)",
)
.bind(&a_id)
.bind(&b_id)
.bind("related_to")
.bind(vf)
.bind(vu)
.bind("ai:tester@host")
.bind("unsigned")
.execute(&store.pool)
.await
.expect("raw insert for temporal probe");
let edges = store
.get_links_for_anchor(&a_id)
.await
.expect("get_links_for_anchor");
let row = edges
.iter()
.find(|l| l.source_id == a_id && l.target_id == b_id)
.expect("just-inserted edge");
assert_eq!(row.valid_from.as_deref(), Some("2026-01-01T00:00:00+00:00"));
assert_eq!(
row.valid_until.as_deref(),
Some("2026-12-31T23:59:59+00:00")
);
assert_eq!(row.observed_by.as_deref(), Some("ai:tester@host"));
assert_eq!(row.attest_level.as_deref(), Some("unsigned"));
}
#[tokio::test]
async fn live_list_namespaces_groups_by_count() {
let Some(url) = postgres_url() else {
eprintln!("skip: AI_MEMORY_TEST_POSTGRES_URL not set");
return;
};
let store = PostgresStore::connect(&url).await.expect("connect");
let ctx = CallerContext::for_agent("ai:sal-test");
let unique = uuid::Uuid::new_v4();
let ns_alpha = format!("fxc2-ns-alpha-{unique}");
let ns_beta = format!("fxc2-ns-beta-{unique}");
for i in 0..3 {
let id = format!("a-{i}-{unique}");
let mem = sample_memory(&id, &ns_alpha, &format!("t-{i}"), "body content");
store.store(&ctx, &mem).await.expect("store alpha");
}
let mem = sample_memory(&format!("b-{unique}"), &ns_beta, "tb", "beta body content");
store.store(&ctx, &mem).await.expect("store beta");
let rows = store.list_namespaces().await.expect("list_namespaces");
let alpha = rows
.iter()
.find(|r| r.namespace == ns_alpha)
.expect("alpha namespace");
let beta = rows
.iter()
.find(|r| r.namespace == ns_beta)
.expect("beta namespace");
assert_eq!(alpha.count, 3);
assert_eq!(beta.count, 1);
}
#[tokio::test]
async fn live_get_taxonomy_assembles_hierarchical_tree() {
let Some(url) = postgres_url() else {
eprintln!("skip: AI_MEMORY_TEST_POSTGRES_URL not set");
return;
};
let store = PostgresStore::connect(&url).await.expect("connect");
let ctx = CallerContext::for_agent("ai:sal-test");
let unique = uuid::Uuid::new_v4();
let root = format!("fxc2-tax-{unique}");
let team = format!("{root}/team");
let secrets = format!("{root}/team/secrets");
let mem_root = sample_memory(&format!("root-{unique}"), &root, "rt", "root body content");
store.store(&ctx, &mem_root).await.expect("store root");
for i in 0..2 {
let mem = sample_memory(
&format!("team-{i}-{unique}"),
&team,
&format!("t-{i}"),
"team body content",
);
store.store(&ctx, &mem).await.expect("store team");
}
let mem_sec = sample_memory(
&format!("sec-{unique}"),
&secrets,
"ss",
"secrets body content",
);
store.store(&ctx, &mem_sec).await.expect("store secrets");
let tax = store
.get_taxonomy(Some(&root), 8, 100)
.await
.expect("get_taxonomy");
assert_eq!(tax.total_count, 4, "1 root + 2 team + 1 secrets = 4");
assert_eq!(tax.tree.namespace, root);
assert_eq!(tax.tree.subtree_count, 4);
assert!(!tax.truncated);
}
#[tokio::test]
async fn live_list_agents_roundtrip() {
let Some(url) = postgres_url() else {
eprintln!("skip: AI_MEMORY_TEST_POSTGRES_URL not set");
return;
};
let store = PostgresStore::connect(&url).await.expect("connect");
let ctx = CallerContext::for_agent("daemon");
let unique = uuid::Uuid::new_v4();
let agent_id = format!("ai:fxc2-tester-{unique}");
let agent = AgentRegistration {
agent_id: agent_id.clone(),
agent_type: "test".to_string(),
capabilities: vec!["recall".to_string()],
registered_at: String::new(),
last_seen_at: String::new(),
};
store
.register_agent(&ctx, &agent)
.await
.expect("register_agent");
let listed = store.list_agents().await.expect("list_agents");
assert!(
listed.iter().any(|r| r.agent_id == agent_id),
"registered agent must surface in list_agents"
);
}
#[tokio::test]
async fn live_list_pending_actions_returns_seeded_row() {
let Some(url) = postgres_url() else {
eprintln!("skip: AI_MEMORY_TEST_POSTGRES_URL not set");
return;
};
let store = PostgresStore::connect(&url).await.expect("connect");
let unique = uuid::Uuid::new_v4();
let ns = format!("fxc2-pa-{unique}");
let pid = format!("pa-{unique}");
sqlx::query(
"INSERT INTO pending_actions
(id, action_type, memory_id, namespace, payload, requested_by,
requested_at, status, approvals)
VALUES ($1, $2, $3, $4, $5, $6, NOW(), $7, $8)",
)
.bind(&pid)
.bind("Store")
.bind(None::<String>)
.bind(&ns)
.bind(serde_json::json!({"title":"t","content":"c"}))
.bind("alice")
.bind("pending")
.bind(serde_json::json!([]))
.execute(&store.pool)
.await
.expect("seed pending_actions row");
let rows =
<PostgresStore as MemoryStore>::list_pending_actions(&store, Some("pending"), 100)
.await
.expect("list_pending_actions");
let row = rows
.iter()
.find(|r| r.id == pid)
.expect("seeded pending row must surface");
assert_eq!(row.namespace, ns);
assert_eq!(row.status, "pending");
assert_eq!(row.requested_by, "alice");
}
#[tokio::test]
async fn live_entity_get_by_alias_resolves_canonical_record() {
let Some(url) = postgres_url() else {
eprintln!("skip: AI_MEMORY_TEST_POSTGRES_URL not set");
return;
};
let store = PostgresStore::connect(&url).await.expect("connect");
let ctx = CallerContext::for_agent("ai:sal-test");
let unique = uuid::Uuid::new_v4();
let ns = format!("fxc2-ent-{unique}");
let entity_id = format!("ent-{unique}");
let mut mem = sample_memory(
&entity_id,
&ns,
&format!("alphaone-co-{unique}"),
"entity row body",
);
mem.metadata = serde_json::json!({
"kind": "entity",
"agent_id": "ai:sal-test",
});
store.store(&ctx, &mem).await.expect("store entity");
let alias = format!("AlphaOne-{unique}");
sqlx::query("INSERT INTO entity_aliases (entity_id, alias) VALUES ($1, $2)")
.bind(&entity_id)
.bind(&alias)
.execute(&store.pool)
.await
.expect("insert alias");
let rec = store
.entity_get_by_alias(&alias, Some(&ns))
.await
.expect("entity_get_by_alias");
let rec = rec.expect("entity must resolve");
assert_eq!(rec.entity_id, entity_id);
assert_eq!(rec.namespace, ns);
assert!(rec.aliases.iter().any(|a| a == &alias));
}
#[tokio::test]
async fn live_health_check_returns_true() {
let Some(url) = postgres_url() else {
eprintln!("skip: AI_MEMORY_TEST_POSTGRES_URL not set");
return;
};
let store = PostgresStore::connect(&url).await.expect("connect");
let ok = store.health_check().await.expect("health_check");
assert!(ok);
}
#[tokio::test]
async fn live_stats_projects_full_shape() {
let Some(url) = postgres_url() else {
eprintln!("skip: AI_MEMORY_TEST_POSTGRES_URL not set");
return;
};
let store = PostgresStore::connect(&url).await.expect("connect");
let ctx = CallerContext::for_agent("ai:sal-test");
let unique = uuid::Uuid::new_v4();
let ns = format!("fxc2-stats-{unique}");
for i in 0..2 {
let mem = sample_memory(
&format!("stats-{i}-{unique}"),
&ns,
&format!("t-{i}"),
"stats body content",
);
store.store(&ctx, &mem).await.expect("store");
}
let s = store.stats().await.expect("stats");
let ns_row = s
.by_namespace
.iter()
.find(|r| r.namespace == ns)
.expect("ns must surface in stats");
assert_eq!(ns_row.count, 2);
assert!(s.total >= 2, "stats.total must include seeded rows");
let _ = s.db_size_bytes;
}
#[tokio::test]
async fn live_find_by_title_namespace_resolves_id() {
let Some(url) = postgres_url() else {
eprintln!("skip: AI_MEMORY_TEST_POSTGRES_URL not set");
return;
};
let store = PostgresStore::connect(&url).await.expect("connect");
let ctx = CallerContext::for_agent("ai:sal-test");
let unique = uuid::Uuid::new_v4();
let ns = format!("fxc2b4-find-{unique}");
let mem = sample_memory(
&format!("find-{unique}"),
&ns,
"find-target-title",
"find_by_title body",
);
let id = store.store(&ctx, &mem).await.expect("store");
let found = store
.find_by_title_namespace("find-target-title", &ns)
.await
.expect("find_by_title_namespace");
assert_eq!(found.as_deref(), Some(id.as_str()));
let missing = store
.find_by_title_namespace("nonexistent-title", &ns)
.await
.expect("find_by_title_namespace miss");
assert!(missing.is_none());
}
#[tokio::test]
async fn live_next_versioned_title_picks_suffix_on_collision() {
let Some(url) = postgres_url() else {
eprintln!("skip: AI_MEMORY_TEST_POSTGRES_URL not set");
return;
};
let store = PostgresStore::connect(&url).await.expect("connect");
let ctx = CallerContext::for_agent("ai:sal-test");
let unique = uuid::Uuid::new_v4();
let ns = format!("fxc2b4-version-{unique}");
let mem = sample_memory(
&format!("ver-{unique}"),
&ns,
"version-base",
"v1 body content",
);
store.store(&ctx, &mem).await.expect("store");
let picked = store
.next_versioned_title("version-base", &ns)
.await
.expect("next_versioned_title");
assert_eq!(picked, "version-base (2)");
let fresh = store
.next_versioned_title("never-used-title", &ns)
.await
.expect("next_versioned_title fresh");
assert_eq!(fresh, "never-used-title");
}
#[tokio::test]
async fn live_find_contradictions_surfaces_fts_hits() {
let Some(url) = postgres_url() else {
eprintln!("skip: AI_MEMORY_TEST_POSTGRES_URL not set");
return;
};
let store = PostgresStore::connect(&url).await.expect("connect");
let ctx = CallerContext::for_agent("ai:sal-test");
let unique = uuid::Uuid::new_v4();
let ns = format!("fxc2b4-contradictions-{unique}");
let m1 = sample_memory(
&format!("c1-{unique}"),
&ns,
"rust borrow checker semantics",
"rust language safety guarantees",
);
let m2 = sample_memory(
&format!("c2-{unique}"),
&ns,
"completely separate cookbook",
"fish stew recipe and instructions",
);
store.store(&ctx, &m1).await.expect("store m1");
store.store(&ctx, &m2).await.expect("store m2");
let hits = store
.find_contradictions("rust borrow checker", &ns)
.await
.expect("find_contradictions");
assert!(
hits.iter().any(|m| m.title.contains("rust borrow")),
"must surface rust row"
);
assert!(
!hits.iter().any(|m| m.title.contains("cookbook")),
"must not surface unrelated row"
);
}
#[tokio::test]
async fn live_invalidate_link_marks_found() {
let Some(url) = postgres_url() else {
eprintln!("skip: AI_MEMORY_TEST_POSTGRES_URL not set");
return;
};
let store = PostgresStore::connect(&url).await.expect("connect");
let ctx = CallerContext::for_agent("ai:sal-test");
let unique = uuid::Uuid::new_v4();
let ns = format!("fxc2b4-invalidate-{unique}");
let src = sample_memory(
&format!("src-{unique}"),
&ns,
"src-title",
"src body content",
);
let dst = sample_memory(
&format!("dst-{unique}"),
&ns,
"dst-title",
"dst body content",
);
let src_id = store.store(&ctx, &src).await.expect("store src");
let dst_id = store.store(&ctx, &dst).await.expect("store dst");
let link = crate::models::MemoryLink {
source_id: src_id.clone(),
target_id: dst_id.clone(),
relation: crate::models::MemoryLinkRelation::RelatedTo,
created_at: chrono::Utc::now().to_rfc3339(),
signature: None,
observed_by: None,
valid_from: None,
valid_until: None,
attest_level: None,
};
store.link(&ctx, &link).await.expect("create link");
let row = store
.invalidate_link(&src_id, &dst_id, "related_to", Some("2030-01-01T00:00:00Z"))
.await
.expect("invalidate_link");
assert!(row.found, "link must be marked found");
assert!(!row.valid_until.is_empty());
let row2 = store
.invalidate_link(&src_id, &dst_id, "related_to", Some("2031-02-02T00:00:00Z"))
.await
.expect("re-invalidate");
assert!(row2.found);
assert!(row2.previous_valid_until.is_some());
}
#[tokio::test]
async fn live_link_reflects_on_cycle_refused_1568() {
let Some(url) = postgres_url() else {
eprintln!("skip: AI_MEMORY_TEST_POSTGRES_URL not set");
return;
};
let store = PostgresStore::connect(&url).await.expect("connect");
let ctx = CallerContext::for_agent("ai:sal-test");
let unique = uuid::Uuid::new_v4();
let ns = format!("h1res-link-{unique}");
let a = sample_memory(&format!("cyc-a-{unique}"), &ns, "cyc-a", "body a");
let b = sample_memory(&format!("cyc-b-{unique}"), &ns, "cyc-b", "body b");
let a_id = store.store(&ctx, &a).await.expect("store a");
let b_id = store.store(&ctx, &b).await.expect("store b");
let mk = |src: &str, dst: &str| crate::models::MemoryLink {
source_id: src.to_string(),
target_id: dst.to_string(),
relation: crate::models::MemoryLinkRelation::ReflectsOn,
created_at: chrono::Utc::now().to_rfc3339(),
signature: None,
observed_by: None,
valid_from: None,
valid_until: None,
attest_level: None,
};
store.link(&ctx, &mk(&a_id, &b_id)).await.expect("a->b");
let err = store
.link(&ctx, &mk(&b_id, &a_id))
.await
.expect_err("cycle-closing reflects_on edge must be refused");
assert!(
matches!(err, StoreError::LinkRefused { .. }),
"expected LinkRefused, got: {err:?}"
);
assert!(
err.to_string()
.starts_with(crate::storage::LINK_CYCLE_ERR_PREFIX),
"wire message must carry the canonical cycle prefix, got: {err}"
);
let err_self = store
.link(&ctx, &mk(&a_id, &a_id))
.await
.expect_err("self reflects_on must be refused");
assert!(matches!(err_self, StoreError::LinkRefused { .. }));
let mut rel = mk(&b_id, &a_id);
rel.relation = crate::models::MemoryLinkRelation::RelatedTo;
store
.link(&ctx, &rel)
.await
.expect("related_to b->a must still land");
}
#[tokio::test]
async fn live_touch_after_recall_applies_decay_parity_1572() {
let Some(url) = postgres_url() else {
eprintln!("skip: AI_MEMORY_TEST_POSTGRES_URL not set");
return;
};
let store = PostgresStore::connect(&url).await.expect("connect");
let ctx = CallerContext::for_agent("ai:sal-test");
let unique = uuid::Uuid::new_v4();
let ns = format!("m1res-decay-{unique}");
let control = sample_memory(&format!("decay-ctl-{unique}"), &ns, "decay-ctl", "body");
let control_id = store.store(&ctx, &control).await.expect("store control");
store
.touch_after_recall(&[control_id.clone()])
.await
.expect("touch control");
let got = store.get(&ctx, &control_id).await.expect("get control");
assert_eq!(got.confidence_source, ConfidenceSource::CallerProvided);
assert!(got.confidence_decayed_at.is_none());
assert!((got.confidence - 1.0).abs() < f64::EPSILON);
let mem = sample_memory(&format!("decay-{unique}"), &ns, "decay-row", "body");
let id = store.store(&ctx, &mem).await.expect("store");
sqlx::query(
"UPDATE memories SET created_at = NOW() - ($2 || ' days')::interval WHERE id = $1",
)
.bind(&id)
.bind(crate::confidence::DEFAULT_HALF_LIFE_DAYS.to_string())
.execute(&store.pool)
.await
.expect("backdate created_at");
unsafe { std::env::set_var(crate::confidence::decay::ENV_DECAY, "1") };
let touch_result = store.touch_after_recall(&[id.clone()]).await;
unsafe { std::env::remove_var(crate::confidence::decay::ENV_DECAY) };
touch_result.expect("touch with decay enabled");
let got = store.get(&ctx, &id).await.expect("get decayed row");
assert_eq!(
got.confidence_source,
ConfidenceSource::Decayed,
"decay touch must flip provenance to 'decayed'"
);
assert!(
got.confidence_decayed_at.is_some(),
"decay touch must stamp confidence_decayed_at"
);
assert!(
(got.confidence - 0.5).abs() < 0.02,
"one half-life of age must collapse confidence to ~0.5, got {}",
got.confidence
);
}
#[tokio::test]
async fn live_store_with_embedding_persists_full_provenance_1608() {
let Some(url) = postgres_url() else {
eprintln!("skip: AI_MEMORY_TEST_POSTGRES_URL not set");
return;
};
let store = PostgresStore::connect(&url).await.expect("connect");
let ctx = CallerContext::for_agent("ai:sal-test");
let unique = uuid::Uuid::new_v4();
let ns = format!("prov-1608-{unique}");
let mut mem = sample_memory(&format!("prov-{unique}"), &ns, "prov-1608", "body");
mem.confidence_source = ConfidenceSource::Default;
mem.citations = vec![crate::models::Citation {
uri: "doc:rerun-evidence".to_string(),
accessed_at: "2026-06-11T00:00:00Z".to_string(),
hash: None,
span: None,
}];
mem.source_uri = Some("doc:1608-source".to_string());
mem.entity_id = Some("entity-1608".to_string());
mem.persona_version = Some(3);
let embedding = vec![0.5_f32; 384];
let id = store
.store_with_embedding(&ctx, &mem, Some(&embedding))
.await
.expect("store_with_embedding");
let got = store.get(&ctx, &id).await.expect("get back");
assert_eq!(
got.confidence_source,
ConfidenceSource::Default,
"persisted confidence_source must match the handler-stamped value, \
not the schema default (#1608)"
);
assert_eq!(got.citations.len(), 1, "citation must round-trip");
assert_eq!(got.citations[0].uri, "doc:rerun-evidence");
assert_eq!(got.source_uri.as_deref(), Some("doc:1608-source"));
assert_eq!(got.entity_id.as_deref(), Some("entity-1608"));
assert_eq!(got.persona_version, Some(3));
let mut mem2 = sample_memory(&format!("prov2-{unique}"), &ns, "prov-1608-b", "body");
mem2.entity_id = Some("entity-1608-b".to_string());
mem2.persona_version = Some(7);
let id2 = store.store(&ctx, &mem2).await.expect("plain store");
let got2 = store.get(&ctx, &id2).await.expect("get back 2");
assert_eq!(got2.entity_id.as_deref(), Some("entity-1608-b"));
assert_eq!(got2.persona_version, Some(7));
}
#[tokio::test]
async fn live_touch_after_recall_expiry_is_extension_floor_1607() {
let Some(url) = postgres_url() else {
eprintln!("skip: AI_MEMORY_TEST_POSTGRES_URL not set");
return;
};
let store = PostgresStore::connect(&url).await.expect("connect");
let ctx = CallerContext::for_agent("ai:sal-test");
let unique = uuid::Uuid::new_v4();
let ns = format!("floor-1607-{unique}");
let expiry_secs = |id: &str| {
let pool = store.pool.clone();
let id = id.to_string();
async move {
let row: (Option<f64>,) = sqlx::query_as(
"SELECT EXTRACT(EPOCH FROM (expires_at - NOW()))::float8 \
FROM memories WHERE id = $1",
)
.bind(&id)
.fetch_one(&pool)
.await
.expect("read expiry");
row.0.expect("expires_at must be set")
}
};
let mut far = sample_memory(&format!("floor-far-{unique}"), &ns, "floor-far", "body");
far.tier = crate::models::Tier::Mid;
let far_id = store.store(&ctx, &far).await.expect("store far");
sqlx::query("UPDATE memories SET expires_at = NOW() + INTERVAL '7 days' WHERE id = $1")
.bind(&far_id)
.execute(&store.pool)
.await
.expect("set far expiry");
store
.touch_after_recall(&[far_id.clone()])
.await
.expect("touch far");
let day_secs = f64::from(u32::try_from(crate::SECS_PER_DAY).expect("fits u32"));
let far_secs = expiry_secs(&far_id).await;
assert!(
far_secs > 6.0 * day_secs,
"touch must NOT pull a +7d expiry in (floor semantics, #1607); \
remaining secs = {far_secs}"
);
let mut near = sample_memory(&format!("floor-near-{unique}"), &ns, "floor-near", "body");
near.tier = crate::models::Tier::Mid;
let near_id = store.store(&ctx, &near).await.expect("store near");
sqlx::query("UPDATE memories SET expires_at = NOW() + INTERVAL '1 hour' WHERE id = $1")
.bind(&near_id)
.execute(&store.pool)
.await
.expect("set near expiry");
store
.touch_after_recall(&[near_id.clone()])
.await
.expect("touch near");
let near_secs = expiry_secs(&near_id).await;
assert!(
(near_secs - day_secs).abs() < 300.0,
"touch must extend a near expiry out to ~now+1d; remaining secs = {near_secs}"
);
}
#[tokio::test]
async fn live_link_persists_when_age_projection_refused_1542() {
let Some(url) = postgres_url() else {
eprintln!("skip: AI_MEMORY_TEST_POSTGRES_URL not set");
return;
};
let admin = PostgresStore::connect(&url).await.expect("connect admin");
let ctx = CallerContext::for_agent("ai:sal-test");
let unique = uuid::Uuid::new_v4().simple().to_string();
let ns = format!("link-1542-{unique}");
let a = sample_memory(&format!("l42a-{unique}"), &ns, "l42-a", "src");
let b = sample_memory(&format!("l42b-{unique}"), &ns, "l42-b", "dst");
let a_id = admin.store(&ctx, &a).await.expect("store a");
let b_id = admin.store(&ctx, &b).await.expect("store b");
let role = format!("age_refused_1542_{unique}");
let pw = uuid::Uuid::new_v4().simple().to_string();
let admin_role: String = sqlx::query_scalar("SELECT current_user")
.fetch_one(&admin.pool)
.await
.expect("resolve admin role");
for stmt in [
format!("CREATE ROLE {role} LOGIN PASSWORD '{pw}'"),
format!("GRANT {admin_role} TO {role}"),
] {
let _ = sqlx::query(&stmt).execute(&admin.pool).await;
}
let restricted_url = {
let rest = url
.split_once('@')
.map(|(_, tail)| tail.to_string())
.expect("test URL must carry credentials");
format!("postgres://{role}:{pw}@{rest}")
};
let restricted = match PostgresStore::connect(&restricted_url).await {
Ok(s) => s,
Err(e) => {
eprintln!("skip: restricted-role connect failed ({e})");
return;
}
};
let load_err = sqlx::query("LOAD 'age'").execute(&restricted.pool).await;
assert!(
load_err.is_err(),
"test precondition: LOAD 'age' must be refused for the non-superuser role (got Ok — role attributes drifted)"
);
let link = MemoryLink {
source_id: a_id.clone(),
target_id: b_id.clone(),
relation: crate::models::MemoryLinkRelation::RelatedTo,
created_at: String::new(),
valid_from: None,
valid_until: None,
observed_by: None,
signature: None,
attest_level: None,
};
restricted
.link_signed(&ctx, &link, None)
.await
.expect("link_signed must succeed for the restricted role");
let n: i64 = sqlx::query_scalar(
"SELECT count(*) FROM memory_links WHERE source_id = $1 AND target_id = $2",
)
.bind(&a_id)
.bind(&b_id)
.fetch_one(&admin.pool)
.await
.expect("count link rows");
assert_eq!(
n, 1,
"the relational memory_links row must persist even when the \
AGE projection is refused for the writing role (#1542)"
);
}
#[tokio::test]
async fn live_invalidate_link_returns_not_found_for_unknown_triple() {
let Some(url) = postgres_url() else {
eprintln!("skip: AI_MEMORY_TEST_POSTGRES_URL not set");
return;
};
let store = PostgresStore::connect(&url).await.expect("connect");
let row = store
.invalidate_link("nope-src", "nope-dst", "related_to", None)
.await
.expect("invalidate_link miss");
assert!(!row.found);
assert!(row.valid_until.is_empty());
}
#[tokio::test]
async fn live_check_duplicate_with_text_hash_short_circuit() {
let Some(url) = postgres_url() else {
eprintln!("skip: AI_MEMORY_TEST_POSTGRES_URL not set");
return;
};
let store = PostgresStore::connect(&url).await.expect("connect");
let ctx = CallerContext::for_agent("ai:sal-test");
let unique = uuid::Uuid::new_v4();
let ns = format!("fxc2b4-dup-{unique}");
let mem = sample_memory(
&format!("dup-{unique}"),
&ns,
"dup-test-title",
"dup-test body content",
);
store.store(&ctx, &mem).await.expect("store");
let query_text = format!("{} {}", mem.title, mem.content);
let check = store
.check_duplicate_with_text(&[], &query_text, Some(&ns), 0.8)
.await
.expect("check_duplicate_with_text");
assert!(
check.is_duplicate,
"byte-equal text must short-circuit as duplicate"
);
let n = check.nearest.expect("nearest must be populated");
assert!((n.similarity - 1.0).abs() < f32::EPSILON);
}
#[tokio::test]
async fn live_update_embedding_persists_vector() {
let Some(url) = postgres_url() else {
eprintln!("skip: AI_MEMORY_TEST_POSTGRES_URL not set");
return;
};
let store = PostgresStore::connect(&url).await.expect("connect");
let ctx = CallerContext::for_agent("ai:sal-test");
let unique = uuid::Uuid::new_v4();
let ns = format!("fxc2b4-update-emb-{unique}");
let mem = sample_memory(
&format!("emb-{unique}"),
&ns,
"emb-test",
"embedding update body",
);
let id = store.store(&ctx, &mem).await.expect("store");
let dim = store
.current_embedding_dim()
.await
.expect("current_embedding_dim");
let dim_usize = usize::try_from(dim.unwrap_or(384)).unwrap_or(384);
let vec = vec![0.0_f32; dim_usize];
store
.update_embedding(&ctx, &id, Some(&vec))
.await
.expect("update_embedding");
let written: Option<i64> = sqlx::query_scalar(
"SELECT 1::BIGINT FROM memories WHERE id = $1 AND embedding IS NOT NULL",
)
.bind(&id)
.fetch_optional(&store.pool)
.await
.expect("verify embedding");
assert!(written.is_some(), "embedding column must be non-NULL");
}
#[tokio::test]
async fn fx_c2_batch5_live_kg_query_trait_method_works() {
let Some(url) = postgres_url() else {
eprintln!("skip: AI_MEMORY_TEST_POSTGRES_URL not set");
return;
};
let store = PostgresStore::connect(&url).await.expect("connect");
let ctx = CallerContext::for_agent("ai:sal-test");
let unique = uuid::Uuid::new_v4();
let ns = format!("fxc2b5-kgq-{unique}");
let src = sample_memory(&format!("src-{unique}"), &ns, "kg-query-src", "source body");
let dst = sample_memory(&format!("dst-{unique}"), &ns, "kg-query-dst", "target body");
let src_id = store.store(&ctx, &src).await.expect("store src");
let dst_id = store.store(&ctx, &dst).await.expect("store dst");
let link = crate::models::MemoryLink {
source_id: src_id.clone(),
target_id: dst_id.clone(),
relation: crate::models::MemoryLinkRelation::RelatedTo,
created_at: chrono::Utc::now().to_rfc3339(),
signature: None,
observed_by: None,
valid_from: None,
valid_until: None,
attest_level: None,
};
store.link(&ctx, &link).await.expect("create link");
let rows = <PostgresStore as MemoryStore>::kg_query(&store, &src_id, 2, false)
.await
.expect("kg_query trait");
assert!(
rows.iter().any(|r| r.target_id == dst_id),
"kg_query trait method must surface the linked neighbor"
);
}
#[tokio::test]
async fn fx_c2_batch5_live_kg_timeline_trait_method_works() {
let Some(url) = postgres_url() else {
eprintln!("skip: AI_MEMORY_TEST_POSTGRES_URL not set");
return;
};
let store = PostgresStore::connect(&url).await.expect("connect");
let ctx = CallerContext::for_agent("ai:sal-test");
let unique = uuid::Uuid::new_v4();
let ns = format!("fxc2b5-tl-{unique}");
let src = sample_memory(&format!("tl-src-{unique}"), &ns, "tl-src", "src body");
let dst = sample_memory(&format!("tl-dst-{unique}"), &ns, "tl-dst", "dst body");
let src_id = store.store(&ctx, &src).await.expect("store src");
let dst_id = store.store(&ctx, &dst).await.expect("store dst");
sqlx::query(
"INSERT INTO memory_links \
(source_id, target_id, relation, created_at, valid_from, attest_level) \
VALUES ($1, $2, 'related_to', NOW(), '2030-01-01 00:00:00+00', 'unsigned')",
)
.bind(&src_id)
.bind(&dst_id)
.execute(&store.pool)
.await
.expect("insert timeline link");
let events = <PostgresStore as MemoryStore>::kg_timeline(&store, &src_id, None, None, None)
.await
.expect("kg_timeline trait");
assert!(
events.iter().any(|e| e.target_id == dst_id),
"kg_timeline trait method must surface the assertion"
);
}
#[tokio::test]
async fn fx_c2_batch5_live_entity_register_creates_then_idempotent() {
let Some(url) = postgres_url() else {
eprintln!("skip: AI_MEMORY_TEST_POSTGRES_URL not set");
return;
};
let store = PostgresStore::connect(&url).await.expect("connect");
let ctx = CallerContext::for_agent("ai:sal-test");
let unique = uuid::Uuid::new_v4();
let ns = format!("fxc2b5-ent-{unique}");
let first = store
.entity_register(
&ctx,
"AlphaOne LLC",
&ns,
&["AlphaOne".to_string(), "AO".to_string()],
&serde_json::json!({"website": "https://alphaone.example"}),
Some("ai:sal-test"),
)
.await
.expect("entity_register first");
assert!(first.created, "first registration must create the row");
assert_eq!(first.canonical_name, "AlphaOne LLC");
assert!(first.aliases.iter().any(|a| a == "AlphaOne"));
let second = store
.entity_register(
&ctx,
"AlphaOne LLC",
&ns,
&["AO Inc".to_string()],
&serde_json::json!({}),
Some("ai:sal-test"),
)
.await
.expect("entity_register second");
assert!(!second.created, "re-register must not create");
assert!(second.aliases.iter().any(|a| a == "AlphaOne"));
assert!(second.aliases.iter().any(|a| a == "AO"));
assert!(second.aliases.iter().any(|a| a == "AO Inc"));
}
#[tokio::test]
async fn fx_c2_batch5_live_list_archived_trait_method_works() {
let Some(url) = postgres_url() else {
eprintln!("skip: AI_MEMORY_TEST_POSTGRES_URL not set");
return;
};
let store = PostgresStore::connect(&url).await.expect("connect");
let unique = uuid::Uuid::new_v4();
let ns = format!("fxc2b5-arch-{unique}");
let listed = <PostgresStore as MemoryStore>::list_archived(&store, Some(&ns), 100, 0)
.await
.expect("list_archived trait");
assert!(
listed.is_empty(),
"fresh namespace must have zero archived rows"
);
}
#[tokio::test]
async fn fx_c2_batch5_live_decide_pending_action_alias_works() {
let Some(url) = postgres_url() else {
eprintln!("skip: AI_MEMORY_TEST_POSTGRES_URL not set");
return;
};
let store = PostgresStore::connect(&url).await.expect("connect");
let ctx = CallerContext::for_agent("ai:sal-test");
let unique = uuid::Uuid::new_v4();
let pid = format!("pending-{unique}");
let ns = format!("fxc2b5-decide-{unique}");
sqlx::query(
"INSERT INTO pending_actions \
(id, action_type, namespace, memory_id, requested_by, payload, status, requested_at, approvals) \
VALUES ($1, 'store', $2, NULL, 'ai:sal-test', '{}'::jsonb, 'pending', NOW(), '[]'::jsonb)",
)
.bind(&pid)
.bind(&ns)
.execute(&store.pool)
.await
.expect("insert pending");
let result = store
.decide_pending_action(&ctx, &pid, false, "ai:sal-test")
.await
.expect("decide_pending_action");
assert!(result, "first decide must return true");
let second = store
.decide_pending_action(&ctx, &pid, false, "ai:sal-test")
.await
.expect("decide_pending_action second");
assert!(!second, "already-decided rows must return false");
}
#[tokio::test]
async fn fx_c2_batch5_live_approve_with_approver_type_alias_works() {
let Some(url) = postgres_url() else {
eprintln!("skip: AI_MEMORY_TEST_POSTGRES_URL not set");
return;
};
let store = PostgresStore::connect(&url).await.expect("connect");
let ctx = CallerContext::for_agent("ai:sal-test");
let unique = uuid::Uuid::new_v4();
let pid = format!("pending-{unique}");
let ns = format!("fxc2b5-approve-{unique}");
sqlx::query(
"INSERT INTO pending_actions \
(id, action_type, namespace, memory_id, requested_by, payload, status, requested_at, approvals) \
VALUES ($1, 'store', $2, NULL, 'ai:sal-test', '{}'::jsonb, 'pending', NOW(), '[]'::jsonb)",
)
.bind(&pid)
.bind(&ns)
.execute(&store.pool)
.await
.expect("insert pending");
let outcome = store
.approve_with_approver_type(&ctx, &pid, "ai:sal-test")
.await
.expect("approve_with_approver_type");
assert!(matches!(outcome, crate::store::ApproveOutcome::Approved));
}
}