Skip to main content

ai_memory/persona/
mod.rs

1// Copyright 2026 AlphaOne LLC
2// SPDX-License-Identifier: Apache-2.0
3
4//! v0.7.0 QW-2 — Persona-as-artifact engine.
5//!
6//! A **Persona** is a curator-generated Markdown profile of an entity,
7//! synthesised from a cluster of `MemoryKind::Reflection` rows that
8//! reference that entity. Personas are the substrate-native expression
9//! of Tencent's L3 pattern (PersonaMem 48% → 76%): the substrate
10//! distils the agent's reflections about a subject into a stable,
11//! recallable artefact so the agent can re-load "what we know about
12//! Alice" with a single recall hit instead of paging through dozens of
13//! disjoint reflection rows.
14//!
15//! # Engine surface
16//!
17//! ```ignore
18//! use ai_memory::persona::{PersonaConfig, PersonaGenerator};
19//!
20//! let cfg = PersonaConfig::default();
21//! let mut gen = PersonaGenerator::new(&conn, &llm, signer, cfg);
22//! let persona = generator.generate("entity-alice", "team/alpha")?;
23//! ```
24//!
25//! # Persona body shape
26//!
27//! The curator returns a 300–500 word Markdown body. Every claim is
28//! footnoted via `[^ref]` citations whose anchor points at the source
29//! reflection's UUID — operators inspecting `~/.ai-memory/personas/...`
30//! can follow the link back to the originating reflection via
31//! `ai-memory get <id>`. The Persona row carries `entity_id`,
32//! `persona_version`, and a `metadata.persona` envelope that pins:
33//!
34//!   * `entity_id` (redundant with the SQL column for legacy readers),
35//!   * `sources: [reflection_id, …]`,
36//!   * `version` (also pinned on the SQL column),
37//!   * `attest_level` summarising the strongest attestation across
38//!     `derives_from` edges (mirrors QW-1's reflection-export shape).
39//!
40//! # Provenance
41//!
42//! Each generation emits one `derives_from` `memory_link` per source
43//! reflection so the KG walker (`memory_find_paths`, `memory_kg_query`)
44//! can follow the Persona → Reflection → Observation chain end-to-end.
45//! A `persona_generated` row is appended to `signed_events` with the
46//! sources hash; the H5 audit chain captures every regeneration as a
47//! distinct, signed event.
48
49use 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
70/// Default ceiling on how many reflections feed a single persona
71/// generation. Mirrors the prompt budget — Gemma 4 produces tighter
72/// summaries when the source pool stays in single digits to low-20s.
73pub const DEFAULT_MAX_REFLECTION_SOURCES: usize = 20;
74
75/// Default curator family stamp on the Persona's `metadata.agent_id`
76/// when the engine is constructed without an explicit keypair (tests).
77const ANONYMOUS_CURATOR_AGENT_ID: &str = crate::identity::sentinels::AI_CURATOR;
78
79/// v0.7.0 issue #848 — sentinel namespace value reported in
80/// [`PersonaError::NoReflections`] when the caller asked for the
81/// cross-namespace aggregation path and zero matching reflections
82/// existed in ANY namespace. Distinct from any real namespace string
83/// (`"global"`, `"team/alpha"`, etc.) so an operator-facing error
84/// message can distinguish "no reflections in namespace 'X'" from
85/// "no reflections anywhere in the substrate".
86pub const CROSS_NAMESPACE_SENTINEL: &str = "<any namespace>";
87
88/// v0.7.0 issue #848 — namespace-scope discriminator for
89/// [`PersonaGenerator::generate_in_scope`]. Single-namespace mode
90/// preserves the pre-#848 behaviour; `AnyTargeting(ns)` aggregates
91/// every reflection that mentions the entity across all namespaces
92/// and lands the new persona row in `ns`.
93#[derive(Debug, Clone, Copy)]
94enum PersonaScope<'a> {
95    Single(&'a str),
96    AnyTargeting(&'a str),
97}
98
99/// Static configuration for [`PersonaGenerator`].
100#[derive(Debug, Clone)]
101pub struct PersonaConfig {
102    /// Maximum number of source reflections the curator considers per
103    /// generation. Defaults to [`DEFAULT_MAX_REFLECTION_SOURCES`].
104    pub max_reflection_sources: usize,
105    /// Persona memories land at this tier. Defaults to `Tier::Long` —
106    /// personas are the curator's high-confidence distillation and the
107    /// substrate keeps them around indefinitely.
108    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/// Public persona shape returned by [`PersonaGenerator::generate`] and
121/// surfaced over the MCP `memory_persona` read-only tool.
122///
123/// Mirrors the SQL row's columns plus the rendered Markdown body and
124/// the source-id list spliced into `metadata.persona`.
125#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
126pub struct Persona {
127    /// The Persona memory's id (UUIDv4). Stable per (entity_id,
128    /// namespace, version) tuple.
129    pub id: String,
130    /// Subject of the persona.
131    pub entity_id: String,
132    /// Namespace the persona was minted under.
133    pub namespace: String,
134    /// 300–500 word Markdown body with `[^ref]` footnotes.
135    pub body_md: String,
136    /// Source reflection ids — one `derives_from` edge per element.
137    pub sources: Vec<String>,
138    /// RFC3339 generation timestamp.
139    pub generated_at: String,
140    /// Monotonic version counter — `1` on the first generation, then
141    /// `prev + 1` per regeneration.
142    pub version: i32,
143    /// Strongest attestation level across the `derives_from` edges.
144    /// Mirrors QW-1's `attest_level` summary on reflection exports.
145    pub attest_level: String,
146}
147
148/// Errors returned by [`PersonaGenerator::generate`].
149#[derive(Debug)]
150pub enum PersonaError {
151    /// Input validation failure (empty entity_id, malformed namespace).
152    Validation(String),
153    /// The entity has no reflections in this namespace.
154    NoReflections {
155        entity_id: String,
156        namespace: String,
157    },
158    /// The curator LLM failed during synthesis.
159    Llm(String),
160    /// A SQL operation failed.
161    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
195/// The persona-generation engine.
196///
197/// Constructed per call (cheap — just holds references). Generation is
198/// idempotent in the sense that calling `generate` twice writes two
199/// distinct rows with consecutive `version` numbers; the substrate
200/// never overwrites a persona in place so audit trails stay intact.
201pub 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    /// Construct a fresh generator.
210    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    /// Resolve the curator agent_id stamped on every persona this
225    /// generator writes. Falls back to [`ANONYMOUS_CURATOR_AGENT_ID`]
226    /// when no keypair is configured (test paths).
227    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    /// Generate a fresh Persona for `entity_id` in `namespace`.
234    ///
235    /// # Steps
236    ///
237    /// 1. Validate `entity_id` (non-empty, within identity bounds) and
238    ///    `namespace`.
239    /// 2. Load up to `config.max_reflection_sources` Reflection-kind
240    ///    memories from `namespace` referencing the entity.
241    /// 3. Refuse with [`PersonaError::NoReflections`] when the pool is
242    ///    empty — a Persona without sources has no audit trail.
243    /// 4. Resolve the next `version` (max existing + 1, defaulting 1).
244    /// 5. Call the curator (`AutonomyLlm::summarize_memories`) over
245    ///    the sources to produce the Markdown body.
246    /// 6. Insert a `MemoryKind::Persona` memory row with `entity_id` +
247    ///    `persona_version` populated and metadata carrying the
248    ///    `persona` envelope.
249    /// 7. Write one `derives_from` `memory_link` from the persona
250    ///    row to each source reflection.
251    /// 8. Append a `persona_generated` row to `signed_events`.
252    ///
253    /// # Errors
254    ///
255    /// One of the [`PersonaError`] variants. The DB-level errors are
256    /// the only ones without a structured payload — every other
257    /// variant carries enough context for a clean operator message.
258    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    /// v0.7.0 issue #848 — cross-namespace persona generation.
267    ///
268    /// Equivalent to [`Self::generate`] except the source-reflection
269    /// scan is broadened to every namespace the substrate stores. The
270    /// new persona row lands in `target_namespace` (callers
271    /// typically pass `"global"` so subsequent
272    /// `memory_persona(entity_id)` reads have a deterministic
273    /// landing zone). Use this when an NHI agent has spread its
274    /// reflections across multiple namespaces (e.g.
275    /// `global/policies`, `ai-memory/v0.7.0-nhi-testing`, project
276    /// buckets) and needs a single Persona that aggregates the full
277    /// identity arc.
278    ///
279    /// # Errors
280    ///
281    /// Same envelope as [`Self::generate`]. When zero matching
282    /// reflections exist in ANY namespace, the
283    /// `NoReflections.namespace` field is the
284    /// [`CROSS_NAMESPACE_SENTINEL`] string.
285    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    /// Internal common path. Routes to the appropriate source loader
294    /// based on `scope`; centralises validation, version bump,
295    /// curator invocation, write, link emission, and signed-events
296    /// stamping so the single-namespace and cross-namespace entry
297    /// points stay in lockstep for free.
298    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        // Curator synthesis — `AutonomyLlm::summarize_memories` is the
337        // narrow LLM trait every other curator pass already uses; mock
338        // implementations in `llm::test_support` keep tests
339        // deterministic without spinning up Ollama.
340        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        // v0.7.0 issue #810 / #811 / #812 — the in-flight `attest_level`
356        // is unknown until after the `derived_from` link writes complete
357        // (BUG-A's atomic invariant means the row tells the truth about
358        // its signature). We stamp a provisional metadata envelope now,
359        // then patch `attest_level` + `signature` in place once the
360        // post-link computation finishes. This keeps the write order
361        // (memory → links → metadata patch → signed_events) auditable.
362        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            // v0.7.0 issue #1242 — persona rows are curator-engine output,
399            // not caller-supplied. The QW-2 brief pins `confidence = 1.0`
400            // for every Persona; that value is engine-derived (the
401            // generator picked it), so the discriminator must reflect the
402            // provenance. Pre-#1242 these rows mis-labelled
403            // `CallerProvided`, hiding them from the partial
404            // `idx_memories_confidence_source` enumeration and violating
405            // the audit-honesty invariant.
406            confidence_source: ConfidenceSource::CuratorDerived,
407            confidence_signals: None,
408            confidence_decayed_at: None,
409            version: 1,
410        };
411
412        // #1688 — the persona write is documented as atomic (see the "atomic
413        // invariant" note above) but its writes previously ran un-wrapped, so a
414        // mid-sequence failure left an orphaned / mis-attested persona row.
415        // Wrap the memory insert + N derived_from links + the metadata patch
416        // (none of which use an inner transaction) in one unit; the guard rolls
417        // back on any early `?` (Transaction's Drop default). `generate()` is a
418        // top-level call so there is no nesting. The signed_events emit is
419        // committed-after (it self-transacts via append_signed_event).
420        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        // v0.7.0 issue #811 — `derived_from` edges must use the
429        // signing-aware `create_link_signed` shim. The pre-#811 code
430        // path passed `None` here unconditionally even when the
431        // generator was constructed with a keypair; that regression
432        // is what produced unsigned link rows alongside a curator
433        // whose daemon already owned a private key.
434        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        // v0.7.0 issue #812 — sign the persona artifact end-to-end when
446        // the generator was constructed with a signing keypair. The
447        // signature commits to the seven-field SignablePersona envelope
448        // (persona_id, entity_id, namespace, version, generated_at,
449        // sources, body_md_sha256). The body hash is computed over the
450        // rendered Markdown so the bounded payload size is independent
451        // of body length.
452        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        // Resolve the effective `attest_level` for the persona from the
477        // `derived_from` link rows we just wrote. The strongest level
478        // across the source edges is the level the persona truthfully
479        // bears — a Persona whose links are unsigned cannot honestly
480        // claim `self_signed` even when the curator stamps a signature
481        // on its own body. The match between the curator's signing
482        // status and the source edges' signing status keeps the
483        // wire-level `attest_level` strictly monotone.
484        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            // The persona body itself is signed. Prefer the stronger of
488            // the two labels (`peer_attested` beats `self_signed`).
489            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        // Patch the metadata envelope in place — the wire response
502        // returned to the caller, the `metadata.persona.attest_level`
503        // field used by `get_latest_persona` readers, and the
504        // base64-encoded signature all flow from here.
505        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        // #1688 — commit the insert + links + attest_level/signature patch as
530        // ONE unit before emitting the audit event. A failure anywhere above
531        // rolls all of them back (no orphaned / mis-attested row). The
532        // signed_events emit below self-transacts, so it runs AFTER commit: a
533        // failed emit leaves the persona durably correct but un-audited — the
534        // lesser of the two failure modes.
535        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
562/// Validate that `entity_id` is non-empty and inside the same length
563/// envelope `validate::validate_agent_id` enforces — operators
564/// frequently reuse the same identifier for both, so the validation
565/// rule stays symmetric.
566fn 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
581/// Read the most recent persona for `(entity_id, namespace)`, returning
582/// `None` when the entity has never had a persona minted.
583///
584/// Used by the `memory_persona` read-only MCP tool and by the
585/// `ai-memory persona <entity_id>` CLI command. Indexed lookup via
586/// `idx_personas_by_entity`.
587pub 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
646/// Resolve the next `persona_version` for `(entity_id, namespace)`.
647/// Returns `1` when no prior persona exists for the pair.
648///
649/// # Cluster-A COR-2 fix (issue #1241)
650///
651/// Pre-fix the body folded every rusqlite error into `Ok(1)` via the
652/// local `optional_default(0_i32)` shim. That collapsed three
653/// semantically distinct cases — `Ok(n)`, `Err(QueryReturnedNoRows)`,
654/// and `Err(other)` (lock-timeout, IO, schema drift, …) — into a
655/// single happy-path branch. A transient DB lock at the very
656/// moment a new persona row was being minted would silently mint
657/// `persona_version = 1` even when a prior row existed, breaking
658/// the monotonic-version contract that downstream consumers rely
659/// on (forensic audit, federation push, `persona_title()` lookup).
660///
661/// Post-fix mirrors the [`crate::atomisation::read_atomised_into`]
662/// COR-2 pattern: `Ok(_)` returns the value, `Err(QueryReturnedNoRows)`
663/// is the documented "no prior persona" path that maps to `Ok(1)`,
664/// every other rusqlite error propagates via `?` and surfaces as
665/// the caller's `anyhow::Error`. Pinned by
666/// [`tests::next_version_propagates_db_errors`].
667fn 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
683/// Load up to `limit` reflection-kind memories from `namespace` whose
684/// stored `mentioned_entity_id` matches.
685///
686/// v0.7.0 polish PERF-8 (issue #781): previously this matched candidate
687/// reflections with `(title|content|metadata) LIKE '%<entity>%'` — a
688/// full-table scan across three TEXT columns for every reflection in
689/// the namespace. The fix relies on the schema-v42
690/// `mentioned_entity_id` column (populated at write time by
691/// [`crate::storage::extract_mentioned_entity_id`]; backfilled for
692/// legacy rows by the v42 migration) so the source pool is loaded via
693/// an indexed equality lookup against
694/// `idx_memories_mentioned_entity`. The query plan changes from
695/// `SCAN memories` to `SEARCH memories USING INDEX
696/// idx_memories_mentioned_entity (mentioned_entity_id=? AND namespace=?)`.
697///
698/// The lookup is bounded by `limit` so a runaway namespace can't blow
699/// the prompt budget.
700fn 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
733/// v0.7.0 issue #848 — cross-namespace reflection loader. Identical
734/// to [`load_reflections_for_entity`] minus the `namespace = ?`
735/// predicate so a single query aggregates every reflection that
736/// references the entity regardless of which namespace it lives in.
737/// Still rides the `idx_memories_mentioned_entity` PERF-8 index
738/// (mentioned_entity_id is the leading column). Bounded by `limit`.
739fn 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
766/// Compose the on-disk Markdown body. Appends a footer with one
767/// `[^N]: <reflection-id>` line per source so every citation in the
768/// raw body renders as a clickable footnote in standard Markdown
769/// viewers.
770fn 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        // 1-based citation index keeps Markdown readers happy.
776        out.push_str(&format!("[^{}]: {} — `{}`\n", idx + 1, src.title, src.id));
777    }
778    out
779}
780
781/// Title format used for Persona memories. Embeds the version so the
782/// (title, namespace) uniqueness constraint never trips between
783/// generations.
784fn persona_title(entity_id: &str, version: i32) -> String {
785    format!("__persona_{entity_id}_v{version}")
786}
787
788/// Append a `persona_generated` row to the H5 audit chain so an
789/// auditor walking `signed_events` can replay every persona mint /
790/// regeneration with provenance over the source-id list.
791///
792/// v0.7.0 issue #812 / #813 — when the persona was signed, `signature`
793/// carries the 64-byte Ed25519 bytes and `attest_level` stamps the
794/// label the persona ended up with (`self_signed` or `peer_attested`).
795/// When the persona was unsigned both fall back to `None` / `unsigned`
796/// preserving the pre-#812 ledger shape.
797fn 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/// Render a YAML-frontmatter Markdown export of a persona — mirrors
828/// QW-1's reflection-export envelope so operators can `cat` a
829/// persona alongside reflections from the same directory tree.
830#[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/// Render a structured JSON envelope mirroring [`render_persona_md`].
850/// Field order is stable for the test snapshot — we build a
851/// `BTreeMap`-backed `Value` so callers can pin the wire shape.
852#[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// ---------------------------------------------------------------------------
894// Local helper trait removed
895// ---------------------------------------------------------------------------
896//
897// The `OptionalDefault` shim is gone as of #1241. Its only caller
898// (`next_version`) was the source of the COR-2 error-masking bug —
899// rusqlite errors other than `QueryReturnedNoRows` were folded into
900// the default value and silently swallowed. The replacement explicit
901// `match` block at the call-site distinguishes the three result
902// arms (`Ok` / `QueryReturnedNoRows` / `Err(other)`) so transient DB
903// faults propagate via `?` instead of collapsing to `Ok(1)`.
904
905#[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    /// Mock implementation of `AutonomyLlm` that returns a canned
922    /// summary keyed off the source titles — deterministic, no Ollama
923    /// dependency.
924    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            // Echo back the source count so tests can assert the
937            // generator passed the right shape to the curator.
938            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        // Seed a persona row directly to bump version state.
1033        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    /// Regression for issue #1241 — COR-2 error propagation in
1067    /// `persona::next_version`.
1068    ///
1069    /// Pre-fix the body folded every rusqlite error into `Ok(1)` via
1070    /// the local `optional_default(0_i32)` shim. A transient DB
1071    /// fault (lock-timeout, schema drift, IO, …) was indistinguishable
1072    /// from "no prior persona exists" — the function would silently
1073    /// return `1` and the caller would mint a duplicate
1074    /// `persona_version`, breaking the monotonic-version contract.
1075    ///
1076    /// This test forces a non-`QueryReturnedNoRows` error by dropping
1077    /// the `memories` table out from under the query. Post-fix the
1078    /// rusqlite "no such table" `SqliteFailure` propagates via `?`
1079    /// instead of collapsing to `Ok(1)`. Mirrors the atomisation
1080    /// `read_atomised_into` COR-2 pattern pinned by
1081    /// `src/atomisation/mod.rs:484-494`.
1082    #[test]
1083    fn next_version_propagates_db_errors() {
1084        let (conn, _dir) = fresh_db();
1085
1086        // Sanity: the happy path still returns 1 on a fresh empty DB
1087        // (no prior persona row, the `QueryReturnedNoRows` /
1088        // `MAX()`-of-empty-set arm is exercised).
1089        assert_eq!(next_version(&conn, "alice", "team/alpha").unwrap(), 1);
1090
1091        // Drop the table to synthesise a non-`QueryReturnedNoRows`
1092        // rusqlite error on the next query. A real-world transient
1093        // lock-timeout or schema-drift fault surfaces through the
1094        // same `Err(other)` arm, so this is the cleanest in-process
1095        // proxy for the production failure mode the original bug
1096        // masked.
1097        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        // The error chain must carry the underlying rusqlite cause —
1103        // a regression to the old `optional_default` path would
1104        // return `Ok(1)` (no error at all). We also assert the
1105        // surface message references the missing-table sqlite
1106        // failure to lock the propagation contract.
1107        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        // Smoke test that the project's mock LLM scaffolding is reachable.
1159        let _ = MockOllamaClient::new_with_url("http://localhost:11434", "gemma2:2b").unwrap();
1160    }
1161
1162    // -------------------------------------------------------------------------
1163    // v0.7.0 issue #810 / #811 / #812 / #813 — signing-path unit coverage
1164    // -------------------------------------------------------------------------
1165
1166    /// Mint two reflections tagged with `entity_id = alice` so the
1167    /// PERF-8 `mentioned_entity_id` lookup matches. Returns the seeded
1168    /// source ids for downstream assertion.
1169    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        // Pre-#811: every code path through PersonaGenerator wrote
1209        // links via `db::create_link` (the unsigned shim). The
1210        // intentional "passes None as signer" behaviour stays correct
1211        // — the link column AND the persona metadata agree on
1212        // "unsigned" instead of disagreeing.
1213        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        // Every `derived_from` link rooted at the persona must say
1222        // `unsigned` (matching the absent signer).
1223        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        // BUG-B + BUG-C closing test — when a keypair is wired into
1247        // PersonaGenerator the `derived_from` links land signed, the
1248        // persona's metadata carries the base64 signature, and the
1249        // returned struct stamps `self_signed` on `attest_level`.
1250        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        // Links: all signed, 64-byte signature on each row.
1261        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        // Persona metadata carries base64 signature + matching
1283        // attest_level + matching agent_id (curator).
1284        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        // The persona body hash + the metadata signature must verify
1301        // against the curator's public key.
1302        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        // BUG-C — the `persona_generated` audit row must carry the
1334        // same signature bytes the metadata holds, and its
1335        // attest_level must agree.
1336        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        // Cross-check vs the metadata's base64 signature.
1359        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        // A public-only handle (can_sign() == false) must collapse
1378        // to the unsigned path: the substrate must not pretend to
1379        // sign with a key it cannot use.
1380        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        // BUG-B regression — calling generate() twice with the same
1419        // keypair MUST produce two distinct signed personas (different
1420        // ids, different signatures) and v2 must still be signed
1421        // end-to-end. This pins the "regeneration also signs" property.
1422        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        // Both v1 and v2 metadata envelopes carry a 64-byte sig and
1438        // they differ (different persona_id pins different bytes).
1439        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}