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        let persona_id = db::insert(self.conn, &persona_mem)
413            .with_context(|| format!("inserting persona for {entity_id} v{version}"))?;
414
415        // v0.7.0 issue #811 — `derived_from` edges must use the
416        // signing-aware `create_link_signed` shim. The pre-#811 code
417        // path passed `None` here unconditionally even when the
418        // generator was constructed with a keypair; that regression
419        // is what produced unsigned link rows alongside a curator
420        // whose daemon already owned a private key.
421        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        // v0.7.0 issue #812 — sign the persona artifact end-to-end when
433        // the generator was constructed with a signing keypair. The
434        // signature commits to the seven-field SignablePersona envelope
435        // (persona_id, entity_id, namespace, version, generated_at,
436        // sources, body_md_sha256). The body hash is computed over the
437        // rendered Markdown so the bounded payload size is independent
438        // of body length.
439        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        // Resolve the effective `attest_level` for the persona from the
464        // `derived_from` link rows we just wrote. The strongest level
465        // across the source edges is the level the persona truthfully
466        // bears — a Persona whose links are unsigned cannot honestly
467        // claim `self_signed` even when the curator stamps a signature
468        // on its own body. The match between the curator's signing
469        // status and the source edges' signing status keeps the
470        // wire-level `attest_level` strictly monotone.
471        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            // The persona body itself is signed. Prefer the stronger of
475            // the two labels (`peer_attested` beats `self_signed`).
476            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        // Patch the metadata envelope in place — the wire response
489        // returned to the caller, the `metadata.persona.attest_level`
490        // field used by `get_latest_persona` readers, and the
491        // base64-encoded signature all flow from here.
492        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
539/// Validate that `entity_id` is non-empty and inside the same length
540/// envelope `validate::validate_agent_id` enforces — operators
541/// frequently reuse the same identifier for both, so the validation
542/// rule stays symmetric.
543fn 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
558/// Read the most recent persona for `(entity_id, namespace)`, returning
559/// `None` when the entity has never had a persona minted.
560///
561/// Used by the `memory_persona` read-only MCP tool and by the
562/// `ai-memory persona <entity_id>` CLI command. Indexed lookup via
563/// `idx_personas_by_entity`.
564pub 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
623/// Resolve the next `persona_version` for `(entity_id, namespace)`.
624/// Returns `1` when no prior persona exists for the pair.
625///
626/// # Cluster-A COR-2 fix (issue #1241)
627///
628/// Pre-fix the body folded every rusqlite error into `Ok(1)` via the
629/// local `optional_default(0_i32)` shim. That collapsed three
630/// semantically distinct cases — `Ok(n)`, `Err(QueryReturnedNoRows)`,
631/// and `Err(other)` (lock-timeout, IO, schema drift, …) — into a
632/// single happy-path branch. A transient DB lock at the very
633/// moment a new persona row was being minted would silently mint
634/// `persona_version = 1` even when a prior row existed, breaking
635/// the monotonic-version contract that downstream consumers rely
636/// on (forensic audit, federation push, `persona_title()` lookup).
637///
638/// Post-fix mirrors the [`crate::atomisation::read_atomised_into`]
639/// COR-2 pattern: `Ok(_)` returns the value, `Err(QueryReturnedNoRows)`
640/// is the documented "no prior persona" path that maps to `Ok(1)`,
641/// every other rusqlite error propagates via `?` and surfaces as
642/// the caller's `anyhow::Error`. Pinned by
643/// [`tests::next_version_propagates_db_errors`].
644fn 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
660/// Load up to `limit` reflection-kind memories from `namespace` whose
661/// stored `mentioned_entity_id` matches.
662///
663/// v0.7.0 polish PERF-8 (issue #781): previously this matched candidate
664/// reflections with `(title|content|metadata) LIKE '%<entity>%'` — a
665/// full-table scan across three TEXT columns for every reflection in
666/// the namespace. The fix relies on the schema-v42
667/// `mentioned_entity_id` column (populated at write time by
668/// [`crate::storage::extract_mentioned_entity_id`]; backfilled for
669/// legacy rows by the v42 migration) so the source pool is loaded via
670/// an indexed equality lookup against
671/// `idx_memories_mentioned_entity`. The query plan changes from
672/// `SCAN memories` to `SEARCH memories USING INDEX
673/// idx_memories_mentioned_entity (mentioned_entity_id=? AND namespace=?)`.
674///
675/// The lookup is bounded by `limit` so a runaway namespace can't blow
676/// the prompt budget.
677fn 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
710/// v0.7.0 issue #848 — cross-namespace reflection loader. Identical
711/// to [`load_reflections_for_entity`] minus the `namespace = ?`
712/// predicate so a single query aggregates every reflection that
713/// references the entity regardless of which namespace it lives in.
714/// Still rides the `idx_memories_mentioned_entity` PERF-8 index
715/// (mentioned_entity_id is the leading column). Bounded by `limit`.
716fn 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
743/// Compose the on-disk Markdown body. Appends a footer with one
744/// `[^N]: <reflection-id>` line per source so every citation in the
745/// raw body renders as a clickable footnote in standard Markdown
746/// viewers.
747fn 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        // 1-based citation index keeps Markdown readers happy.
753        out.push_str(&format!("[^{}]: {} — `{}`\n", idx + 1, src.title, src.id));
754    }
755    out
756}
757
758/// Title format used for Persona memories. Embeds the version so the
759/// (title, namespace) uniqueness constraint never trips between
760/// generations.
761fn persona_title(entity_id: &str, version: i32) -> String {
762    format!("__persona_{entity_id}_v{version}")
763}
764
765/// Append a `persona_generated` row to the H5 audit chain so an
766/// auditor walking `signed_events` can replay every persona mint /
767/// regeneration with provenance over the source-id list.
768///
769/// v0.7.0 issue #812 / #813 — when the persona was signed, `signature`
770/// carries the 64-byte Ed25519 bytes and `attest_level` stamps the
771/// label the persona ended up with (`self_signed` or `peer_attested`).
772/// When the persona was unsigned both fall back to `None` / `unsigned`
773/// preserving the pre-#812 ledger shape.
774fn 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/// Render a YAML-frontmatter Markdown export of a persona — mirrors
805/// QW-1's reflection-export envelope so operators can `cat` a
806/// persona alongside reflections from the same directory tree.
807#[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/// Render a structured JSON envelope mirroring [`render_persona_md`].
827/// Field order is stable for the test snapshot — we build a
828/// `BTreeMap`-backed `Value` so callers can pin the wire shape.
829#[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// ---------------------------------------------------------------------------
871// Local helper trait removed
872// ---------------------------------------------------------------------------
873//
874// The `OptionalDefault` shim is gone as of #1241. Its only caller
875// (`next_version`) was the source of the COR-2 error-masking bug —
876// rusqlite errors other than `QueryReturnedNoRows` were folded into
877// the default value and silently swallowed. The replacement explicit
878// `match` block at the call-site distinguishes the three result
879// arms (`Ok` / `QueryReturnedNoRows` / `Err(other)`) so transient DB
880// faults propagate via `?` instead of collapsing to `Ok(1)`.
881
882#[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    /// Mock implementation of `AutonomyLlm` that returns a canned
899    /// summary keyed off the source titles — deterministic, no Ollama
900    /// dependency.
901    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            // Echo back the source count so tests can assert the
914            // generator passed the right shape to the curator.
915            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        // Seed a persona row directly to bump version state.
1010        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    /// Regression for issue #1241 — COR-2 error propagation in
1044    /// `persona::next_version`.
1045    ///
1046    /// Pre-fix the body folded every rusqlite error into `Ok(1)` via
1047    /// the local `optional_default(0_i32)` shim. A transient DB
1048    /// fault (lock-timeout, schema drift, IO, …) was indistinguishable
1049    /// from "no prior persona exists" — the function would silently
1050    /// return `1` and the caller would mint a duplicate
1051    /// `persona_version`, breaking the monotonic-version contract.
1052    ///
1053    /// This test forces a non-`QueryReturnedNoRows` error by dropping
1054    /// the `memories` table out from under the query. Post-fix the
1055    /// rusqlite "no such table" `SqliteFailure` propagates via `?`
1056    /// instead of collapsing to `Ok(1)`. Mirrors the atomisation
1057    /// `read_atomised_into` COR-2 pattern pinned by
1058    /// `src/atomisation/mod.rs:484-494`.
1059    #[test]
1060    fn next_version_propagates_db_errors() {
1061        let (conn, _dir) = fresh_db();
1062
1063        // Sanity: the happy path still returns 1 on a fresh empty DB
1064        // (no prior persona row, the `QueryReturnedNoRows` /
1065        // `MAX()`-of-empty-set arm is exercised).
1066        assert_eq!(next_version(&conn, "alice", "team/alpha").unwrap(), 1);
1067
1068        // Drop the table to synthesise a non-`QueryReturnedNoRows`
1069        // rusqlite error on the next query. A real-world transient
1070        // lock-timeout or schema-drift fault surfaces through the
1071        // same `Err(other)` arm, so this is the cleanest in-process
1072        // proxy for the production failure mode the original bug
1073        // masked.
1074        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        // The error chain must carry the underlying rusqlite cause —
1080        // a regression to the old `optional_default` path would
1081        // return `Ok(1)` (no error at all). We also assert the
1082        // surface message references the missing-table sqlite
1083        // failure to lock the propagation contract.
1084        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        // Smoke test that the project's mock LLM scaffolding is reachable.
1136        let _ = MockOllamaClient::new_with_url("http://localhost:11434", "gemma2:2b").unwrap();
1137    }
1138
1139    // -------------------------------------------------------------------------
1140    // v0.7.0 issue #810 / #811 / #812 / #813 — signing-path unit coverage
1141    // -------------------------------------------------------------------------
1142
1143    /// Mint two reflections tagged with `entity_id = alice` so the
1144    /// PERF-8 `mentioned_entity_id` lookup matches. Returns the seeded
1145    /// source ids for downstream assertion.
1146    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        // Pre-#811: every code path through PersonaGenerator wrote
1186        // links via `db::create_link` (the unsigned shim). The
1187        // intentional "passes None as signer" behaviour stays correct
1188        // — the link column AND the persona metadata agree on
1189        // "unsigned" instead of disagreeing.
1190        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        // Every `derived_from` link rooted at the persona must say
1199        // `unsigned` (matching the absent signer).
1200        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        // BUG-B + BUG-C closing test — when a keypair is wired into
1224        // PersonaGenerator the `derived_from` links land signed, the
1225        // persona's metadata carries the base64 signature, and the
1226        // returned struct stamps `self_signed` on `attest_level`.
1227        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        // Links: all signed, 64-byte signature on each row.
1238        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        // Persona metadata carries base64 signature + matching
1260        // attest_level + matching agent_id (curator).
1261        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        // The persona body hash + the metadata signature must verify
1278        // against the curator's public key.
1279        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        // BUG-C — the `persona_generated` audit row must carry the
1311        // same signature bytes the metadata holds, and its
1312        // attest_level must agree.
1313        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        // Cross-check vs the metadata's base64 signature.
1336        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        // A public-only handle (can_sign() == false) must collapse
1355        // to the unsigned path: the substrate must not pretend to
1356        // sign with a key it cannot use.
1357        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        // BUG-B regression — calling generate() twice with the same
1396        // keypair MUST produce two distinct signed personas (different
1397        // ids, different signatures) and v2 must still be signed
1398        // end-to-end. This pins the "regeneration also signs" property.
1399        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        // Both v1 and v2 metadata envelopes carry a 64-byte sig and
1415        // they differ (different persona_id pins different bytes).
1416        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}