From fcbab316e6f1f52cd3786bd922e82a90079da4f5 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 13 Jul 2026 18:51:38 +0100 Subject: [PATCH] Bind Modeling memory to live source truth --- epiphany-core/Cargo.toml | 2 +- .../src/bin/epiphany-memory-graph.rs | 53 ++- .../src/coordinator_launch_context.rs | 50 ++- epiphany-core/src/lib.rs | 2 + epiphany-core/src/memory_graph/context_cut.rs | 8 +- epiphany-core/src/memory_graph/mod.rs | 3 + .../src/memory_graph/profiles/agent.rs | 2 +- .../src/memory_graph/profiles/heartbeat.rs | 2 +- .../src/memory_graph/profiles/mod.rs | 2 + .../src/memory_graph/profiles/repo.rs | 393 ++++++++++++++++-- epiphany-core/src/memory_graph/store.rs | 35 +- epiphany-core/src/memory_graph/tests.rs | 37 ++ epiphany-state-model/src/lib.rs | 10 + ...onceptual-substitution-audit-2026-07-12.md | 23 + notes/fresh-workspace-handoff.md | 13 + .../cultnet/epiphany.memory-graph.schema.json | 20 +- schemas/cultnet/index.json | 2 +- state/ledgers.msgpack | Bin 486426 -> 486834 bytes state/map.yaml | 2 +- 19 files changed, 583 insertions(+), 76 deletions(-) diff --git a/epiphany-core/Cargo.toml b/epiphany-core/Cargo.toml index 2a102a0d..6b34b2d8 100644 --- a/epiphany-core/Cargo.toml +++ b/epiphany-core/Cargo.toml @@ -39,7 +39,7 @@ tokio = { version = "1", features = ["rt", "rt-multi-thread"] } wiremock = "0.6" [target.'cfg(windows)'.dependencies] -windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_System_Threading"] } +windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_Threading"] } [target.'cfg(unix)'.dependencies] libc = "0.2" diff --git a/epiphany-core/src/bin/epiphany-memory-graph.rs b/epiphany-core/src/bin/epiphany-memory-graph.rs index 42bf1e04..7aea7989 100644 --- a/epiphany-core/src/bin/epiphany-memory-graph.rs +++ b/epiphany-core/src/bin/epiphany-memory-graph.rs @@ -27,6 +27,7 @@ use epiphany_core::memory_graph_from_epiphany_graphs; use epiphany_core::memory_graph_from_heartbeat_cognition; use epiphany_core::memory_graph_node_id; use epiphany_core::plan_memory_graph_context_cut; +use epiphany_core::refresh_or_validate_repo_memory_graph; use epiphany_core::validate_memory_graph_snapshot; use epiphany_core::write_memory_graph_snapshot; use std::env; @@ -291,6 +292,41 @@ fn read_refresh_args(args: impl Iterator) -> Result } fn refresh_memory_graph_store(args: RefreshArgs) -> Result { + if args.sources.is_empty() + && args.agent_store.is_none() + && args.heartbeat_store.is_none() + && let Some(thread_state_store) = &args.thread_state_store + { + let state = load_thread_state(thread_state_store)?.ok_or_else(|| { + anyhow!( + "thread state store {} is missing", + thread_state_store.display() + ) + })?; + let repo_root = thread_state_store + .parent() + .and_then(std::path::Path::parent) + .unwrap_or_else(|| std::path::Path::new(".")); + let source_identity = thread_state_store + .canonicalize() + .unwrap_or_else(|_| thread_state_store.clone()) + .to_string_lossy() + .into_owned(); + let (snapshot, refresh) = refresh_or_validate_repo_memory_graph( + &args.store, + &source_identity, + &state, + repo_root, + args.graph_id, + )?; + return Ok(serde_json::json!({ + "ok": true, "store": args.store, "graphId": snapshot.graph_id, + "source": snapshot.source, "refresh": format!("{refresh:?}").to_lowercase(), + "domains": snapshot.domains.len(), "nodes": snapshot.nodes.len(), + "edges": snapshot.edges.len(), "summaries": snapshot.summaries.len(), + "freshness": snapshot.freshness, + })); + } let mut snapshots = Vec::new(); let mut source_status = Vec::new(); @@ -341,7 +377,22 @@ fn refresh_memory_graph_store(args: RefreshArgs) -> Result { thread_state_store.display() ) })?; - let snapshot = memory_graph_from_epiphany_graphs("repo-profile", &state.graphs); + let repo_root = thread_state_store + .parent() + .and_then(std::path::Path::parent) + .unwrap_or_else(|| std::path::Path::new(".")); + let source_identity = thread_state_store + .canonicalize() + .unwrap_or_else(|_| thread_state_store.clone()) + .to_string_lossy() + .into_owned(); + let snapshot = memory_graph_from_epiphany_graphs( + "repo-profile", + &state.graphs, + source_identity, + state.revision, + repo_root, + )?; source_status.push(serde_json::json!({ "kind": "thread_state_store", "source": thread_state_store, diff --git a/epiphany-core/src/coordinator_launch_context.rs b/epiphany-core/src/coordinator_launch_context.rs index 71acf41a..26300a19 100644 --- a/epiphany-core/src/coordinator_launch_context.rs +++ b/epiphany-core/src/coordinator_launch_context.rs @@ -5,21 +5,20 @@ use crate::EpiphanyMemoryContextPacket; use crate::EpiphanyMemoryContextQuery; use crate::EpiphanyMemoryProfile; use crate::EpiphanyPromptContextInput; +use crate::RepoMemoryGraphRefresh; use crate::RuntimeHandsReceiptChainSummary; use crate::load_epiphany_cultmesh_cluster_topology; use crate::load_epiphany_cultmesh_status; use crate::load_latest_epiphany_cultmesh_work_loop_telemetry; -use crate::load_memory_graph_snapshot; -use crate::memory_graph_from_epiphany_graphs; use crate::plan_memory_graph_context_cut; use crate::query_epiphany_local_verse_context; +use crate::refresh_or_validate_repo_memory_graph; use crate::render_epiphany_prompt_context; use crate::runtime_hands_command_receipt; use crate::runtime_hands_commit_receipt; use crate::runtime_hands_patch_receipt; use crate::runtime_latest_hands_receipt_chain_after; use crate::write_epiphany_cultmesh_work_loop_telemetry; -use crate::write_memory_graph_snapshot; use chrono::SecondsFormat; use chrono::Utc; use epiphany_state_model::EpiphanyAcceptanceReceipt; @@ -632,28 +631,27 @@ fn launch_memory_context( focus: &str, ) -> Result { let memory_graph_store = memory_graph_store_path(runtime_store_path); - let (snapshot, refreshed_from_state) = match load_memory_graph_snapshot(&memory_graph_store) - .map_err(|error| { - format!( - "failed to load memory graph store {}: {error}", - memory_graph_store.display() - ) - })? { - Some(snapshot) => (snapshot, false), - None => { - let snapshot = memory_graph_from_epiphany_graphs( - format!("bridge-launch-state-rev-{}", state.revision), - &state.graphs, - ); - write_memory_graph_snapshot(&memory_graph_store, &snapshot).map_err(|error| { - format!( - "failed to write memory graph store {} from thread state: {error}", - memory_graph_store.display() - ) - })?; - (snapshot, true) - } - }; + let repo_root = runtime_store_path + .parent() + .and_then(Path::parent) + .unwrap_or_else(|| Path::new(".")); + let source_identity = fs::canonicalize(runtime_store_path) + .unwrap_or_else(|_| runtime_store_path.to_path_buf()) + .to_string_lossy() + .into_owned(); + let (snapshot, refresh) = refresh_or_validate_repo_memory_graph( + &memory_graph_store, + &source_identity, + state, + repo_root, + format!("bridge-launch-state-rev-{}", state.revision), + ) + .map_err(|error| { + format!( + "failed to refresh memory graph store {}: {error}", + memory_graph_store.display() + ) + })?; let mut packet = plan_memory_graph_context_cut( &snapshot, @@ -665,7 +663,7 @@ fn launch_memory_context( ..Default::default() }, ); - if refreshed_from_state { + if refresh == RepoMemoryGraphRefresh::Refreshed { packet.warnings.push(format!( "Memory graph store was refreshed from current thread-state repo graph at {}.", memory_graph_store.display() diff --git a/epiphany-core/src/lib.rs b/epiphany-core/src/lib.rs index a69f66d2..a926babd 100644 --- a/epiphany-core/src/lib.rs +++ b/epiphany-core/src/lib.rs @@ -617,6 +617,7 @@ pub use memory_graph::EpiphanyMemoryPatchCandidate; pub use memory_graph::MEMORY_GRAPH_KEY; pub use memory_graph::MEMORY_GRAPH_SCHEMA_VERSION; pub use memory_graph::MEMORY_GRAPH_TYPE; +pub use memory_graph::RepoMemoryGraphRefresh; pub use memory_graph::compose_memory_graph_snapshots; pub use memory_graph::derive_memory_graph_freshness; pub use memory_graph::lifecycle_allowed_for_profile; @@ -630,6 +631,7 @@ pub use memory_graph::memory_graph_from_epiphany_graphs; pub use memory_graph::memory_graph_from_heartbeat_cognition; pub use memory_graph::memory_graph_node_id; pub use memory_graph::plan_memory_graph_context_cut; +pub use memory_graph::refresh_or_validate_repo_memory_graph; pub use memory_graph::validate_memory_graph_entry; pub use memory_graph::validate_memory_graph_snapshot; pub use memory_graph::write_memory_graph_entry; diff --git a/epiphany-core/src/memory_graph/context_cut.rs b/epiphany-core/src/memory_graph/context_cut.rs index 828775f4..592b3eba 100644 --- a/epiphany-core/src/memory_graph/context_cut.rs +++ b/epiphany-core/src/memory_graph/context_cut.rs @@ -18,10 +18,12 @@ pub fn plan_memory_graph_context_cut( query: &EpiphanyMemoryContextQuery, ) -> EpiphanyMemoryContextPacket { let budget = query.budget.unwrap_or(12).clamp(1, 64) as usize; - let freshness = snapshot + let dirty_source_hashes = snapshot .freshness - .clone() - .unwrap_or_else(|| derive_memory_graph_freshness(snapshot, &[])); + .as_ref() + .map(|freshness| freshness.dirty_source_hashes.as_slice()) + .unwrap_or(&[]); + let freshness = derive_memory_graph_freshness(snapshot, dirty_source_hashes); let stale_nodes: HashSet<&str> = freshness .stale_node_ids .iter() diff --git a/epiphany-core/src/memory_graph/mod.rs b/epiphany-core/src/memory_graph/mod.rs index c7bc937d..fc7797ce 100644 --- a/epiphany-core/src/memory_graph/mod.rs +++ b/epiphany-core/src/memory_graph/mod.rs @@ -16,6 +16,7 @@ pub use epiphany_state_model::EpiphanyMemoryEmbeddingManifest; pub use epiphany_state_model::EpiphanyMemoryFreshness; pub use epiphany_state_model::EpiphanyMemoryFreshnessStatus; pub use epiphany_state_model::EpiphanyMemoryGraphSnapshot; +pub use epiphany_state_model::EpiphanyMemoryGraphSource; pub use epiphany_state_model::EpiphanyMemoryLifecycle; pub use epiphany_state_model::EpiphanyMemoryLifecycleReceipt; pub use epiphany_state_model::EpiphanyMemoryNode; @@ -30,9 +31,11 @@ pub use freshness::derive_memory_graph_freshness; pub use ids::memory_graph_domain_id; pub use ids::memory_graph_edge_id; pub use ids::memory_graph_node_id; +pub use profiles::RepoMemoryGraphRefresh; pub use profiles::memory_graph_from_agent_memories; pub use profiles::memory_graph_from_epiphany_graphs; pub use profiles::memory_graph_from_heartbeat_cognition; +pub use profiles::refresh_or_validate_repo_memory_graph; pub use store::EpiphanyMemoryGraphEntry; pub use store::MEMORY_GRAPH_KEY; pub use store::MEMORY_GRAPH_SCHEMA_VERSION; diff --git a/epiphany-core/src/memory_graph/profiles/agent.rs b/epiphany-core/src/memory_graph/profiles/agent.rs index 92bcadc0..61803385 100644 --- a/epiphany-core/src/memory_graph/profiles/agent.rs +++ b/epiphany-core/src/memory_graph/profiles/agent.rs @@ -92,7 +92,7 @@ pub fn memory_graph_from_agent_memories( } EpiphanyMemoryGraphSnapshot { - schema_version: Some("epiphany.memory_graph.v0".to_string()), + schema_version: Some("epiphany.memory_graph.v1".to_string()), graph_id: graph_id.into(), domains, nodes, diff --git a/epiphany-core/src/memory_graph/profiles/heartbeat.rs b/epiphany-core/src/memory_graph/profiles/heartbeat.rs index 99be3e45..2d42c584 100644 --- a/epiphany-core/src/memory_graph/profiles/heartbeat.rs +++ b/epiphany-core/src/memory_graph/profiles/heartbeat.rs @@ -45,7 +45,7 @@ pub fn memory_graph_from_heartbeat_cognition( import_bridge_pressure(cognition, &mut domains, &mut nodes, &mut summaries); EpiphanyMemoryGraphSnapshot { - schema_version: Some("epiphany.memory_graph.v0".to_string()), + schema_version: Some("epiphany.memory_graph.v1".to_string()), graph_id: graph_id.into(), domains, nodes, diff --git a/epiphany-core/src/memory_graph/profiles/mod.rs b/epiphany-core/src/memory_graph/profiles/mod.rs index be54c9e2..e9de15e3 100644 --- a/epiphany-core/src/memory_graph/profiles/mod.rs +++ b/epiphany-core/src/memory_graph/profiles/mod.rs @@ -4,4 +4,6 @@ pub mod repo; pub use agent::memory_graph_from_agent_memories; pub use heartbeat::memory_graph_from_heartbeat_cognition; +pub use repo::RepoMemoryGraphRefresh; pub use repo::memory_graph_from_epiphany_graphs; +pub use repo::refresh_or_validate_repo_memory_graph; diff --git a/epiphany-core/src/memory_graph/profiles/repo.rs b/epiphany-core/src/memory_graph/profiles/repo.rs index 28764ddf..a6b6a6dd 100644 --- a/epiphany-core/src/memory_graph/profiles/repo.rs +++ b/epiphany-core/src/memory_graph/profiles/repo.rs @@ -13,18 +13,128 @@ use crate::memory_graph::EpiphanyMemorySummary; use crate::memory_graph::memory_graph_domain_id; use crate::memory_graph::memory_graph_edge_id; use crate::memory_graph::memory_graph_node_id; +use anyhow::{Context, Result}; use epiphany_state_model::EpiphanyCodeRef; use epiphany_state_model::EpiphanyGraph; use epiphany_state_model::EpiphanyGraphEdge; use epiphany_state_model::EpiphanyGraphLink; use epiphany_state_model::EpiphanyGraphNode; use epiphany_state_model::EpiphanyGraphs; +use epiphany_state_model::EpiphanyThreadState; +use sha2::{Digest, Sha256}; use std::collections::HashMap; +use std::fs; +use std::path::Path; +use uuid::Uuid; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RepoMemoryGraphRefresh { + Reused, + Refreshed, +} + +pub fn refresh_or_validate_repo_memory_graph( + store_path: &Path, + source_identity: &str, + state: &EpiphanyThreadState, + repo_root: &Path, + graph_id: impl Into, +) -> Result<(EpiphanyMemoryGraphSnapshot, RepoMemoryGraphRefresh)> { + let current_entry = crate::memory_graph::load_memory_graph_entry(store_path)?; + if let Some(snapshot) = current_entry + .filter(|entry| entry.schema_version == crate::memory_graph::MEMORY_GRAPH_SCHEMA_VERSION) + .and_then(|entry| entry.snapshot().ok()) + .filter(|snapshot| { + snapshot.schema_version.as_deref() + == Some(crate::memory_graph::MEMORY_GRAPH_SCHEMA_VERSION) + }) + { + let provenance_matches = snapshot.source.as_ref().is_some_and(|source| { + source.kind == "thread_state_repo_graph" + && source.identity == source_identity + && source.revision == state.revision + }); + if provenance_matches && snapshot_anchor_bytes_match(&snapshot, repo_root) { + return Ok((snapshot, RepoMemoryGraphRefresh::Reused)); + } + } + + let snapshot = memory_graph_from_epiphany_graphs( + graph_id, + &state.graphs, + source_identity, + state.revision, + repo_root, + )?; + let temporary = store_path.with_extension(format!("refresh-{}.tmp", Uuid::new_v4())); + crate::memory_graph::write_memory_graph_snapshot(&temporary, &snapshot)?; + atomic_replace_file(&temporary, store_path).with_context(|| { + format!( + "atomically replace memory graph store {}", + store_path.display() + ) + })?; + Ok((snapshot, RepoMemoryGraphRefresh::Refreshed)) +} + +#[cfg(windows)] +fn atomic_replace_file(source: &Path, destination: &Path) -> Result<()> { + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::Storage::FileSystem::{ + MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, MoveFileExW, + }; + let source = source + .as_os_str() + .encode_wide() + .chain(Some(0)) + .collect::>(); + let destination = destination + .as_os_str() + .encode_wide() + .chain(Some(0)) + .collect::>(); + let moved = unsafe { + MoveFileExW( + source.as_ptr(), + destination.as_ptr(), + MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH, + ) + }; + if moved == 0 { + return Err(std::io::Error::last_os_error().into()); + } + Ok(()) +} + +#[cfg(not(windows))] +fn atomic_replace_file(source: &Path, destination: &Path) -> Result<()> { + fs::rename(source, destination)?; + Ok(()) +} + +fn snapshot_anchor_bytes_match(snapshot: &EpiphanyMemoryGraphSnapshot, repo_root: &Path) -> bool { + snapshot + .nodes + .iter() + .flat_map(|node| &node.anchors) + .chain(snapshot.edges.iter().flat_map(|edge| &edge.anchors)) + .all(|anchor| { + let (Some(code_ref), Some(expected)) = (&anchor.code_ref, &anchor.source_hash) else { + return false; + }; + fs::read(repo_root.join(&code_ref.path)) + .ok() + .is_some_and(|bytes| format!("sha256:{:x}", Sha256::digest(bytes)) == *expected) + }) +} pub fn memory_graph_from_epiphany_graphs( graph_id: impl Into, graphs: &EpiphanyGraphs, -) -> EpiphanyMemoryGraphSnapshot { + source_identity: impl Into, + source_revision: u64, + repo_root: &Path, +) -> Result { let architecture_domain = memory_graph_domain_id( EpiphanyMemoryProfile::RepoArchitecture, "accepted_graph", @@ -54,12 +164,14 @@ pub fn memory_graph_from_epiphany_graphs( EpiphanyMemoryProfile::RepoArchitecture, &mut node_map, &mut nodes, + repo_root, ); import_graph_edges( &graphs.architecture, EpiphanyMemoryProfile::RepoArchitecture, &node_map, &mut edges, + repo_root, ); summaries.push(repo_summary( "summary-accepted-architecture-graph", @@ -84,12 +196,14 @@ pub fn memory_graph_from_epiphany_graphs( EpiphanyMemoryProfile::RepoDataflow, &mut node_map, &mut nodes, + repo_root, ); import_graph_edges( &graphs.dataflow, EpiphanyMemoryProfile::RepoDataflow, &node_map, &mut edges, + repo_root, ); summaries.push(repo_summary( "summary-accepted-dataflow-graph", @@ -102,22 +216,56 @@ pub fn memory_graph_from_epiphany_graphs( )); } - import_graph_links(graphs.links.as_slice(), &node_map, &mut edges); + import_graph_links(graphs.links.as_slice(), &node_map, &mut edges, repo_root); - EpiphanyMemoryGraphSnapshot { - schema_version: Some("epiphany.memory_graph.v0".to_string()), + let stale_node_ids = nodes + .iter() + .filter(|node| node.lifecycle == EpiphanyMemoryLifecycle::Stale) + .map(|node| node.id.clone()) + .collect::>(); + let stale_edge_ids = edges + .iter() + .filter(|edge| edge.lifecycle == EpiphanyMemoryLifecycle::Stale) + .map(|edge| edge.id.clone()) + .collect::>(); + let stale = !stale_node_ids.is_empty() || !stale_edge_ids.is_empty(); + if stale { + for summary in &mut summaries { + summary.freshness = EpiphanyMemoryFreshnessStatus::Stale; + } + } + Ok(EpiphanyMemoryGraphSnapshot { + schema_version: Some("epiphany.memory_graph.v1".to_string()), graph_id: graph_id.into(), + source: Some(crate::memory_graph::EpiphanyMemoryGraphSource { + kind: "thread_state_repo_graph".to_string(), + identity: source_identity.into(), + revision: source_revision, + }), domains, nodes, edges, summaries, freshness: Some(EpiphanyMemoryFreshness { - status: EpiphanyMemoryFreshnessStatus::Ready, - note: Some("Imported from accepted Epiphany graph state.".to_string()), + status: if stale { + EpiphanyMemoryFreshnessStatus::Stale + } else { + EpiphanyMemoryFreshnessStatus::Ready + }, + stale_node_ids, + stale_edge_ids, + note: Some( + if stale { + "One or more accepted graph anchors could not be verified against source bytes." + } else { + "Imported from accepted Epiphany graph state." + } + .to_string(), + ), ..Default::default() }), ..Default::default() - } + }) } fn repo_domain(id: String, profile: EpiphanyMemoryProfile, title: &str) -> EpiphanyMemoryDomain { @@ -136,13 +284,15 @@ fn import_graph_nodes( profile: EpiphanyMemoryProfile, node_map: &mut HashMap, nodes: &mut Vec, + repo_root: &Path, ) { for node in &graph.nodes { let memory_id = memory_graph_node_id(domain_id, "accepted_graph_node", &node.id, None); node_map.insert(node.id.clone(), memory_id.clone()); - nodes.push(memory_node_from_graph_node( - memory_id, domain_id, profile, node, - )); + nodes.push( + memory_node_from_graph_node(memory_id.clone(), domain_id, profile, node, repo_root) + .unwrap_or_else(|_| stale_memory_node(memory_id, domain_id, profile, node)), + ); } } @@ -151,14 +301,19 @@ fn memory_node_from_graph_node( domain_id: &str, profile: EpiphanyMemoryProfile, node: &EpiphanyGraphNode, -) -> EpiphanyMemoryNode { - let anchors = anchors_from_code_refs(&id, &node.code_refs); + repo_root: &Path, +) -> Result { + let anchors = anchors_from_code_refs(&id, &node.code_refs, repo_root)?; let source_hashes = if anchors.is_empty() { vec!["anchor:missing".to_string()] } else { - anchors.iter().map(|anchor| anchor.id.clone()).collect() + anchors + .iter() + .filter_map(|anchor| anchor.source_hash.clone()) + .collect() }; - EpiphanyMemoryNode { + let grounded = !anchors.is_empty(); + Ok(EpiphanyMemoryNode { id, domain_id: domain_id.to_string(), profile, @@ -173,9 +328,37 @@ fn memory_node_from_graph_node( ), anchors, source_hashes, - lifecycle: graph_status_lifecycle(node.status.as_deref()), + lifecycle: if grounded { + graph_status_lifecycle(node.status.as_deref()) + } else { + EpiphanyMemoryLifecycle::Stale + }, salience: 70, - confidence: 80, + confidence: if grounded { 80 } else { 0 }, + ..Default::default() + }) +} + +fn stale_memory_node( + id: String, + domain_id: &str, + profile: EpiphanyMemoryProfile, + node: &EpiphanyGraphNode, +) -> EpiphanyMemoryNode { + EpiphanyMemoryNode { + id, + domain_id: domain_id.to_string(), + profile, + kind: memory_node_kind(node), + title: node.title.clone(), + claim: graph_node_claim(node), + question: "Source anchor is unavailable; refresh after restoring it.".to_string(), + tension: "Accepted anatomy cannot be verified against source bytes.".to_string(), + action_implication: "Do not use this claim as fresh compressed anatomy.".to_string(), + source_hashes: vec!["anchor:missing".to_string()], + lifecycle: EpiphanyMemoryLifecycle::Stale, + salience: 70, + confidence: 0, ..Default::default() } } @@ -185,6 +368,7 @@ fn import_graph_edges( profile: EpiphanyMemoryProfile, node_map: &HashMap, edges: &mut Vec, + repo_root: &Path, ) { for edge in &graph.edges { let source_id = node_map @@ -196,7 +380,7 @@ fn import_graph_edges( .cloned() .unwrap_or_else(|| edge.target_id.clone()); edges.push(memory_edge_from_graph_edge( - source_id, target_id, profile, edge, + source_id, target_id, profile, edge, repo_root, )); } } @@ -206,6 +390,7 @@ fn memory_edge_from_graph_edge( target_id: String, profile: EpiphanyMemoryProfile, edge: &EpiphanyGraphEdge, + repo_root: &Path, ) -> EpiphanyMemoryEdge { let id = edge.id.clone().unwrap_or_else(|| { memory_graph_edge_id( @@ -215,6 +400,12 @@ fn memory_edge_from_graph_edge( edge.code_refs.iter().map(code_ref_key), ) }); + let anchors = anchors_from_code_refs( + edge.id.as_deref().unwrap_or("edge"), + &edge.code_refs, + repo_root, + ); + let grounded = edge.code_refs.is_empty() || anchors.is_ok(); EpiphanyMemoryEdge { id, source_id, @@ -222,9 +413,13 @@ fn memory_edge_from_graph_edge( kind: memory_edge_kind(edge.kind.as_str()), profile, claim: graph_edge_claim(edge), - anchors: anchors_from_code_refs(edge.id.as_deref().unwrap_or("edge"), &edge.code_refs), - lifecycle: EpiphanyMemoryLifecycle::Accepted, - confidence: 80, + anchors: anchors.unwrap_or_default(), + lifecycle: if grounded { + EpiphanyMemoryLifecycle::Accepted + } else { + EpiphanyMemoryLifecycle::Stale + }, + confidence: if grounded { 80 } else { 0 }, } } @@ -232,6 +427,7 @@ fn import_graph_links( links: &[EpiphanyGraphLink], node_map: &HashMap, edges: &mut Vec, + repo_root: &Path, ) { for link in links { let Some(source_id) = node_map.get(&link.dataflow_node_id).cloned() else { @@ -240,6 +436,8 @@ fn import_graph_links( let Some(target_id) = node_map.get(&link.architecture_node_id).cloned() else { continue; }; + let anchors = anchors_from_code_refs("link", &link.code_refs, repo_root); + let grounded = link.code_refs.is_empty() || anchors.is_ok(); edges.push(EpiphanyMemoryEdge { id: memory_graph_edge_id( &source_id, @@ -255,9 +453,13 @@ fn import_graph_links( .relationship .clone() .unwrap_or_else(|| "Dataflow node is linked to architecture node.".to_string()), - anchors: anchors_from_code_refs("link", &link.code_refs), - lifecycle: EpiphanyMemoryLifecycle::Accepted, - confidence: 75, + anchors: anchors.unwrap_or_default(), + lifecycle: if grounded { + EpiphanyMemoryLifecycle::Accepted + } else { + EpiphanyMemoryLifecycle::Stale + }, + confidence: if grounded { 75 } else { 0 }, }); } } @@ -306,17 +508,23 @@ fn repo_summary( fn anchors_from_code_refs( prefix: &str, code_refs: &[EpiphanyCodeRef], -) -> Vec { + repo_root: &Path, +) -> Result> { code_refs .iter() .enumerate() - .map(|(index, code_ref)| EpiphanyMemoryAnchor { - id: format!("anchor-{prefix}-{index}"), - kind: "code_ref".to_string(), - target: code_ref_key(code_ref), - code_ref: Some(code_ref.clone()), - source_hash: Some(code_ref_key(code_ref)), - ..Default::default() + .map(|(index, code_ref)| { + let bytes = fs::read(repo_root.join(&code_ref.path)) + .with_context(|| format!("missing source anchor {}", code_ref.path.display()))?; + let digest = format!("sha256:{:x}", Sha256::digest(&bytes)); + Ok(EpiphanyMemoryAnchor { + id: format!("anchor-{prefix}-{index}"), + kind: "code_ref".to_string(), + target: code_ref_key(code_ref), + code_ref: Some(code_ref.clone()), + source_hash: Some(digest), + ..Default::default() + }) }) .collect() } @@ -429,7 +637,14 @@ mod tests { ..Default::default() }; - let snapshot = memory_graph_from_epiphany_graphs("repo-profile", &graphs); + let snapshot = memory_graph_from_epiphany_graphs( + "repo-profile", + &graphs, + "test-state", + 1, + Path::new("."), + ) + .unwrap(); let errors = validate_memory_graph_snapshot(&snapshot); assert!(errors.is_empty(), "{errors:?}"); @@ -458,7 +673,14 @@ mod tests { ..Default::default() }; - let snapshot = memory_graph_from_epiphany_graphs("repo-profile", &graphs); + let snapshot = memory_graph_from_epiphany_graphs( + "repo-profile", + &graphs, + "test-state", + 1, + Path::new("."), + ) + .unwrap(); let errors = validate_memory_graph_snapshot(&snapshot); assert!( @@ -472,4 +694,109 @@ mod tests { .any(|error| error.message.contains("missing target node")) ); } + + fn anchored_state(path: &str, revision: u64) -> EpiphanyThreadState { + EpiphanyThreadState { + revision, + graphs: EpiphanyGraphs { + architecture: EpiphanyGraph { + nodes: vec![EpiphanyGraphNode { + id: "body".to_string(), + title: "Body".to_string(), + purpose: "Grounded body anatomy.".to_string(), + code_refs: vec![EpiphanyCodeRef { + path: PathBuf::from(path), + ..Default::default() + }], + ..Default::default() + }], + ..Default::default() + }, + ..Default::default() + }, + ..Default::default() + } + } + + #[test] + fn repo_cache_reuse_requires_revision_and_anchor_bytes() -> anyhow::Result<()> { + let temp = tempfile::tempdir()?; + std::fs::write(temp.path().join("body.rs"), "one")?; + let store = temp.path().join("memory-graph.msgpack"); + let state = anchored_state("body.rs", 1); + + let (first, first_refresh) = + refresh_or_validate_repo_memory_graph(&store, "thread", &state, temp.path(), "repo")?; + assert_eq!(first_refresh, RepoMemoryGraphRefresh::Refreshed); + assert!(first.nodes[0].source_hashes[0].starts_with("sha256:")); + let (same, same_refresh) = + refresh_or_validate_repo_memory_graph(&store, "thread", &state, temp.path(), "repo")?; + assert_eq!(same_refresh, RepoMemoryGraphRefresh::Reused); + assert_eq!(first.source, same.source); + + std::fs::write(temp.path().join("body.rs"), "two")?; + let (changed, bytes_refresh) = + refresh_or_validate_repo_memory_graph(&store, "thread", &state, temp.path(), "repo")?; + assert_eq!(bytes_refresh, RepoMemoryGraphRefresh::Refreshed); + assert_ne!(first.nodes[0].source_hashes, changed.nodes[0].source_hashes); + + let next = anchored_state("body.rs", 2); + let (revised, revision_refresh) = + refresh_or_validate_repo_memory_graph(&store, "thread", &next, temp.path(), "repo")?; + assert_eq!(revision_refresh, RepoMemoryGraphRefresh::Refreshed); + assert_eq!(revised.source.unwrap().revision, 2); + Ok(()) + } + + #[test] + fn missing_anchor_cannot_emit_ready_summary() -> anyhow::Result<()> { + let temp = tempfile::tempdir()?; + let snapshot = memory_graph_from_epiphany_graphs( + "repo", + &anchored_state("gone.rs", 1).graphs, + "thread", + 1, + temp.path(), + )?; + assert_eq!( + snapshot.freshness.unwrap().status, + EpiphanyMemoryFreshnessStatus::Stale + ); + assert!( + snapshot + .summaries + .iter() + .all(|summary| summary.freshness == EpiphanyMemoryFreshnessStatus::Stale) + ); + Ok(()) + } + + #[test] + fn missing_edge_anchor_stales_edge_summary_and_snapshot() -> anyhow::Result<()> { + let temp = tempfile::tempdir()?; + std::fs::write(temp.path().join("body.rs"), "body")?; + let mut state = anchored_state("body.rs", 1); + state.graphs.architecture.edges.push(EpiphanyGraphEdge { + id: Some("edge-body".to_string()), + source_id: "body".to_string(), + target_id: "body".to_string(), + kind: "owns".to_string(), + code_refs: vec![EpiphanyCodeRef { + path: PathBuf::from("missing-edge.rs"), + ..Default::default() + }], + ..Default::default() + }); + let snapshot = + memory_graph_from_epiphany_graphs("repo", &state.graphs, "thread", 1, temp.path())?; + assert_eq!(snapshot.edges[0].lifecycle, EpiphanyMemoryLifecycle::Stale); + assert_eq!( + snapshot.summaries[0].freshness, + EpiphanyMemoryFreshnessStatus::Stale + ); + let freshness = snapshot.freshness.expect("freshness"); + assert_eq!(freshness.status, EpiphanyMemoryFreshnessStatus::Stale); + assert_eq!(freshness.stale_edge_ids, vec!["edge-body".to_string()]); + Ok(()) + } } diff --git a/epiphany-core/src/memory_graph/store.rs b/epiphany-core/src/memory_graph/store.rs index 1df8155f..604a4fd1 100644 --- a/epiphany-core/src/memory_graph/store.rs +++ b/epiphany-core/src/memory_graph/store.rs @@ -1,4 +1,6 @@ +use super::EpiphanyMemoryFreshnessStatus; use super::EpiphanyMemoryGraphSnapshot; +use super::derive_memory_graph_freshness; use super::validate_memory_graph_snapshot; use anyhow::Result; use anyhow::anyhow; @@ -9,7 +11,7 @@ use std::path::Path; pub const MEMORY_GRAPH_TYPE: &str = "epiphany.memory_graph"; pub const MEMORY_GRAPH_KEY: &str = "default"; -pub const MEMORY_GRAPH_SCHEMA_VERSION: &str = "epiphany.memory_graph.v0"; +pub const MEMORY_GRAPH_SCHEMA_VERSION: &str = "epiphany.memory_graph.v1"; #[derive(Clone, Debug, PartialEq, DatabaseEntry)] #[cultcache(type = "epiphany.memory_graph", schema = "EpiphanyMemoryGraphEntry")] @@ -89,6 +91,13 @@ pub fn validate_memory_graph_entry(entry: &EpiphanyMemoryGraphEntry) -> Result<( )); } let snapshot = entry.snapshot()?; + if snapshot.schema_version.as_deref() != Some(MEMORY_GRAPH_SCHEMA_VERSION) { + return Err(anyhow!( + "memory graph snapshot schema_version is {:?}, expected {:?}", + snapshot.schema_version, + MEMORY_GRAPH_SCHEMA_VERSION + )); + } if entry.graph_id != snapshot.graph_id { return Err(anyhow!( "memory graph entry graph_id {:?} does not match snapshot graph_id {:?}", @@ -105,5 +114,29 @@ pub fn validate_memory_graph_entry(entry: &EpiphanyMemoryGraphEntry) -> Result<( .join("; "); return Err(anyhow!("memory graph validation failed: {message}")); } + let dirty = snapshot + .freshness + .as_ref() + .map(|freshness| freshness.dirty_source_hashes.as_slice()) + .unwrap_or(&[]); + let derived = derive_memory_graph_freshness(&snapshot, dirty); + if snapshot.freshness.as_ref().is_some_and(|stored| { + stored.status != derived.status + || stored.stale_node_ids != derived.stale_node_ids + || stored.stale_edge_ids != derived.stale_edge_ids + || stored.stale_summary_ids != derived.stale_summary_ids + }) { + return Err(anyhow!( + "memory graph stored freshness disagrees with derived lifecycle/source freshness" + )); + } + if snapshot.summaries.iter().any(|summary| { + derived.stale_summary_ids.contains(&summary.id) + && summary.freshness != EpiphanyMemoryFreshnessStatus::Stale + }) { + return Err(anyhow!( + "memory graph has a ready summary covering stale anatomy" + )); + } Ok(()) } diff --git a/epiphany-core/src/memory_graph/tests.rs b/epiphany-core/src/memory_graph/tests.rs index a1b19163..d6ea08e4 100644 --- a/epiphany-core/src/memory_graph/tests.rs +++ b/epiphany-core/src/memory_graph/tests.rs @@ -23,6 +23,7 @@ fn fixture_snapshot() -> EpiphanyMemoryGraphSnapshot { let node_b = memory_graph_node_id(&domain_id, "module", "src/memory_graph.rs", Some("b")); let edge = memory_graph_edge_id(&node_a, &node_b, "owns", ["src/memory_graph.rs"]); EpiphanyMemoryGraphSnapshot { + schema_version: Some(MEMORY_GRAPH_SCHEMA_VERSION.to_string()), graph_id: "graph".to_string(), domains: vec![EpiphanyMemoryDomain { id: domain_id.clone(), @@ -197,6 +198,15 @@ fn memory_graph_entry_validation_rejects_mismatched_identity() { assert!(error.to_string().contains("does not match snapshot")); } +#[test] +fn memory_graph_entry_rejects_embedded_legacy_schema() { + let mut snapshot = fixture_snapshot(); + snapshot.schema_version = Some("epiphany.memory_graph.v0".to_string()); + let entry = EpiphanyMemoryGraphEntry::from_snapshot(&snapshot).expect("snapshot entry"); + let error = validate_memory_graph_entry(&entry).expect_err("embedded v0 is invalid"); + assert!(error.to_string().contains("snapshot schema_version")); +} + #[test] fn memory_graph_profile_law_keeps_role_memory_from_repo_lifecycle() { assert!(lifecycle_allowed_for_profile( @@ -362,6 +372,33 @@ fn memory_graph_context_cut_descends_when_summary_is_stale() { ); } +#[test] +fn memory_graph_context_cut_rederives_forged_ready_freshness() { + let mut snapshot = fixture_snapshot(); + snapshot.nodes[0].lifecycle = EpiphanyMemoryLifecycle::Stale; + snapshot.freshness = Some(EpiphanyMemoryFreshness { + status: EpiphanyMemoryFreshnessStatus::Ready, + note: Some("forged cache claim".to_string()), + ..Default::default() + }); + let packet = plan_memory_graph_context_cut( + &snapshot, + &EpiphanyMemoryContextQuery { + id: "query".to_string(), + profile: Some(EpiphanyMemoryProfile::RepoArchitecture), + text: Some("shared graph law".to_string()), + ..Default::default() + }, + ); + assert!(packet.summaries.is_empty()); + assert!( + packet + .warnings + .iter() + .any(|warning| warning.contains("descended")) + ); +} + #[test] fn memory_graph_context_cut_reports_missing_explicit_ids() { let snapshot = fixture_snapshot(); diff --git a/epiphany-state-model/src/lib.rs b/epiphany-state-model/src/lib.rs index 944942b7..8b432ed9 100644 --- a/epiphany-state-model/src/lib.rs +++ b/epiphany-state-model/src/lib.rs @@ -728,6 +728,9 @@ pub struct EpiphanyMemoryGraphSnapshot { #[ts(type = "string | null")] pub schema_version: Option, pub graph_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "EpiphanyMemoryGraphSource | null")] + pub source: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] #[ts(type = "Array")] pub domains: Vec, @@ -751,6 +754,13 @@ pub struct EpiphanyMemoryGraphSnapshot { pub lifecycle_receipts: Vec, } +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema, TS)] +pub struct EpiphanyMemoryGraphSource { + pub kind: String, + pub identity: String, + pub revision: u64, +} + #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema, TS, Default)] pub struct EpiphanyMemoryDomain { pub id: String, diff --git a/notes/conceptual-substitution-audit-2026-07-12.md b/notes/conceptual-substitution-audit-2026-07-12.md index 8acaf7f2..92670035 100644 --- a/notes/conceptual-substitution-audit-2026-07-12.md +++ b/notes/conceptual-substitution-audit-2026-07-12.md @@ -1618,6 +1618,29 @@ local Verse, result/checked ref, watched ref, source commit, runbook commit, and current HEAD. Readiness re-resolves the underlying deployment receipt; absence or any mismatch remains incomplete. +## Cache existence is not fresh Modeling anatomy + +The persistent repo memory graph was reused indefinitely whenever +`memory-graph.msgpack` existed. Repo import stamped anatomy `Ready`, while its +so-called source hashes were only `path#symbol` locators. Launch context could +therefore inject stale compressed summaries after accepted state revisions or +source bytes changed. + +`epiphany.memory_graph.v1` now carries typed source identity and accepted state +revision. Repo anchors use SHA-256 source-byte digests. Launch and explicit +thread-state refresh share one refresh/validate primitive; reuse requires the +same state-store identity, revision, schema, and every anchored source digest. +Missing or changed node, edge, or link anchors become stale, and context cuts +derive freshness from lifecycle/source evidence rather than trusting a cached +freshness claim. Replacement is atomic on Windows and Unix. The graph embedding +manifest still has no production indexer; Modeling semantic-index availability +is therefore `unavailable`, not borrowed from generic workspace retrieval. + +The independent Eyes pass found the next P0 frontier: repo-work `run_adopt` +locally authors a document claiming Mind review and then approves Hands from +field predicates. The existing typed Mind gateway must own an immutable review +bound to the exact plan; Self/scheduler may route it but may not impersonate it. + ## Ambient command identity is not a typed crossing command Bifrost's source establishes one typed command -> one crossing attempt -> one diff --git a/notes/fresh-workspace-handoff.md b/notes/fresh-workspace-handoff.md index 113aec95..1760a13e 100644 --- a/notes/fresh-workspace-handoff.md +++ b/notes/fresh-workspace-handoff.md @@ -55,6 +55,19 @@ readiness no longer launders its cached projection. Typed Idunn deployment and aftercare receipts must resolve and bind across identity, runtime, Verse, result/ref, runbook, source commit, and current HEAD. +The next Modeling pass repaired the targeting organ itself. Memory-graph v1 +binds repo anatomy to the actual state-store identity, accepted revision, and +SHA-256 source bytes. Cache presence, path/symbol locators, and serialized +freshness no longer make anatomy Ready. Launch and explicit refresh share one +validator; missing/changed node, edge, or link anchors stale their summaries. +There is still no production semantic index for graph claims, so Modeling index +availability is honestly unavailable rather than inferred from code search. + +Next P0 cut: `epiphany-work run_adopt` currently synthesizes Mind ownership and +an approved Hands review from local JSON predicates. Route adoption through the +existing typed Mind gateway, immutably bound to the exact plan, and delete the +scheduler's ability to author Mind/Hands consequence. + Epiphany is in an authority-provenance purification pass. The live question is not whether a command can produce a plausible document; it is whether the subsystem writing that document owns the fact it asserts. The confirmed conceptual substitutions have been cut: diff --git a/schemas/cultnet/epiphany.memory-graph.schema.json b/schemas/cultnet/epiphany.memory-graph.schema.json index 498a3bfc..e9350633 100644 --- a/schemas/cultnet/epiphany.memory-graph.schema.json +++ b/schemas/cultnet/epiphany.memory-graph.schema.json @@ -4,17 +4,22 @@ "title": "Epiphany Memory Graph", "description": "Unified typed memory graph for repo architecture/dataflow, role memory, heartbeat pressure, incubation, agency, candidate interventions, and evidence. Embeddings are cache metadata, not canonical truth.", "type": "object", - "additionalProperties": true, + "additionalProperties": false, "properties": { - "schemaVersion": { - "type": [ - "string", - "null" - ] - }, + "schemaVersion": { "const": "epiphany.memory_graph.v1" }, "graphId": { "type": "string" }, + "source": { + "type": ["object", "null"], + "additionalProperties": false, + "required": ["kind", "identity", "revision"], + "properties": { + "kind": { "type": "string" }, + "identity": { "type": "string" }, + "revision": { "type": "integer", "minimum": 0 } + } + }, "domains": { "type": "array", "items": { @@ -66,6 +71,7 @@ } }, "required": [ + "schemaVersion", "graphId" ] } diff --git a/schemas/cultnet/index.json b/schemas/cultnet/index.json index fca528b9..021a39a9 100644 --- a/schemas/cultnet/index.json +++ b/schemas/cultnet/index.json @@ -293,7 +293,7 @@ "wireContracts": [ "cultnet.schema.v0" ], - "schemaVersion": "epiphany.memory_graph.v0", + "schemaVersion": "epiphany.memory_graph.v1", "documentType": "epiphany.memory_graph", "title": "Epiphany Memory Graph", "path": "epiphany.memory-graph.schema.json" diff --git a/state/ledgers.msgpack b/state/ledgers.msgpack index 4450c043ab373791d43e91f1f1fba3e409b54ad1..1f1c957f911a32710ce7c9ada5a19b17ddc8ccc3 100644 GIT binary patch delta 449 zcmXYty-EW?5XZgvRoGeC1O{w9%|)V+R4Ifsf})Lfo89ql$=>d{vwP&K6e5-u8U&w0 zv=CpwXApcD=fL8>1GDq-`_Jo7_x-o~wv%iPvSBv3{$ANMj~~0qZIV3QEjtg(PQq&A zDr=3k&f54~b~f+z_VZW2*FQ>o$7yzWnH>)Y*~|IWYIjkLF%;;vfK#W?Xq!VNh`{4C zY7;nC4oWPYZy@)gDnJm_*$^m*fGGv%5wt=ZbZGWKNQqTI#eKCV?JTFPf{V#G?GKKC zT=WvtX%mq1vTzGekh>ba1rc1SB{xRG5k#AN7p>w^mci%%Rn9L1&ZymO5Ga Jm5IGI699nA6QKYA diff --git a/state/map.yaml b/state/map.yaml index c9d7a020..667931d9 100644 --- a/state/map.yaml +++ b/state/map.yaml @@ -661,4 +661,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 observation-to-consequence audit using live organ-shaped prompt probes. Preserve the Aetheria dogfood lesson: Modeling's typed persistent anatomy, freshness, frontier, and semantic-index knowledgebase is the targeting organ for Hands, not a transient prelude. Idunn managed-child sight now feeds swarm overview as typed renderer-neutral rows, with executable identity required before PID liveness can satisfy recovery. Deployment/aftercare completion now accepts only a conjunctively bound typed Idunn CultMesh chain; raw response files and cached projection laundering are deleted. Model the next live frontier before cutting it. The intentionally absent Other-world surface action still requires a typed intent contract and real command owner; never advertise a wish. Keep closure's zero-substring invariant. + next_action: Cut repo-work Mind impersonation. `epiphany-work run_adopt` currently manufactures a Mind-owned adoption document and approved Hands review from local JSON predicates. Self/scheduler may route a plan, but only a resolved typed Mind gateway review immutably bound to the exact plan may authorize the admission primitive that creates Hands review. Delete the synthetic owner/interpreter path and prove that a structurally plausible plan without Mind receipt creates neither adoption nor Hands authority. Preserve the Aetheria lesson: memory-graph v1 now binds Modeling anatomy to state-store identity, accepted revision, and SHA-256 source bytes; launch and explicit refresh share validation; missing/changed anchors stale summaries. Modeling semantic graph indexing remains honestly unavailable until a real graph-claim indexer exists. The intentionally absent Other-world action still requires a typed intent and real owner. Keep closure's zero-substring invariant.