use crate::models::ConfidenceSource;
use crate::models::field_names;
use std::collections::BTreeMap;
use std::fmt;
use anyhow::{Context, Result};
use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use chrono::Utc;
use rusqlite::Connection;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::autonomy::AutonomyLlm;
use crate::identity::keypair::AgentKeypair;
use crate::identity::sign::{SignablePersona, sign_persona};
use crate::models::{Memory, MemoryKind, Tier};
use crate::signed_events::{SignedEvent, append_signed_event};
use crate::storage as db;
use crate::validate;
pub const DEFAULT_MAX_REFLECTION_SOURCES: usize = 20;
const ANONYMOUS_CURATOR_AGENT_ID: &str = crate::identity::sentinels::AI_CURATOR;
pub const CROSS_NAMESPACE_SENTINEL: &str = "<any namespace>";
#[derive(Debug, Clone, Copy)]
enum PersonaScope<'a> {
Single(&'a str),
AnyTargeting(&'a str),
}
#[derive(Debug, Clone)]
pub struct PersonaConfig {
pub max_reflection_sources: usize,
pub tier: Tier,
}
impl Default for PersonaConfig {
fn default() -> Self {
Self {
max_reflection_sources: DEFAULT_MAX_REFLECTION_SOURCES,
tier: Tier::Long,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Persona {
pub id: String,
pub entity_id: String,
pub namespace: String,
pub body_md: String,
pub sources: Vec<String>,
pub generated_at: String,
pub version: i32,
pub attest_level: String,
}
#[derive(Debug)]
pub enum PersonaError {
Validation(String),
NoReflections {
entity_id: String,
namespace: String,
},
Llm(String),
Db(anyhow::Error),
}
impl fmt::Display for PersonaError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Validation(msg) => write!(f, "persona validation failed: {msg}"),
Self::NoReflections {
entity_id,
namespace,
} => write!(
f,
"no reflections found for entity '{entity_id}' in namespace '{namespace}'"
),
Self::Llm(msg) => write!(f, "curator synthesis failed: {msg}"),
Self::Db(e) => write!(f, "persona db error: {e}"),
}
}
}
impl std::error::Error for PersonaError {}
impl From<anyhow::Error> for PersonaError {
fn from(e: anyhow::Error) -> Self {
Self::Db(e)
}
}
impl From<rusqlite::Error> for PersonaError {
fn from(e: rusqlite::Error) -> Self {
Self::Db(anyhow::Error::from(e))
}
}
pub struct PersonaGenerator<'a> {
conn: &'a Connection,
llm: &'a dyn AutonomyLlm,
signer: Option<&'a AgentKeypair>,
config: PersonaConfig,
}
impl<'a> PersonaGenerator<'a> {
pub fn new(
conn: &'a Connection,
llm: &'a dyn AutonomyLlm,
signer: Option<&'a AgentKeypair>,
config: PersonaConfig,
) -> Self {
Self {
conn,
llm,
signer,
config,
}
}
fn agent_id(&self) -> String {
self.signer
.map(|kp| kp.agent_id.clone())
.unwrap_or_else(|| ANONYMOUS_CURATOR_AGENT_ID.to_string())
}
pub fn generate(
&self,
entity_id: &str,
namespace: &str,
) -> std::result::Result<Persona, PersonaError> {
self.generate_in_scope(entity_id, PersonaScope::Single(namespace))
}
pub fn generate_cross_namespace(
&self,
entity_id: &str,
target_namespace: &str,
) -> std::result::Result<Persona, PersonaError> {
self.generate_in_scope(entity_id, PersonaScope::AnyTargeting(target_namespace))
}
fn generate_in_scope(
&self,
entity_id: &str,
scope: PersonaScope<'_>,
) -> std::result::Result<Persona, PersonaError> {
validate_entity_id(entity_id)?;
let namespace = match scope {
PersonaScope::Single(ns) | PersonaScope::AnyTargeting(ns) => ns,
};
validate::validate_namespace(namespace)
.map_err(|e| PersonaError::Validation(e.to_string()))?;
let sources = match scope {
PersonaScope::Single(ns) => load_reflections_for_entity(
self.conn,
entity_id,
ns,
self.config.max_reflection_sources,
)?,
PersonaScope::AnyTargeting(_) => load_reflections_for_entity_any_namespace(
self.conn,
entity_id,
self.config.max_reflection_sources,
)?,
};
if sources.is_empty() {
let reported_ns = match scope {
PersonaScope::Single(ns) => ns.to_string(),
PersonaScope::AnyTargeting(_) => CROSS_NAMESPACE_SENTINEL.to_string(),
};
return Err(PersonaError::NoReflections {
entity_id: entity_id.to_string(),
namespace: reported_ns,
});
}
let version = next_version(self.conn, entity_id, namespace)?;
let llm_input: Vec<(String, String)> = sources
.iter()
.map(|m| (m.title.clone(), m.content.clone()))
.collect();
let body_md_raw = self
.llm
.summarize_memories(&llm_input)
.map_err(|e| PersonaError::Llm(e.to_string()))?;
let body_md = render_body_with_footnotes(&body_md_raw, &sources);
let now = Utc::now().to_rfc3339();
let agent_id = self.agent_id();
let title = persona_title(entity_id, version);
let source_ids: Vec<String> = sources.iter().map(|m| m.id.clone()).collect();
let persona_id_local = uuid::Uuid::new_v4().to_string();
let mut metadata = serde_json::json!({
"agent_id": agent_id,
"persona": {
"entity_id": entity_id,
"sources": source_ids.clone(),
"version": version,
(field_names::ATTEST_LEVEL): crate::models::AttestLevel::Unsigned.as_str(),
(field_names::GENERATED_AT): now,
}
});
let persona_mem = Memory {
id: persona_id_local.clone(),
tier: self.config.tier.clone(),
namespace: namespace.to_string(),
title,
content: body_md.clone(),
tags: vec!["persona".to_string()],
priority: 7,
confidence: 1.0,
source: "curator".to_string(),
access_count: 0,
created_at: now.clone(),
updated_at: now.clone(),
last_accessed_at: None,
expires_at: None,
metadata: metadata.clone(),
reflection_depth: 0,
memory_kind: MemoryKind::Persona,
entity_id: Some(entity_id.to_string()),
persona_version: Some(version),
citations: Vec::new(),
source_uri: None,
source_span: None,
confidence_source: ConfidenceSource::CuratorDerived,
confidence_signals: None,
confidence_decayed_at: None,
version: 1,
};
let persona_id = db::insert(self.conn, &persona_mem)
.with_context(|| format!("inserting persona for {entity_id} v{version}"))?;
for source in &sources {
db::create_link_signed(
self.conn,
&persona_id,
&source.id,
crate::models::MemoryLinkRelation::DerivedFrom.as_str(),
self.signer,
)
.with_context(|| format!("linking persona {persona_id} -> source {}", source.id))?;
}
let body_hash = {
let mut h = Sha256::new();
h.update(body_md.as_bytes());
let mut out = [0u8; 32];
out.copy_from_slice(&h.finalize());
out
};
let signature_bytes: Option<Vec<u8>> = match self.signer {
Some(kp) if kp.can_sign() => {
let p = SignablePersona {
persona_id: persona_id.as_str(),
entity_id,
namespace,
version,
generated_at: now.as_str(),
sources: &source_ids,
body_md_sha256: &body_hash,
};
Some(sign_persona(kp, &p).context("sign persona artifact")?)
}
_ => None,
};
let link_attest = db::strongest_attest_level_for_source(self.conn, &persona_id)
.context("resolve strongest link attest_level")?;
let attest_level = if signature_bytes.is_some() {
match link_attest.as_str() {
s if s == crate::models::AttestLevel::PeerAttested.as_str() => {
crate::models::AttestLevel::PeerAttested
.as_str()
.to_string()
}
_ => crate::models::AttestLevel::SelfSigned.as_str().to_string(),
}
} else {
link_attest
};
if let Some(env) = metadata
.get_mut("persona")
.and_then(serde_json::Value::as_object_mut)
{
env.insert(
field_names::ATTEST_LEVEL.to_string(),
serde_json::Value::String(attest_level.clone()),
);
if let Some(sig) = signature_bytes.as_ref() {
env.insert(
"signature".to_string(),
serde_json::Value::String(BASE64_STANDARD.encode(sig)),
);
}
}
let new_metadata_str = serde_json::to_string(&metadata)
.context("serialise updated persona metadata envelope")?;
self.conn
.execute(
"UPDATE memories SET metadata = ?1, updated_at = ?2 WHERE id = ?3",
rusqlite::params![new_metadata_str, &now, &persona_id],
)
.context("patch persona metadata with signature/attest_level")?;
emit_persona_generated_event(
self.conn,
&persona_id,
&agent_id,
&source_ids,
&now,
signature_bytes.as_deref(),
&attest_level,
)?;
Ok(Persona {
id: persona_id,
entity_id: entity_id.to_string(),
namespace: namespace.to_string(),
body_md,
sources: source_ids,
generated_at: now,
version,
attest_level,
})
}
}
fn validate_entity_id(entity_id: &str) -> std::result::Result<(), PersonaError> {
if entity_id.trim().is_empty() {
return Err(PersonaError::Validation(
crate::errors::msg::ENTITY_ID_EMPTY.into(),
));
}
if entity_id.len() > 128 {
return Err(PersonaError::Validation(format!(
"entity_id exceeds 128 characters (got {})",
entity_id.len()
)));
}
Ok(())
}
pub fn get_latest_persona(
conn: &Connection,
entity_id: &str,
namespace: &str,
) -> Result<Option<Persona>> {
let mut stmt = conn.prepare(
"SELECT id, entity_id, namespace, content, created_at, COALESCE(persona_version, 1), metadata
FROM memories
WHERE memory_kind = 'persona'
AND entity_id = ?1
AND namespace = ?2
ORDER BY COALESCE(persona_version, 0) DESC, created_at DESC
LIMIT 1",
)?;
let row: Option<(String, String, String, String, String, i32, String)> = stmt
.query_row(rusqlite::params![entity_id, namespace], |r| {
Ok((
r.get(0)?,
r.get(1)?,
r.get(2)?,
r.get(3)?,
r.get(4)?,
r.get(5)?,
r.get(6)?,
))
})
.ok();
let Some((id, entity_id, namespace, body_md, generated_at, version, metadata_str)) = row else {
return Ok(None);
};
let meta: serde_json::Value =
serde_json::from_str(&metadata_str).unwrap_or_else(|_| serde_json::json!({}));
let envelope = meta.get("persona").cloned().unwrap_or_default();
let sources = envelope
.get("sources")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default();
let attest_level = envelope
.get(field_names::ATTEST_LEVEL)
.and_then(|v| v.as_str())
.unwrap_or(crate::models::AttestLevel::Unsigned.as_str())
.to_string();
Ok(Some(Persona {
id,
entity_id,
namespace,
body_md,
sources,
generated_at,
version,
attest_level,
}))
}
fn next_version(conn: &Connection, entity_id: &str, namespace: &str) -> Result<i32> {
match conn.query_row(
"SELECT COALESCE(MAX(persona_version), 0)
FROM memories
WHERE memory_kind = 'persona'
AND entity_id = ?1
AND namespace = ?2",
rusqlite::params![entity_id, namespace],
|r| r.get::<_, i32>(0),
) {
Ok(n) => Ok(n + 1),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(1),
Err(e) => Err(e.into()),
}
}
fn load_reflections_for_entity(
conn: &Connection,
entity_id: &str,
namespace: &str,
limit: usize,
) -> Result<Vec<Memory>> {
let mut stmt = conn.prepare(
"SELECT id, tier, namespace, title, content, tags, priority, confidence, source,
access_count, created_at, updated_at, last_accessed_at, expires_at,
metadata, COALESCE(reflection_depth, 0), COALESCE(memory_kind, 'observation'),
entity_id, persona_version
FROM memories
WHERE namespace = ?1
AND memory_kind = 'reflection'
AND mentioned_entity_id = ?2
ORDER BY priority DESC, created_at DESC
LIMIT ?3",
)?;
let rows = stmt.query_map(
rusqlite::params![
namespace,
entity_id,
i64::try_from(limit).unwrap_or(i64::MAX)
],
crate::storage::row_to_memory,
)?;
let mut out = Vec::new();
for row in rows {
out.push(row?);
}
Ok(out)
}
fn load_reflections_for_entity_any_namespace(
conn: &Connection,
entity_id: &str,
limit: usize,
) -> Result<Vec<Memory>> {
let mut stmt = conn.prepare(
"SELECT id, tier, namespace, title, content, tags, priority, confidence, source,
access_count, created_at, updated_at, last_accessed_at, expires_at,
metadata, COALESCE(reflection_depth, 0), COALESCE(memory_kind, 'observation'),
entity_id, persona_version
FROM memories
WHERE memory_kind = 'reflection'
AND mentioned_entity_id = ?1
ORDER BY priority DESC, created_at DESC
LIMIT ?2",
)?;
let rows = stmt.query_map(
rusqlite::params![entity_id, i64::try_from(limit).unwrap_or(i64::MAX)],
crate::storage::row_to_memory,
)?;
let mut out = Vec::new();
for row in rows {
out.push(row?);
}
Ok(out)
}
fn render_body_with_footnotes(raw: &str, sources: &[Memory]) -> String {
let mut out = String::with_capacity(raw.len() + sources.len() * 64);
out.push_str(raw.trim_end());
out.push_str("\n\n## Sources\n\n");
for (idx, src) in sources.iter().enumerate() {
out.push_str(&format!("[^{}]: {} — `{}`\n", idx + 1, src.title, src.id));
}
out
}
fn persona_title(entity_id: &str, version: i32) -> String {
format!("__persona_{entity_id}_v{version}")
}
fn emit_persona_generated_event(
conn: &Connection,
persona_id: &str,
agent_id: &str,
sources: &[String],
now: &str,
signature: Option<&[u8]>,
attest_level: &str,
) -> Result<()> {
let mut hasher = Sha256::new();
hasher.update(persona_id.as_bytes());
hasher.update(b"\x1f");
for src in sources {
hasher.update(src.as_bytes());
hasher.update(b"\x1f");
}
let payload_hash = hasher.finalize().to_vec();
let event = SignedEvent {
id: uuid::Uuid::new_v4().to_string(),
agent_id: agent_id.to_string(),
event_type: crate::signed_events::event_types::PERSONA_GENERATED.to_string(),
payload_hash,
signature: signature.map(<[u8]>::to_vec),
attest_level: attest_level.to_string(),
timestamp: now.to_string(),
..SignedEvent::default()
};
append_signed_event(conn, &event)
}
#[must_use]
pub fn render_persona_md(persona: &Persona) -> String {
let mut out = String::with_capacity(persona.body_md.len() + 256);
out.push_str("---\n");
out.push_str(&format!("memory_id: {}\n", persona.id));
out.push_str(&format!("entity_id: {}\n", persona.entity_id));
out.push_str(&format!("namespace: {}\n", persona.namespace));
out.push_str(&format!("persona_version: {}\n", persona.version));
out.push_str(&format!("generated_at: {}\n", persona.generated_at));
out.push_str(&format!("attest_level: {}\n", persona.attest_level));
out.push_str(&format!("sources: {}\n", persona.sources.len()));
out.push_str("---\n\n");
out.push_str(&persona.body_md);
if !out.ends_with('\n') {
out.push('\n');
}
out
}
#[must_use]
pub fn render_persona_json(persona: &Persona) -> String {
let mut map: BTreeMap<&str, serde_json::Value> = BTreeMap::new();
map.insert("memory_id", serde_json::Value::String(persona.id.clone()));
map.insert(
"entity_id",
serde_json::Value::String(persona.entity_id.clone()),
);
map.insert(
"namespace",
serde_json::Value::String(persona.namespace.clone()),
);
map.insert(
field_names::PERSONA_VERSION,
serde_json::Value::Number(serde_json::Number::from(persona.version)),
);
map.insert(
field_names::GENERATED_AT,
serde_json::Value::String(persona.generated_at.clone()),
);
map.insert(
field_names::ATTEST_LEVEL,
serde_json::Value::String(persona.attest_level.clone()),
);
map.insert(
"sources",
serde_json::Value::Array(
persona
.sources
.iter()
.map(|s| serde_json::Value::String(s.clone()))
.collect(),
),
);
map.insert(
"body_md",
serde_json::Value::String(persona.body_md.clone()),
);
serde_json::to_string_pretty(&map).unwrap_or_else(|_| "{}".to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::llm::test_support::MockOllamaClient;
use crate::models::{Memory, MemoryKind, Tier};
use crate::storage as db;
use rusqlite::Connection;
use tempfile::TempDir;
fn fresh_db() -> (Connection, TempDir) {
let dir = TempDir::new().unwrap();
let path = dir.path().join("ai-memory.db");
let conn = db::open(&path).unwrap();
(conn, dir)
}
struct StubLlm {
canned: String,
}
impl AutonomyLlm for StubLlm {
fn auto_tag(&self, _title: &str, _content: &str) -> anyhow::Result<Vec<String>> {
Ok(Vec::new())
}
fn detect_contradiction(&self, _a: &str, _b: &str) -> anyhow::Result<bool> {
Ok(false)
}
fn summarize_memories(&self, memories: &[(String, String)]) -> anyhow::Result<String> {
Ok(format!("{} [from {} sources]", self.canned, memories.len()))
}
}
fn seed_reflection(conn: &Connection, namespace: &str, title: &str, body: &str) -> String {
let now = Utc::now().to_rfc3339();
let mem = Memory {
id: uuid::Uuid::new_v4().to_string(),
tier: Tier::Mid,
namespace: namespace.to_string(),
title: title.to_string(),
content: body.to_string(),
tags: vec!["reflection".into()],
priority: 5,
confidence: 1.0,
source: "test".into(),
access_count: 0,
created_at: now.clone(),
updated_at: now,
last_accessed_at: None,
expires_at: None,
metadata: serde_json::json!({"agent_id": "ai:test"}),
reflection_depth: 1,
memory_kind: MemoryKind::Reflection,
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,
};
db::insert(conn, &mem).unwrap()
}
#[test]
fn validate_entity_id_rejects_empty() {
assert!(matches!(
validate_entity_id(""),
Err(PersonaError::Validation(_))
));
assert!(matches!(
validate_entity_id(" "),
Err(PersonaError::Validation(_))
));
}
#[test]
fn validate_entity_id_rejects_overlong() {
let long = "x".repeat(129);
assert!(matches!(
validate_entity_id(&long),
Err(PersonaError::Validation(_))
));
}
#[test]
fn validate_entity_id_accepts_normal_ids() {
assert!(validate_entity_id("alice").is_ok());
assert!(validate_entity_id("entity-42").is_ok());
}
#[test]
fn generate_refuses_when_no_reflections() {
let (conn, _dir) = fresh_db();
let llm = StubLlm {
canned: "irrelevant".into(),
};
let generator = PersonaGenerator::new(&conn, &llm, None, PersonaConfig::default());
let err = generator.generate("alice", "team/alpha").unwrap_err();
assert!(matches!(err, PersonaError::NoReflections { .. }));
}
#[test]
fn render_body_with_footnotes_appends_sources_block() {
let (conn, _dir) = fresh_db();
let id1 = seed_reflection(&conn, "team/alpha", "ref-1 about alice", "alice does X");
let id2 = seed_reflection(&conn, "team/alpha", "ref-2 about alice", "alice does Y");
let mems = vec![
db::get(&conn, &id1).unwrap().unwrap(),
db::get(&conn, &id2).unwrap().unwrap(),
];
let body = render_body_with_footnotes("Alice is composed and thoughtful.", &mems);
assert!(body.contains("## Sources"));
assert!(body.contains(&format!("[^1]: ref-1 about alice — `{id1}`")));
assert!(body.contains(&format!("[^2]: ref-2 about alice — `{id2}`")));
}
#[test]
fn next_version_starts_at_one_then_increments() {
let (conn, _dir) = fresh_db();
assert_eq!(next_version(&conn, "alice", "team/alpha").unwrap(), 1);
let now = Utc::now().to_rfc3339();
let mem = Memory {
id: uuid::Uuid::new_v4().to_string(),
tier: Tier::Long,
namespace: "team/alpha".into(),
title: persona_title("alice", 1),
content: "x".into(),
tags: vec![],
priority: 7,
confidence: 1.0,
source: "curator".into(),
access_count: 0,
created_at: now.clone(),
updated_at: now,
last_accessed_at: None,
expires_at: None,
metadata: serde_json::json!({}),
reflection_depth: 0,
memory_kind: MemoryKind::Persona,
entity_id: Some("alice".into()),
persona_version: Some(1),
citations: Vec::new(),
source_uri: None,
source_span: None,
confidence_source: ConfidenceSource::CallerProvided,
confidence_signals: None,
confidence_decayed_at: None,
version: 1,
};
db::insert(&conn, &mem).unwrap();
assert_eq!(next_version(&conn, "alice", "team/alpha").unwrap(), 2);
}
#[test]
fn next_version_propagates_db_errors() {
let (conn, _dir) = fresh_db();
assert_eq!(next_version(&conn, "alice", "team/alpha").unwrap(), 1);
conn.execute("DROP TABLE memories", []).unwrap();
let err = next_version(&conn, "alice", "team/alpha")
.expect_err("next_version must propagate non-NoRows DB errors, not collapse to Ok(1)");
let msg = format!("{err:#}");
assert!(
msg.to_lowercase().contains("no such table") || msg.to_lowercase().contains("memories"),
"expected propagated rusqlite error to mention the missing \
memories table, got: {msg}"
);
}
#[test]
fn render_persona_md_includes_frontmatter() {
let p = Persona {
id: "p1".into(),
entity_id: "alice".into(),
namespace: "team/alpha".into(),
body_md: "Alice is composed.".into(),
sources: vec!["s1".into(), "s2".into()],
generated_at: "2026-05-15T00:00:00Z".into(),
version: 1,
attest_level: "unsigned".into(),
};
let md = render_persona_md(&p);
assert!(md.starts_with("---\n"));
assert!(md.contains("memory_id: p1\n"));
assert!(md.contains("entity_id: alice\n"));
assert!(md.contains("namespace: team/alpha\n"));
assert!(md.contains("persona_version: 1\n"));
assert!(md.contains("sources: 2\n"));
assert!(md.contains("Alice is composed."));
}
#[test]
fn render_persona_json_round_trips() {
let p = Persona {
id: "p1".into(),
entity_id: "alice".into(),
namespace: "team/alpha".into(),
body_md: "body".into(),
sources: vec!["s1".into()],
generated_at: "2026-05-15T00:00:00Z".into(),
version: 2,
attest_level: "unsigned".into(),
};
let s = render_persona_json(&p);
let v: serde_json::Value = serde_json::from_str(&s).unwrap();
assert_eq!(v["memory_id"], "p1");
assert_eq!(v["entity_id"], "alice");
assert_eq!(v["persona_version"], 2);
}
#[test]
fn mock_llm_available() {
let _ = MockOllamaClient::new_with_url("http://localhost:11434", "gemma2:2b").unwrap();
}
fn seed_two_alice_reflections(conn: &Connection, namespace: &str) -> Vec<String> {
let mut ids = Vec::new();
for i in 0..2 {
let now = Utc::now().to_rfc3339();
let mem = Memory {
id: uuid::Uuid::new_v4().to_string(),
tier: Tier::Mid,
namespace: namespace.to_string(),
title: format!("obs-{i} about alice"),
content: format!("alice did thing {i}"),
tags: vec!["reflection".into()],
priority: 5,
confidence: 1.0,
source: "test".into(),
access_count: 0,
created_at: now.clone(),
updated_at: now,
last_accessed_at: None,
expires_at: None,
metadata: serde_json::json!({"agent_id": "ai:test", "entity_id": "alice"}),
reflection_depth: 1,
memory_kind: MemoryKind::Reflection,
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,
};
ids.push(db::insert(conn, &mem).unwrap());
}
ids
}
#[test]
fn generate_unsigned_path_writes_unsigned_links() {
let (conn, _dir) = fresh_db();
seed_two_alice_reflections(&conn, "team/alpha");
let llm = StubLlm {
canned: "Alice is methodical.".into(),
};
let generator = PersonaGenerator::new(&conn, &llm, None, PersonaConfig::default());
let persona = generator.generate("alice", "team/alpha").expect("generate");
assert_eq!(persona.attest_level, "unsigned");
let links: Vec<(Option<Vec<u8>>, String)> = {
let mut stmt = conn
.prepare(
"SELECT signature, attest_level FROM memory_links \
WHERE source_id = ?1 AND relation = 'derived_from'",
)
.unwrap();
stmt.query_map(rusqlite::params![&persona.id], |r| {
Ok((r.get::<_, Option<Vec<u8>>>(0)?, r.get::<_, String>(1)?))
})
.unwrap()
.collect::<rusqlite::Result<_>>()
.unwrap()
};
assert_eq!(links.len(), 2);
for (sig, level) in &links {
assert!(sig.is_none(), "unsigned link must have NULL signature");
assert_eq!(level, "unsigned");
}
}
#[test]
fn generate_signed_path_writes_signed_links_and_metadata() {
let (conn, _dir) = fresh_db();
seed_two_alice_reflections(&conn, "team/alpha");
let kp = crate::identity::keypair::generate("ai:curator").unwrap();
let llm = StubLlm {
canned: "Signed alice body".into(),
};
let generator = PersonaGenerator::new(&conn, &llm, Some(&kp), PersonaConfig::default());
let persona = generator.generate("alice", "team/alpha").expect("generate");
assert_eq!(persona.attest_level, "self_signed");
let links: Vec<(Option<Vec<u8>>, String)> = {
let mut stmt = conn
.prepare(
"SELECT signature, attest_level FROM memory_links \
WHERE source_id = ?1 AND relation = 'derived_from'",
)
.unwrap();
stmt.query_map(rusqlite::params![&persona.id], |r| {
Ok((r.get::<_, Option<Vec<u8>>>(0)?, r.get::<_, String>(1)?))
})
.unwrap()
.collect::<rusqlite::Result<_>>()
.unwrap()
};
assert_eq!(links.len(), 2);
for (sig, level) in &links {
assert_eq!(level, "self_signed");
let sig_bytes = sig.as_ref().expect("signed link must have signature blob");
assert_eq!(sig_bytes.len(), 64, "Ed25519 signatures are 64 bytes");
}
let meta_str: String = conn
.query_row(
"SELECT metadata FROM memories WHERE id = ?1",
rusqlite::params![&persona.id],
|r| r.get(0),
)
.unwrap();
let meta: serde_json::Value = serde_json::from_str(&meta_str).unwrap();
assert_eq!(meta["agent_id"], "ai:curator");
assert_eq!(meta["persona"]["attest_level"], "self_signed");
let b64 = meta["persona"]["signature"]
.as_str()
.expect("metadata.persona.signature must be a string");
let decoded = BASE64_STANDARD.decode(b64).expect("base64 decode");
assert_eq!(decoded.len(), 64, "decoded sig must be 64 bytes");
let body_md: String = conn
.query_row(
"SELECT content FROM memories WHERE id = ?1",
rusqlite::params![&persona.id],
|r| r.get(0),
)
.unwrap();
let mut hasher = Sha256::new();
hasher.update(body_md.as_bytes());
let mut body_hash = [0u8; 32];
body_hash.copy_from_slice(&hasher.finalize());
let signable = SignablePersona {
persona_id: persona.id.as_str(),
entity_id: persona.entity_id.as_str(),
namespace: persona.namespace.as_str(),
version: persona.version,
generated_at: persona.generated_at.as_str(),
sources: &persona.sources,
body_md_sha256: &body_hash,
};
let bytes = crate::identity::sign::canonical_cbor_persona(&signable).unwrap();
let mut arr = [0u8; 64];
arr.copy_from_slice(&decoded);
let sig = ed25519_dalek::Signature::from_bytes(&arr);
use ed25519_dalek::Verifier;
kp.public.verify(&bytes, &sig).expect("verify persona sig");
}
#[test]
fn generate_signed_path_emits_signed_event() {
let (conn, _dir) = fresh_db();
seed_two_alice_reflections(&conn, "team/alpha");
let kp = crate::identity::keypair::generate("ai:curator").unwrap();
let llm = StubLlm {
canned: "body".into(),
};
let generator = PersonaGenerator::new(&conn, &llm, Some(&kp), PersonaConfig::default());
let persona = generator.generate("alice", "team/alpha").expect("generate");
let (sig, attest): (Option<Vec<u8>>, String) = conn
.query_row(
"SELECT signature, attest_level FROM signed_events \
WHERE event_type = 'persona_generated' \
ORDER BY sequence DESC LIMIT 1",
[],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.unwrap();
assert_eq!(attest, "self_signed");
let sig_bytes = sig.expect("signed audit row must have signature");
assert_eq!(sig_bytes.len(), 64);
let meta_str: String = conn
.query_row(
"SELECT metadata FROM memories WHERE id = ?1",
rusqlite::params![&persona.id],
|r| r.get(0),
)
.unwrap();
let meta: serde_json::Value = serde_json::from_str(&meta_str).unwrap();
let b64 = meta["persona"]["signature"].as_str().unwrap();
let decoded = BASE64_STANDARD.decode(b64).unwrap();
assert_eq!(
decoded, sig_bytes,
"metadata signature must match signed_events.signature"
);
}
#[test]
fn generate_with_public_only_keypair_falls_back_to_unsigned() {
let (conn, _dir) = fresh_db();
seed_two_alice_reflections(&conn, "team/alpha");
let kp = crate::identity::keypair::generate("ai:curator").unwrap();
let pub_only = crate::identity::keypair::AgentKeypair {
agent_id: "ai:curator".to_string(),
public: kp.public,
private: None,
};
let llm = StubLlm {
canned: "body".into(),
};
let generator =
PersonaGenerator::new(&conn, &llm, Some(&pub_only), PersonaConfig::default());
let persona = generator.generate("alice", "team/alpha").expect("generate");
assert_eq!(
persona.attest_level, "unsigned",
"public-only keypair must NOT produce self_signed"
);
let meta_str: String = conn
.query_row(
"SELECT metadata FROM memories WHERE id = ?1",
rusqlite::params![&persona.id],
|r| r.get(0),
)
.unwrap();
let meta: serde_json::Value = serde_json::from_str(&meta_str).unwrap();
assert!(
meta["persona"]["signature"].is_null()
|| !meta["persona"]
.as_object()
.unwrap()
.contains_key("signature"),
"metadata must not carry a signature for the unsigned path"
);
}
#[test]
fn signed_persona_v2_regenerates_with_fresh_signature() {
let (conn, _dir) = fresh_db();
seed_two_alice_reflections(&conn, "team/alpha");
let kp = crate::identity::keypair::generate("ai:curator").unwrap();
let llm = StubLlm {
canned: "body".into(),
};
let generator = PersonaGenerator::new(&conn, &llm, Some(&kp), PersonaConfig::default());
let v1 = generator.generate("alice", "team/alpha").expect("v1");
let v2 = generator.generate("alice", "team/alpha").expect("v2");
assert_eq!(v1.version, 1);
assert_eq!(v2.version, 2);
assert_ne!(v1.id, v2.id);
assert_eq!(v1.attest_level, "self_signed");
assert_eq!(v2.attest_level, "self_signed");
let sigs: Vec<Vec<u8>> = [&v1.id, &v2.id]
.iter()
.map(|id| {
let meta_str: String = conn
.query_row(
"SELECT metadata FROM memories WHERE id = ?1",
rusqlite::params![id],
|r| r.get(0),
)
.unwrap();
let meta: serde_json::Value = serde_json::from_str(&meta_str).unwrap();
let b64 = meta["persona"]["signature"].as_str().expect("sig present");
BASE64_STANDARD.decode(b64).unwrap()
})
.collect();
assert_eq!(sigs[0].len(), 64);
assert_eq!(sigs[1].len(), 64);
assert_ne!(sigs[0], sigs[1], "v1 + v2 signatures must differ");
}
}