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_id = db::insert(self.conn, &persona_mem)
413 .with_context(|| format!("inserting persona for {entity_id} v{version}"))?;
414
415 for source in &sources {
422 db::create_link_signed(
423 self.conn,
424 &persona_id,
425 &source.id,
426 crate::models::MemoryLinkRelation::DerivedFrom.as_str(),
427 self.signer,
428 )
429 .with_context(|| format!("linking persona {persona_id} -> source {}", source.id))?;
430 }
431
432 let body_hash = {
440 let mut h = Sha256::new();
441 h.update(body_md.as_bytes());
442 let mut out = [0u8; 32];
443 out.copy_from_slice(&h.finalize());
444 out
445 };
446
447 let signature_bytes: Option<Vec<u8>> = match self.signer {
448 Some(kp) if kp.can_sign() => {
449 let p = SignablePersona {
450 persona_id: persona_id.as_str(),
451 entity_id,
452 namespace,
453 version,
454 generated_at: now.as_str(),
455 sources: &source_ids,
456 body_md_sha256: &body_hash,
457 };
458 Some(sign_persona(kp, &p).context("sign persona artifact")?)
459 }
460 _ => None,
461 };
462
463 let link_attest = db::strongest_attest_level_for_source(self.conn, &persona_id)
472 .context("resolve strongest link attest_level")?;
473 let attest_level = if signature_bytes.is_some() {
474 match link_attest.as_str() {
477 s if s == crate::models::AttestLevel::PeerAttested.as_str() => {
478 crate::models::AttestLevel::PeerAttested
479 .as_str()
480 .to_string()
481 }
482 _ => crate::models::AttestLevel::SelfSigned.as_str().to_string(),
483 }
484 } else {
485 link_attest
486 };
487
488 if let Some(env) = metadata
493 .get_mut("persona")
494 .and_then(serde_json::Value::as_object_mut)
495 {
496 env.insert(
497 field_names::ATTEST_LEVEL.to_string(),
498 serde_json::Value::String(attest_level.clone()),
499 );
500 if let Some(sig) = signature_bytes.as_ref() {
501 env.insert(
502 "signature".to_string(),
503 serde_json::Value::String(BASE64_STANDARD.encode(sig)),
504 );
505 }
506 }
507 let new_metadata_str = serde_json::to_string(&metadata)
508 .context("serialise updated persona metadata envelope")?;
509 self.conn
510 .execute(
511 "UPDATE memories SET metadata = ?1, updated_at = ?2 WHERE id = ?3",
512 rusqlite::params![new_metadata_str, &now, &persona_id],
513 )
514 .context("patch persona metadata with signature/attest_level")?;
515
516 emit_persona_generated_event(
517 self.conn,
518 &persona_id,
519 &agent_id,
520 &source_ids,
521 &now,
522 signature_bytes.as_deref(),
523 &attest_level,
524 )?;
525
526 Ok(Persona {
527 id: persona_id,
528 entity_id: entity_id.to_string(),
529 namespace: namespace.to_string(),
530 body_md,
531 sources: source_ids,
532 generated_at: now,
533 version,
534 attest_level,
535 })
536 }
537}
538
539fn validate_entity_id(entity_id: &str) -> std::result::Result<(), PersonaError> {
544 if entity_id.trim().is_empty() {
545 return Err(PersonaError::Validation(
546 crate::errors::msg::ENTITY_ID_EMPTY.into(),
547 ));
548 }
549 if entity_id.len() > 128 {
550 return Err(PersonaError::Validation(format!(
551 "entity_id exceeds 128 characters (got {})",
552 entity_id.len()
553 )));
554 }
555 Ok(())
556}
557
558pub fn get_latest_persona(
565 conn: &Connection,
566 entity_id: &str,
567 namespace: &str,
568) -> Result<Option<Persona>> {
569 let mut stmt = conn.prepare(
570 "SELECT id, entity_id, namespace, content, created_at, COALESCE(persona_version, 1), metadata
571 FROM memories
572 WHERE memory_kind = 'persona'
573 AND entity_id = ?1
574 AND namespace = ?2
575 ORDER BY COALESCE(persona_version, 0) DESC, created_at DESC
576 LIMIT 1",
577 )?;
578 let row: Option<(String, String, String, String, String, i32, String)> = stmt
579 .query_row(rusqlite::params![entity_id, namespace], |r| {
580 Ok((
581 r.get(0)?,
582 r.get(1)?,
583 r.get(2)?,
584 r.get(3)?,
585 r.get(4)?,
586 r.get(5)?,
587 r.get(6)?,
588 ))
589 })
590 .ok();
591 let Some((id, entity_id, namespace, body_md, generated_at, version, metadata_str)) = row else {
592 return Ok(None);
593 };
594 let meta: serde_json::Value =
595 serde_json::from_str(&metadata_str).unwrap_or_else(|_| serde_json::json!({}));
596 let envelope = meta.get("persona").cloned().unwrap_or_default();
597 let sources = envelope
598 .get("sources")
599 .and_then(|v| v.as_array())
600 .map(|arr| {
601 arr.iter()
602 .filter_map(|v| v.as_str().map(str::to_string))
603 .collect()
604 })
605 .unwrap_or_default();
606 let attest_level = envelope
607 .get(field_names::ATTEST_LEVEL)
608 .and_then(|v| v.as_str())
609 .unwrap_or(crate::models::AttestLevel::Unsigned.as_str())
610 .to_string();
611 Ok(Some(Persona {
612 id,
613 entity_id,
614 namespace,
615 body_md,
616 sources,
617 generated_at,
618 version,
619 attest_level,
620 }))
621}
622
623fn next_version(conn: &Connection, entity_id: &str, namespace: &str) -> Result<i32> {
645 match conn.query_row(
646 "SELECT COALESCE(MAX(persona_version), 0)
647 FROM memories
648 WHERE memory_kind = 'persona'
649 AND entity_id = ?1
650 AND namespace = ?2",
651 rusqlite::params![entity_id, namespace],
652 |r| r.get::<_, i32>(0),
653 ) {
654 Ok(n) => Ok(n + 1),
655 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(1),
656 Err(e) => Err(e.into()),
657 }
658}
659
660fn load_reflections_for_entity(
678 conn: &Connection,
679 entity_id: &str,
680 namespace: &str,
681 limit: usize,
682) -> Result<Vec<Memory>> {
683 let mut stmt = conn.prepare(
684 "SELECT id, tier, namespace, title, content, tags, priority, confidence, source,
685 access_count, created_at, updated_at, last_accessed_at, expires_at,
686 metadata, COALESCE(reflection_depth, 0), COALESCE(memory_kind, 'observation'),
687 entity_id, persona_version
688 FROM memories
689 WHERE namespace = ?1
690 AND memory_kind = 'reflection'
691 AND mentioned_entity_id = ?2
692 ORDER BY priority DESC, created_at DESC
693 LIMIT ?3",
694 )?;
695 let rows = stmt.query_map(
696 rusqlite::params![
697 namespace,
698 entity_id,
699 i64::try_from(limit).unwrap_or(i64::MAX)
700 ],
701 crate::storage::row_to_memory,
702 )?;
703 let mut out = Vec::new();
704 for row in rows {
705 out.push(row?);
706 }
707 Ok(out)
708}
709
710fn load_reflections_for_entity_any_namespace(
717 conn: &Connection,
718 entity_id: &str,
719 limit: usize,
720) -> Result<Vec<Memory>> {
721 let mut stmt = conn.prepare(
722 "SELECT id, tier, namespace, title, content, tags, priority, confidence, source,
723 access_count, created_at, updated_at, last_accessed_at, expires_at,
724 metadata, COALESCE(reflection_depth, 0), COALESCE(memory_kind, 'observation'),
725 entity_id, persona_version
726 FROM memories
727 WHERE memory_kind = 'reflection'
728 AND mentioned_entity_id = ?1
729 ORDER BY priority DESC, created_at DESC
730 LIMIT ?2",
731 )?;
732 let rows = stmt.query_map(
733 rusqlite::params![entity_id, i64::try_from(limit).unwrap_or(i64::MAX)],
734 crate::storage::row_to_memory,
735 )?;
736 let mut out = Vec::new();
737 for row in rows {
738 out.push(row?);
739 }
740 Ok(out)
741}
742
743fn render_body_with_footnotes(raw: &str, sources: &[Memory]) -> String {
748 let mut out = String::with_capacity(raw.len() + sources.len() * 64);
749 out.push_str(raw.trim_end());
750 out.push_str("\n\n## Sources\n\n");
751 for (idx, src) in sources.iter().enumerate() {
752 out.push_str(&format!("[^{}]: {} — `{}`\n", idx + 1, src.title, src.id));
754 }
755 out
756}
757
758fn persona_title(entity_id: &str, version: i32) -> String {
762 format!("__persona_{entity_id}_v{version}")
763}
764
765fn emit_persona_generated_event(
775 conn: &Connection,
776 persona_id: &str,
777 agent_id: &str,
778 sources: &[String],
779 now: &str,
780 signature: Option<&[u8]>,
781 attest_level: &str,
782) -> Result<()> {
783 let mut hasher = Sha256::new();
784 hasher.update(persona_id.as_bytes());
785 hasher.update(b"\x1f");
786 for src in sources {
787 hasher.update(src.as_bytes());
788 hasher.update(b"\x1f");
789 }
790 let payload_hash = hasher.finalize().to_vec();
791 let event = SignedEvent {
792 id: uuid::Uuid::new_v4().to_string(),
793 agent_id: agent_id.to_string(),
794 event_type: crate::signed_events::event_types::PERSONA_GENERATED.to_string(),
795 payload_hash,
796 signature: signature.map(<[u8]>::to_vec),
797 attest_level: attest_level.to_string(),
798 timestamp: now.to_string(),
799 ..SignedEvent::default()
800 };
801 append_signed_event(conn, &event)
802}
803
804#[must_use]
808pub fn render_persona_md(persona: &Persona) -> String {
809 let mut out = String::with_capacity(persona.body_md.len() + 256);
810 out.push_str("---\n");
811 out.push_str(&format!("memory_id: {}\n", persona.id));
812 out.push_str(&format!("entity_id: {}\n", persona.entity_id));
813 out.push_str(&format!("namespace: {}\n", persona.namespace));
814 out.push_str(&format!("persona_version: {}\n", persona.version));
815 out.push_str(&format!("generated_at: {}\n", persona.generated_at));
816 out.push_str(&format!("attest_level: {}\n", persona.attest_level));
817 out.push_str(&format!("sources: {}\n", persona.sources.len()));
818 out.push_str("---\n\n");
819 out.push_str(&persona.body_md);
820 if !out.ends_with('\n') {
821 out.push('\n');
822 }
823 out
824}
825
826#[must_use]
830pub fn render_persona_json(persona: &Persona) -> String {
831 let mut map: BTreeMap<&str, serde_json::Value> = BTreeMap::new();
832 map.insert("memory_id", serde_json::Value::String(persona.id.clone()));
833 map.insert(
834 "entity_id",
835 serde_json::Value::String(persona.entity_id.clone()),
836 );
837 map.insert(
838 "namespace",
839 serde_json::Value::String(persona.namespace.clone()),
840 );
841 map.insert(
842 field_names::PERSONA_VERSION,
843 serde_json::Value::Number(serde_json::Number::from(persona.version)),
844 );
845 map.insert(
846 field_names::GENERATED_AT,
847 serde_json::Value::String(persona.generated_at.clone()),
848 );
849 map.insert(
850 field_names::ATTEST_LEVEL,
851 serde_json::Value::String(persona.attest_level.clone()),
852 );
853 map.insert(
854 "sources",
855 serde_json::Value::Array(
856 persona
857 .sources
858 .iter()
859 .map(|s| serde_json::Value::String(s.clone()))
860 .collect(),
861 ),
862 );
863 map.insert(
864 "body_md",
865 serde_json::Value::String(persona.body_md.clone()),
866 );
867 serde_json::to_string_pretty(&map).unwrap_or_else(|_| "{}".to_string())
868}
869
870#[cfg(test)]
883mod tests {
884 use super::*;
885 use crate::llm::test_support::MockOllamaClient;
886 use crate::models::{Memory, MemoryKind, Tier};
887 use crate::storage as db;
888 use rusqlite::Connection;
889 use tempfile::TempDir;
890
891 fn fresh_db() -> (Connection, TempDir) {
892 let dir = TempDir::new().unwrap();
893 let path = dir.path().join("ai-memory.db");
894 let conn = db::open(&path).unwrap();
895 (conn, dir)
896 }
897
898 struct StubLlm {
902 canned: String,
903 }
904
905 impl AutonomyLlm for StubLlm {
906 fn auto_tag(&self, _title: &str, _content: &str) -> anyhow::Result<Vec<String>> {
907 Ok(Vec::new())
908 }
909 fn detect_contradiction(&self, _a: &str, _b: &str) -> anyhow::Result<bool> {
910 Ok(false)
911 }
912 fn summarize_memories(&self, memories: &[(String, String)]) -> anyhow::Result<String> {
913 Ok(format!("{} [from {} sources]", self.canned, memories.len()))
916 }
917 }
918
919 fn seed_reflection(conn: &Connection, namespace: &str, title: &str, body: &str) -> String {
920 let now = Utc::now().to_rfc3339();
921 let mem = Memory {
922 id: uuid::Uuid::new_v4().to_string(),
923 tier: Tier::Mid,
924 namespace: namespace.to_string(),
925 title: title.to_string(),
926 content: body.to_string(),
927 tags: vec!["reflection".into()],
928 priority: 5,
929 confidence: 1.0,
930 source: "test".into(),
931 access_count: 0,
932 created_at: now.clone(),
933 updated_at: now,
934 last_accessed_at: None,
935 expires_at: None,
936 metadata: serde_json::json!({"agent_id": "ai:test"}),
937 reflection_depth: 1,
938 memory_kind: MemoryKind::Reflection,
939 entity_id: None,
940 persona_version: None,
941 citations: Vec::new(),
942 source_uri: None,
943 source_span: None,
944 confidence_source: ConfidenceSource::CallerProvided,
945 confidence_signals: None,
946 confidence_decayed_at: None,
947 version: 1,
948 };
949 db::insert(conn, &mem).unwrap()
950 }
951
952 #[test]
953 fn validate_entity_id_rejects_empty() {
954 assert!(matches!(
955 validate_entity_id(""),
956 Err(PersonaError::Validation(_))
957 ));
958 assert!(matches!(
959 validate_entity_id(" "),
960 Err(PersonaError::Validation(_))
961 ));
962 }
963
964 #[test]
965 fn validate_entity_id_rejects_overlong() {
966 let long = "x".repeat(129);
967 assert!(matches!(
968 validate_entity_id(&long),
969 Err(PersonaError::Validation(_))
970 ));
971 }
972
973 #[test]
974 fn validate_entity_id_accepts_normal_ids() {
975 assert!(validate_entity_id("alice").is_ok());
976 assert!(validate_entity_id("entity-42").is_ok());
977 }
978
979 #[test]
980 fn generate_refuses_when_no_reflections() {
981 let (conn, _dir) = fresh_db();
982 let llm = StubLlm {
983 canned: "irrelevant".into(),
984 };
985 let generator = PersonaGenerator::new(&conn, &llm, None, PersonaConfig::default());
986 let err = generator.generate("alice", "team/alpha").unwrap_err();
987 assert!(matches!(err, PersonaError::NoReflections { .. }));
988 }
989
990 #[test]
991 fn render_body_with_footnotes_appends_sources_block() {
992 let (conn, _dir) = fresh_db();
993 let id1 = seed_reflection(&conn, "team/alpha", "ref-1 about alice", "alice does X");
994 let id2 = seed_reflection(&conn, "team/alpha", "ref-2 about alice", "alice does Y");
995 let mems = vec![
996 db::get(&conn, &id1).unwrap().unwrap(),
997 db::get(&conn, &id2).unwrap().unwrap(),
998 ];
999 let body = render_body_with_footnotes("Alice is composed and thoughtful.", &mems);
1000 assert!(body.contains("## Sources"));
1001 assert!(body.contains(&format!("[^1]: ref-1 about alice — `{id1}`")));
1002 assert!(body.contains(&format!("[^2]: ref-2 about alice — `{id2}`")));
1003 }
1004
1005 #[test]
1006 fn next_version_starts_at_one_then_increments() {
1007 let (conn, _dir) = fresh_db();
1008 assert_eq!(next_version(&conn, "alice", "team/alpha").unwrap(), 1);
1009 let now = Utc::now().to_rfc3339();
1011 let mem = Memory {
1012 id: uuid::Uuid::new_v4().to_string(),
1013 tier: Tier::Long,
1014 namespace: "team/alpha".into(),
1015 title: persona_title("alice", 1),
1016 content: "x".into(),
1017 tags: vec![],
1018 priority: 7,
1019 confidence: 1.0,
1020 source: "curator".into(),
1021 access_count: 0,
1022 created_at: now.clone(),
1023 updated_at: now,
1024 last_accessed_at: None,
1025 expires_at: None,
1026 metadata: serde_json::json!({}),
1027 reflection_depth: 0,
1028 memory_kind: MemoryKind::Persona,
1029 entity_id: Some("alice".into()),
1030 persona_version: Some(1),
1031 citations: Vec::new(),
1032 source_uri: None,
1033 source_span: None,
1034 confidence_source: ConfidenceSource::CallerProvided,
1035 confidence_signals: None,
1036 confidence_decayed_at: None,
1037 version: 1,
1038 };
1039 db::insert(&conn, &mem).unwrap();
1040 assert_eq!(next_version(&conn, "alice", "team/alpha").unwrap(), 2);
1041 }
1042
1043 #[test]
1060 fn next_version_propagates_db_errors() {
1061 let (conn, _dir) = fresh_db();
1062
1063 assert_eq!(next_version(&conn, "alice", "team/alpha").unwrap(), 1);
1067
1068 conn.execute("DROP TABLE memories", []).unwrap();
1075
1076 let err = next_version(&conn, "alice", "team/alpha")
1077 .expect_err("next_version must propagate non-NoRows DB errors, not collapse to Ok(1)");
1078
1079 let msg = format!("{err:#}");
1085 assert!(
1086 msg.to_lowercase().contains("no such table") || msg.to_lowercase().contains("memories"),
1087 "expected propagated rusqlite error to mention the missing \
1088 memories table, got: {msg}"
1089 );
1090 }
1091
1092 #[test]
1093 fn render_persona_md_includes_frontmatter() {
1094 let p = Persona {
1095 id: "p1".into(),
1096 entity_id: "alice".into(),
1097 namespace: "team/alpha".into(),
1098 body_md: "Alice is composed.".into(),
1099 sources: vec!["s1".into(), "s2".into()],
1100 generated_at: "2026-05-15T00:00:00Z".into(),
1101 version: 1,
1102 attest_level: "unsigned".into(),
1103 };
1104 let md = render_persona_md(&p);
1105 assert!(md.starts_with("---\n"));
1106 assert!(md.contains("memory_id: p1\n"));
1107 assert!(md.contains("entity_id: alice\n"));
1108 assert!(md.contains("namespace: team/alpha\n"));
1109 assert!(md.contains("persona_version: 1\n"));
1110 assert!(md.contains("sources: 2\n"));
1111 assert!(md.contains("Alice is composed."));
1112 }
1113
1114 #[test]
1115 fn render_persona_json_round_trips() {
1116 let p = Persona {
1117 id: "p1".into(),
1118 entity_id: "alice".into(),
1119 namespace: "team/alpha".into(),
1120 body_md: "body".into(),
1121 sources: vec!["s1".into()],
1122 generated_at: "2026-05-15T00:00:00Z".into(),
1123 version: 2,
1124 attest_level: "unsigned".into(),
1125 };
1126 let s = render_persona_json(&p);
1127 let v: serde_json::Value = serde_json::from_str(&s).unwrap();
1128 assert_eq!(v["memory_id"], "p1");
1129 assert_eq!(v["entity_id"], "alice");
1130 assert_eq!(v["persona_version"], 2);
1131 }
1132
1133 #[test]
1134 fn mock_llm_available() {
1135 let _ = MockOllamaClient::new_with_url("http://localhost:11434", "gemma2:2b").unwrap();
1137 }
1138
1139 fn seed_two_alice_reflections(conn: &Connection, namespace: &str) -> Vec<String> {
1147 let mut ids = Vec::new();
1148 for i in 0..2 {
1149 let now = Utc::now().to_rfc3339();
1150 let mem = Memory {
1151 id: uuid::Uuid::new_v4().to_string(),
1152 tier: Tier::Mid,
1153 namespace: namespace.to_string(),
1154 title: format!("obs-{i} about alice"),
1155 content: format!("alice did thing {i}"),
1156 tags: vec!["reflection".into()],
1157 priority: 5,
1158 confidence: 1.0,
1159 source: "test".into(),
1160 access_count: 0,
1161 created_at: now.clone(),
1162 updated_at: now,
1163 last_accessed_at: None,
1164 expires_at: None,
1165 metadata: serde_json::json!({"agent_id": "ai:test", "entity_id": "alice"}),
1166 reflection_depth: 1,
1167 memory_kind: MemoryKind::Reflection,
1168 entity_id: None,
1169 persona_version: None,
1170 citations: Vec::new(),
1171 source_uri: None,
1172 source_span: None,
1173 confidence_source: ConfidenceSource::CallerProvided,
1174 confidence_signals: None,
1175 confidence_decayed_at: None,
1176 version: 1,
1177 };
1178 ids.push(db::insert(conn, &mem).unwrap());
1179 }
1180 ids
1181 }
1182
1183 #[test]
1184 fn generate_unsigned_path_writes_unsigned_links() {
1185 let (conn, _dir) = fresh_db();
1191 seed_two_alice_reflections(&conn, "team/alpha");
1192 let llm = StubLlm {
1193 canned: "Alice is methodical.".into(),
1194 };
1195 let generator = PersonaGenerator::new(&conn, &llm, None, PersonaConfig::default());
1196 let persona = generator.generate("alice", "team/alpha").expect("generate");
1197 assert_eq!(persona.attest_level, "unsigned");
1198 let links: Vec<(Option<Vec<u8>>, String)> = {
1201 let mut stmt = conn
1202 .prepare(
1203 "SELECT signature, attest_level FROM memory_links \
1204 WHERE source_id = ?1 AND relation = 'derived_from'",
1205 )
1206 .unwrap();
1207 stmt.query_map(rusqlite::params![&persona.id], |r| {
1208 Ok((r.get::<_, Option<Vec<u8>>>(0)?, r.get::<_, String>(1)?))
1209 })
1210 .unwrap()
1211 .collect::<rusqlite::Result<_>>()
1212 .unwrap()
1213 };
1214 assert_eq!(links.len(), 2);
1215 for (sig, level) in &links {
1216 assert!(sig.is_none(), "unsigned link must have NULL signature");
1217 assert_eq!(level, "unsigned");
1218 }
1219 }
1220
1221 #[test]
1222 fn generate_signed_path_writes_signed_links_and_metadata() {
1223 let (conn, _dir) = fresh_db();
1228 seed_two_alice_reflections(&conn, "team/alpha");
1229 let kp = crate::identity::keypair::generate("ai:curator").unwrap();
1230 let llm = StubLlm {
1231 canned: "Signed alice body".into(),
1232 };
1233 let generator = PersonaGenerator::new(&conn, &llm, Some(&kp), PersonaConfig::default());
1234 let persona = generator.generate("alice", "team/alpha").expect("generate");
1235 assert_eq!(persona.attest_level, "self_signed");
1236
1237 let links: Vec<(Option<Vec<u8>>, String)> = {
1239 let mut stmt = conn
1240 .prepare(
1241 "SELECT signature, attest_level FROM memory_links \
1242 WHERE source_id = ?1 AND relation = 'derived_from'",
1243 )
1244 .unwrap();
1245 stmt.query_map(rusqlite::params![&persona.id], |r| {
1246 Ok((r.get::<_, Option<Vec<u8>>>(0)?, r.get::<_, String>(1)?))
1247 })
1248 .unwrap()
1249 .collect::<rusqlite::Result<_>>()
1250 .unwrap()
1251 };
1252 assert_eq!(links.len(), 2);
1253 for (sig, level) in &links {
1254 assert_eq!(level, "self_signed");
1255 let sig_bytes = sig.as_ref().expect("signed link must have signature blob");
1256 assert_eq!(sig_bytes.len(), 64, "Ed25519 signatures are 64 bytes");
1257 }
1258
1259 let meta_str: String = conn
1262 .query_row(
1263 "SELECT metadata FROM memories WHERE id = ?1",
1264 rusqlite::params![&persona.id],
1265 |r| r.get(0),
1266 )
1267 .unwrap();
1268 let meta: serde_json::Value = serde_json::from_str(&meta_str).unwrap();
1269 assert_eq!(meta["agent_id"], "ai:curator");
1270 assert_eq!(meta["persona"]["attest_level"], "self_signed");
1271 let b64 = meta["persona"]["signature"]
1272 .as_str()
1273 .expect("metadata.persona.signature must be a string");
1274 let decoded = BASE64_STANDARD.decode(b64).expect("base64 decode");
1275 assert_eq!(decoded.len(), 64, "decoded sig must be 64 bytes");
1276
1277 let body_md: String = conn
1280 .query_row(
1281 "SELECT content FROM memories WHERE id = ?1",
1282 rusqlite::params![&persona.id],
1283 |r| r.get(0),
1284 )
1285 .unwrap();
1286 let mut hasher = Sha256::new();
1287 hasher.update(body_md.as_bytes());
1288 let mut body_hash = [0u8; 32];
1289 body_hash.copy_from_slice(&hasher.finalize());
1290
1291 let signable = SignablePersona {
1292 persona_id: persona.id.as_str(),
1293 entity_id: persona.entity_id.as_str(),
1294 namespace: persona.namespace.as_str(),
1295 version: persona.version,
1296 generated_at: persona.generated_at.as_str(),
1297 sources: &persona.sources,
1298 body_md_sha256: &body_hash,
1299 };
1300 let bytes = crate::identity::sign::canonical_cbor_persona(&signable).unwrap();
1301 let mut arr = [0u8; 64];
1302 arr.copy_from_slice(&decoded);
1303 let sig = ed25519_dalek::Signature::from_bytes(&arr);
1304 use ed25519_dalek::Verifier;
1305 kp.public.verify(&bytes, &sig).expect("verify persona sig");
1306 }
1307
1308 #[test]
1309 fn generate_signed_path_emits_signed_event() {
1310 let (conn, _dir) = fresh_db();
1314 seed_two_alice_reflections(&conn, "team/alpha");
1315 let kp = crate::identity::keypair::generate("ai:curator").unwrap();
1316 let llm = StubLlm {
1317 canned: "body".into(),
1318 };
1319 let generator = PersonaGenerator::new(&conn, &llm, Some(&kp), PersonaConfig::default());
1320 let persona = generator.generate("alice", "team/alpha").expect("generate");
1321
1322 let (sig, attest): (Option<Vec<u8>>, String) = conn
1323 .query_row(
1324 "SELECT signature, attest_level FROM signed_events \
1325 WHERE event_type = 'persona_generated' \
1326 ORDER BY sequence DESC LIMIT 1",
1327 [],
1328 |r| Ok((r.get(0)?, r.get(1)?)),
1329 )
1330 .unwrap();
1331 assert_eq!(attest, "self_signed");
1332 let sig_bytes = sig.expect("signed audit row must have signature");
1333 assert_eq!(sig_bytes.len(), 64);
1334
1335 let meta_str: String = conn
1337 .query_row(
1338 "SELECT metadata FROM memories WHERE id = ?1",
1339 rusqlite::params![&persona.id],
1340 |r| r.get(0),
1341 )
1342 .unwrap();
1343 let meta: serde_json::Value = serde_json::from_str(&meta_str).unwrap();
1344 let b64 = meta["persona"]["signature"].as_str().unwrap();
1345 let decoded = BASE64_STANDARD.decode(b64).unwrap();
1346 assert_eq!(
1347 decoded, sig_bytes,
1348 "metadata signature must match signed_events.signature"
1349 );
1350 }
1351
1352 #[test]
1353 fn generate_with_public_only_keypair_falls_back_to_unsigned() {
1354 let (conn, _dir) = fresh_db();
1358 seed_two_alice_reflections(&conn, "team/alpha");
1359 let kp = crate::identity::keypair::generate("ai:curator").unwrap();
1360 let pub_only = crate::identity::keypair::AgentKeypair {
1361 agent_id: "ai:curator".to_string(),
1362 public: kp.public,
1363 private: None,
1364 };
1365 let llm = StubLlm {
1366 canned: "body".into(),
1367 };
1368 let generator =
1369 PersonaGenerator::new(&conn, &llm, Some(&pub_only), PersonaConfig::default());
1370 let persona = generator.generate("alice", "team/alpha").expect("generate");
1371 assert_eq!(
1372 persona.attest_level, "unsigned",
1373 "public-only keypair must NOT produce self_signed"
1374 );
1375 let meta_str: String = conn
1376 .query_row(
1377 "SELECT metadata FROM memories WHERE id = ?1",
1378 rusqlite::params![&persona.id],
1379 |r| r.get(0),
1380 )
1381 .unwrap();
1382 let meta: serde_json::Value = serde_json::from_str(&meta_str).unwrap();
1383 assert!(
1384 meta["persona"]["signature"].is_null()
1385 || !meta["persona"]
1386 .as_object()
1387 .unwrap()
1388 .contains_key("signature"),
1389 "metadata must not carry a signature for the unsigned path"
1390 );
1391 }
1392
1393 #[test]
1394 fn signed_persona_v2_regenerates_with_fresh_signature() {
1395 let (conn, _dir) = fresh_db();
1400 seed_two_alice_reflections(&conn, "team/alpha");
1401 let kp = crate::identity::keypair::generate("ai:curator").unwrap();
1402 let llm = StubLlm {
1403 canned: "body".into(),
1404 };
1405 let generator = PersonaGenerator::new(&conn, &llm, Some(&kp), PersonaConfig::default());
1406 let v1 = generator.generate("alice", "team/alpha").expect("v1");
1407 let v2 = generator.generate("alice", "team/alpha").expect("v2");
1408 assert_eq!(v1.version, 1);
1409 assert_eq!(v2.version, 2);
1410 assert_ne!(v1.id, v2.id);
1411 assert_eq!(v1.attest_level, "self_signed");
1412 assert_eq!(v2.attest_level, "self_signed");
1413
1414 let sigs: Vec<Vec<u8>> = [&v1.id, &v2.id]
1417 .iter()
1418 .map(|id| {
1419 let meta_str: String = conn
1420 .query_row(
1421 "SELECT metadata FROM memories WHERE id = ?1",
1422 rusqlite::params![id],
1423 |r| r.get(0),
1424 )
1425 .unwrap();
1426 let meta: serde_json::Value = serde_json::from_str(&meta_str).unwrap();
1427 let b64 = meta["persona"]["signature"].as_str().expect("sig present");
1428 BASE64_STANDARD.decode(b64).unwrap()
1429 })
1430 .collect();
1431 assert_eq!(sigs[0].len(), 64);
1432 assert_eq!(sigs[1].len(), 64);
1433 assert_ne!(sigs[0], sigs[1], "v1 + v2 signatures must differ");
1434 }
1435}