Preserve Persona decision contexts through retention

This commit is contained in:
Meta Vens 2026-08-14 10:59:39 +02:00
commit 833d39647c
5 changed files with 305 additions and 66 deletions

View file

@ -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;

View file

@ -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<String>,
#[cultcache(key = 9)]
pub private_state_exposed: bool,
#[cultcache(key = 10)]
pub model_terminal_receipt_id: Option<String>,
#[cultcache(key = 11)]
pub interpreter_decision_context_id: Option<String>,
}
/// Advances one reserved Persona turn across the signed Epiphany→Bifrost
@ -144,7 +149,7 @@ pub fn poll_persona_discord_crossing(
let effects =
runtime_document::<PersonaInterpreterEffectDocument>(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<Option<PersonaConversationExecutionReceipt>> {
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::<PersonaModelTerminalReceipt>(runtime_store, &model_terminal_id)?
.ok_or_else(|| anyhow!("Persona model terminal disappeared during retention"))?;
let mut runtime_ids = vec![
(
<PersonaConversationExecutionReceipt as DatabaseEntry>::TYPE,
receipt_id,
),
(
<PersonaInterpreterEffectDocument as DatabaseEntry>::TYPE,
effects.document_id.clone(),
),
(
<PersonaModelTerminalReceipt as DatabaseEntry>::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((
<PersonaDiscordDeliveryEvidence as DatabaseEntry>::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()?,
&[(
<crate::PersonaDiscordDeliveryRequest as DatabaseEntry>::TYPE,
crossing_id.clone(),
)],
)?;
crossing_receipt_envelopes = retention_envelopes(
&SingleFileMessagePackBackingStore::new(receipt_store).pull_all()?,
&[(
<crate::PersonaDiscordDeliveryReceipt as DatabaseEntry>::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<PersonaModelTerminalReceipt> {
let terminal_id = format!("persona-terminal:{}", effects.turn_id);
let terminal = runtime_document::<PersonaModelTerminalReceipt>(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::<PersonaModelStageReceipt>(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::<EpiphanyDecisionContext>(runtime_store, context_id)?
.ok_or_else(|| anyhow!("Persona {expected} decision context is missing"))?;
let basis = runtime_document::<EpiphanyReasoningBasis>(
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::<PersonaConversationExecutionReceipt>(
&runtime,
"persona-conversation:turn-old"
)?
.is_none()
);
let retired_conversation = runtime_document::<PersonaConversationExecutionReceipt>(
&runtime,
"persona-conversation:turn-old",
)?
.expect("retention must preserve the structured conversation decision");
let retired_terminal = runtime_document::<PersonaModelTerminalReceipt>(
&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::<PersonaInterpreterEffectDocument>(
&runtime,
&retired_conversation.effect_document_id,
)?
.is_some());
assert!(runtime_document::<EpiphanyDecisionContext>(
&runtime,
retired_conversation
.interpreter_decision_context_id
.as_deref()
.expect("conversation must route to its interpreter context"),
)?
.is_some());
assert!(runtime_document::<PersonaModelStageReceipt>(
&runtime,
&retired_terminal.stage_receipt_ids[0],
)?
.is_none());
assert!(
runtime_document::<PersonaConversationExecutionReceipt>(
&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)?;

View file

@ -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<PersonaInterpreterEffect>,
#[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<String>,
#[cultcache(key = 11)]
pub decision_context_ids: Vec<String>,
}
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::<PersonaInterpreterEffectDocument>(&effect.document_id)?;
let existing_terminal = cache.get::<PersonaModelTerminalReceipt>(&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)]

View file

@ -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<String>,
) -> Result<Self> {
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)]

View file

@ -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<R: PersonaModelRunner>(
"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<R: PersonaModelRunner>(
));
}
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<R: PersonaModelRunner>(
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<R: PersonaModelRunner>(
"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<R: PersonaModelRunner>(
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<R: PersonaModelRunner>(
"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<R: PersonaModelRunner>(
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<R: PersonaModelRunner>(
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<R: PersonaModelRunner>(
stage: &str,
prompt: String,
output_schema_json: Option<String>,
projection: epiphany_core::EpiphanyReasoningProjection,
predecessor_decision_context_ids: Vec<String>,
) -> Result<CompletedPersonaStage> {
require_persona_execution_unbraked(plan)?;
let receipt_id = stage_receipt_id(&plan.turn_id, stage);
@ -231,6 +250,8 @@ async fn run_stage<R: PersonaModelRunner>(
|| 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<R: PersonaModelRunner>(
.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<R: PersonaModelRunner>(
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::<epiphany_core::EpiphanyDecisionContext>(
store_path,
&receipt.decision_context_id,
)?
.ok_or_else(|| anyhow!("Persona model terminal decision context is missing"))?;
let basis = load_document::<epiphany_core::EpiphanyReasoningBasis>(
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<Box<dyn Future<Output = Result<String>> + 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)?;