diff --git a/epiphany-core/src/agent_launch.rs b/epiphany-core/src/agent_launch.rs index b15bfcf4..6161c572 100644 --- a/epiphany-core/src/agent_launch.rs +++ b/epiphany-core/src/agent_launch.rs @@ -412,6 +412,61 @@ pub fn epiphany_role_launch_output_schema(role_id: EpiphanyRoleResultRoleId) -> schema } +pub fn epiphany_frontier_planning_output_schema() -> serde_json::Value { + let mut schema = epiphany_role_launch_output_schema(EpiphanyRoleResultRoleId::Imagination); + let properties = schema["properties"] + .as_object_mut() + .expect("role output schema properties"); + properties.remove("statePatch"); + properties.remove("selfPatch"); + properties.insert( + "frontierPlanningRequestId".to_string(), + serde_json::json!({ + "type": "string", + "minLength": 1, + "description": "Exact echo of the coordinator-bound repo frontier planning request." + }), + ); + properties.insert( + "frontierPlanCandidate".to_string(), + serde_json::json!({ + "type": "object", + "required": [ + "planning_request_id", "model_revision", "model_hash", + "frontier_item_id", "frontier_item_hash", "safe_paths", "action", "command", + "checks", "stop_conditions", "rollback_steps", "commit_message", "proposed_at" + ], + "properties": { + "planning_request_id": {"type": "string", "minLength": 1}, + "model_revision": {"type": "integer", "minimum": 0}, + "model_hash": {"type": "string", "minLength": 1}, + "frontier_item_id": {"type": "string", "minLength": 1}, + "frontier_item_hash": {"type": "string", "minLength": 1}, + "safe_paths": {"type": "array", "minItems": 1, "items": {"type": "string", "minLength": 1}}, + "action": {"type": "string", "minLength": 1}, + "command": {"type": "string", "minLength": 1}, + "checks": {"type": "array", "minItems": 1, "items": {"type": "string", "minLength": 1}}, + "stop_conditions": {"type": "array", "minItems": 1, "items": {"type": "string", "minLength": 1}}, + "rollback_steps": {"type": "array", "minItems": 1, "items": {"type": "string", "minLength": 1}}, + "commit_message": {"type": "string", "minLength": 1}, + "proposed_at": {"type": "string", "minLength": 1} + }, + "additionalProperties": false + }), + ); + schema["required"] = serde_json::json!([ + "roleId", + "verdict", + "summary", + "nextSafeMove", + "filesInspected", + "frontierPlanningRequestId", + "frontierPlanCandidate" + ]); + schema["additionalProperties"] = serde_json::Value::Bool(false); + schema +} + pub fn epiphany_reorient_launch_output_schema() -> serde_json::Value { serde_json::json!({ "type": "object", @@ -956,6 +1011,21 @@ mod tests { ); } + #[test] + fn frontier_planning_schema_exposes_candidate_without_generic_patch_mouths() { + let schema = epiphany_frontier_planning_output_schema(); + assert!( + schema["properties"] + .get("frontierPlanningRequestId") + .is_some() + ); + assert!(schema["properties"].get("frontierPlanCandidate").is_some()); + assert!(schema["properties"].get("statePatch").is_none()); + assert!(schema["properties"].get("selfPatch").is_none()); + assert!(schema["properties"].get("repoModelPatch").is_none()); + assert_eq!(schema["additionalProperties"], false); + } + #[test] fn reorient_worker_prompt_is_bounded_not_full_persistent_memory() { let prompt = build_epiphany_reorient_launch_instruction(CoreEpiphanyReorientAction::Resume); diff --git a/epiphany-core/src/coordinator_launch.rs b/epiphany-core/src/coordinator_launch.rs index be78e454..46d93164 100644 --- a/epiphany-core/src/coordinator_launch.rs +++ b/epiphany-core/src/coordinator_launch.rs @@ -838,6 +838,62 @@ pub(crate) mod tests { Ok((store, state, launch, planning)) } + fn frontier_planning_result( + planning: &RepoFrontierPlanningRequest, + job_id: &str, + proposed_at: &str, + ) -> Result { + let mut candidate = RepoFrontierPlanCandidate { + schema_version: REPO_FRONTIER_PLAN_CANDIDATE_SCHEMA_VERSION.into(), + candidate_id: String::new(), + planning_request_id: planning.request_id.clone(), + model_revision: planning.model_revision, + model_hash: planning.model_hash.clone(), + frontier_item_id: planning.frontier_item_id.clone(), + frontier_item_hash: planning.frontier_item_hash.clone(), + safe_paths: vec![planning.source_scope[0].clone()], + action: "Implement the bounded frontier plan.".into(), + command: "cargo test --lib".into(), + checks: vec!["Focused tests pass.".into()], + stop_conditions: vec!["Authority scope changes.".into()], + rollback_steps: vec!["Revert the bounded commit.".into()], + commit_message: "Implement bounded frontier plan".into(), + proposed_at: proposed_at.into(), + contract: REPO_FRONTIER_PLANNING_CONTRACT.into(), + }; + candidate.candidate_id = canonical_repo_frontier_plan_candidate_id(&candidate)?; + Ok(EpiphanyRuntimeRoleWorkerResult { + schema_version: RUNTIME_ROLE_WORKER_RESULT_SCHEMA_VERSION.into(), + result_id: format!("frontier-planning-result-{job_id}"), + job_id: job_id.into(), + role_id: "imagination".into(), + verdict: "draft-ready".into(), + summary: "Produced one bounded candidate.".into(), + next_safe_move: "Mind admission.".into(), + checkpoint_summary: None, + scratch_summary: None, + files_inspected: planning.source_scope.clone(), + frontier_node_ids: vec![planning.frontier_item_id.clone()], + evidence_ids: vec![planning.admission_receipt_id.clone()], + artifact_refs: Vec::new(), + open_questions: Vec::new(), + evidence_gaps: Vec::new(), + risks: Vec::new(), + state_patch_msgpack: None, + self_patch_msgpack: None, + item_error: None, + metadata: std::collections::BTreeMap::new(), + repo_model_patch_msgpack: None, + verification_request_id: None, + frontier_route_id: None, + repo_frontier_modeling_request_id: None, + proposal_modeling_request_id: None, + claim_repair_request_id: None, + frontier_planning_request_id: Some(planning.request_id.clone()), + frontier_plan_candidate_msgpack: Some(rmp_serde::to_vec_named(&candidate)?), + }) + } + fn research_launch(state: &EpiphanyThreadState) -> EpiphanyJobLaunchRequest { build_epiphany_role_launch_request( "thread-1", @@ -1321,6 +1377,107 @@ pub(crate) mod tests { Ok(()) } + #[test] + fn frontier_planning_result_is_exact_immutable_and_patch_sealed() -> Result<()> { + let root = tempfile::tempdir()?; + let (store, state, launch, planning) = + frontier_planning_launch_fixture(root.path(), "result-exact")?; + let plan = plan_coordinator_job_launch( + &state, + &launch, + &store, + "launcher-result".into(), + "backend-result".into(), + )?; + commit_coordinator_job_launch( + &store, + &planning.thread_id, + &state, + &launch, + &plan, + "2026-07-15T09:00:03Z".into(), + )?; + let result = frontier_planning_result(&planning, "backend-result", "2026-07-15T09:00:04Z")?; + put_runtime_role_worker_result(&store, &result)?; + put_runtime_role_worker_result(&store, &result)?; + let stored = runtime_role_worker_result(&store, "backend-result")? + .expect("immutable planning result"); + assert_eq!(stored, result); + let candidate = stored + .frontier_plan_candidate()? + .expect("typed candidate payload"); + assert_eq!(candidate.planning_request_id, planning.request_id); + assert_eq!( + candidate.candidate_id, + canonical_repo_frontier_plan_candidate_id(&candidate)? + ); + assert!( + coordinator_acceptance_cache(&store)? + .get_all::()? + .is_empty(), + "candidate must remain inside immutable result until Mind admission" + ); + let mut counterfeit = result; + counterfeit.summary = "different result bytes".into(); + let before = std::fs::read(&store)?; + assert!(put_runtime_role_worker_result(&store, &counterfeit).is_err()); + assert_eq!(std::fs::read(&store)?, before); + Ok(()) + } + + #[test] + fn frontier_planning_result_refuses_hostile_substitution_before_persistence() -> Result<()> { + let root = tempfile::tempdir()?; + for mutation in 0..9 { + let (store, state, launch, planning) = + frontier_planning_launch_fixture(root.path(), &format!("result-{mutation}"))?; + let job_id = format!("backend-result-{mutation}"); + let plan = plan_coordinator_job_launch( + &state, + &launch, + &store, + format!("launcher-result-{mutation}"), + job_id.clone(), + )?; + commit_coordinator_job_launch( + &store, + &planning.thread_id, + &state, + &launch, + &plan, + "2026-07-15T09:00:03Z".into(), + )?; + let mut result = frontier_planning_result(&planning, &job_id, "2026-07-15T09:00:04Z")?; + match mutation { + 0 => result.frontier_planning_request_id = Some("swapped-request".into()), + 1 => result.frontier_planning_request_id = None, + 2 => result.frontier_plan_candidate_msgpack = None, + 3 => result.role_id = "modeling".into(), + 4 => result.state_patch_msgpack = Some(vec![0]), + 5 => result.self_patch_msgpack = Some(vec![0]), + 6 => result.repo_model_patch_msgpack = Some(vec![0]), + _ => { + let mut candidate = result.frontier_plan_candidate()?.unwrap(); + if mutation == 7 { + candidate.frontier_item_hash = "adjacent-frontier".into(); + } else { + candidate.safe_paths = vec!["outside/authority".into()]; + } + result.frontier_plan_candidate_msgpack = + Some(rmp_serde::to_vec_named(&candidate)?); + } + } + let before = std::fs::read(&store)?; + assert!( + put_runtime_role_worker_result(&store, &result).is_err(), + "hostile planning result mutation {mutation} must fail" + ); + assert_eq!(std::fs::read(&store)?, before); + assert!(runtime_role_worker_result(&store, &job_id)?.is_none()); + } + Ok(()) + } + #[test] fn concurrent_frontier_planning_launches_leave_only_winner_artifacts() -> Result<()> { let root = tempfile::tempdir()?; diff --git a/epiphany-core/src/coordinator_launch_context.rs b/epiphany-core/src/coordinator_launch_context.rs index 6911fe94..2f812d4e 100644 --- a/epiphany-core/src/coordinator_launch_context.rs +++ b/epiphany-core/src/coordinator_launch_context.rs @@ -938,6 +938,8 @@ mod tests { repo_frontier_modeling_request_id: None, proposal_modeling_request_id: Some(selection.request_id), claim_repair_request_id: None, + frontier_planning_request_id: None, + frontier_plan_candidate_msgpack: None, }; crate::put_runtime_role_worker_result(store, &result)?; crate::commit_repo_model_admission( @@ -1419,6 +1421,8 @@ mod tests { repo_frontier_modeling_request_id: None, proposal_modeling_request_id: None, claim_repair_request_id: None, + frontier_planning_request_id: None, + frontier_plan_candidate_msgpack: None, }; crate::put_runtime_role_worker_result(&runtime_store, &verification_result)?; crate::put_soul_verdict_receipt( diff --git a/epiphany-core/src/lib.rs b/epiphany-core/src/lib.rs index 466c3b9b..336bc920 100644 --- a/epiphany-core/src/lib.rs +++ b/epiphany-core/src/lib.rs @@ -59,6 +59,7 @@ pub use agent_launch::build_epiphany_reorient_launch_request_with_dynamic_contex pub use agent_launch::build_epiphany_role_launch_request; pub use agent_launch::build_epiphany_role_launch_request_with_dynamic_context; pub use agent_launch::epiphany_agent_prompt_with_memory; +pub use agent_launch::epiphany_frontier_planning_output_schema; pub use agent_launch::epiphany_reorient_launch_output_schema; pub use agent_launch::epiphany_role_binding_id; pub use agent_launch::epiphany_role_label; @@ -776,6 +777,7 @@ pub use runtime_spine::RuntimeSpineJobOptions; pub use runtime_spine::RuntimeSpineJobResultOptions; pub use runtime_spine::RuntimeSpineSessionOptions; pub use runtime_spine::append_runtime_event; +pub use runtime_spine::canonical_repo_frontier_plan_candidate_id; pub use runtime_spine::clear_epiphany_job_binding_backend; pub use runtime_spine::commit_repo_frontier_modeling_request; pub use runtime_spine::commit_repo_frontier_verification_request_for_chain; diff --git a/epiphany-core/src/runtime_spine.rs b/epiphany-core/src/runtime_spine.rs index 96addcb8..ec5b61a8 100644 --- a/epiphany-core/src/runtime_spine.rs +++ b/epiphany-core/src/runtime_spine.rs @@ -48,7 +48,9 @@ use crate::repo_model_gateway::{ REPO_FRONTIER_HANDS_AUTHORITY_CONTRACT, REPO_FRONTIER_HANDS_AUTHORITY_SCHEMA_VERSION, REPO_FRONTIER_MODELING_REQUEST_CONTRACT, REPO_FRONTIER_MODELING_REQUEST_SCHEMA_VERSION, REPO_FRONTIER_PLAN_ADOPTION_SCHEMA_VERSION, REPO_FRONTIER_PLAN_CANDIDATE_SCHEMA_VERSION, - REPO_FRONTIER_PLANNING_CONTRACT, REPO_FRONTIER_PLANNING_REQUEST_SCHEMA_VERSION, + REPO_FRONTIER_PLANNING_CONTRACT, REPO_FRONTIER_PLANNING_LAUNCH_BINDING_CONTRACT, + REPO_FRONTIER_PLANNING_LAUNCH_BINDING_SCHEMA_VERSION, + REPO_FRONTIER_PLANNING_REQUEST_SCHEMA_VERSION, REPO_FRONTIER_PROPOSAL_MODELING_LAUNCH_BINDING_CONTRACT, REPO_FRONTIER_PROPOSAL_MODELING_LAUNCH_BINDING_SCHEMA_VERSION, REPO_FRONTIER_PROPOSAL_MODELING_REQUEST_CONTRACT, @@ -174,7 +176,7 @@ pub const RUNTIME_SPINE_SCHEMA_VERSION: &str = "epiphany.runtime_spine.v0"; pub const RUNTIME_WORKER_LAUNCH_REQUEST_SCHEMA_VERSION: &str = "epiphany.runtime.worker_launch_request.v1"; pub const RUNTIME_ROLE_WORKER_RESULT_SCHEMA_VERSION: &str = - "epiphany.runtime.role_worker_result.v1"; + "epiphany.runtime.role_worker_result.v2"; pub const RUNTIME_REORIENT_WORKER_RESULT_SCHEMA_VERSION: &str = "epiphany.runtime.reorient_worker_result.v0"; pub const COORDINATOR_RUN_RECEIPT_SCHEMA_VERSION: &str = "epiphany.coordinator_run_receipt.v0"; @@ -410,6 +412,10 @@ pub struct EpiphanyRuntimeRoleWorkerResult { pub proposal_modeling_request_id: Option, #[cultcache(key = 25, default)] pub claim_repair_request_id: Option, + #[cultcache(key = 26, default)] + pub frontier_planning_request_id: Option, + #[cultcache(key = 27, default)] + pub frontier_plan_candidate_msgpack: Option>, } impl EpiphanyRuntimeRoleWorkerResult { @@ -430,6 +436,13 @@ impl EpiphanyRuntimeRoleWorkerResult { "role worker repoModelPatch", ) } + + pub fn frontier_plan_candidate(&self) -> Result> { + decode_optional_msgpack( + self.frontier_plan_candidate_msgpack.as_deref(), + "role worker frontierPlanCandidate", + ) + } } #[derive(Clone, Debug, PartialEq, DatabaseEntry)] @@ -1457,6 +1470,11 @@ pub fn put_runtime_role_worker_result( validate_non_empty(&result.job_id, "role worker result job id")?; validate_non_empty(&result.result_id, "role worker result id")?; validate_non_empty(&result.role_id, "role worker result role id")?; + if result.schema_version != RUNTIME_ROLE_WORKER_RESULT_SCHEMA_VERSION { + return Err(anyhow!("role worker result schema version mismatch")); + } + let mut cache = runtime_spine_cache(store_path)?; + cache.pull_all_backing_stores()?; let is_verification = result.role_id == "verification"; if is_verification != (result @@ -1553,8 +1571,94 @@ pub fn put_runtime_role_worker_result( _ => {} } } - let mut cache = runtime_spine_cache(store_path)?; - cache.pull_all_backing_stores()?; + let has_planning_echo = result.frontier_planning_request_id.is_some(); + let has_planning_candidate = result.frontier_plan_candidate_msgpack.is_some(); + if has_planning_echo != has_planning_candidate { + return Err(anyhow!( + "frontier planning result requires both its request echo and typed candidate" + )); + } + if has_planning_echo { + if !result.role_id.eq_ignore_ascii_case("imagination") { + return Err(anyhow!( + "only Imagination results may echo a frontier planning request" + )); + } + if result.state_patch_msgpack.is_some() + || result.self_patch_msgpack.is_some() + || result.repo_model_patch_msgpack.is_some() + || result.verification_request_id.is_some() + || result.frontier_route_id.is_some() + || result.repo_frontier_modeling_request_id.is_some() + || result.proposal_modeling_request_id.is_some() + || result.claim_repair_request_id.is_some() + { + return Err(anyhow!( + "frontier planning result may carry only its request echo and typed candidate authority" + )); + } + let request_id = result + .frontier_planning_request_id + .as_deref() + .ok_or_else(|| anyhow!("frontier planning request echo disappeared"))?; + let request = cache + .get::(request_id)? + .ok_or_else(|| anyhow!("frontier planning result requires persisted request"))?; + validate_current_repo_frontier_planning_request(&cache, &request)?; + let candidate = result + .frontier_plan_candidate()? + .ok_or_else(|| anyhow!("frontier planning candidate disappeared"))?; + validate_repo_frontier_plan_candidate_against_request(&cache, &candidate, &request)?; + let bindings = cache + .get_all::()? + .into_iter() + .filter(|binding| binding.planning_request_id == request.request_id) + .collect::>(); + if bindings.len() != 1 { + return Err(anyhow!( + "frontier planning result requires exactly one coordinator launch binding" + )); + } + let binding = &bindings[0]; + let worker_launch = cache + .get::(&result.job_id)? + .ok_or_else(|| anyhow!("frontier planning result requires its worker launch"))?; + let launch_document = worker_launch.launch_document()?; + let projection = match &launch_document { + EpiphanyWorkerLaunchDocument::Role(document) => { + document.frontier_planning_context.as_ref() + } + EpiphanyWorkerLaunchDocument::Reorient(_) => None, + }; + let expected_projection = + crate::RepoFrontierPlanningContextProjection::from_request(&request); + let launch_hash = format!( + "{:x}", + Sha256::digest(&worker_launch.launch_document_msgpack) + ); + if binding.schema_version != REPO_FRONTIER_PLANNING_LAUNCH_BINDING_SCHEMA_VERSION + || binding.contract != REPO_FRONTIER_PLANNING_LAUNCH_BINDING_CONTRACT + || binding.binding_record_id + != format!("repo-frontier-planning-launch-{}", request.request_id) + || binding.job_id != result.job_id + || binding.binding_id != EPIPHANY_IMAGINATION_ROLE_BINDING_ID + || binding.runtime_id != request.runtime_id + || binding.thread_id != request.thread_id + || binding.worker_launch_document_sha256 != launch_hash + || worker_launch.job_id != result.job_id + || worker_launch.role != EPIPHANY_IMAGINATION_OWNER_ROLE + || worker_launch.binding_id != EPIPHANY_IMAGINATION_ROLE_BINDING_ID + || worker_launch.frontier_planning_request_id.as_deref() + != Some(request.request_id.as_str()) + || worker_launch.proposal_modeling_request_id.is_some() + || worker_launch.claim_repair_request_id.is_some() + || projection != Some(&expected_projection) + { + return Err(anyhow!( + "frontier planning result does not exactly bind request, launch, runtime, thread, and candidate" + )); + } + } if let Some(existing) = cache.get::(&result.job_id)? { return if existing == *result { Ok(()) @@ -3540,6 +3644,94 @@ pub(crate) fn put_repo_frontier_plan_candidate( put_immutable_planning_entry(store_path, &candidate.candidate_id, candidate) } +fn validate_repo_frontier_plan_candidate_against_request( + cache: &CultCache, + candidate: &RepoFrontierPlanCandidate, + request: &RepoFrontierPlanningRequest, +) -> Result<()> { + if candidate.schema_version != REPO_FRONTIER_PLAN_CANDIDATE_SCHEMA_VERSION + || candidate.contract != REPO_FRONTIER_PLANNING_CONTRACT + || candidate.selected_fields_invalid() + || candidate.planning_request_id != request.request_id + || candidate.model_revision != request.model_revision + || candidate.model_hash != request.model_hash + || candidate.frontier_item_id != request.frontier_item_id + || candidate.frontier_item_hash != request.frontier_item_hash + { + return Err(anyhow!( + "frontier planning candidate substituted request identity or required cargo" + )); + } + let expected_candidate_id = canonical_repo_frontier_plan_candidate_id(candidate)?; + if candidate.candidate_id != expected_candidate_id { + return Err(anyhow!("frontier planning candidate id is not canonical")); + } + let admission = cache + .get::(&request.admission_receipt_id)? + .ok_or_else(|| anyhow!("frontier planning candidate requires exact admission receipt"))?; + if admission.schema_version != REPO_MODEL_ADMISSION_RECEIPT_SCHEMA_VERSION + || admission.contract != REPO_MODEL_ADMISSION_CONTRACT + || admission.admitted_revision != request.model_revision + || admission.admitted_hash != request.model_hash + { + return Err(anyhow!( + "frontier planning candidate admission binding mismatch" + )); + } + let entry = cache + .get::(crate::MEMORY_GRAPH_KEY)? + .ok_or_else(|| anyhow!("frontier planning candidate requires canonical model"))?; + let model = entry.snapshot()?; + if model.model_revision != request.model_revision + || crate::memory_graph_model_hash(&model)? != request.model_hash + { + return Err(anyhow!("frontier planning candidate model is stale")); + } + let item = model + .frontier + .iter() + .find(|item| item.id == request.frontier_item_id) + .ok_or_else(|| anyhow!("frontier planning candidate frontier disappeared"))?; + if format!("{:x}", Sha256::digest(rmp_serde::to_vec_named(item)?)) != request.frontier_item_hash + || item.source_scope != request.source_scope + || !candidate.safe_paths.iter().all(|path| { + request.source_scope.iter().any(|scope| { + path == scope + || path.starts_with(&format!("{}/", scope.trim_end_matches(['/', '\\']))) + }) + }) + { + return Err(anyhow!( + "frontier planning candidate exceeds exact frontier authority" + )); + } + Ok(()) +} + +pub fn canonical_repo_frontier_plan_candidate_id( + candidate: &RepoFrontierPlanCandidate, +) -> Result { + let semantic_bytes = rmp_serde::to_vec_named(&( + &candidate.planning_request_id, + candidate.model_revision, + &candidate.model_hash, + &candidate.frontier_item_id, + &candidate.frontier_item_hash, + &candidate.safe_paths, + &candidate.action, + &candidate.command, + &candidate.checks, + &candidate.stop_conditions, + &candidate.rollback_steps, + &candidate.commit_message, + &candidate.proposed_at, + ))?; + Ok(format!( + "repo-frontier-plan-candidate-{:x}", + Sha256::digest(semantic_bytes) + )) +} + impl RepoFrontierPlanCandidate { fn selected_fields_invalid(&self) -> bool { self.candidate_id.trim().is_empty() @@ -6980,6 +7172,8 @@ pub(crate) mod tests { repo_frontier_modeling_request_id: None, proposal_modeling_request_id: Some(selection.request_id), claim_repair_request_id: None, + frontier_planning_request_id: None, + frontier_plan_candidate_msgpack: None, }; let review = RepoModelAdmissionReview { schema_version: REPO_MODEL_ADMISSION_REVIEW_SCHEMA_VERSION.to_string(), @@ -7109,6 +7303,8 @@ pub(crate) mod tests { repo_frontier_modeling_request_id: None, proposal_modeling_request_id: None, claim_repair_request_id: Some(repair.request_id.clone()), + frontier_planning_request_id: None, + frontier_plan_candidate_msgpack: None, }; let review = RepoModelAdmissionReview { schema_version: REPO_MODEL_ADMISSION_REVIEW_SCHEMA_VERSION.into(), @@ -8382,6 +8578,8 @@ pub(crate) mod tests { repo_frontier_modeling_request_id: None, proposal_modeling_request_id: None, claim_repair_request_id: None, + frontier_planning_request_id: None, + frontier_plan_candidate_msgpack: None, }; put_runtime_role_worker_result(&store, &verification_result)?; let verdict = SoulVerdictReceipt { @@ -8501,6 +8699,8 @@ pub(crate) mod tests { repo_frontier_modeling_request_id: Some(fixture.modeling_request.request_id.clone()), proposal_modeling_request_id: None, claim_repair_request_id: None, + frontier_planning_request_id: None, + frontier_plan_candidate_msgpack: None, }; let review = RepoModelAdmissionReview { schema_version: REPO_MODEL_ADMISSION_REVIEW_SCHEMA_VERSION.to_string(), @@ -8777,6 +8977,8 @@ pub(crate) mod tests { repo_frontier_modeling_request_id: None, proposal_modeling_request_id: None, claim_repair_request_id: None, + frontier_planning_request_id: None, + frontier_plan_candidate_msgpack: None, }; put_runtime_role_worker_result(&fixture.store, &adjacent)?; put_soul_verdict_receipt( diff --git a/epiphany-core/src/surfaces/worker_launch.rs b/epiphany-core/src/surfaces/worker_launch.rs index c00b2e73..5bddb549 100644 --- a/epiphany-core/src/surfaces/worker_launch.rs +++ b/epiphany-core/src/surfaces/worker_launch.rs @@ -24,7 +24,7 @@ use crate::EpiphanyReorientFreshnessStatus; use crate::EpiphanyReorientPressureLevel; use crate::EpiphanyReorientReason; -pub const ROLE_WORKER_OUTPUT_CONTRACT_ID: &str = "epiphany.worker.role_result.v1"; +pub const ROLE_WORKER_OUTPUT_CONTRACT_ID: &str = "epiphany.worker.role_result.v2"; pub const REORIENT_WORKER_OUTPUT_CONTRACT_ID: &str = "epiphany.worker.reorient_result.v0"; pub const REPO_FRONTIER_PROPOSAL_MODELING_CONTEXT_SCHEMA_VERSION: &str = "epiphany.worker.repo_frontier_proposal_modeling_context.v0"; diff --git a/epiphany-openai-runtime/src/bin/epiphany-openai-runtime.rs b/epiphany-openai-runtime/src/bin/epiphany-openai-runtime.rs index 629ec269..deaa0401 100644 --- a/epiphany-openai-runtime/src/bin/epiphany-openai-runtime.rs +++ b/epiphany-openai-runtime/src/bin/epiphany-openai-runtime.rs @@ -971,6 +971,9 @@ mod tests { state_revision: 1, objective: Some("Verify the worker loop.".to_string()), dynamic_prompt_context: None, + proposal_modeling_context: None, + claim_repair_context: None, + frontier_planning_context: None, active_subgoal_id: None, active_subgoals: Vec::new(), active_graph_node_ids: Vec::new(), @@ -992,6 +995,9 @@ mod tests { "role", epiphany_core::ROLE_WORKER_OUTPUT_CONTRACT_ID, ), + proposal_modeling_request_id: None, + claim_repair_request_id: None, + frontier_planning_request_id: None, created_at: now(), }, )?; @@ -1051,6 +1057,9 @@ mod tests { state_revision: 1, objective: Some("Verify runtime error sealing.".to_string()), dynamic_prompt_context: None, + proposal_modeling_context: None, + claim_repair_context: None, + frontier_planning_context: None, active_subgoal_id: None, active_subgoals: Vec::new(), active_graph_node_ids: Vec::new(), @@ -1072,6 +1081,9 @@ mod tests { "role", epiphany_core::ROLE_WORKER_OUTPUT_CONTRACT_ID, ), + proposal_modeling_request_id: None, + claim_repair_request_id: None, + frontier_planning_request_id: None, created_at: now(), }, )?; @@ -1119,6 +1131,9 @@ mod tests { state_revision: 1, objective: Some("Verify the worker loop ceiling.".to_string()), dynamic_prompt_context: None, + proposal_modeling_context: None, + claim_repair_context: None, + frontier_planning_context: None, active_subgoal_id: None, active_subgoals: Vec::new(), active_graph_node_ids: Vec::new(), @@ -1140,6 +1155,9 @@ mod tests { "role", epiphany_core::ROLE_WORKER_OUTPUT_CONTRACT_ID, ), + proposal_modeling_request_id: None, + claim_repair_request_id: None, + frontier_planning_request_id: None, created_at: now(), }, )?; diff --git a/epiphany-openai-runtime/src/lib.rs b/epiphany-openai-runtime/src/lib.rs index d38d89ab..564d41a8 100644 --- a/epiphany-openai-runtime/src/lib.rs +++ b/epiphany-openai-runtime/src/lib.rs @@ -1298,7 +1298,11 @@ fn worker_output_schema_json(document: &EpiphanyWorkerLaunchDocument) -> Result< EpiphanyWorkerLaunchDocument::Role(document) => { let role_id = role_result_id_for_launch_role(&document.role_id) .with_context(|| format!("unknown role launch id {:?}", document.role_id))?; - epiphany_core::epiphany_role_launch_output_schema(role_id) + if document.frontier_planning_context.is_some() { + epiphany_core::epiphany_frontier_planning_output_schema() + } else { + epiphany_core::epiphany_role_launch_output_schema(role_id) + } } EpiphanyWorkerLaunchDocument::Reorient(_) => { epiphany_core::epiphany_reorient_launch_output_schema() @@ -1323,8 +1327,13 @@ fn role_result_id_for_launch_role( fn worker_output_contract_text(document: &EpiphanyWorkerLaunchDocument) -> &'static str { match document { + EpiphanyWorkerLaunchDocument::Role(document) + if document.frontier_planning_context.is_some() => + { + "Required frontier-planning result fields: roleId, verdict, summary, nextSafeMove, filesInspected, frontierPlanningRequestId, frontierPlanCandidate. Echo the exact request and candidate identity from the typed launch context. Do not emit statePatch, selfPatch, or repoModelPatch." + } EpiphanyWorkerLaunchDocument::Role(_) => { - "Required role-result fields: roleId, verdict, summary, nextSafeMove, filesInspected. Modeling workers must include repoModelPatch; Imagination workers must include statePatch. Modeling statePatch is optional observations/evidence only. Use arrays for frontierNodeIds, evidenceIds, openQuestions, evidenceGaps, risks, and artifactRefs when present." + "Required role-result fields: roleId, verdict, summary, nextSafeMove, filesInspected. Modeling workers must include repoModelPatch; ordinary Imagination workers must include statePatch. Modeling statePatch is optional observations/evidence only. Use arrays for frontierNodeIds, evidenceIds, openQuestions, evidenceGaps, risks, and artifactRefs when present." } EpiphanyWorkerLaunchDocument::Reorient(_) => { "Required reorient-result fields: mode, summary, nextSafeMove. Include checkpointStillValid, filesInspected, frontierNodeIds, evidenceIds, openQuestions, and continuityRisks when present." @@ -1355,6 +1364,27 @@ struct RoleWorkerResultIngress { frontier_route_id: Option, repo_frontier_modeling_request_id: Option, proposal_modeling_request_id: Option, + claim_repair_request_id: Option, + frontier_planning_request_id: Option, + frontier_plan_candidate: Option, +} + +#[derive(Debug, Clone, Default, serde::Deserialize)] +#[serde(default)] +struct RepoFrontierPlanCandidateIngress { + planning_request_id: String, + model_revision: u64, + model_hash: String, + frontier_item_id: String, + frontier_item_hash: String, + safe_paths: Vec, + action: String, + command: String, + checks: Vec, + stop_conditions: Vec, + rollback_steps: Vec, + commit_message: String, + proposed_at: String, } #[derive(Debug, Clone, Default, serde::Deserialize)] @@ -1450,6 +1480,37 @@ fn role_worker_result_from_ingress( encode_optional_document(&result.repo_model_patch, "repoModelPatch"); let (self_patch_msgpack, self_patch_error) = encode_optional_document(&result.self_patch, "selfPatch"); + let (frontier_plan_candidate_msgpack, frontier_plan_candidate_error) = if let Some(ingress) = + result.frontier_plan_candidate.as_ref() + { + let mut candidate = epiphany_core::RepoFrontierPlanCandidate { + schema_version: epiphany_core::REPO_FRONTIER_PLAN_CANDIDATE_SCHEMA_VERSION.to_string(), + candidate_id: String::new(), + planning_request_id: ingress.planning_request_id.clone(), + model_revision: ingress.model_revision, + model_hash: ingress.model_hash.clone(), + frontier_item_id: ingress.frontier_item_id.clone(), + frontier_item_hash: ingress.frontier_item_hash.clone(), + safe_paths: clean_string_vec(&ingress.safe_paths), + action: ingress.action.trim().to_string(), + command: ingress.command.trim().to_string(), + checks: clean_string_vec(&ingress.checks), + stop_conditions: clean_string_vec(&ingress.stop_conditions), + rollback_steps: clean_string_vec(&ingress.rollback_steps), + commit_message: ingress.commit_message.trim().to_string(), + proposed_at: ingress.proposed_at.trim().to_string(), + contract: epiphany_core::REPO_FRONTIER_PLANNING_CONTRACT.to_string(), + }; + match epiphany_core::canonical_repo_frontier_plan_candidate_id(&candidate) { + Ok(candidate_id) => { + candidate.candidate_id = candidate_id; + encode_optional_document(&Some(candidate), "frontierPlanCandidate") + } + Err(error) => (None, Some(format!("frontierPlanCandidate: {error}"))), + } + } else { + (None, None) + }; EpiphanyRuntimeRoleWorkerResult { schema_version: epiphany_core::RUNTIME_ROLE_WORKER_RESULT_SCHEMA_VERSION.to_string(), result_id: result_id.to_string(), @@ -1475,8 +1536,11 @@ fn role_worker_result_from_ingress( state_patch_msgpack, self_patch_msgpack, item_error: merge_optional_errors( - merge_optional_errors(state_patch_error, self_patch_error), - repo_model_patch_error, + merge_optional_errors( + merge_optional_errors(state_patch_error, self_patch_error), + repo_model_patch_error, + ), + frontier_plan_candidate_error, ), metadata: std::collections::BTreeMap::new(), repo_model_patch_msgpack, @@ -1488,6 +1552,11 @@ fn role_worker_result_from_ingress( proposal_modeling_request_id: clean_optional_string( result.proposal_modeling_request_id.as_deref(), ), + claim_repair_request_id: clean_optional_string(result.claim_repair_request_id.as_deref()), + frontier_planning_request_id: clean_optional_string( + result.frontier_planning_request_id.as_deref(), + ), + frontier_plan_candidate_msgpack, } } @@ -1626,6 +1695,8 @@ mod tests { epiphany_core::ROLE_WORKER_OUTPUT_CONTRACT_ID, ), proposal_modeling_request_id: None, + claim_repair_request_id: None, + frontier_planning_request_id: None, }; let result = role_worker_result_from_ingress( &launch, @@ -1645,6 +1716,80 @@ mod tests { Ok(()) } + #[test] + fn frontier_planning_ingress_derives_typed_candidate_identity() -> Result<()> { + let parsed = parse_assistant_json::( + r#"{ + "roleId":"imagination", + "verdict":"draft-ready", + "summary":"bounded plan", + "nextSafeMove":"Mind admission", + "frontierPlanningRequestId":"planning-request-1", + "frontierPlanCandidate":{ + "planning_request_id":"planning-request-1", + "model_revision":7, + "model_hash":"model-hash", + "frontier_item_id":"frontier-1", + "frontier_item_hash":"frontier-hash", + "safe_paths":["src"], + "action":"Implement the bounded cut.", + "command":"cargo test --lib", + "checks":["focused test passes"], + "stop_conditions":["scope changes"], + "rollback_steps":["revert commit"], + "commit_message":"Implement bounded cut", + "proposed_at":"2026-07-15T10:00:00Z" + } + }"#, + )?; + let launch = EpiphanyRuntimeWorkerLaunchRequest { + schema_version: epiphany_core::RUNTIME_WORKER_LAUNCH_REQUEST_SCHEMA_VERSION.to_string(), + job_id: "planning-job-1".into(), + binding_id: epiphany_core::EPIPHANY_IMAGINATION_ROLE_BINDING_ID.into(), + role: epiphany_core::EPIPHANY_IMAGINATION_OWNER_ROLE.into(), + authority_scope: "epiphany.role.imagination".into(), + instruction: "plan".into(), + output_contract_id: epiphany_core::ROLE_WORKER_OUTPUT_CONTRACT_ID.into(), + document_kind: "role".into(), + launch_document_msgpack: Vec::new(), + metadata: std::collections::BTreeMap::new(), + organ_launch_contract: epiphany_core::default_launch_organ_contract( + "epiphany.role.imagination", + "role", + epiphany_core::ROLE_WORKER_OUTPUT_CONTRACT_ID, + ), + proposal_modeling_request_id: None, + claim_repair_request_id: None, + frontier_planning_request_id: Some("planning-request-1".into()), + }; + let result = role_worker_result_from_ingress( + &launch, + "imagination", + "planning-result-1", + &parsed, + Vec::new(), + ); + assert_eq!( + result.frontier_planning_request_id.as_deref(), + Some("planning-request-1") + ); + assert!(result.state_patch_msgpack.is_none()); + assert!(result.self_patch_msgpack.is_none()); + let candidate = result + .frontier_plan_candidate()? + .expect("typed frontier candidate"); + assert_eq!(candidate.planning_request_id, "planning-request-1"); + assert_eq!( + candidate.candidate_id, + epiphany_core::canonical_repo_frontier_plan_candidate_id(&candidate)? + ); + assert_eq!( + candidate.schema_version, + epiphany_core::REPO_FRONTIER_PLAN_CANDIDATE_SCHEMA_VERSION + ); + Ok(()) + } + fn test_openai_event( request_id: &str, sequence: u64, @@ -1818,6 +1963,8 @@ mod tests { .to_string(), ), proposal_modeling_context: None, + claim_repair_context: None, + frontier_planning_context: None, active_subgoal_id: None, active_subgoals: Vec::new(), active_graph_node_ids: Vec::new(), @@ -1840,6 +1987,8 @@ mod tests { epiphany_core::ROLE_WORKER_OUTPUT_CONTRACT_ID, ), proposal_modeling_request_id: None, + claim_repair_request_id: None, + frontier_planning_request_id: None, created_at: now(), }, )?; @@ -1945,6 +2094,8 @@ mod tests { .to_string(), ), proposal_modeling_context: None, + claim_repair_context: None, + frontier_planning_context: None, active_subgoal_id: None, active_subgoals: Vec::new(), active_graph_node_ids: Vec::new(), @@ -1967,6 +2118,8 @@ mod tests { epiphany_core::ROLE_WORKER_OUTPUT_CONTRACT_ID, ), proposal_modeling_request_id: None, + claim_repair_request_id: None, + frontier_planning_request_id: None, created_at: now(), }, )?; diff --git a/notes/fresh-workspace-handoff.md b/notes/fresh-workspace-handoff.md index 204457eb..59877ffd 100644 --- a/notes/fresh-workspace-handoff.md +++ b/notes/fresh-workspace-handoff.md @@ -965,3 +965,20 @@ and the loser leaves no job, worker launch, or opened event. Full core proof: 322 passed, 1 ignored. Next build the exclusive immutable Imagination result echo carrying the typed candidate, then Mind-only Adopt/Refuse/Hold admission and the narrow Adopt model transition that makes Hands routing true. + +The immutable Imagination result slice is now complete. Runtime role result v2 +adds an exclusive `frontier_planning_request_id` plus dedicated canonical +MessagePack `RepoFrontierPlanCandidate`. Persistence replays the exact planning +request, request-keyed launch binding, stored launch-document hash/projection, +runtime/thread, current model/admission/frontier, deterministic candidate +identity, and bounded safe paths. Planning results reject generic state, Self, +RepoModel, Verification, and Modeling authority cargo before writing. The +OpenAI ingress uses a specialized planning schema, supplies candidate +schema/contract itself, derives candidate identity from semantic cargo, and +does not expose generic patch mouths. The candidate remains embedded in the +immutable result; no standalone candidate document is written. Proof: core +325 passed/1 ignored; OpenAI runtime 9 passed; hostile missing/swapped request, +missing/adjacent/escaped candidate, wrong role, generic patch smuggling, exact +retry, and immutable collision checks pass. Next implement Mind-only terminal +decision plus the dedicated Adopt RepoModel transition from Imagination to +Hands, then delete the old internal candidate/adoption fixture writers. diff --git a/notes/repo-frontier-planning-authority-map.md b/notes/repo-frontier-planning-authority-map.md index ce131b00..117c7b4d 100644 --- a/notes/repo-frontier-planning-authority-map.md +++ b/notes/repo-frontier-planning-authority-map.md @@ -11,10 +11,18 @@ worker launch document. Caller-authored projections, mixed authority carriers, stale requests, causal substitutions, and duplicate/racing launches are refused without loser artifacts. -The downstream nerve is still incomplete. Imagination has no exclusive -immutable result carrying the candidate; Mind has no correlated admission; the -old internal candidate/adoption fixture writers still exist; and Adopt still -changes no RepoModel bytes or Hands truth. +Imagination's output is now a v2 immutable runtime result with an exclusive +planning-request echo and one dedicated canonical MessagePack candidate. Result +persistence replays the current request, launch binding, stored launch bytes, +projection, runtime/thread, model, admission, frontier, candidate identity, and +safe-path scope before its absent-only write. Generic state, Self, RepoModel, +Verification, and Modeling authority cargo are forbidden on this path. The +candidate remains inside the immutable result until Mind admission; no +independent candidate writer participates in production. + +The downstream nerve is still incomplete. Mind has no correlated admission; +the old internal candidate/adoption fixture writers still exist for the legacy +test chain; and Adopt still changes no RepoModel bytes or Hands truth. The public candidate/adoption writers remain removed. The existing internal functions are test scaffolding only and die when result correlation and Mind diff --git a/state/ledgers.msgpack b/state/ledgers.msgpack index 49f31ea5..e8ede157 100644 Binary files a/state/ledgers.msgpack and b/state/ledgers.msgpack differ diff --git a/state/map.yaml b/state/map.yaml index 0c3ab28d..9f949902 100644 --- a/state/map.yaml +++ b/state/map.yaml @@ -714,4 +714,4 @@ current_status: - Service lifecycle receipt history is now event-only: `load_epiphany_cultmesh_daemon_service_lifecycle_receipts` filters out the `epiphany-local/daemon-service-lifecycle-receipt/latest` mirror, while `load_latest_epiphany_cultmesh_daemon_service_lifecycle_receipt` remains the latest-state reader. Focused unit test `service_lifecycle_receipt_history_excludes_latest_mirror` proves two per-receipt writes return exactly two history records and latest points at the second. Fresh smoke `verse-query-lifecycle-history-filter-smoke` and live overview/triage `local-20260618-145632-497-efac6f0d` / `local-20260618-145632-321-867947af` prove compact swarm sights still expose service lifecycle attention plus the present elevated runbook artifact without daemon pokes or service mutation. Do not use a broad unscoped cargo test filter for this check on Windows; it fans out across bin harnesses and hit a broken pipe during this pass. - `epiphany-verse-query seed-compact` is the compact seed receipt for wrappers and read-only rites. The older `seed` command still prints the full local Verse context for explicit debugging, but compact wrapper modes now avoid full `seed`/final `query` readback after the combined overview proof exposed Windows debug stack overflow from full-context serialization pressure. last_completed_repo_swarm_cut: Huginn live-fire has crossed the first real repo Body from Persona pressure to branch-local Hands commit and Soul/Mind closure, and now has repeatable closure and redacted-public-proof mouths. On `E:\Projects\Huginn` branch `epiphany/huginn/first-awakening`, `tools/epiphany_local_run.ps1 -Mode repo-persona-intake` accepted item `huginn-inspection-readback-livefire`, Weksa/Persona intake remained sealed, and `epiphany-work derive-plan` lowered it to model-authored safe family `repo.status_section` for `README.md` only. `tools/epiphany_local_run.ps1 -Mode repo-swarm-run` advanced four bounded pulses: run-from-plan, adopt-from-plan, execute-from-plan, and close-from-execute. Huginn commit `e9b07c38fceabbdc89ea35c2156326c80c0393a2` updated only `README.md`; closure receipt `.epiphany\work\work-close-huginn-inspection-readback-livefire.json` records status `closed`, Soul verdict `passed`, Mind state commit `repo-work-close-huginn-inspection-readback-livefire-mind-commit`, safe family `repo.status_section`, path scope `README.md`, Bifrost publication gate, and `privateStateExposed=false`. Verification also ran Huginn's TypeScript compiler through concrete Node path: `C:\Program Files\nodejs\node.exe E:\Projects\Huginn\node_modules\typescript\bin\tsc -p tsconfig.json` passed. Native verifier `epiphany-repo-livefire-closure-smoke --workspace E:\Projects\Huginn --item huginn-inspection-readback-livefire --expected-path README.md --expected-family repo.status_section --expected-gate awaiting-publication --expected-blocker bifrost-publication-missing` checks closure receipt, Mind map entry, overview gate/blocker, authority seals, and `git show` changed paths, returning `status=ok`, `soulVerdict=passed`, `publicationGate=Bifrost`, and `privateStateExposed=false`. Wrapper `tools/epiphany_local_run.ps1 -Mode repo-livefire-closure -Workspace E:\Projects\Huginn -RepoWorkItem huginn-inspection-readback-livefire -SkipBuild` exposes the same read-only proof through the local operator mouth; artifact `local-20260621-190156-803-2f85ba15` printed commit, path, gate, blocker, Soul verdict, Mind commit receipt, and `privateStateExposed=False` without push, merge, publication, or target mutation. New wrapper `tools/epiphany_local_run.ps1 -Mode repo-work-public-proof -Workspace E:\Projects\Huginn -RepoWorkItem huginn-inspection-readback-livefire -SkipBuild` exports the redacted proof bundle and repo-local Verse index without publishing; artifact `local-20260621-190645-345-4555b1dc` wrote `.epiphany\public\proof-bundles\repo-work-public-proof-huginn-inspection-readback-livefire.json`, proof id `repo-work-public-proof-huginn-inspection-readback-livefire`, SHA-256 `857545b199f3e37eb06c2e757e5cfc201958e46667bb1f8403fd333a22620f63`, nine artifact rows, zero publication rows, and `privateStateExposed=False`. swarm overview readback `local-20260621-190701-140-b9519ec6` sees the `REPO-WORK-PUBLIC-PROOF` row and action row from Huginn's repo-local Verse with the same SHA-256 while Bifrost publication remains missing. Launcher compact modes are now named compact operator-context modes instead of read-only modes, and `bifrost-public-proof` summaries print item, target public Verse, room, proof SHA-256, review receipts, and credit receipts so publication receipts are auditable without claiming upstream sync. - next_action: Continue the repo-frontier planning nerve from notes/repo-frontier-planning-authority-map.md. The Self-to-Imagination launch artery is now real: planning request v1 binds runtime/thread, coordinator commit replays current model/admission/frontier/challenges/state, injects the typed projection, and atomically persists one request-keyed launch binding with the exact stored launch-document hash; hostile substitutions and a two-launch race leave no loser artifacts. Next add one exclusive planning-request echo on immutable worker result v1 containing the typed candidate, then replace the internal candidate/adoption fixture writers with Mind-only correlated Adopt/Refuse/Hold admission. Adopt must cause a narrow RepoModel transition that makes downstream Hands truth real; Refuse/Hold remain inert immutable receipts. After the nerve lands, add the production semantic index over typed graph claims so Modeling maintains a live searchable body map instead of forcing Hands to rediscover anatomy. Do not revive Proprioception, epiphany-work, metadata adapters, generic work-order routers, or inference from namespaces/prompt prose/evidence coincidence. Keep closure's zero-substring invariant. + next_action: Continue the repo-frontier planning nerve from notes/repo-frontier-planning-authority-map.md. Self-to-Imagination launch and result arteries are real: request v1 binds model/frontier/runtime/thread; coordinator atomically injects and binds the exact launch; immutable worker result v2 exclusively echoes that request and embeds one canonical typed candidate after full request/launch/model/frontier replay, with generic patch mouths sealed. Next replace the internal candidate/adoption fixture writers with one Mind-only correlated Adopt/Refuse/Hold admission. Adopt must atomically install the exact candidate cargo and provenance into the canonical frontier, change only Imagination to Hands through a dedicated patch purpose/operation, and admit the new RepoModel; routes must copy execution cargo from that model, never consult a candidate/adoption shadow store. Refuse/Hold remain inert immutable receipts. After the nerve lands, add the production semantic index over typed graph claims so Modeling maintains a live searchable body map instead of forcing Hands to rediscover anatomy. Do not revive Proprioception, epiphany-work, metadata adapters, generic work-order routers, or inference from namespaces/prompt prose/evidence coincidence. Keep closure's zero-substring invariant.