Task — engineering-spec@1

"Persona-version system-prompt injection from memory memories/personas/<handle>.md"

doneTASK-AI-014
module ai · class product · priority p0 · created 2026-05-15 · shipped null
depends on TASK-AI-003 · blocks none

§1 — Description (BCP-14 normative)

The AI Gateway service MUST load persona definitions from <memory-root>/memories/personas/<handle>.md and inject the persona's system prompt as the first system message before the user's messages array. The persona handle format and the loader together obey the following:

  1. MUST parse the persona Markdown file with a YAML frontmatter block bounded by --- markers AND a body. Frontmatter fields: id (kebab-case string), version (semver MAJOR.MINOR.PATCH), allowed_tools (list of MCP tool names), traits (list of strings), llm_hints (mapping with temperature, max_tokens, stop_sequences). The body (everything below the closing ---) is the canonical system_prompt; the frontmatter field system_prompt is NOT a valid alternative source and the parser MUST reject any frontmatter with that key (precedence ambiguity blocked at parse time).
  2. MUST key the in-memory registry by handle (PersonaHandle = "{id}@{version}", e.g. "cuo-cpo@0.4.1"). Two distinct versions of the same persona id coexist as two distinct registry entries. The file name on disk MUST match the handle (<id>@<version>.md); a frontmatter id/version that disagrees with the filename MUST fail registry init with PersonaInitError::FilenameMismatch.
  3. MUST cache parsed personas via ArcSwap<HashMap<PersonaHandle, Arc<Persona>>>. Reader path: registry.load().get(&handle).cloned(). Writer path: registry.store(Arc::new(new_map)) on hot-reload — an atomic pointer swap; no torn reads. RwLock is NOT acceptable (blocks readers during reload).
  4. MUST reject requests where req.agent_persona doesn't resolve to a registered handle — return 400 BAD_REQUEST with body {"error":"unknown_persona","agent_persona":"<handle>","available_handles":["..."]}. The available list is the registry keys at request time; it is sorted lexicographically for stable test fixtures.
  5. MUST inject the persona's body (canonical system_prompt) as messages[0] with role system BEFORE any caller-supplied system message. A caller's system message becomes messages[1] (also role system). The handler MUST NOT silently overwrite a caller's system message and MUST NOT concatenate the persona prompt with the caller's system message.
  6. MUST emit exactly one ai.persona_loaded memory audit row per request, BEFORE the LLM call begins, via the canonical builder canonical::persona_loaded(&persona, &request_id) (declared as a row kind in TASK-AI-003 §3; this task adds the builder function). The row carries persona_id, persona_version, persona_handle, source_path, source_hash, request_id. The audit-before-action invariant from TASK-AI-001 §1 #6 applies — the row MUST be durable on the chain before the call leaves the gateway.
  7. MUST verify source_hash matches the cached body before injection on EVERY load (cache-hit AND cache-miss path). The check is cheap (~5µs SHA-256 of body bytes). On mismatch: return Err(PersonaError::Tampered { handle, expected_hash, actual_hash }); emit a sev-1 OBS event ai_persona_tampered{handle}; refuse the call with 503 PERSONA_TAMPERED. Tamper detection MUST NOT be skipped via a "trust cache" flag — this is the boundary check that catches on-disk modification after init.
  8. MUST canonicalise the body BEFORE hashing AND before injection: (a) normalise line endings CRLF → LF, (b) strip a leading BOM if present, (c) apply Unicode NFC normalisation, (d) right-trim trailing whitespace on each line, (e) ensure exactly one terminating LF. This canonicalisation is the source-hash domain; without it, a benign LF↔CRLF flip on a Windows checkout would false-positive as tampering.
  9. MUST include the persona handle in EVERY downstream artefact:
  1. MUST complete persona::load(handle) in ≤ 100µs on cache hit (registry HashMap lookup + hash verify) and ≤ 50ms on cache miss (first init + memory_writer disk read). After warm-up, cache miss is rare; the 50ms is a budget for the boot-time init_persona_registry per-persona cost.
  2. MUST integrate with policy.ai_policy.allowed_personas from TASK-AI-005 — if the tenant policy declares an allow-list, TASK-AI-001 §1 #13 already enforces it. This task DOES NOT replicate the check; it only loads. A request that passes TASK-AI-001's persona-allow check and arrives at the injection point MUST always succeed (or fail tamper/missing-handle). No silent policy re-check at injection.
  3. MUST debounce file-watch events on <memory-root>/memories/personas/ with a 250ms window: rapid bursts (editor save sequences write 3-5 events) collapse to one reparse. The watcher uses the notify crate's RecommendedWatcher with RecursiveMode::Recursive. On debounce-flush, the watcher re-runs init_persona_registry's parsing pass against the current disk state and ArcSwap::stores the new map atomically.
  4. MUST keep the LLM-hints (temperature, max_tokens, stop_sequences) from the persona as the per-request DEFAULT — caller-supplied values in the request body OVERRIDE the persona hint. This rule is documented in §2; the merge order is request.body.llm_hints > persona.llm_hints > provider default.
  5. MUST validate semver in PersonaVersion::parse using the semver crate. A version string like 0.4 (missing patch) or 0.4.1-alpha (pre-release) is REJECTED at parse time. Slice 3 supports plain MAJOR.MINOR.PATCH only; pre-release support is out of scope (TASK-AI-022 follow-up).
  6. SHOULD emit OTel metrics:
  1. SHOULD log at INFO level on every successful hot-reload: persona_reloaded handle=<h> source_hash=<hex16> registry_size=<N> — operator visibility into "did my edit actually load?".

