1use crate::models::ConfidenceSource;
50use crate::models::field_names;
51use std::collections::BTreeMap;
52use std::fmt;
53
54use anyhow::{Context, Result};
55use base64::Engine;
56use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
57use chrono::Utc;
58use rusqlite::Connection;
59use serde::{Deserialize, Serialize};
60use sha2::{Digest, Sha256};
61
62use crate::autonomy::AutonomyLlm;
63use crate::identity::keypair::AgentKeypair;
64use crate::identity::sign::{SignablePersona, sign_persona};
65use crate::models::{Memory, MemoryKind, Tier};
66use crate::signed_events::{SignedEvent, append_signed_event};
67use crate::storage as db;
68use crate::validate;
69
70pub const DEFAULT_MAX_REFLECTION_SOURCES: usize = 20;
74
75const ANONYMOUS_CURATOR_AGENT_ID: &str = crate::identity::sentinels::AI_CURATOR;
78
79pub const CROSS_NAMESPACE_SENTINEL: &str = "<any namespace>";
87
88#[derive(Debug, Clone, Copy)]
94enum PersonaScope<'a> {
95 Single(&'a str),
96 AnyTargeting(&'a str),
97}
98
99#[derive(Debug, Clone)]
101pub struct PersonaConfig {
102 pub max_reflection_sources: usize,
105 pub tier: Tier,
109}
110
111impl Default for PersonaConfig {
112 fn default() -> Self {
113 Self {
114 max_reflection_sources: DEFAULT_MAX_REFLECTION_SOURCES,
115 tier: Tier::Long,
116 }
117 }
118}
119
120#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
126pub struct Persona {
127 pub id: String,
130 pub entity_id: String,
132 pub namespace: String,
134 pub body_md: String,
136 pub sources: Vec<String>,
138 pub generated_at: String,
140 pub version: i32,
143 pub attest_level: String,
146}
147
148#[derive(Debug)]
150pub enum PersonaError {
151 Validation(String),
153 NoReflections {
155 entity_id: String,
156 namespace: String,
157 },
158 Llm(String),
160 Db(anyhow::Error),
162}
163
164impl fmt::Display for PersonaError {
165 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
166 match self {
167 Self::Validation(msg) => write!(f, "persona validation failed: {msg}"),
168 Self::NoReflections {
169 entity_id,
170 namespace,
171 } => write!(
172 f,
173 "no reflections found for entity '{entity_id}' in namespace '{namespace}'"
174 ),
175 Self::Llm(msg) => write!(f, "curator synthesis failed: {msg}"),
176 Self::Db(e) => write!(f, "persona db error: {e}"),
177 }
178 }
179}
180
181impl std::error::Error for PersonaError {}
182
183impl From<anyhow::Error> for PersonaError {
184 fn from(e: anyhow::Error) -> Self {
185 Self::Db(e)
186 }
187}
188
189impl From<rusqlite::Error> for PersonaError {
190 fn from(e: rusqlite::Error) -> Self {
191 Self::Db(anyhow::Error::from(e))
192 }
193}
194
195pub struct PersonaGenerator<'a> {
202 conn: &'a Connection,
203 llm: &'a dyn AutonomyLlm,
204 signer: Option<&'a AgentKeypair>,
205 config: PersonaConfig,
206}
207
208impl<'a> PersonaGenerator<'a> {
209 pub fn new(
211 conn: &'a Connection,
212 llm: &'a dyn AutonomyLlm,
213 signer: Option<&'a AgentKeypair>,
214 config: PersonaConfig,
215 ) -> Self {
216 Self {
217 conn,
218 llm,
219 signer,
220 config,
221 }
222 }
223
224 fn agent_id(&self) -> String {
228 self.signer
229 .map(|kp| kp.agent_id.clone())
230 .unwrap_or_else(|| ANONYMOUS_CURATOR_AGENT_ID.to_string())
231 }
232
233 pub fn generate(
259 &self,
260 entity_id: &str,
261 namespace: &str,
262 ) -> std::result::Result<Persona, PersonaError> {
263 self.generate_in_scope(entity_id, PersonaScope::Single(namespace))
264 }
265
266 pub fn generate_cross_namespace(
286 &self,
287 entity_id: &str,
288 target_namespace: &str,
289 ) -> std::result::Result<Persona, PersonaError> {
290 self.generate_in_scope(entity_id, PersonaScope::AnyTargeting(target_namespace))
291 }
292
293 fn generate_in_scope(
299 &self,
300 entity_id: &str,
301 scope: PersonaScope<'_>,
302 ) -> std::result::Result<Persona, PersonaError> {
303 validate_entity_id(entity_id)?;
304 let namespace = match scope {
305 PersonaScope::Single(ns) | PersonaScope::AnyTargeting(ns) => ns,
306 };
307 validate::validate_namespace(namespace)
308 .map_err(|e| PersonaError::Validation(e.to_string()))?;
309
310 let sources = match scope {
311 PersonaScope::Single(ns) => load_reflections_for_entity(
312 self.conn,
313 entity_id,
314 ns,
315 self.config.max_reflection_sources,
316 )?,
317 PersonaScope::AnyTargeting(_) => load_reflections_for_entity_any_namespace(
318 self.conn,
319 entity_id,
320 self.config.max_reflection_sources,
321 )?,
322 };
323 if sources.is_empty() {
324 let reported_ns = match scope {
325 PersonaScope::Single(ns) => ns.to_string(),
326 PersonaScope::AnyTargeting(_) => CROSS_NAMESPACE_SENTINEL.to_string(),
327 };
328 return Err(PersonaError::NoReflections {
329 entity_id: entity_id.to_string(),
330 namespace: reported_ns,
331 });
332 }
333
334 let version = next_version(self.conn, entity_id, namespace)?;
335
336 let llm_input: Vec<(String, String)> = sources
341 .iter()
342 .map(|m| (m.title.clone(), m.content.clone()))
343 .collect();
344 let body_md_raw = self
345 .llm
346 .summarize_memories(&llm_input)
347 .map_err(|e| PersonaError::Llm(e.to_string()))?;
348 let body_md = render_body_with_footnotes(&body_md_raw, &sources);
349
350 let now = Utc::now().to_rfc3339();
351 let agent_id = self.agent_id();
352 let title = persona_title(entity_id, version);
353 let source_ids: Vec<String> = sources.iter().map(|m| m.id.clone()).collect();
354
355 let persona_id_local = uuid::Uuid::new_v4().to_string();
363
364 let mut metadata = serde_json::json!({
365 "agent_id": agent_id,
366 "persona": {
367 "entity_id": entity_id,
368 "sources": source_ids.clone(),
369 "version": version,
370 (field_names::ATTEST_LEVEL): crate::models::AttestLevel::Unsigned.as_str(),
371 (field_names::GENERATED_AT): now,
372 }
373 });
374
375 let persona_mem = Memory {
376 id: persona_id_local.clone(),
377 tier: self.config.tier.clone(),
378 namespace: namespace.to_string(),
379 title,
380 content: body_md.clone(),
381 tags: vec!["persona".to_string()],
382 priority: 7,
383 confidence: 1.0,
384 source: "curator".to_string(),
385 access_count: 0,
386 created_at: now.clone(),
387 updated_at: now.clone(),
388 last_accessed_at: None,
389 expires_at: None,
390 metadata: metadata.clone(),
391 reflection_depth: 0,
392 memory_kind: MemoryKind::Persona,
393 entity_id: Some(entity_id.to_string()),
394 persona_version: Some(version),
395 citations: Vec::new(),
396 source_uri: None,
397 source_span: None,
398 confidence_source: ConfidenceSource::CuratorDerived,
407 confidence_signals: None,
408 confidence_decayed_at: None,
409 version: 1,
410 };
411
412 let persona_tx = self
421 .conn
422 .unchecked_transaction()
423 .context("begin persona write transaction")?;
424
425 let persona_id = db::insert(self.conn, &persona_mem)
426 .with_context(|| format!("inserting persona for {entity_id} v{version}"))?;
427
428 for source in &sources {
435 db::create_link_signed(
436 self.conn,
437 &persona_id,
438 &source.id,
439 crate::models::MemoryLinkRelation::DerivedFrom.as_str(),
440 self.signer,
441 )
442 .with_context(|| format!("linking persona {persona_id} -> source {}", source.id))?;
443 }
444
445 let body_hash = {
453 let mut h = Sha256::new();
454 h.update(body_md.as_bytes());
455 let mut out = [0u8; 32];
456 out.copy_from_slice(&h.finalize());
457 out
458 };
459
460 let signature_bytes: Option<Vec<u8>> = match self.signer {
461 Some(kp) if kp.can_sign() => {
462 let p = SignablePersona {
463 persona_id: persona_id.as_str(),
464 entity_id,
465 namespace,
466 version,
467 generated_at: now.as_str(),
468 sources: &source_ids,
469 body_md_sha256: &body_hash,
470 };
471 Some(sign_persona(kp, &p).context("sign persona artifact")?)
472 }
473 _ => None,
474 };
475
476 let link_attest = db::strongest_attest_level_for_source(self.conn, &persona_id)
485 .context("resolve strongest link attest_level")?;
486 let attest_level = if signature_bytes.is_some() {
487 match link_attest.as_str() {
490 s if s == crate::models::AttestLevel::PeerAttested.as_str() => {
491 crate::models::AttestLevel::PeerAttested
492 .as_str()
493 .to_string()
494 }
495 _ => crate::models::AttestLevel::SelfSigned.as_str().to_string(),
496 }
497 } else {
498 link_attest
499 };
500
501 if let Some(env) = metadata
506 .get_mut("persona")
507 .and_then(serde_json::Value::as_object_mut)
508 {
509 env.insert(
510 field_names::ATTEST_LEVEL.to_string(),
511 serde_json::Value::String(attest_level.clone()),
512 );
513 if let Some(sig) = signature_bytes.as_ref() {
514 env.insert(
515 "signature".to_string(),
516 serde_json::Value::String(BASE64_STANDARD.encode(sig)),
517 );
518 }
519 }
520 let new_metadata_str = serde_json::to_string(&metadata)
521 .context("serialise updated persona metadata envelope")?;
522 self.conn
523 .execute(
524 "UPDATE memories SET metadata = ?1, updated_at = ?2 WHERE id = ?3",
525 rusqlite::params![new_metadata_str, &now, &persona_id],
526 )
527 .context("patch persona metadata with signature/attest_level")?;
528
529 persona_tx
536 .commit()
537 .context("commit persona write transaction")?;
538
539 emit_persona_generated_event(
540 self.conn,
541 &persona_id,
542 &agent_id,
543 &source_ids,
544 &now,
545 signature_bytes.as_deref(),
546 &attest_level,
547 )?;
548
549 Ok(Persona {
550 id: persona_id,
551 entity_id: entity_id.to_string(),
552 namespace: namespace.to_string(),
553 body_md,
554 sources: source_ids,
555 generated_at: now,
556 version,
557 attest_level,
558 })
559 }
560}
561
562fn validate_entity_id(entity_id: &str) -> std::result::Result<(), PersonaError> {
567 if entity_id.trim().is_empty() {
568 return Err(PersonaError::Validation(
569 crate::errors::msg::ENTITY_ID_EMPTY.into(),
570 ));
571 }
572 if entity_id.len() > 128 {
573 return Err(PersonaError::Validation(format!(
574 "entity_id exceeds 128 characters (got {})",
575 entity_id.len()
576 )));
577 }
578 Ok(())
579}
580
581pub fn get_latest_persona(
588 conn: &Connection,
589 entity_id: &str,
590 namespace: &str,
591) -> Result<Option<Persona>> {
592 let mut stmt = conn.prepare(
593 "SELECT id, entity_id, namespace, content, created_at, COALESCE(persona_version, 1), metadata
594 FROM memories
595 WHERE memory_kind = 'persona'
596 AND entity_id = ?1
597 AND namespace = ?2
598 ORDER BY COALESCE(persona_version, 0) DESC, created_at DESC
599 LIMIT 1",
600 )?;
601 let row: Option<(String, String, String, String, String, i32, String)> = stmt
602 .query_row(rusqlite::params![entity_id, namespace], |r| {
603 Ok((
604 r.get(0)?,
605 r.get(1)?,
606 r.get(2)?,
607 r.get(3)?,
608 r.get(4)?,
609 r.get(5)?,
610 r.get(6)?,
611 ))
612 })
613 .ok();
614 let Some((id, entity_id, namespace, body_md, generated_at, version, metadata_str)) = row else {
615 return Ok(None);
616 };
617 let meta: serde_json::Value =
618 serde_json::from_str(&metadata_str).unwrap_or_else(|_| serde_json::json!({}));
619 let envelope = meta.get("persona").cloned().unwrap_or_default();
620 let sources = envelope
621 .get("sources")
622 .and_then(|v| v.as_array())
623 .map(|arr| {
624 arr.iter()
625 .filter_map(|v| v.as_str().map(str::to_string))
626 .collect()
627 })
628 .unwrap_or_default();
629 let attest_level = envelope
630 .get(field_names::ATTEST_LEVEL)
631 .and_then(|v| v.as_str())
632 .unwrap_or(crate::models::AttestLevel::Unsigned.as_str())
633 .to_string();
634 Ok(Some(Persona {
635 id,
636 entity_id,
637 namespace,
638 body_md,
639 sources,
640 generated_at,
641 version,
642 attest_level,
643 }))
644}
645
646fn next_version(conn: &Connection, entity_id: &str, namespace: &str) -> Result<i32> {
668 match conn.query_row(
669 "SELECT COALESCE(MAX(persona_version), 0)
670 FROM memories
671 WHERE memory_kind = 'persona'
672 AND entity_id = ?1
673 AND namespace = ?2",
674 rusqlite::params![entity_id, namespace],
675 |r| r.get::<_, i32>(0),
676 ) {
677 Ok(n) => Ok(n + 1),
678 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(1),
679 Err(e) => Err(e.into()),
680 }
681}
682
683fn load_reflections_for_entity(
701 conn: &Connection,
702 entity_id: &str,
703 namespace: &str,
704 limit: usize,
705) -> Result<Vec<Memory>> {
706 let mut stmt = conn.prepare(
707 "SELECT id, tier, namespace, title, content, tags, priority, confidence, source,
708 access_count, created_at, updated_at, last_accessed_at, expires_at,
709 metadata, COALESCE(reflection_depth, 0), COALESCE(memory_kind, 'observation'),
710 entity_id, persona_version
711 FROM memories
712 WHERE namespace = ?1
713 AND memory_kind = 'reflection'
714 AND mentioned_entity_id = ?2
715 ORDER BY priority DESC, created_at DESC
716 LIMIT ?3",
717 )?;
718 let rows = stmt.query_map(
719 rusqlite::params![
720 namespace,
721 entity_id,
722 i64::try_from(limit).unwrap_or(i64::MAX)
723 ],
724 crate::storage::row_to_memory,
725 )?;
726 let mut out = Vec::new();
727 for row in rows {
728 out.push(row?);
729 }
730 Ok(out)
731}
732
733fn load_reflections_for_entity_any_namespace(
740 conn: &Connection,
741 entity_id: &str,
742 limit: usize,
743) -> Result<Vec<Memory>> {
744 let mut stmt = conn.prepare(
745 "SELECT id, tier, namespace, title, content, tags, priority, confidence, source,
746 access_count, created_at, updated_at, last_accessed_at, expires_at,
747 metadata, COALESCE(reflection_depth, 0), COALESCE(memory_kind, 'observation'),
748 entity_id, persona_version
749 FROM memories
750 WHERE memory_kind = 'reflection'
751 AND mentioned_entity_id = ?1
752 ORDER BY priority DESC, created_at DESC
753 LIMIT ?2",
754 )?;
755 let rows = stmt.query_map(
756 rusqlite::params![entity_id, i64::try_from(limit).unwrap_or(i64::MAX)],
757 crate::storage::row_to_memory,
758 )?;
759 let mut out = Vec::new();
760 for row in rows {
761 out.push(row?);
762 }
763 Ok(out)
764}
765
766fn render_body_with_footnotes(raw: &str, sources: &[Memory]) -> String {
771 let mut out = String::with_capacity(raw.len() + sources.len() * 64);
772 out.push_str(raw.trim_end());
773 out.push_str("\n\n## Sources\n\n");
774 for (idx, src) in sources.iter().enumerate() {
775 out.push_str(&format!("[^{}]: {} — `{}`\n", idx + 1, src.title, src.id));
777 }
778 out
779}
780
781fn persona_title(entity_id: &str, version: i32) -> String {
785 format!("__persona_{entity_id}_v{version}")
786}
787
788fn emit_persona_generated_event(
798 conn: &Connection,
799 persona_id: &str,
800 agent_id: &str,
801 sources: &[String],
802 now: &str,
803 signature: Option<&[u8]>,
804 attest_level: &str,
805) -> Result<()> {
806 let mut hasher = Sha256::new();
807 hasher.update(persona_id.as_bytes());
808 hasher.update(b"\x1f");
809 for src in sources {
810 hasher.update(src.as_bytes());
811 hasher.update(b"\x1f");
812 }
813 let payload_hash = hasher.finalize().to_vec();
814 let event = SignedEvent {
815 id: uuid::Uuid::new_v4().to_string(),
816 agent_id: agent_id.to_string(),
817 event_type: crate::signed_events::event_types::PERSONA_GENERATED.to_string(),
818 payload_hash,
819 signature: signature.map(<[u8]>::to_vec),
820 attest_level: attest_level.to_string(),
821 timestamp: now.to_string(),
822 ..SignedEvent::default()
823 };
824 append_signed_event(conn, &event)
825}
826
827#[must_use]
831pub fn render_persona_md(persona: &Persona) -> String {
832 let mut out = String::with_capacity(persona.body_md.len() + 256);
833 out.push_str("---\n");
834 out.push_str(&format!("memory_id: {}\n", persona.id));
835 out.push_str(&format!("entity_id: {}\n", persona.entity_id));
836 out.push_str(&format!("namespace: {}\n", persona.namespace));
837 out.push_str(&format!("persona_version: {}\n", persona.version));
838 out.push_str(&format!("generated_at: {}\n", persona.generated_at));
839 out.push_str(&format!("attest_level: {}\n", persona.attest_level));
840 out.push_str(&format!("sources: {}\n", persona.sources.len()));
841 out.push_str("---\n\n");
842 out.push_str(&persona.body_md);
843 if !out.ends_with('\n') {
844 out.push('\n');
845 }
846 out
847}
848
849#[must_use]
853pub fn render_persona_json(persona: &Persona) -> String {
854 let mut map: BTreeMap<&str, serde_json::Value> = BTreeMap::new();
855 map.insert("memory_id", serde_json::Value::String(persona.id.clone()));
856 map.insert(
857 "entity_id",
858 serde_json::Value::String(persona.entity_id.clone()),
859 );
860 map.insert(
861 "namespace",
862 serde_json::Value::String(persona.namespace.clone()),
863 );
864 map.insert(
865 field_names::PERSONA_VERSION,
866 serde_json::Value::Number(serde_json::Number::from(persona.version)),
867 );
868 map.insert(
869 field_names::GENERATED_AT,
870 serde_json::Value::String(persona.generated_at.clone()),
871 );
872 map.insert(
873 field_names::ATTEST_LEVEL,
874 serde_json::Value::String(persona.attest_level.clone()),
875 );
876 map.insert(
877 "sources",
878 serde_json::Value::Array(
879 persona
880 .sources
881 .iter()
882 .map(|s| serde_json::Value::String(s.clone()))
883 .collect(),
884 ),
885 );
886 map.insert(
887 "body_md",
888 serde_json::Value::String(persona.body_md.clone()),
889 );
890 serde_json::to_string_pretty(&map).unwrap_or_else(|_| "{}".to_string())
891}
892
893#[cfg(test)]
906mod tests {
907 use super::*;
908 use crate::llm::test_support::MockOllamaClient;
909 use crate::models::{Memory, MemoryKind, Tier};
910 use crate::storage as db;
911 use rusqlite::Connection;
912 use tempfile::TempDir;
913
914 fn fresh_db() -> (Connection, TempDir) {
915 let dir = TempDir::new().unwrap();
916 let path = dir.path().join("ai-memory.db");
917 let conn = db::open(&path).unwrap();
918 (conn, dir)
919 }
920
921 struct StubLlm {
925 canned: String,
926 }
927
928 impl AutonomyLlm for StubLlm {
929 fn auto_tag(&self, _title: &str, _content: &str) -> anyhow::Result<Vec<String>> {
930 Ok(Vec::new())
931 }
932 fn detect_contradiction(&self, _a: &str, _b: &str) -> anyhow::Result<bool> {
933 Ok(false)
934 }
935 fn summarize_memories(&self, memories: &[(String, String)]) -> anyhow::Result<String> {
936 Ok(format!("{} [from {} sources]", self.canned, memories.len()))
939 }
940 }
941
942 fn seed_reflection(conn: &Connection, namespace: &str, title: &str, body: &str) -> String {
943 let now = Utc::now().to_rfc3339();
944 let mem = Memory {
945 id: uuid::Uuid::new_v4().to_string(),
946 tier: Tier::Mid,
947 namespace: namespace.to_string(),
948 title: title.to_string(),
949 content: body.to_string(),
950 tags: vec!["reflection".into()],
951 priority: 5,
952 confidence: 1.0,
953 source: "test".into(),
954 access_count: 0,
955 created_at: now.clone(),
956 updated_at: now,
957 last_accessed_at: None,
958 expires_at: None,
959 metadata: serde_json::json!({"agent_id": "ai:test"}),
960 reflection_depth: 1,
961 memory_kind: MemoryKind::Reflection,
962 entity_id: None,
963 persona_version: None,
964 citations: Vec::new(),
965 source_uri: None,
966 source_span: None,
967 confidence_source: ConfidenceSource::CallerProvided,
968 confidence_signals: None,
969 confidence_decayed_at: None,
970 version: 1,
971 };
972 db::insert(conn, &mem).unwrap()
973 }
974
975 #[test]
976 fn validate_entity_id_rejects_empty() {
977 assert!(matches!(
978 validate_entity_id(""),
979 Err(PersonaError::Validation(_))
980 ));
981 assert!(matches!(
982 validate_entity_id(" "),
983 Err(PersonaError::Validation(_))
984 ));
985 }
986
987 #[test]
988 fn validate_entity_id_rejects_overlong() {
989 let long = "x".repeat(129);
990 assert!(matches!(
991 validate_entity_id(&long),
992 Err(PersonaError::Validation(_))
993 ));
994 }
995
996 #[test]
997 fn validate_entity_id_accepts_normal_ids() {
998 assert!(validate_entity_id("alice").is_ok());
999 assert!(validate_entity_id("entity-42").is_ok());
1000 }
1001
1002 #[test]
1003 fn generate_refuses_when_no_reflections() {
1004 let (conn, _dir) = fresh_db();
1005 let llm = StubLlm {
1006 canned: "irrelevant".into(),
1007 };
1008 let generator = PersonaGenerator::new(&conn, &llm, None, PersonaConfig::default());
1009 let err = generator.generate("alice", "team/alpha").unwrap_err();
1010 assert!(matches!(err, PersonaError::NoReflections { .. }));
1011 }
1012
1013 #[test]
1014 fn render_body_with_footnotes_appends_sources_block() {
1015 let (conn, _dir) = fresh_db();
1016 let id1 = seed_reflection(&conn, "team/alpha", "ref-1 about alice", "alice does X");
1017 let id2 = seed_reflection(&conn, "team/alpha", "ref-2 about alice", "alice does Y");
1018 let mems = vec![
1019 db::get(&conn, &id1).unwrap().unwrap(),
1020 db::get(&conn, &id2).unwrap().unwrap(),
1021 ];
1022 let body = render_body_with_footnotes("Alice is composed and thoughtful.", &mems);
1023 assert!(body.contains("## Sources"));
1024 assert!(body.contains(&format!("[^1]: ref-1 about alice — `{id1}`")));
1025 assert!(body.contains(&format!("[^2]: ref-2 about alice — `{id2}`")));
1026 }
1027
1028 #[test]
1029 fn next_version_starts_at_one_then_increments() {
1030 let (conn, _dir) = fresh_db();
1031 assert_eq!(next_version(&conn, "alice", "team/alpha").unwrap(), 1);
1032 let now = Utc::now().to_rfc3339();
1034 let mem = Memory {
1035 id: uuid::Uuid::new_v4().to_string(),
1036 tier: Tier::Long,
1037 namespace: "team/alpha".into(),
1038 title: persona_title("alice", 1),
1039 content: "x".into(),
1040 tags: vec![],
1041 priority: 7,
1042 confidence: 1.0,
1043 source: "curator".into(),
1044 access_count: 0,
1045 created_at: now.clone(),
1046 updated_at: now,
1047 last_accessed_at: None,
1048 expires_at: None,
1049 metadata: serde_json::json!({}),
1050 reflection_depth: 0,
1051 memory_kind: MemoryKind::Persona,
1052 entity_id: Some("alice".into()),
1053 persona_version: Some(1),
1054 citations: Vec::new(),
1055 source_uri: None,
1056 source_span: None,
1057 confidence_source: ConfidenceSource::CallerProvided,
1058 confidence_signals: None,
1059 confidence_decayed_at: None,
1060 version: 1,
1061 };
1062 db::insert(&conn, &mem).unwrap();
1063 assert_eq!(next_version(&conn, "alice", "team/alpha").unwrap(), 2);
1064 }
1065
1066 #[test]
1083 fn next_version_propagates_db_errors() {
1084 let (conn, _dir) = fresh_db();
1085
1086 assert_eq!(next_version(&conn, "alice", "team/alpha").unwrap(), 1);
1090
1091 conn.execute("DROP TABLE memories", []).unwrap();
1098
1099 let err = next_version(&conn, "alice", "team/alpha")
1100 .expect_err("next_version must propagate non-NoRows DB errors, not collapse to Ok(1)");
1101
1102 let msg = format!("{err:#}");
1108 assert!(
1109 msg.to_lowercase().contains("no such table") || msg.to_lowercase().contains("memories"),
1110 "expected propagated rusqlite error to mention the missing \
1111 memories table, got: {msg}"
1112 );
1113 }
1114
1115 #[test]
1116 fn render_persona_md_includes_frontmatter() {
1117 let p = Persona {
1118 id: "p1".into(),
1119 entity_id: "alice".into(),
1120 namespace: "team/alpha".into(),
1121 body_md: "Alice is composed.".into(),
1122 sources: vec!["s1".into(), "s2".into()],
1123 generated_at: "2026-05-15T00:00:00Z".into(),
1124 version: 1,
1125 attest_level: "unsigned".into(),
1126 };
1127 let md = render_persona_md(&p);
1128 assert!(md.starts_with("---\n"));
1129 assert!(md.contains("memory_id: p1\n"));
1130 assert!(md.contains("entity_id: alice\n"));
1131 assert!(md.contains("namespace: team/alpha\n"));
1132 assert!(md.contains("persona_version: 1\n"));
1133 assert!(md.contains("sources: 2\n"));
1134 assert!(md.contains("Alice is composed."));
1135 }
1136
1137 #[test]
1138 fn render_persona_json_round_trips() {
1139 let p = Persona {
1140 id: "p1".into(),
1141 entity_id: "alice".into(),
1142 namespace: "team/alpha".into(),
1143 body_md: "body".into(),
1144 sources: vec!["s1".into()],
1145 generated_at: "2026-05-15T00:00:00Z".into(),
1146 version: 2,
1147 attest_level: "unsigned".into(),
1148 };
1149 let s = render_persona_json(&p);
1150 let v: serde_json::Value = serde_json::from_str(&s).unwrap();
1151 assert_eq!(v["memory_id"], "p1");
1152 assert_eq!(v["entity_id"], "alice");
1153 assert_eq!(v["persona_version"], 2);
1154 }
1155
1156 #[test]
1157 fn mock_llm_available() {
1158 let _ = MockOllamaClient::new_with_url("http://localhost:11434", "gemma2:2b").unwrap();
1160 }
1161
1162 fn seed_two_alice_reflections(conn: &Connection, namespace: &str) -> Vec<String> {
1170 let mut ids = Vec::new();
1171 for i in 0..2 {
1172 let now = Utc::now().to_rfc3339();
1173 let mem = Memory {
1174 id: uuid::Uuid::new_v4().to_string(),
1175 tier: Tier::Mid,
1176 namespace: namespace.to_string(),
1177 title: format!("obs-{i} about alice"),
1178 content: format!("alice did thing {i}"),
1179 tags: vec!["reflection".into()],
1180 priority: 5,
1181 confidence: 1.0,
1182 source: "test".into(),
1183 access_count: 0,
1184 created_at: now.clone(),
1185 updated_at: now,
1186 last_accessed_at: None,
1187 expires_at: None,
1188 metadata: serde_json::json!({"agent_id": "ai:test", "entity_id": "alice"}),
1189 reflection_depth: 1,
1190 memory_kind: MemoryKind::Reflection,
1191 entity_id: None,
1192 persona_version: None,
1193 citations: Vec::new(),
1194 source_uri: None,
1195 source_span: None,
1196 confidence_source: ConfidenceSource::CallerProvided,
1197 confidence_signals: None,
1198 confidence_decayed_at: None,
1199 version: 1,
1200 };
1201 ids.push(db::insert(conn, &mem).unwrap());
1202 }
1203 ids
1204 }
1205
1206 #[test]
1207 fn generate_unsigned_path_writes_unsigned_links() {
1208 let (conn, _dir) = fresh_db();
1214 seed_two_alice_reflections(&conn, "team/alpha");
1215 let llm = StubLlm {
1216 canned: "Alice is methodical.".into(),
1217 };
1218 let generator = PersonaGenerator::new(&conn, &llm, None, PersonaConfig::default());
1219 let persona = generator.generate("alice", "team/alpha").expect("generate");
1220 assert_eq!(persona.attest_level, "unsigned");
1221 let links: Vec<(Option<Vec<u8>>, String)> = {
1224 let mut stmt = conn
1225 .prepare(
1226 "SELECT signature, attest_level FROM memory_links \
1227 WHERE source_id = ?1 AND relation = 'derived_from'",
1228 )
1229 .unwrap();
1230 stmt.query_map(rusqlite::params![&persona.id], |r| {
1231 Ok((r.get::<_, Option<Vec<u8>>>(0)?, r.get::<_, String>(1)?))
1232 })
1233 .unwrap()
1234 .collect::<rusqlite::Result<_>>()
1235 .unwrap()
1236 };
1237 assert_eq!(links.len(), 2);
1238 for (sig, level) in &links {
1239 assert!(sig.is_none(), "unsigned link must have NULL signature");
1240 assert_eq!(level, "unsigned");
1241 }
1242 }
1243
1244 #[test]
1245 fn generate_signed_path_writes_signed_links_and_metadata() {
1246 let (conn, _dir) = fresh_db();
1251 seed_two_alice_reflections(&conn, "team/alpha");
1252 let kp = crate::identity::keypair::generate("ai:curator").unwrap();
1253 let llm = StubLlm {
1254 canned: "Signed alice body".into(),
1255 };
1256 let generator = PersonaGenerator::new(&conn, &llm, Some(&kp), PersonaConfig::default());
1257 let persona = generator.generate("alice", "team/alpha").expect("generate");
1258 assert_eq!(persona.attest_level, "self_signed");
1259
1260 let links: Vec<(Option<Vec<u8>>, String)> = {
1262 let mut stmt = conn
1263 .prepare(
1264 "SELECT signature, attest_level FROM memory_links \
1265 WHERE source_id = ?1 AND relation = 'derived_from'",
1266 )
1267 .unwrap();
1268 stmt.query_map(rusqlite::params![&persona.id], |r| {
1269 Ok((r.get::<_, Option<Vec<u8>>>(0)?, r.get::<_, String>(1)?))
1270 })
1271 .unwrap()
1272 .collect::<rusqlite::Result<_>>()
1273 .unwrap()
1274 };
1275 assert_eq!(links.len(), 2);
1276 for (sig, level) in &links {
1277 assert_eq!(level, "self_signed");
1278 let sig_bytes = sig.as_ref().expect("signed link must have signature blob");
1279 assert_eq!(sig_bytes.len(), 64, "Ed25519 signatures are 64 bytes");
1280 }
1281
1282 let meta_str: String = conn
1285 .query_row(
1286 "SELECT metadata FROM memories WHERE id = ?1",
1287 rusqlite::params![&persona.id],
1288 |r| r.get(0),
1289 )
1290 .unwrap();
1291 let meta: serde_json::Value = serde_json::from_str(&meta_str).unwrap();
1292 assert_eq!(meta["agent_id"], "ai:curator");
1293 assert_eq!(meta["persona"]["attest_level"], "self_signed");
1294 let b64 = meta["persona"]["signature"]
1295 .as_str()
1296 .expect("metadata.persona.signature must be a string");
1297 let decoded = BASE64_STANDARD.decode(b64).expect("base64 decode");
1298 assert_eq!(decoded.len(), 64, "decoded sig must be 64 bytes");
1299
1300 let body_md: String = conn
1303 .query_row(
1304 "SELECT content FROM memories WHERE id = ?1",
1305 rusqlite::params![&persona.id],
1306 |r| r.get(0),
1307 )
1308 .unwrap();
1309 let mut hasher = Sha256::new();
1310 hasher.update(body_md.as_bytes());
1311 let mut body_hash = [0u8; 32];
1312 body_hash.copy_from_slice(&hasher.finalize());
1313
1314 let signable = SignablePersona {
1315 persona_id: persona.id.as_str(),
1316 entity_id: persona.entity_id.as_str(),
1317 namespace: persona.namespace.as_str(),
1318 version: persona.version,
1319 generated_at: persona.generated_at.as_str(),
1320 sources: &persona.sources,
1321 body_md_sha256: &body_hash,
1322 };
1323 let bytes = crate::identity::sign::canonical_cbor_persona(&signable).unwrap();
1324 let mut arr = [0u8; 64];
1325 arr.copy_from_slice(&decoded);
1326 let sig = ed25519_dalek::Signature::from_bytes(&arr);
1327 use ed25519_dalek::Verifier;
1328 kp.public.verify(&bytes, &sig).expect("verify persona sig");
1329 }
1330
1331 #[test]
1332 fn generate_signed_path_emits_signed_event() {
1333 let (conn, _dir) = fresh_db();
1337 seed_two_alice_reflections(&conn, "team/alpha");
1338 let kp = crate::identity::keypair::generate("ai:curator").unwrap();
1339 let llm = StubLlm {
1340 canned: "body".into(),
1341 };
1342 let generator = PersonaGenerator::new(&conn, &llm, Some(&kp), PersonaConfig::default());
1343 let persona = generator.generate("alice", "team/alpha").expect("generate");
1344
1345 let (sig, attest): (Option<Vec<u8>>, String) = conn
1346 .query_row(
1347 "SELECT signature, attest_level FROM signed_events \
1348 WHERE event_type = 'persona_generated' \
1349 ORDER BY sequence DESC LIMIT 1",
1350 [],
1351 |r| Ok((r.get(0)?, r.get(1)?)),
1352 )
1353 .unwrap();
1354 assert_eq!(attest, "self_signed");
1355 let sig_bytes = sig.expect("signed audit row must have signature");
1356 assert_eq!(sig_bytes.len(), 64);
1357
1358 let meta_str: String = conn
1360 .query_row(
1361 "SELECT metadata FROM memories WHERE id = ?1",
1362 rusqlite::params![&persona.id],
1363 |r| r.get(0),
1364 )
1365 .unwrap();
1366 let meta: serde_json::Value = serde_json::from_str(&meta_str).unwrap();
1367 let b64 = meta["persona"]["signature"].as_str().unwrap();
1368 let decoded = BASE64_STANDARD.decode(b64).unwrap();
1369 assert_eq!(
1370 decoded, sig_bytes,
1371 "metadata signature must match signed_events.signature"
1372 );
1373 }
1374
1375 #[test]
1376 fn generate_with_public_only_keypair_falls_back_to_unsigned() {
1377 let (conn, _dir) = fresh_db();
1381 seed_two_alice_reflections(&conn, "team/alpha");
1382 let kp = crate::identity::keypair::generate("ai:curator").unwrap();
1383 let pub_only = crate::identity::keypair::AgentKeypair {
1384 agent_id: "ai:curator".to_string(),
1385 public: kp.public,
1386 private: None,
1387 };
1388 let llm = StubLlm {
1389 canned: "body".into(),
1390 };
1391 let generator =
1392 PersonaGenerator::new(&conn, &llm, Some(&pub_only), PersonaConfig::default());
1393 let persona = generator.generate("alice", "team/alpha").expect("generate");
1394 assert_eq!(
1395 persona.attest_level, "unsigned",
1396 "public-only keypair must NOT produce self_signed"
1397 );
1398 let meta_str: String = conn
1399 .query_row(
1400 "SELECT metadata FROM memories WHERE id = ?1",
1401 rusqlite::params![&persona.id],
1402 |r| r.get(0),
1403 )
1404 .unwrap();
1405 let meta: serde_json::Value = serde_json::from_str(&meta_str).unwrap();
1406 assert!(
1407 meta["persona"]["signature"].is_null()
1408 || !meta["persona"]
1409 .as_object()
1410 .unwrap()
1411 .contains_key("signature"),
1412 "metadata must not carry a signature for the unsigned path"
1413 );
1414 }
1415
1416 #[test]
1417 fn signed_persona_v2_regenerates_with_fresh_signature() {
1418 let (conn, _dir) = fresh_db();
1423 seed_two_alice_reflections(&conn, "team/alpha");
1424 let kp = crate::identity::keypair::generate("ai:curator").unwrap();
1425 let llm = StubLlm {
1426 canned: "body".into(),
1427 };
1428 let generator = PersonaGenerator::new(&conn, &llm, Some(&kp), PersonaConfig::default());
1429 let v1 = generator.generate("alice", "team/alpha").expect("v1");
1430 let v2 = generator.generate("alice", "team/alpha").expect("v2");
1431 assert_eq!(v1.version, 1);
1432 assert_eq!(v2.version, 2);
1433 assert_ne!(v1.id, v2.id);
1434 assert_eq!(v1.attest_level, "self_signed");
1435 assert_eq!(v2.attest_level, "self_signed");
1436
1437 let sigs: Vec<Vec<u8>> = [&v1.id, &v2.id]
1440 .iter()
1441 .map(|id| {
1442 let meta_str: String = conn
1443 .query_row(
1444 "SELECT metadata FROM memories WHERE id = ?1",
1445 rusqlite::params![id],
1446 |r| r.get(0),
1447 )
1448 .unwrap();
1449 let meta: serde_json::Value = serde_json::from_str(&meta_str).unwrap();
1450 let b64 = meta["persona"]["signature"].as_str().expect("sig present");
1451 BASE64_STANDARD.decode(b64).unwrap()
1452 })
1453 .collect();
1454 assert_eq!(sigs[0].len(), 64);
1455 assert_eq!(sigs[1].len(), 64);
1456 assert_ne!(sigs[0], sigs[1], "v1 + v2 signatures must differ");
1457 }
1458}