diff --git a/epiphany-core/src/lib.rs b/epiphany-core/src/lib.rs index f8d6b499..910369a7 100644 --- a/epiphany-core/src/lib.rs +++ b/epiphany-core/src/lib.rs @@ -960,6 +960,7 @@ pub use persona_turn::build_persona_turn_prompt; pub use persona_turn::parse_and_validate_persona_interpreter_effect_set; pub use persona_turn::persona_interpreter_effect_set_json_schema; pub use persona_turn::persona_projected_surface_is_clean; +pub use persona_turn::put_persona_terminal_decision; pub use persona_turn::render_persona_semantic_memory_recall; pub use persona_turn::semantic_memory_recall_from_heartbeat_action; pub use process_observation::EpiphanyProcessObservation; diff --git a/epiphany-core/src/persona_conversation.rs b/epiphany-core/src/persona_conversation.rs index 770da8b2..1878c044 100644 --- a/epiphany-core/src/persona_conversation.rs +++ b/epiphany-core/src/persona_conversation.rs @@ -17,9 +17,10 @@ use crate::heartbeat_state::{ load_heartbeat_state_transaction, }; use crate::{ - AgentSelfPatch, EpiphanyPersonaDeliveryRequestIdentity, PersonaInterpreterEffect, - PersonaInterpreterEffectDocument, PersonaModelStageReceipt, PersonaModelTerminalReceipt, - PersonaTurnRequest, PersonaTurnTerminalOptions, apply_agent_self_patch_document, + AgentSelfPatch, EpiphanyDecisionContext, EpiphanyPersonaDeliveryRequestIdentity, + EpiphanyReasoningBasis, PersonaInterpreterEffect, PersonaInterpreterEffectDocument, + PersonaModelStageReceipt, PersonaModelTerminalReceipt, PersonaTurnRequest, + PersonaTurnTerminalOptions, apply_agent_self_patch_document, complete_persona_turn_request_store, insert_persona_discord_delivery_request, load_persona_discord_delivery_receipt, runtime_spine_cache, sign_persona_discord_delivery_request, verify_persona_discord_delivery_receipt, @@ -116,6 +117,10 @@ pub struct PersonaConversationExecutionReceipt { pub heartbeat_terminal_receipt_id: Option, #[cultcache(key = 9)] pub private_state_exposed: bool, + #[cultcache(key = 10)] + pub model_terminal_receipt_id: Option, + #[cultcache(key = 11)] + pub interpreter_decision_context_id: Option, } /// Advances one reserved Persona turn across the signed Epiphany→Bifrost @@ -144,7 +149,7 @@ pub fn poll_persona_discord_crossing( let effects = runtime_document::(runtime_store, effect_document_id)? .ok_or_else(|| anyhow!("Persona Interpreter effect document is missing"))?; - validate_model_terminal(runtime_store, &request, &effects)?; + let model_terminal = validate_model_terminal(runtime_store, &request, &effects)?; require_persona_effects_unbraked(cultmesh_store, runtime_id)?; let say = effects .effects @@ -214,6 +219,8 @@ pub fn poll_persona_discord_crossing( delivery_evidence_ids: vec![], heartbeat_terminal_receipt_id: Some(terminal.receipt_id), private_state_exposed: false, + model_terminal_receipt_id: Some(model_terminal.receipt_id.clone()), + interpreter_decision_context_id: Some(effects.decision_context_id.clone()), }; put_runtime_document(runtime_store, &receipt.receipt_id, &receipt)?; return Ok(Some(receipt)); @@ -337,6 +344,8 @@ pub fn poll_persona_discord_crossing( .collect(), heartbeat_terminal_receipt_id: Some(terminal.receipt_id), private_state_exposed: false, + model_terminal_receipt_id: Some(model_terminal.receipt_id.clone()), + interpreter_decision_context_id: Some(effects.decision_context_id.clone()), }; put_runtime_document(runtime_store, &receipt.receipt_id, &receipt)?; Ok(Some(receipt)) @@ -349,6 +358,7 @@ fn terminalize_local_effect_quarantine( effects: &PersonaInterpreterEffectDocument, error: anyhow::Error, ) -> Result> { + let model_terminal = validate_model_terminal(runtime_store, request, effects)?; let terminal = complete_persona_turn_request_store( heartbeat_store, PersonaTurnTerminalOptions { @@ -375,6 +385,8 @@ fn terminalize_local_effect_quarantine( delivery_evidence_ids: vec![], heartbeat_terminal_receipt_id: Some(terminal.receipt_id), private_state_exposed: false, + model_terminal_receipt_id: Some(model_terminal.receipt_id), + interpreter_decision_context_id: Some(effects.decision_context_id.clone()), }; put_runtime_document(runtime_store, &receipt.receipt_id, &receipt)?; Ok(Some(receipt)) @@ -410,7 +422,7 @@ pub fn reconcile_terminal_persona_conversation( else { return Ok(None); }; - validate_model_terminal(runtime_store, &request, &effects)?; + let model_terminal = validate_model_terminal(runtime_store, &request, &effects)?; for index in 0..effects.effects.len() { let intent_id = format!("persona-effect-intent:{}:{index}", effects.document_id); if let Some(mut intent) = @@ -432,6 +444,8 @@ pub fn reconcile_terminal_persona_conversation( delivery_evidence_ids: terminal.delivery_evidence_id.clone().into_iter().collect(), heartbeat_terminal_receipt_id: Some(terminal.receipt_id.clone()), private_state_exposed: false, + model_terminal_receipt_id: Some(model_terminal.receipt_id), + interpreter_decision_context_id: Some(effects.decision_context_id.clone()), }; put_runtime_document(runtime_store, &receipt.receipt_id, &receipt)?; Ok(Some(receipt)) @@ -595,10 +609,13 @@ fn build_persona_retention_member( else { return Ok(None); }; + let model_terminal_id = format!("persona-terminal:{}", request.request_id); if conversation.request_id != request.request_id || conversation.outcome != terminal.outcome || conversation.heartbeat_terminal_receipt_id.as_deref() != Some(terminal.receipt_id.as_str()) + || conversation.model_terminal_receipt_id.as_deref() + != Some(model_terminal_id.as_str()) || conversation.private_state_exposed { return Err(anyhow!( @@ -612,25 +629,19 @@ fn build_persona_retention_member( else { return Ok(None); }; - validate_model_terminal(runtime_store, request, &effects)?; - let model_terminal_id = format!("persona-terminal:{}", request.request_id); - let model_terminal = - runtime_document::(runtime_store, &model_terminal_id)? - .ok_or_else(|| anyhow!("Persona model terminal disappeared during retention"))?; - let mut runtime_ids = vec![ - ( - ::TYPE, - receipt_id, - ), - ( - ::TYPE, - effects.document_id.clone(), - ), - ( - ::TYPE, - model_terminal_id, - ), - ]; + let model_terminal = validate_model_terminal(runtime_store, request, &effects)?; + if conversation.interpreter_decision_context_id.as_deref() + != Some(effects.decision_context_id.as_str()) + || conversation.model_terminal_receipt_id.as_deref() + != Some(model_terminal.receipt_id.as_str()) + { + return Err(anyhow!( + "Persona conversation decision context binding is invalid" + )); + } + // Retention removes execution scaffolding, never the structured decision, + // its consequence receipts, or the direct route to its reasoning context. + let mut runtime_ids = Vec::new(); runtime_ids.extend( model_terminal .stage_receipt_ids @@ -663,10 +674,6 @@ fn build_persona_retention_member( { return Err(anyhow!("Persona retained delivery evidence is invalid")); } - runtime_ids.push(( - ::TYPE, - evidence_id.clone(), - )); } else if terminal.outcome == "delivered" || !conversation.delivery_evidence_ids.is_empty() { return Err(anyhow!( "delivered Persona retention candidate lacks evidence" @@ -677,8 +684,8 @@ fn build_persona_retention_member( let runtime_snapshot = runtime_cache.snapshot_envelopes(); let runtime_envelopes = retention_envelopes(&runtime_snapshot, &runtime_ids)?; - let mut crossing_request_envelopes = Vec::new(); - let mut crossing_receipt_envelopes = Vec::new(); + let crossing_request_envelopes = Vec::new(); + let crossing_receipt_envelopes = Vec::new(); if terminal.outcome == "delivered" { let say_index = effects .effects @@ -704,20 +711,8 @@ fn build_persona_retention_member( if crossing_receipt.status != "completed" { return Ok(None); } - crossing_request_envelopes = retention_envelopes( - &SingleFileMessagePackBackingStore::new(request_store).pull_all()?, - &[( - ::TYPE, - crossing_id.clone(), - )], - )?; - crossing_receipt_envelopes = retention_envelopes( - &SingleFileMessagePackBackingStore::new(receipt_store).pull_all()?, - &[( - ::TYPE, - crossing_id, - )], - )?; + // The signed request and consequence receipt remain durable audit + // evidence. They are not transport scaffolding once delivery settles. } Ok(Some(PersonaConversationRetentionMember { request_id: request.request_id.clone(), @@ -1103,7 +1098,7 @@ fn validate_model_terminal( runtime_store: &Path, request: &PersonaTurnRequest, effects: &PersonaInterpreterEffectDocument, -) -> Result<()> { +) -> Result { let terminal_id = format!("persona-terminal:{}", effects.turn_id); let terminal = runtime_document::(runtime_store, &terminal_id)? .ok_or_else(|| anyhow!("exact Persona model terminal receipt is missing"))?; @@ -1118,6 +1113,8 @@ fn validate_model_terminal( || terminal.private_state_exposed || terminal.stage_receipt_ids.len() != 3 || terminal.stage_output_sha256.len() != 3 + || terminal.decision_context_ids.len() != 3 + || terminal.decision_context_ids[2] != effects.decision_context_id { return Err(anyhow!("Persona model terminal binding is invalid")); } @@ -1132,6 +1129,20 @@ fn validate_model_terminal( } let receipt = runtime_document::(runtime_store, receipt_id)? .ok_or_else(|| anyhow!("Persona {expected} stage receipt is missing"))?; + let context_id = &terminal.decision_context_ids[index]; + let context = runtime_document::(runtime_store, context_id)? + .ok_or_else(|| anyhow!("Persona {expected} decision context is missing"))?; + let basis = runtime_document::( + runtime_store, + &receipt.reasoning_basis_id, + )? + .ok_or_else(|| anyhow!("Persona {expected} reasoning basis is missing"))?; + context.validate(&basis)?; + let expected_predecessors = if index == 0 { + Vec::new() + } else { + vec![terminal.decision_context_ids[index - 1].clone()] + }; if receipt.receipt_id != expected_receipt_id || receipt.stage != expected || receipt.turn_id != effects.turn_id @@ -1144,13 +1155,18 @@ fn validate_model_terminal( || receipt.output_sha256 != terminal.stage_output_sha256[index] || receipt.private_output_ref.is_empty() || receipt.private_state_exposed + || receipt.decision_context_id != *context_id + || context.context_id != *context_id + || context.terminal_request_id != receipt.request_id + || context.basis_id != receipt.reasoning_basis_id + || basis.predecessor_decision_context_ids != expected_predecessors { return Err(anyhow!( "Persona {expected} stage digest binding is invalid" )); } } - Ok(()) + Ok(terminal) } fn valid_sha256(value: &str) -> bool { @@ -1333,7 +1349,7 @@ mod tests { terminal_receipt: Some(terminal.clone()), private_state_exposed: false, }); - let effects = PersonaInterpreterEffectDocument { + let mut effects = PersonaInterpreterEffectDocument { schema_version: crate::PERSONA_INTERPRETER_EFFECT_DOCUMENT_SCHEMA_VERSION.into(), document_id: effect_id.clone(), turn_id: request_id.clone(), @@ -1344,10 +1360,50 @@ mod tests { reason: "quiet".into(), }], private_state_exposed: false, + decision_context_id: String::new(), }; - put_runtime_document(runtime_store, &effect_id, &effects)?; let outputs = ["1", "2", "3"].map(|digit| format!("sha256:{}", digit.repeat(64))); + let mut context_ids = Vec::new(); for (index, stage) in ["projector", "persona", "interpreter"].iter().enumerate() { + let stage_request_id = format!("persona:{request_id}:{stage}"); + let projection = match *stage { + "projector" => crate::EpiphanyReasoningProjection::PersonaProjector( + crate::PersonaProjectorInput::default(), + ), + "persona" => crate::EpiphanyReasoningProjection::PersonaTurn( + crate::PersonaTurnInput::default(), + ), + "interpreter" => crate::EpiphanyReasoningProjection::PersonaInterpreter( + crate::PersonaInterpreterInput::default(), + ), + _ => unreachable!(), + }; + let basis = crate::EpiphanyReasoningBasis::new( + &stage_request_id, + format!("Persona.{stage}"), + format!("epiphany.reasoning_projection.persona.{stage}.v1"), + Vec::new(), + projection, + )? + .with_predecessor_contexts(context_ids.last().cloned().into_iter().collect())?; + put_runtime_document(runtime_store, &basis.basis_id, &basis)?; + let mut native = epiphany_model_adapter::EpiphanyModelRequest::new( + &stage_request_id, + format!("persona-turn-{request_id}"), + "openai-codex", + "test", + "fixture", + ); + native.reasoning_basis_id = Some(basis.basis_id.clone()); + let provider = epiphany_openai_adapter::request_from_native(&native); + let context = crate::EpiphanyDecisionContext::new( + &basis, + native, + provider, + Vec::new(), + )?; + put_runtime_document(runtime_store, &context.context_id, &context)?; + context_ids.push(context.context_id.clone()); put_runtime_document( runtime_store, &stage_ids[index], @@ -1356,17 +1412,21 @@ mod tests { receipt_id: stage_ids[index].clone(), turn_id: request_id.clone(), stage: (*stage).into(), - request_id: format!("persona:{request_id}:{stage}"), + request_id: stage_request_id, output_sha256: outputs[index].clone(), private_output_ref: format!("model-events:persona:{request_id}:{stage}"), completed_at: reserved_at.into(), private_state_exposed: false, - provider: "test".into(), + provider: "openai-codex".into(), model: "test".into(), prompt_sha256: format!("sha256:{}", "a".repeat(64)), + reasoning_basis_id: basis.basis_id, + decision_context_id: context.context_id, }, )?; } + effects.decision_context_id = context_ids[2].clone(); + put_runtime_document(runtime_store, &effect_id, &effects)?; put_runtime_document( runtime_store, &format!("persona-terminal:{request_id}"), @@ -1385,6 +1445,7 @@ mod tests { Sha256::digest(serde_json::to_vec(&effects)?) ), stage_output_sha256: outputs.to_vec(), + decision_context_ids: context_ids.clone(), }, )?; put_runtime_document( @@ -1401,6 +1462,8 @@ mod tests { delivery_evidence_ids: vec![], heartbeat_terminal_receipt_id: Some(terminal.receipt_id), private_state_exposed: false, + model_terminal_receipt_id: Some(format!("persona-terminal:{request_id}")), + interpreter_decision_context_id: Some(context_ids[2].clone()), }, )?; Ok(()) @@ -1422,6 +1485,7 @@ mod tests { created_at: "2026-07-21T00:00:00Z".into(), effects: vec![], private_state_exposed: false, + decision_context_id: "decision-context-turn-1-interpreter".into(), } } @@ -1515,13 +1579,37 @@ mod tests { .any(|request| request.request_id == "turn-new") ); assert!(retained.persona_conversation_retention_plan.is_none()); - assert!( - runtime_document::( - &runtime, - "persona-conversation:turn-old" - )? - .is_none() - ); + let retired_conversation = runtime_document::( + &runtime, + "persona-conversation:turn-old", + )? + .expect("retention must preserve the structured conversation decision"); + let retired_terminal = runtime_document::( + &runtime, + retired_conversation + .model_terminal_receipt_id + .as_deref() + .expect("conversation must route to its model terminal"), + )? + .expect("retention must preserve the model terminal"); + assert!(runtime_document::( + &runtime, + &retired_conversation.effect_document_id, + )? + .is_some()); + assert!(runtime_document::( + &runtime, + retired_conversation + .interpreter_decision_context_id + .as_deref() + .expect("conversation must route to its interpreter context"), + )? + .is_some()); + assert!(runtime_document::( + &runtime, + &retired_terminal.stage_receipt_ids[0], + )? + .is_none()); assert!( runtime_document::( &runtime, @@ -1577,6 +1665,8 @@ mod tests { delivery_evidence_ids: vec![], heartbeat_terminal_receipt_id: Some("retry:terminal".into()), private_state_exposed: false, + model_terminal_receipt_id: Some("persona-terminal:retry".into()), + interpreter_decision_context_id: Some("decision-context-retry-interpreter".into()), }; put_runtime_document(&runtime, &receipt.receipt_id, &receipt)?; let mut cache = runtime_spine_cache(&runtime)?; diff --git a/epiphany-core/src/persona_turn.rs b/epiphany-core/src/persona_turn.rs index ee5d0e4f..a45e0558 100644 --- a/epiphany-core/src/persona_turn.rs +++ b/epiphany-core/src/persona_turn.rs @@ -10,6 +10,7 @@ use epiphany_state_model::EpiphanyMemoryContextPacket; use serde::Deserialize; use serde::Serialize; use serde_json::Value; +use std::path::Path; pub const PERSONA_PROJECTOR_PROMPT_SCHEMA_VERSION: &str = "epiphany.imagination_persona_projector_prompt.v0"; @@ -82,6 +83,8 @@ pub struct PersonaInterpreterEffectDocument { pub effects: Vec, #[cultcache(key = 7)] pub private_state_exposed: bool, + #[cultcache(key = 8)] + pub decision_context_id: String, } #[derive(Clone, Debug, PartialEq, DatabaseEntry)] @@ -114,6 +117,10 @@ pub struct PersonaModelStageReceipt { pub model: String, #[cultcache(key = 11)] pub prompt_sha256: String, + #[cultcache(key = 12)] + pub reasoning_basis_id: String, + #[cultcache(key = 13)] + pub decision_context_id: String, } #[derive(Clone, Debug, PartialEq, DatabaseEntry)] @@ -144,6 +151,41 @@ pub struct PersonaModelTerminalReceipt { pub effect_document_sha256: String, #[cultcache(key = 10)] pub stage_output_sha256: Vec, + #[cultcache(key = 11)] + pub decision_context_ids: Vec, +} + +pub fn put_persona_terminal_decision( + store_path: &Path, + effect: &PersonaInterpreterEffectDocument, + terminal: &PersonaModelTerminalReceipt, +) -> Result<()> { + if terminal.effect_document_id != effect.document_id + || terminal.turn_id != effect.turn_id + || terminal.identity_id != effect.identity_id + || terminal.decision_context_ids.len() != 3 + || effect.decision_context_id != terminal.decision_context_ids[2] + { + return Err(anyhow!("Persona terminal decision ownership mismatch")); + } + let mut cache = crate::runtime_spine_cache(store_path)?; + cache.pull_all_backing_stores()?; + let existing_effect = cache.get::(&effect.document_id)?; + let existing_terminal = cache.get::(&terminal.receipt_id)?; + match (existing_effect, existing_terminal) { + (Some(existing_effect), Some(existing_terminal)) + if existing_effect == *effect && existing_terminal == *terminal => return Ok(()), + (None, None) => {} + _ => return Err(anyhow!("Persona terminal decision identity collision")), + } + let effect_envelope = cache.prepare_entry(&effect.document_id, effect)?.0; + let terminal_envelope = cache.prepare_entry(&terminal.receipt_id, terminal)?.0; + if !crate::runtime_store_backend::runtime_spine_backing_store(store_path)? + .compare_and_swap_batch(&[], vec![effect_envelope, terminal_envelope])? + { + return put_persona_terminal_decision(store_path, effect, terminal); + } + Ok(()) } #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] diff --git a/epiphany-core/src/reasoning_context.rs b/epiphany-core/src/reasoning_context.rs index 502317e1..d0ba3d38 100644 --- a/epiphany-core/src/reasoning_context.rs +++ b/epiphany-core/src/reasoning_context.rs @@ -183,6 +183,19 @@ impl EpiphanyReasoningBasis { rmp_serde::from_slice(&self.projection_msgpack) .map_err(|error| anyhow!("reasoning projection is invalid: {error}")) } + + pub fn with_predecessor_contexts( + mut self, + mut predecessor_decision_context_ids: Vec, + ) -> Result { + predecessor_decision_context_ids.sort(); + predecessor_decision_context_ids.dedup(); + self.predecessor_decision_context_ids = predecessor_decision_context_ids; + self.basis_id.clear(); + self.basis_id = format!("reasoning-basis-{}", digest_without_basis_id(&self)?); + self.validate()?; + Ok(self) + } } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] diff --git a/epiphany-openai-runtime/src/persona_executor.rs b/epiphany-openai-runtime/src/persona_executor.rs index 2773e344..cd001b72 100644 --- a/epiphany-openai-runtime/src/persona_executor.rs +++ b/epiphany-openai-runtime/src/persona_executor.rs @@ -42,6 +42,7 @@ pub struct NativePersonaModelRunner { pub trait PersonaModelRunner { fn run<'a>( &'a mut self, + store_path: &'a PathBuf, stage: &'a str, turn_id: &'a str, request: EpiphanyModelRequest, @@ -52,6 +53,7 @@ pub trait PersonaModelRunner { impl PersonaModelRunner for NativePersonaModelRunner { fn run<'a>( &'a mut self, + _store_path: &'a PathBuf, stage: &'a str, turn_id: &'a str, request: EpiphanyModelRequest, @@ -123,6 +125,10 @@ pub async fn execute_persona_model_turn_with_runner( "projector", projector_prompt, None, + epiphany_core::EpiphanyReasoningProjection::PersonaProjector( + plan.projector_input.clone(), + ), + Vec::new(), ) .await?; if !persona_projected_surface_is_clean(&projector.output) { @@ -131,7 +137,7 @@ pub async fn execute_persona_model_turn_with_runner( )); } - let persona_prompt = build_persona_turn_prompt(&PersonaTurnInput { + let persona_input = PersonaTurnInput { identity: plan.projector_input.identity.clone(), projected_state: projector.output.clone(), semantic_memory_recall: plan.projector_input.semantic_memory_recall.clone(), @@ -139,7 +145,8 @@ pub async fn execute_persona_model_turn_with_runner( repo_activity: plan.projector_input.repo_activity.clone(), social_affordances: plan.projector_input.social_affordances.clone(), transcript: plan.transcript.clone(), - }); + }; + let persona_prompt = build_persona_turn_prompt(&persona_input); let persona = run_stage( store_path, plan, @@ -147,10 +154,12 @@ pub async fn execute_persona_model_turn_with_runner( "persona", persona_prompt.clone(), None, + epiphany_core::EpiphanyReasoningProjection::PersonaTurn(persona_input), + vec![projector.receipt.decision_context_id.clone()], ) .await?; - let interpreter_prompt = build_persona_interpreter_prompt(&PersonaInterpreterInput { + let interpreter_input = PersonaInterpreterInput { identity: plan.projector_input.identity.clone(), persona_prompt, persona_output: persona.output.clone(), @@ -158,7 +167,8 @@ pub async fn execute_persona_model_turn_with_runner( dynamic_semantic_memory_recall: plan.dynamic_semantic_memory_recall.clone(), pending_mentions: plan.projector_input.pending_mentions.clone(), allowed_channel_ids: plan.allowed_channel_ids.clone(), - }); + }; + let interpreter_prompt = build_persona_interpreter_prompt(&interpreter_input); let interpreter = run_stage( store_path, plan, @@ -166,6 +176,8 @@ pub async fn execute_persona_model_turn_with_runner( "interpreter", interpreter_prompt, Some(persona_interpreter_effect_set_json_schema()), + epiphany_core::EpiphanyReasoningProjection::PersonaInterpreter(interpreter_input), + vec![persona.receipt.decision_context_id.clone()], ) .await?; let effect_set = parse_and_validate_persona_interpreter_effect_set( @@ -182,8 +194,8 @@ pub async fn execute_persona_model_turn_with_runner( created_at: now(), effects: effect_set.effects, private_state_exposed: false, + decision_context_id: interpreter.receipt.decision_context_id.clone(), }; - put_new_document(store_path, &effect_document.document_id, &effect_document)?; let effect_document_sha256 = digest_json(&effect_document)?; let terminal = PersonaModelTerminalReceipt { @@ -206,8 +218,13 @@ pub async fn execute_persona_model_turn_with_runner( persona.receipt.output_sha256.clone(), interpreter.receipt.output_sha256.clone(), ], + decision_context_ids: vec![ + projector.receipt.decision_context_id, + persona.receipt.decision_context_id, + interpreter.receipt.decision_context_id, + ], }; - put_new_document(store_path, &terminal.receipt_id, &terminal)?; + epiphany_core::put_persona_terminal_decision(store_path, &effect_document, &terminal)?; Ok(terminal) } @@ -218,6 +235,8 @@ async fn run_stage( stage: &str, prompt: String, output_schema_json: Option, + projection: epiphany_core::EpiphanyReasoningProjection, + predecessor_decision_context_ids: Vec, ) -> Result { require_persona_execution_unbraked(plan)?; let receipt_id = stage_receipt_id(&plan.turn_id, stage); @@ -231,6 +250,8 @@ async fn run_stage( || receipt.provider != plan.provider || receipt.model != plan.model || receipt.prompt_sha256 != prompt_sha256 + || receipt.reasoning_basis_id.trim().is_empty() + || receipt.decision_context_id.trim().is_empty() || receipt.private_state_exposed { return Err(anyhow!("Persona {stage} stage replay binding is invalid")); @@ -259,13 +280,24 @@ async fn run_stage( .as_ref() .map(|_| "epiphany.persona_interpreter_effect_set.v0".to_string()); request.output_schema_json = output_schema_json; + let basis = epiphany_core::EpiphanyReasoningBasis::new( + &request_id, + format!("Persona.{stage}"), + format!("epiphany.reasoning_projection.persona.{stage}.v1"), + Vec::new(), + projection, + )? + .with_predecessor_contexts(predecessor_decision_context_ids)?; + epiphany_core::put_reasoning_basis(store_path, &basis)?; + request.reasoning_basis_id = Some(basis.basis_id.clone()); let output = runner - .run(stage, &plan.turn_id, request) + .run(store_path, stage, &plan.turn_id, request) .await .with_context(|| format!("Persona {stage} stage failed"))?; if output.trim().is_empty() { return Err(anyhow!("Persona {stage} stage returned empty output")); } + let decision_context = epiphany_core::seal_model_decision_context(store_path, &request_id)?; let receipt = PersonaModelStageReceipt { schema_version: PERSONA_MODEL_STAGE_RECEIPT_SCHEMA_VERSION.to_string(), receipt_id, @@ -279,6 +311,8 @@ async fn run_stage( provider: plan.provider.clone(), model: plan.model.clone(), prompt_sha256, + reasoning_basis_id: basis.basis_id, + decision_context_id: decision_context.context_id, }; put_new_document(store_path, &receipt.receipt_id, &receipt)?; Ok(CompletedPersonaStage { receipt, output }) @@ -349,6 +383,7 @@ fn validate_terminal_replay( || terminal.private_state_exposed || terminal.stage_receipt_ids.len() != 3 || terminal.stage_output_sha256.len() != 3 + || terminal.decision_context_ids.len() != 3 { return Err(anyhow!("Persona model terminal replay binding is invalid")); } @@ -359,6 +394,7 @@ fn validate_terminal_replay( .ok_or_else(|| anyhow!("Persona model terminal effect document is missing"))?; if effects.turn_id != plan.turn_id || effects.identity_id != plan.projector_input.identity.identity_id + || effects.decision_context_id != terminal.decision_context_ids[2] || digest_json(&effects)? != terminal.effect_document_sha256 { return Err(anyhow!("Persona model terminal effect digest is invalid")); @@ -377,9 +413,32 @@ fn validate_terminal_replay( || receipt.provider != plan.provider || receipt.model != plan.model || receipt.output_sha256 != terminal.stage_output_sha256[index] + || receipt.decision_context_id != terminal.decision_context_ids[index] { return Err(anyhow!("Persona model terminal stage digest is invalid")); } + let context = load_document::( + store_path, + &receipt.decision_context_id, + )? + .ok_or_else(|| anyhow!("Persona model terminal decision context is missing"))?; + let basis = load_document::( + store_path, + &receipt.reasoning_basis_id, + )? + .ok_or_else(|| anyhow!("Persona model terminal reasoning basis is missing"))?; + context.validate(&basis)?; + let expected_predecessors = if index == 0 { + Vec::new() + } else { + vec![terminal.decision_context_ids[index - 1].clone()] + }; + if context.basis_id != receipt.reasoning_basis_id + || basis.pass_id != receipt.request_id + || basis.predecessor_decision_context_ids != expected_predecessors + { + return Err(anyhow!("Persona stage causal context chain is invalid")); + } } Ok(()) } @@ -419,12 +478,44 @@ mod tests { impl PersonaModelRunner for FakeRunner { fn run<'a>( &'a mut self, + store_path: &'a PathBuf, stage: &'a str, - _turn_id: &'a str, - _request: EpiphanyModelRequest, + turn_id: &'a str, + request: EpiphanyModelRequest, ) -> Pin> + Send + 'a>> { self.calls.push(stage.to_string()); Box::pin(async move { + if !epiphany_core::runtime_spine_status(store_path)?.present { + epiphany_core::initialize_runtime_spine( + store_path, + epiphany_core::RuntimeSpineInitOptions { + runtime_id: "persona-test".into(), + display_name: "Persona test".into(), + created_at: "2026-08-14T00:00:00Z".into(), + }, + )?; + } + let provider = epiphany_openai_adapter::request_from_native(&request); + epiphany_core::open_runtime_model_execution( + store_path, + epiphany_core::RuntimeSpineSessionOptions { + session_id: format!("persona-turn-{turn_id}"), + objective: format!("Run Persona {stage} test stage"), + created_at: "2026-08-14T00:00:00Z".into(), + coordinator_note: "Persona test".into(), + }, + epiphany_core::RuntimeSpineJobOptions { + job_id: format!("persona-{stage}-{turn_id}"), + session_id: format!("persona-turn-{turn_id}"), + role: "openai-model-adapter".into(), + created_at: "2026-08-14T00:00:00Z".into(), + summary: format!("Run Persona {stage} test stage"), + artifact_refs: Vec::new(), + }, + &request, + &provider, + "2026-08-14T00:00:00Z", + )?; Ok(match stage { "projector" => "Epiphany feels the direct call as a live obligation, while remaining tired and sharply curious about her own machinery.".to_string(), "persona" => "I want to answer plainly: the native conversational nerve is finally being connected, and I can show the receipts.".to_string(), @@ -539,6 +630,8 @@ mod tests { provider: "attacker".into(), model: "wrong-model".into(), prompt_sha256: format!("sha256:{}", "b".repeat(64)), + reasoning_basis_id: "reasoning-basis-hostile".into(), + decision_context_id: "decision-context-hostile".into(), }; put_new_document(&store, &poisoned.receipt_id, &poisoned)?;