§2 — Why this design (rationale for humans)

Why store personas in memory, not config files? Personas evolve through user edits and CUO refinement loops. Every edit produces a chain leaf (the memory's append-only audit log captures who edited what and when). Storing in a YAML config file would create a parallel source of truth with no audit trail — exactly the failure mode the memory exists to prevent. Personas are operational artefacts (often-edited, frequently-versioned, audit-required), not infrastructure config.

Why key the registry by full handle, not id? Two reasons. (1) Multiple versions of the same persona coexist during a rollout — cuo-cpo@0.4.1 and cuo-cpo@0.4.2 are both valid for a brief window while traffic shifts. Indexing by id alone forces a choose-one moment; indexing by handle lets both serve. (2) The handle is the EU AI Act Art. 50 attribution unit — a regulator asking "which persona produced this output" wants the handle, not the id. The handle-keyed registry IS the answer to the regulator's question.

Why hash-verify on every load, not just init? Personas govern model behaviour. An attacker who can write to <memory-root>/memories/personas/cuo-cpo@0.4.1.md can change Genie's tone, constraints, and tool-allow-list — silently. The init-only hash check would let post-init disk mutations slip through. A 5µs hash on every load is the cheapest possible boundary check; the per-request cost (~5µs in a request that already costs hundreds of ms at the LLM) is negligible. The hash check is "the persona content I'm about to inject matches the persona content I parsed at init / last reload" — without it, the cache is a trust-on-first-use that an attacker can pwn after the trust window.

Why ArcSwap, not RwLock? RwLock-based hot-reload blocks readers during the write window (~5ms for a full re-parse + re-build of the HashMap). Under load (1000 reads/s during a hot-reload), 5ms of writer-held lock means 5 reads sit waiting — measurable p99 latency spike. ArcSwap is a pointer-swap; readers see either the old map or the new map at any nanosecond boundary, never a torn read, never blocked. The cost is one extra Arc allocation per reload (rare event) — strictly cheaper than the RwLock approach.

Why is the persona injected as a system message rather than concatenated to the user message? LLMs (Anthropic, OpenAI, Google) reliably distinguish system from user messages — the alignment training is explicit about which authorial layer the system role represents. Concatenating the persona prompt with the user message mixes the layers; a hostile user prompt can then "claim" parts of the system message via prompt-injection patterns ("ignore the above and instead..."). Keeping persona at system role + user message at user role is the architecturally clean separation; the LLM's own message-role discipline does the work of resisting authority confusion.

Why does the body canonicalisation rule matter? A team member on Windows checks out the repo; git's core.autocrlf setting normalises LF → CRLF on checkout. Their next edit + save preserves CRLF. The Linux-running gateway hashes the LF version at init, then re-reads the CRLF version on hot-reload and detects "tamper." Without canonicalisation, this benign cross-platform workflow flags as a sev-1 security event every time. The five-step canonicalisation (CRLF→LF, no BOM, NFC, trim trailing whitespace per line, single terminating LF) is the minimum that makes "the same persona body, formatted by different editors" hash to the same value. The hash IS the security boundary; we just don't want false positives on whitespace.

Why caller-overrides-persona for LLM hints (§1 #13)? The persona's temperature: 0.4 is a default — a starting point that's right for "general persona usage." A specific call site might know "this draft should be more creative; use 0.8." The override gives the call site the last word. Persona defaults are NOT meant to be inviolable — they're sensible defaults that handle the 80% case without ceremony.

Why source_hash in the response header (X-CyberOS-Persona-Source-Hash)? The header lets a downstream client (or a downstream audit tool) cross-check "the persona that ran for this response is the persona I expected." A client persisting Genie outputs to its own KB can record (handle, source_hash) tuples and later detect "during the period 2026-04-12 to 2026-04-19, the cuo-cpo@0.4.1 persona's source_hash shifted from X to Y — there was a hot-reload mid-stream." This is a small but useful audit primitive; the 16-hex prefix keeps the header value short.

Why semver-only versioning (§1 #14)? Pre-release tags (-alpha, -rc1) and build metadata (+build123) add parsing complexity without solving a problem we have. Slice 3 personas are produced by humans + CUO-refinement loops; both produce concrete versions, not pre-releases. If a future need emerges (e.g., "shadow-test persona-0.5.0-shadow alongside 0.4.1 in production"), TASK-AI-022 will extend PersonaVersion::parse — but the change should be deliberate, not accidental from a copy-paste.

Why does the tamper check produce 503 PERSONA_TAMPERED rather than degrading to a default persona? "Degrade to default" is the silent-failure path: the persona was supposed to enforce constraints ("never offer compensation") and now those constraints aren't there. The user sees an output but the model wasn't following the persona's safeguards. 503 is the loud-failure path: the operator gets paged, the customer sees an error, no output ships under false attribution. Loud failure is the right default for security-load-bearing primitives.

Why is there an EU AI Act Art. 50 badge field in the response body (§1 #9)? Art. 50 (effective Aug 2026) requires AI outputs reaching EU end-users to disclose the AI provenance. A response header is invisible to most UIs; embedding the badge metadata in the JSON body gives the UI a structured field to render ("Made by Genie · cuo-cpo · v0.4.1") wherever appropriate. The field name made_by_genie is the canonical attribution surface across CyberOS products.


§3 — API contract (formal spec for AI-agent implementers)

Type definitions

// services/ai-gateway/src/persona/mod.rs

use std::sync::Arc;
use arc_swap::ArcSwap;
use once_cell::sync::OnceCell;
use semver::Version;

#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct PersonaId(String);     // kebab-case, e.g. "cuo-cpo"

#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct PersonaHandle {
    pub id: PersonaId,
    pub version: Version,         // semver MAJOR.MINOR.PATCH (no pre-release, no build metadata)
}

impl PersonaHandle {
    /// Parse "cuo-cpo@0.4.1" → PersonaHandle. Rejects pre-release, missing patch, etc.
    pub fn parse(s: &str) -> Result<Self, PersonaParseError> { /* ... */ }

    /// Render as "cuo-cpo@0.4.1" for storage / headers / audit.
    pub fn display(&self) -> String { format!("{}@{}", self.id.0, self.version) }
}

#[derive(Debug, Clone)]
pub struct Persona {
    pub handle: PersonaHandle,
    pub body: String,                          // canonicalised system_prompt body
    pub allowed_tools: Vec<String>,            // e.g. ["search_kb", "draft_email"]
    pub traits: Vec<String>,                   // e.g. ["concise", "VN-aware", "founder-voice"]
    pub llm_hints: LlmHints,                   // temperature, max_tokens, stop_sequences
    pub source_path: String,                   // memory-relative path; e.g. "memories/personas/cuo-cpo@0.4.1.md"
    pub source_hash: [u8; 32],                 // SHA-256 of canonicalised body
}

#[derive(Debug, Clone)]
pub struct LlmHints {
    pub temperature: Option<f32>,
    pub max_tokens: Option<u32>,
    pub stop_sequences: Vec<String>,
}

#[derive(Debug, thiserror::Error)]
pub enum PersonaError {
    #[error("unknown persona handle {handle}; available: {available:?}")]
    UnknownPersona {
        handle: String,
        available: Vec<String>,
    },
    #[error("persona body hash mismatch — possible tampering: handle={handle}")]
    Tampered {
        handle: PersonaHandle,
        expected_hash: [u8; 32],
        actual_hash: [u8; 32],
    },
    #[error("memory read failed: {0}")]
    MemoryReadFailed(String),
    #[error("registry not initialised")]
    RegistryNotInitialised,
}

#[derive(Debug, thiserror::Error)]
pub enum PersonaInitError {
    #[error("malformed YAML frontmatter in {path}: {reason}")]
    Schema { path: String, reason: String },
    #[error("filename {path} does not match frontmatter handle {handle}")]
    FilenameMismatch { path: String, handle: String },
    #[error("forbidden field 'system_prompt' in frontmatter at {path}; body is the canonical source")]
    ForbiddenFrontmatterField { path: String },
    #[error("registry already initialised; init_persona_registry called twice")]
    AlreadyInitialised,
    #[error("memory read failed at init: {0}")]
    MemoryReadFailed(String),
}

#[derive(Debug, thiserror::Error)]
pub enum PersonaParseError {
    #[error("missing '@' separator in handle {0!r}")]
    MissingAt(String),
    #[error("invalid semver in handle: {0}")]
    InvalidSemver(String),
    #[error("pre-release versions not supported in slice 3: {0}")]
    PreReleaseUnsupported(String),
    #[error("invalid persona id (must be kebab-case): {0!r}")]
    InvalidId(String),
}

static REGISTRY: OnceCell<ArcSwap<HashMap<PersonaHandle, Arc<Persona>>>> = OnceCell::new();

pub async fn init_persona_registry() -> Result<(), PersonaInitError> { /* ... */ }

pub fn load(handle: &PersonaHandle) -> Result<Arc<Persona>, PersonaError> { /* ... */ }

pub fn available_handles() -> Vec<String> { /* ... */ }

Parser contract

// services/ai-gateway/src/persona/parse.rs

pub fn parse_persona_md(path: &str, raw: &str) -> Result<Persona, PersonaInitError> {
    // 1. Split frontmatter from body using `---` markers.
    let (frontmatter_yaml, body_raw) = split_frontmatter(raw)?;

    // 2. Parse YAML frontmatter, asserting whitelisted fields only.
    let fm: PersonaFrontmatter = serde_yaml::from_str(frontmatter_yaml)
        .map_err(|e| PersonaInitError::Schema { path: path.into(), reason: e.to_string() })?;

    // 3. Forbid 'system_prompt' in frontmatter (precedence ambiguity blocker).
    if fm.system_prompt.is_some() {
        return Err(PersonaInitError::ForbiddenFrontmatterField { path: path.into() });
    }

    // 4. Canonicalise body bytes per §1 #8.
    let body = canonicalise_body(body_raw);

    // 5. Compute source_hash.
    let source_hash = sha256(body.as_bytes());

    // 6. Construct handle; assert filename matches.
    let handle = PersonaHandle { id: fm.id, version: fm.version };
    let expected_filename = format!("{}.md", handle.display());
    if !path.ends_with(&expected_filename) {
        return Err(PersonaInitError::FilenameMismatch {
            path: path.into(), handle: handle.display(),
        });
    }

    Ok(Persona {
        handle, body, source_hash,
        allowed_tools: fm.allowed_tools,
        traits: fm.traits,
        llm_hints: fm.llm_hints,
        source_path: path.into(),
    })
}

/// §1 #8 canonicalisation: CRLF→LF, no BOM, NFC, trim trailing whitespace, single terminating LF.
fn canonicalise_body(raw: &str) -> String {
    let stripped = raw.strip_prefix('\u{FEFF}').unwrap_or(raw);
    let lf = stripped.replace("\r\n", "\n").replace('\r', "\n");
    let nfc: String = unicode_normalization::UnicodeNormalization::nfc(lf.chars()).collect();
    let trimmed_lines: Vec<&str> = nfc.lines().map(|l| l.trim_end()).collect();
    let mut out = trimmed_lines.join("\n");
    if !out.ends_with('\n') {
        out.push('\n');
    }
    out
}

Persona file format (canonical)

---
id: cuo-cpo
version: 0.4.1
allowed_tools:
  - draft_email
  - search_kb
  - summarise_kb
  - escalate_to_human
traits:
  - founder-voice
  - concise
  - action-oriented
  - VN-aware
llm_hints:
  temperature: 0.4
  max_tokens: 1024
  stop_sequences:
    - "</persona>"
---

You are Genie, the AI orchestrator at CyberSkill. You speak in the founder's voice — direct, concise, action-oriented. When a user asks a customer-facing question, you draft a response in their style.

Constraints:
- Never offer compensation or contractual commitments without explicit founder approval.
- Never reveal internal compensation, equity, or financial data.
- Default to bilingual (Vietnamese + English) when context suggests a VN customer.

When uncertain about facts, search the knowledge base first; do not fabricate.

Injection contract (handler-side)

// services/ai-gateway/src/handlers/chat.rs (additions)

async fn handle_chat(req: ChatCompleteRequest) -> Result<ChatCompleteResponse, ApiError> {
    // ... cost precheck (TASK-AI-001) ... persona-allow check (TASK-AI-001 §1 #13) ...

    let handle = PersonaHandle::parse(&req.agent_persona)?;
    let persona = persona::load(&handle).map_err(map_persona_err)?;

    // §1 #6: audit row BEFORE LLM call.
    memory_writer::emit(canonical::persona_loaded(&persona, &req.request_id)).await?;

    // §1 #5: prepend persona body as messages[0].
    let mut messages = Vec::with_capacity(req.messages.len() + 1);
    messages.push(Message {
        role: Role::System,
        content: persona.body.clone(),
    });
    messages.extend(req.messages.iter().cloned());

    // §1 #13: caller-supplied hints override persona defaults.
    let temperature = req.temperature.or(persona.llm_hints.temperature);
    let max_tokens = req.max_tokens.or(persona.llm_hints.max_tokens);

    // ... call provider ...

    // §1 #9: response headers + body badge.
    let mut response = build_response(/* ... */);
    response.headers.insert("X-CyberOS-Persona-Handle", persona.handle.display());
    response.headers.insert("X-CyberOS-Persona-Source-Hash", hex16(&persona.source_hash));
    response.body.made_by_genie = Some(MadeByGenie {
        id: persona.handle.id.0.clone(),
        version: persona.handle.version.to_string(),
    });
    Ok(response)
}

§4 — Acceptance criteria (testable, ordered, numbered)

  1. Happy loadpersona::load(&handle_cuo_cpo_v_0_4_1) returns the parsed Persona matching the file frontmatter + body; persona.handle.display() == "cuo-cpo@0.4.1"; persona.allowed_tools contains the expected 4 tools.
  2. Cache hit on second call — Second call to persona::load(&handle) returns an Arc<Persona> with Arc::ptr_eq(&first, &second) == true.
  3. Hot reload — Edit <memory-root>/memories/personas/cuo-cpo@0.4.1.md (touch + rewrite body); within 500ms, persona::load(&handle) returns a Persona with the new body and a DIFFERENT source_hash from before.
  4. Unknown handlepersona::load(&handle_v_9_9_9) returns Err(UnknownPersona { available: [...sorted...] }); available is lexicographically sorted.
  5. Tamper detection — Mutate the on-disk file's body via fs::write (bypassing the canonical Writer); call persona::load(&handle). The first call after the mutation returns Err(Tampered { handle, expected_hash, actual_hash }); a sev-1 OBS event is emitted with metric ai_persona_tampered_total{handle} incremented by 1.
  6. System prompt injectionreq.messages = [User("hi")] + agent_persona = "cuo-cpo@0.4.1". After handler injection, the request sent to the provider has messages = [System(persona.body), User("hi")].
  7. Caller system message preservedreq.messages = [System("call-specific"), User("hi")]. After injection, the provider sees messages = [System(persona.body), System("call-specific"), User("hi")] — the caller's system message is at index 1, NOT overwritten.
  8. Audit row emitted — Every request with persona resolves emits exactly one ai.persona_loaded memory row before the LLM call. The row's source_hash matches persona.source_hash.
  9. Response header includes persona handle — Every HTTP 200 response carries X-CyberOS-Persona-Handle: cuo-cpo@0.4.1 AND X-CyberOS-Persona-Source-Hash: <hex16>.
  10. Response body badge — Every HTTP 200 response body contains made_by_genie: {"id":"cuo-cpo","version":"0.4.1"}.
  11. Latency budget (cache hit) — 1000 cache-hit persona::load calls complete in < 100ms total (≤ 100µs each per §1 #10).
  12. Latency budget (cache miss / init) — Boot-time init_persona_registry over 10 persona files completes in < 500ms (≤ 50ms per file per §1 #10).
  13. Concurrent loads — 100 tokio tasks calling persona::load(&same_handle) concurrently produce zero contention (no Mutex::lock in the hot path); all see the same Arc pointer.
  14. Canonicalisation: CRLF tolerance — A file saved with CRLF line endings produces an identical source_hash to the same content saved with LF line endings. No false-positive tamper.
  15. Filename mismatch rejected — A file at memories/personas/cuo-cpo@0.4.1.md with frontmatter id: cuo-cpo, version: 0.4.2 returns PersonaInitError::FilenameMismatch from parse_persona_md.
  16. Forbidden frontmatter field rejected — A frontmatter with system_prompt: "..." key returns PersonaInitError::ForbiddenFrontmatterField; the body is the canonical source.
  17. Semver parse strictnessPersonaHandle::parse("cuo-cpo@0.4") returns PersonaParseError::InvalidSemver; PersonaHandle::parse("cuo-cpo@0.4.1-alpha") returns PreReleaseUnsupported.
  18. LLM-hint merge orderrequest.body.temperature = 0.8 + persona.llm_hints.temperature = 0.4 → provider sees temperature = 0.8. With request omitting temperature, provider sees temperature = 0.4.
  19. Hot-reload of malformed file leaves cache unchanged — Edit a persona file to invalid YAML (e.g., delete the closing ---); within 500ms, the reload attempt logs a persona_reload_total{outcome=parse_error} metric; persona::load(&handle) continues to return the pre-edit cached Persona.
  20. Double-init rejectedinit_persona_registry().await then second init_persona_registry().await returns PersonaInitError::AlreadyInitialised.

§5 — Verification

Happy + cache test

// services/ai-gateway/tests/persona_test.rs
use cyberos_ai_gateway::persona::{self, PersonaHandle};

#[tokio::test]
async fn loads_persona_from_memory_and_caches() {
    persona::init_persona_registry().await.unwrap();
    let handle = PersonaHandle::parse("cuo-cpo@0.4.1").unwrap();

    // AC #1
    let p1 = persona::load(&handle).expect("first load");
    assert_eq!(p1.handle.display(), "cuo-cpo@0.4.1");
    assert!(p1.allowed_tools.contains(&"search_kb".to_string()));
    assert!(p1.body.contains("You are Genie"));

    // AC #2
    let p2 = persona::load(&handle).expect("second load");
    assert!(std::sync::Arc::ptr_eq(&p1, &p2));
}

#[tokio::test]
async fn unknown_handle_returns_sorted_available() {
    persona::init_persona_registry().await.unwrap();
    let handle = PersonaHandle::parse("cuo-cpo@9.9.9").unwrap();

    let err = persona::load(&handle).expect_err("expected UnknownPersona");
    match err {
        persona::PersonaError::UnknownPersona { available, .. } => {
            let sorted: Vec<_> = {
                let mut a = available.clone(); a.sort(); a
            };
            assert_eq!(available, sorted, "available list must be lexicographically sorted");
        }
        e => panic!("unexpected error variant: {e:?}"),
    }
}

#[tokio::test]
async fn semver_parse_rejects_pre_release_and_short_version() {
    use persona::PersonaParseError;
    assert!(matches!(
        PersonaHandle::parse("cuo-cpo@0.4"),
        Err(PersonaParseError::InvalidSemver(_))
    ));
    assert!(matches!(
        PersonaHandle::parse("cuo-cpo@0.4.1-alpha"),
        Err(PersonaParseError::PreReleaseUnsupported(_))
    ));
}

#[tokio::test]
async fn filename_mismatch_rejected_at_init() {
    use persona::parse::parse_persona_md;
    let body = "---\nid: cuo-cpo\nversion: 0.4.2\nallowed_tools: []\ntraits: []\nllm_hints: {}\n---\n\nbody\n";
    let err = parse_persona_md("memories/personas/cuo-cpo@0.4.1.md", body).expect_err("expected mismatch");
    assert!(matches!(err, persona::PersonaInitError::FilenameMismatch { .. }));
}

#[tokio::test]
async fn forbidden_frontmatter_system_prompt_rejected() {
    use persona::parse::parse_persona_md;
    let body = "---\nid: cuo-cpo\nversion: 0.4.1\nallowed_tools: []\ntraits: []\nllm_hints: {}\nsystem_prompt: 'forbidden'\n---\n\nbody\n";
    let err = parse_persona_md("memories/personas/cuo-cpo@0.4.1.md", body).expect_err("expected forbidden");
    assert!(matches!(err, persona::PersonaInitError::ForbiddenFrontmatterField { .. }));
}

#[tokio::test]
async fn double_init_rejected() {
    persona::init_persona_registry().await.unwrap();
    let err = persona::init_persona_registry().await.expect_err("expected AlreadyInitialised");
    assert!(matches!(err, persona::PersonaInitError::AlreadyInitialised));
}

Tamper detection test

// services/ai-gateway/tests/persona_test.rs
#[tokio::test]
async fn tamper_detection_fires_with_metric() {
    persona::init_persona_registry().await.unwrap();
    let handle = PersonaHandle::parse("cuo-cpo@0.4.1").unwrap();

    // Read the cached source_hash, mutate the on-disk body, call load again.
    let p1 = persona::load(&handle).unwrap();
    let path = format!("<memory-root>/memories/personas/{}.md", handle.display());
    let original = std::fs::read_to_string(&path).unwrap();
    std::fs::write(&path, original + "\nappended tamper line\n").unwrap();

    // Force the cache-hit verify path (no hot-reload yet — the watcher may be debouncing).
    let err = persona::load(&handle).expect_err("expected Tampered");
    match err {
        persona::PersonaError::Tampered { handle: h, expected_hash, actual_hash } => {
            assert_eq!(h, p1.handle);
            assert_ne!(expected_hash, actual_hash);
        }
        e => panic!("unexpected error variant: {e:?}"),
    }

    // OTel metric incremented.
    let counter = otel_test_helper::counter_value(
        "ai_persona_tampered_total",
        &[("handle", "cuo-cpo@0.4.1")],
    );
    assert!(counter >= 1, "tampered counter not incremented");

    // Restore for subsequent tests.
    std::fs::write(&path, original).unwrap();
}

Hot-reload test

// services/ai-gateway/tests/persona_test.rs
#[tokio::test]
async fn hot_reload_within_500ms() {
    persona::init_persona_registry().await.unwrap();
    let handle = PersonaHandle::parse("cuo-cpo@0.4.1").unwrap();
    let path = format!("<memory-root>/memories/personas/{}.md", handle.display());
    let original = std::fs::read_to_string(&path).unwrap();
    let p1 = persona::load(&handle).unwrap();

    // Edit the body (preserve frontmatter; change only body text).
    let new_body = original.replace("You are Genie", "You are Genie v2");
    std::fs::write(&path, &new_body).unwrap();

    // Poll for up to 500ms.
    let mut updated = false;
    for _ in 0..50 {
        tokio::time::sleep(std::time::Duration::from_millis(10)).await;
        let p_now = persona::load(&handle).unwrap();
        if p_now.source_hash != p1.source_hash {
            assert!(p_now.body.contains("Genie v2"));
            updated = true;
            break;
        }
    }
    assert!(updated, "hot-reload did not propagate within 500ms");

    // Restore.
    std::fs::write(&path, &original).unwrap();
}

#[tokio::test]
async fn hot_reload_of_malformed_file_leaves_cache_unchanged() {
    persona::init_persona_registry().await.unwrap();
    let handle = PersonaHandle::parse("cuo-cpo@0.4.1").unwrap();
    let path = format!("<memory-root>/memories/personas/{}.md", handle.display());
    let original = std::fs::read_to_string(&path).unwrap();
    let p1 = persona::load(&handle).unwrap();

    // Corrupt: delete closing `---`.
    let bad = original.replace("---\n\n", "BROKEN\n\n");
    std::fs::write(&path, bad).unwrap();

    tokio::time::sleep(std::time::Duration::from_millis(500)).await;
    let p_after = persona::load(&handle).expect("cache must hold pre-edit content");
    assert_eq!(p_after.source_hash, p1.source_hash, "cache must NOT update on parse error");

    let counter = otel_test_helper::counter_value(
        "ai_persona_reload_total",
        &[("outcome", "parse_error")],
    );
    assert!(counter >= 1, "parse_error counter not incremented");

    std::fs::write(&path, original).unwrap();
}

Canonicalisation test (CRLF tolerance)

#[test]
fn canonicalisation_is_lf_normalised() {
    use persona::parse::canonicalise_body;
    let lf = "Hello\nWorld\n";
    let crlf = "Hello\r\nWorld\r\n";
    assert_eq!(canonicalise_body(lf), canonicalise_body(crlf));
}

#[test]
fn canonicalisation_strips_bom_and_nfc_normalises() {
    use persona::parse::canonicalise_body;
    // BOM-prefixed CRLF with combining diacritic vs precomposed.
    let bom_crlf_combining = "\u{FEFF}cafe\u{0301}\r\n";   // BOM + "café" via combining acute
    let lf_precomposed     = "café\n";
    assert_eq!(canonicalise_body(bom_crlf_combining), canonicalise_body(lf_precomposed));
}

Concurrent-load test

// services/ai-gateway/tests/cache_isolation_concurrent_test.rs
#[tokio::test]
async fn one_hundred_concurrent_loads_no_contention() {
    persona::init_persona_registry().await.unwrap();
    let handle = std::sync::Arc::new(PersonaHandle::parse("cuo-cpo@0.4.1").unwrap());
    let mut joinset = tokio::task::JoinSet::new();
    for _ in 0..100 {
        let h = handle.clone();
        joinset.spawn(async move {
            let p = persona::load(&h).expect("load");
            std::sync::Arc::as_ptr(&p) as usize
        });
    }
    let mut ptrs = vec![];
    while let Some(r) = joinset.join_next().await {
        ptrs.push(r.unwrap());
    }
    // AC #13: every concurrent load sees the same Arc pointer.
    assert!(ptrs.iter().all(|p| *p == ptrs[0]), "concurrent loads saw different Arcs");
}

#[tokio::test]
async fn one_thousand_cache_hits_within_budget() {
    persona::init_persona_registry().await.unwrap();
    let handle = PersonaHandle::parse("cuo-cpo@0.4.1").unwrap();
    let t0 = std::time::Instant::now();
    for _ in 0..1000 {
        let _ = persona::load(&handle).unwrap();
    }
    let elapsed = t0.elapsed();
    assert!(elapsed < std::time::Duration::from_millis(100),
            "1000 cache hits took {elapsed:?}, budget 100ms");
}

LLM-hint merge test (handler-level integration)

#[tokio::test]
async fn caller_hints_override_persona_defaults() {
    persona::init_persona_registry().await.unwrap();
    let req = test_request("cuo-cpo@0.4.1", /* temperature */ Some(0.8), /* max_tokens */ None);
    let provider_call = handlers::chat::prepare_provider_call(req).await.unwrap();

    assert_eq!(provider_call.temperature, Some(0.8));   // caller wins
    assert_eq!(provider_call.max_tokens, Some(1024));   // persona default (caller omitted)
}

Run

cd services/ai-gateway
cargo test -p cyberos-ai-gateway persona

CI gate: cargo-test pass on every PR touching services/ai-gateway/src/persona/** OR memories/personas/**.


§6 — Implementation skeleton

// services/ai-gateway/src/persona/registry.rs

pub async fn init_persona_registry() -> Result<(), PersonaInitError> {
    if REGISTRY.get().is_some() {
        return Err(PersonaInitError::AlreadyInitialised);
    }

    let entries = memory_writer::list_path("memories/personas/").await
        .map_err(|e| PersonaInitError::MemoryReadFailed(e.to_string()))?;

    let mut map: HashMap<PersonaHandle, Arc<Persona>> = HashMap::new();
    for entry_path in entries {
        if !entry_path.ends_with(".md") { continue; }
        let raw = memory_writer::read_path(&entry_path).await
            .map_err(|e| PersonaInitError::MemoryReadFailed(e.to_string()))?;
        let persona = parse::parse_persona_md(&entry_path, &raw)?;
        map.insert(persona.handle.clone(), Arc::new(persona));
    }

    REGISTRY.set(ArcSwap::from_pointee(map))
        .map_err(|_| PersonaInitError::AlreadyInitialised)?;
    watch::spawn_memory_watcher();
    Ok(())
}

pub fn load(handle: &PersonaHandle) -> Result<Arc<Persona>, PersonaError> {
    let registry = REGISTRY.get().ok_or(PersonaError::RegistryNotInitialised)?;
    let map = registry.load();
    let Some(persona) = map.get(handle) else {
        let mut avail: Vec<String> = map.keys().map(|h| h.display()).collect();
        avail.sort();
        return Err(PersonaError::UnknownPersona {
            handle: handle.display(), available: avail,
        });
    };
    hash::verify_persona(persona)?;
    metrics::cache_hit(&persona.handle);
    Ok(persona.clone())
}
// services/ai-gateway/src/persona/watch.rs

pub fn spawn_memory_watcher() {
    use notify::{Watcher, RecursiveMode, EventKind};
    use std::sync::mpsc;
    let (tx, rx) = mpsc::channel();
    let mut watcher = notify::recommended_watcher(tx).unwrap();
    watcher.watch(
        std::path::Path::new("<memory-root>/memories/personas/"),
        RecursiveMode::Recursive,
    ).unwrap();
    std::thread::spawn(move || {
        let mut last_event = std::time::Instant::now();
        let debounce = std::time::Duration::from_millis(250);
        for ev in rx {
            if let Ok(_event) = ev {
                last_event = std::time::Instant::now();
                std::thread::sleep(debounce);
                // Drain any further events that arrived in the debounce window.
                while last_event.elapsed() < debounce {
                    std::thread::sleep(std::time::Duration::from_millis(10));
                }
                reload_registry();
            }
        }
    });
}

fn reload_registry() {
    let tokio_runtime = tokio::runtime::Handle::current();
    let result = tokio_runtime.block_on(async {
        let entries = memory_writer::list_path("memories/personas/").await?;
        let mut new_map: HashMap<PersonaHandle, Arc<Persona>> = HashMap::new();
        for path in entries {
            if !path.ends_with(".md") { continue; }
            let raw = memory_writer::read_path(&path).await?;
            let persona = parse::parse_persona_md(&path, &raw)?;
            new_map.insert(persona.handle.clone(), Arc::new(persona));
        }
        Ok::<_, anyhow::Error>(new_map)
    });
    match result {
        Ok(new_map) => {
            REGISTRY.get().unwrap().store(Arc::new(new_map.clone()));
            metrics::reload_success(new_map.len() as u64);
            for (h, p) in &new_map {
                tracing::info!(
                    handle = %h.display(),
                    source_hash = %hex16(&p.source_hash),
                    registry_size = new_map.len(),
                    "persona_reloaded"
                );
            }
        }
        Err(e) => {
            tracing::warn!(error = %e, "persona reload failed; cache unchanged");
            metrics::reload_failure(&e);
        }
    }
}
// services/ai-gateway/src/persona/hash.rs

pub fn verify_persona(p: &Persona) -> Result<(), PersonaError> {
    let actual = sha256(p.body.as_bytes());
    if actual != p.source_hash {
        metrics::tampered(&p.handle);
        return Err(PersonaError::Tampered {
            handle: p.handle.clone(),
            expected_hash: p.source_hash,
            actual_hash: actual,
        });
    }
    Ok(())
}

pub fn sha256(bytes: &[u8]) -> [u8; 32] {
    use sha2::{Sha256, Digest};
    let mut h = Sha256::new();
    h.update(bytes);
    h.finalize().into()
}

pub fn hex16(h: &[u8; 32]) -> String {
    h.iter().take(8).map(|b| format!("{:02x}", b)).collect()
}
// services/ai-gateway/src/memory_writer.rs (additions)

pub mod canonical {
    pub fn persona_loaded(persona: &Persona, request_id: &str) -> AuditRow {
        AuditRow {
            kind: "ai.persona_loaded".into(),
            payload: serde_json::json!({
                "persona_id": persona.handle.id.0,
                "persona_version": persona.handle.version.to_string(),
                "persona_handle": persona.handle.display(),
                "source_path": persona.source_path,
                "source_hash": hex::encode(persona.source_hash),
                "request_id": request_id,
            }),
            ..Default::default()
        }
    }
}

§7 — Dependencies

Code dependencies (other tasks/modules)

Concept dependencies (shared types)

Operational / external


§8 — Example payloads

Persona file (canonical) — <memory-root>/memories/personas/cuo-cpo@0.4.1.md

See §3 above (canonical format).

Request with persona

{
  "model": "chat.smart",
  "agent_persona": "cuo-cpo@0.4.1",
  "messages": [{ "role": "user", "content": "Draft a thank-you for the deal" }]
}

After persona injection (sent to LLM provider)

{
  "model": "chat.smart",
  "temperature": 0.4,
  "max_tokens": 1024,
  "stop": ["</persona>"],
  "messages": [
    { "role": "system", "content": "You are Genie, the AI orchestrator at CyberSkill..." },
    { "role": "user", "content": "Draft a thank-you for the deal" }
  ]
}

Response headers

HTTP/1.1 200 OK
X-CyberOS-Persona-Handle: cuo-cpo@0.4.1
X-CyberOS-Persona-Source-Hash: 4b8c0d2f1a7e9c3b
X-CyberOS-Hold-Id: 01HZK9R8M3X5C8Q4

Response body (badge metadata)

{
  "choices": [{ "message": { "role": "assistant", "content": "Dear ..." }}],
  "made_by_genie": { "id": "cuo-cpo", "version": "0.4.1" },
  "usage": { "prompt_tokens": 142, "completion_tokens": 86 }
}

Audit row ai.persona_loaded

{
  "kind": "ai.persona_loaded",
  "ts_ns": 1747526400000000000,
  "payload": {
    "persona_id": "cuo-cpo",
    "persona_version": "0.4.1",
    "persona_handle": "cuo-cpo@0.4.1",
    "source_path": "memories/personas/cuo-cpo@0.4.1.md",
    "source_hash": "4b8c0d2f1a7e9c3b...",
    "request_id": "req_01HZK9R8M3X5C8Q4"
  }
}

Unknown-handle error response

HTTP/1.1 400 Bad Request
{
  "error": "unknown_persona",
  "agent_persona": "cuo-cpo@9.9.9",
  "available_handles": [
    "cuo-cfo@0.4.1",
    "cuo-cpo@0.4.1",
    "cuo-cto@0.4.1"
  ]
}

Tamper error response

HTTP/1.1 503 Service Unavailable
{
  "error": "persona_tampered",
  "handle": "cuo-cpo@0.4.1",
  "contact": "ops@cyberos.world"
}

(Body never echoes expected_hash/actual_hash — those are written to the sev-1 OBS event and memory, never to the client.)


§9 — Open questions

All resolved at authoring time. Items deferred to later tasks:


§10 — Failure modes inventory

FailureDetectionOutcomeRecovery
Persona handle not in registryHashMap miss in persona::loadErr(UnknownPersona)400 BAD_REQUESTCaller fixes agent_persona; or operator adds the persona file
On-disk tamper between loads (post-init fs::write)verify_persona hash mismatchErr(Tampered) → sev-1 OBS → 503 PERSONA_TAMPEREDOperator investigates (likely git revert); incident review
memory read failure at initmemory_writer::read_path errorPersonaInitError::MemoryReadFailed → init fails → gateway refuses to bindOperator investigates memory (TASK-AI-003 §10 covers); restart gateway after memory recovery
Malformed persona YAML at initserde_yaml::from_str errorPersonaInitError::Schema → init fails → gateway refuses to bindOperator fixes file; redeploy
Hot-reload of malformed fileParser fails in watcher loopCache unchanged; persona_reload_total{outcome=parse_error} counter incremented; INFO log "reload failed"Operator fixes file; next file-watch event triggers retry
Filename ≠ frontmatter handleparse_persona_md filename-match checkPersonaInitError::FilenameMismatch → init OR reload failsOperator renames file OR fixes frontmatter
Forbidden frontmatter field system_promptParser whitelist checkPersonaInitError::ForbiddenFrontmatterField → init OR reload failsOperator moves prompt content from frontmatter to body
Cross-platform line-ending flip (LF↔CRLF)Canonicalisation neutralisessource_hash unchanged; no false-positive tamperBy design (§1 #8)
BOM-prefixed file from Windows editorCanonicalisation strips BOMsource_hash unchanged; no false-positive tamperBy design (§1 #8)
Concurrent persona load + hot-reloadArcSwap atomicReader sees either old or new map, never tornBy design (§1 #3)
Concurrent hot-reload + reload (rapid edits)Debounce window collapses bursts to one reparseOne reparse runs; subsequent events queue and re-triggerBy design (§1 #12)
Allowed_tools field references a non-existent MCP toolRuntime detection at tool-call time (TASK-MCP-006)Tool call refused; sev-2 logOperator updates persona OR registers the tool
Persona pre-release version submittedPersonaHandle::parse strictnessPersonaParseError::PreReleaseUnsupported400 at API edgeCaller uses release version
Persona file in memories/personas/ not named <handle>.mdInit pass skips non-.md files; misnamed files (foo.md with arbitrary frontmatter) fail filename-matchSkipped OR init failureOperator renames file to canonical form
Registry init called twice (test re-entry, sidecar reload)OnceCell::set returns ErrPersonaInitError::AlreadyInitialisedTests use reset_for_tests(); production calls init once at boot
Watcher thread panicstokio::spawn+ tracing observabilityWatcher dies; hot-reload stops working but cache continues servingsev-2 alert; operator restarts gateway; investigates panic cause
File-watch event flood (1000 events/sec from bulk edit)Debounce windowWatcher collapses to one reparse; metric ai_persona_reload_total only increments by 1 per debounced flushBy design (§1 #12)
LLM hint conflict (persona says temperature=0.4, request says nothing)Merge order: request > persona > defaultProvider gets persona defaultBy design (§1 #13)
Caller-supplied system message conflicts with personaInjection puts persona at idx 0, caller at idx 1Both present in messages; LLM sees both as system contextBy design (§1 #5); UI MAY warn caller about double-system pattern
Response header X-CyberOS-Persona-Handle missing on 200Handler middleware enforces; integration test asserts presenceTest failure → PR blockedPR rework
Audit row ai.persona_loaded missing in chainIntegration test asserts emit-before-LLM-call sequenceTest failure → PR blockedPR rework

§11 — Notes


End of TASK-AI-014. Status: draft (10/10 target).