From 5bd1b2bd1572b2da51fcec7f0b456865fd16fb08 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 15 Jul 2026 06:09:01 +0100 Subject: [PATCH] Physically fence semantic projection epochs --- .../src/memory_graph/semantic_index.rs | 147 +++++++++++++- .../src/memory_graph/semantic_projector.rs | 181 ++++++++++++++++-- notes/fresh-workspace-handoff.md | 30 +++ ...graph-semantic-projection-authority-map.md | 118 ++++++++++++ state/agents.msgpack | Bin 22594 -> 24006 bytes state/ledgers.msgpack | Bin 497189 -> 497658 bytes state/map.yaml | 2 +- 7 files changed, 451 insertions(+), 27 deletions(-) diff --git a/epiphany-core/src/memory_graph/semantic_index.rs b/epiphany-core/src/memory_graph/semantic_index.rs index efb74918..5afc581b 100644 --- a/epiphany-core/src/memory_graph/semantic_index.rs +++ b/epiphany-core/src/memory_graph/semantic_index.rs @@ -14,9 +14,10 @@ use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::collections::{BTreeMap, HashSet}; use std::env; +use uuid::Uuid; pub const MEMORY_SEMANTIC_INDEX_RECEIPT_SCHEMA_VERSION: &str = - "gamecult.epiphany.memory_semantic_index_receipt.v0"; + "gamecult.epiphany.memory_semantic_index_receipt.v1"; pub const MEMORY_SEMANTIC_PROJECTION_OBLIGATION_SCHEMA_VERSION: &str = "gamecult.epiphany.memory_semantic_projection_obligation.v0"; pub const MEMORY_SEMANTIC_PROJECTION_ATTEMPT_SCHEMA_VERSION: &str = @@ -89,6 +90,9 @@ struct MemorySemanticPointPayload { point_id: String, swarm_id: String, partition: SemanticPartition, + obligation_id: String, + claim_id: String, + claim_epoch: String, canonical_locator: String, canonical_type: String, canonical_key: String, @@ -245,6 +249,17 @@ pub struct MemorySemanticIndexReceipt { pub source_generation: u64, #[cultcache(key = 20)] pub projection_schema_version: String, + #[cultcache(key = 21, default)] + pub claim_id: String, + #[cultcache(key = 22, default)] + pub claim_epoch: u64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) struct MemorySemanticProjectionNamespace { + pub(super) obligation_id: String, + pub(super) claim_id: String, + pub(super) claim_epoch: u64, } pub fn validate_memory_semantic_projection_obligation( @@ -497,6 +512,8 @@ fn receipt_matches_obligation( && receipt.model_hash == obligation.source_model_hash && receipt.canonical_content_set_hash == obligation.canonical_content_set_hash && receipt.projection_schema_version == obligation.projection_schema_version + && !receipt.claim_id.trim().is_empty() + && receipt.claim_epoch > 0 && chrono::DateTime::parse_from_rfc3339(&receipt.indexed_at).is_ok() } @@ -504,6 +521,7 @@ pub(super) fn index_memory_semantic_partition( snapshot: &EpiphanyMemoryGraphSnapshot, swarm_id: &str, partition: SemanticPartition, + namespace: &MemorySemanticProjectionNamespace, indexed_at: &str, config: &MemorySemanticIndexConfig, ) -> Result { @@ -513,9 +531,19 @@ pub(super) fn index_memory_semantic_partition( .collect::>(); let backend = qdrant(config)?; let collection = config.collection(partition); + if namespace.obligation_id.trim().is_empty() + || namespace.claim_id.trim().is_empty() + || namespace.claim_epoch == 0 + { + return Err(anyhow!("semantic projection physical namespace is invalid")); + } + let claim_epoch = namespace.claim_epoch.to_string(); let scope = [ ("swarmId", swarm_id), ("partition", partition_name(partition)), + ("obligationId", namespace.obligation_id.as_str()), + ("claimId", namespace.claim_id.as_str()), + ("claimEpoch", claim_epoch.as_str()), ]; let content_set_hash = canonical_content_set_hash(&documents); let model_hash = crate::memory_graph_model_hash(snapshot)?; @@ -555,6 +583,7 @@ pub(super) fn index_memory_semantic_partition( vector_size, 0, deleted_document_count, + namespace, )); } @@ -584,15 +613,15 @@ pub(super) fn index_memory_semantic_partition( .iter() .zip(embeddings) .map(|(document, vector)| SemanticPoint { - id: document.point_id.clone(), + id: physical_point_id(namespace, &document.point_id), vector, - payload: point_payload(document, config), + payload: point_payload(document, config, namespace), }) .collect::>(); backend.upsert_points(collection, &points)?; let live_ids = documents .iter() - .map(|document| document.point_id.as_str()) + .map(|document| physical_point_id(namespace, &document.point_id)) .collect::>(); let deleted_ids = existing_ids .into_iter() @@ -602,7 +631,7 @@ pub(super) fn index_memory_semantic_partition( let observed_ids = backend.point_ids_for_scope(collection, &scope)?; let desired_ids = documents .iter() - .map(|document| document.point_id.clone()) + .map(|document| physical_point_id(namespace, &document.point_id)) .collect::>(); let observed_ids = observed_ids.into_iter().collect::>(); if observed_ids != desired_ids { @@ -622,7 +651,12 @@ pub(super) fn index_memory_semantic_partition( .collect::>>()?; let desired_payloads = documents .iter() - .map(|document| (document.point_id.clone(), point_payload(document, config))) + .map(|document| { + ( + physical_point_id(namespace, &document.point_id), + point_payload(document, config, namespace), + ) + }) .collect::>(); if observed_payloads != desired_payloads { return Err(anyhow!( @@ -641,6 +675,7 @@ pub(super) fn index_memory_semantic_partition( vector_size as u32, documents.len() as u32, deleted_ids.len() as u32, + namespace, )) } @@ -657,13 +692,23 @@ fn memory_semantic_index_receipt( vector_dimensions: u32, indexed_document_count: u32, deleted_document_count: u32, + namespace: &MemorySemanticProjectionNamespace, ) -> MemorySemanticIndexReceipt { let receipt_id = format!( "memory-semantic-index-{}-{}-{}-{}", partition_name(partition), snapshot.model_revision, &content_set_hash[..16], - &format!("{:x}", Sha256::digest(indexed_at.as_bytes()))[..12] + &format!( + "{:x}", + Sha256::digest( + format!( + "{}|{}|{}|{}", + indexed_at, namespace.obligation_id, namespace.claim_id, namespace.claim_epoch + ) + .as_bytes() + ) + )[..12] ); MemorySemanticIndexReceipt { schema_version: MEMORY_SEMANTIC_INDEX_RECEIPT_SCHEMA_VERSION.to_string(), @@ -682,11 +727,13 @@ fn memory_semantic_index_receipt( canonical_content_set_hash: content_set_hash.to_string(), indexed_at: indexed_at.to_string(), status: "ready".to_string(), - obligation_id: String::new(), + obligation_id: namespace.obligation_id.clone(), canonical_source_id: snapshot.graph_id.clone(), source_commit_id: String::new(), source_generation: snapshot.model_revision, projection_schema_version: SEMANTIC_PROJECTION_SCHEMA_VERSION.to_string(), + claim_id: namespace.claim_id.clone(), + claim_epoch: namespace.claim_epoch, } } @@ -730,7 +777,8 @@ pub fn semantic_memory_context( ); return packet; } - match try_semantic_memory_context(snapshot, swarm_id, partition, query, config) { + let readiness = readiness.expect("eligible semantic readiness disappeared"); + match try_semantic_memory_context(snapshot, swarm_id, partition, query, readiness, config) { Ok(packet) => packet, Err(error) => { let mut packet = @@ -748,6 +796,7 @@ fn try_semantic_memory_context( swarm_id: &str, partition: SemanticPartition, query: &EpiphanyMemoryContextQuery, + readiness: &MemorySemanticProjectionReadiness, config: &MemorySemanticIndexConfig, ) -> Result { let text = query @@ -769,6 +818,7 @@ fn try_semantic_memory_context( return Err(anyhow!("semantic collection compatibility mismatch")); } let limit = query.budget.unwrap_or(12).clamp(1, QUERY_LIMIT_MAX as u32) as usize; + let query_claim_epoch = readiness.receipt.claim_epoch.to_string(); let ranked = backend.query_points_for_scope::( collection, &vector, @@ -776,6 +826,9 @@ fn try_semantic_memory_context( &[ ("swarmId", swarm_id), ("partition", partition_name(partition)), + ("obligationId", readiness.receipt.obligation_id.as_str()), + ("claimId", readiness.receipt.claim_id.as_str()), + ("claimEpoch", query_claim_epoch.as_str()), ], )?; let mut ranked_ids = Vec::new(); @@ -785,6 +838,9 @@ fn try_semantic_memory_context( .ok_or_else(|| anyhow!("semantic candidate omitted its typed locator payload"))?; if payload.swarm_id != swarm_id || payload.partition != partition + || payload.obligation_id != readiness.receipt.obligation_id + || payload.claim_id != readiness.receipt.claim_id + || payload.claim_epoch != readiness.receipt.claim_epoch.to_string() || payload.embedding_provider_id != config.embedding_provider_id || payload.embedding_model != config.ollama_model || payload.projection_schema_version != SEMANTIC_PROJECTION_SCHEMA_VERSION @@ -822,11 +878,15 @@ fn try_semantic_memory_context( fn point_payload( document: &SemanticProjectionDocument, config: &MemorySemanticIndexConfig, + namespace: &MemorySemanticProjectionNamespace, ) -> MemorySemanticPointPayload { MemorySemanticPointPayload { point_id: document.point_id.clone(), swarm_id: document.swarm_id.clone(), partition: document.partition, + obligation_id: namespace.obligation_id.clone(), + claim_id: namespace.claim_id.clone(), + claim_epoch: namespace.claim_epoch.to_string(), canonical_locator: document.canonical.locator.clone(), canonical_type: document.canonical.canonical_type.clone(), canonical_key: document.canonical.canonical_key.clone(), @@ -842,6 +902,21 @@ fn point_payload( } } +fn physical_point_id( + namespace: &MemorySemanticProjectionNamespace, + canonical_point_id: &str, +) -> String { + Uuid::new_v5( + &Uuid::NAMESPACE_URL, + format!( + "{}|{}|{}|{}", + namespace.obligation_id, namespace.claim_id, namespace.claim_epoch, canonical_point_id + ) + .as_bytes(), + ) + .to_string() +} + fn qdrant(config: &MemorySemanticIndexConfig) -> Result { QdrantBackend::new(QdrantConfig { url: config.qdrant_url.clone(), @@ -984,6 +1059,11 @@ mod tests { &empty, "swarm-empty", SemanticPartition::Modeling, + &MemorySemanticProjectionNamespace { + obligation_id: "obligation-empty".to_string(), + claim_id: "claim-empty".to_string(), + claim_epoch: 1, + }, "2026-07-15T12:00:00Z", &config, )?; @@ -1110,6 +1190,8 @@ mod tests { source_commit_id: obligation.source_commit_id.clone(), source_generation: obligation.source_generation, projection_schema_version: obligation.projection_schema_version.clone(), + claim_id: "claim-current".to_string(), + claim_epoch: 1, }; let query = EpiphanyMemoryContextQuery { id: "swapped-source".to_string(), @@ -1151,6 +1233,51 @@ mod tests { ); } + #[test] + fn physical_point_identity_is_claim_epoch_isolated_while_locator_stays_canonical() { + let document = derive_semantic_projection("swarm-a", &snapshot()) + .unwrap() + .into_iter() + .next() + .expect("semantic document"); + let first = MemorySemanticProjectionNamespace { + obligation_id: "obligation-a".to_string(), + claim_id: "claim-a".to_string(), + claim_epoch: 1, + }; + let fenced = MemorySemanticProjectionNamespace { + claim_id: "claim-b".to_string(), + claim_epoch: 2, + ..first.clone() + }; + let first_id = physical_point_id(&first, &document.point_id); + let fenced_id = physical_point_id(&fenced, &document.point_id); + assert_ne!(first_id, fenced_id); + assert!(Uuid::parse_str(&first_id).is_ok()); + assert!(Uuid::parse_str(&fenced_id).is_ok()); + + let payload = point_payload(&document, &MemorySemanticIndexConfig::from_env(), &fenced); + assert_eq!(payload.point_id, document.point_id); + assert_eq!(payload.obligation_id, fenced.obligation_id); + assert_eq!(payload.claim_id, fenced.claim_id); + assert_eq!(payload.claim_epoch, fenced.claim_epoch.to_string()); + } + + #[test] + fn pre_namespace_receipt_is_never_query_eligible() { + let obligation = obligation(); + let current = source_head(); + let mut legacy = receipt(); + legacy.schema_version = "gamecult.epiphany.memory_semantic_index_receipt.v0".to_string(); + legacy.claim_id.clear(); + legacy.claim_epoch = 0; + assert!(!memory_semantic_projection_query_eligible( + &obligation, + ¤t, + &legacy + )); + } + fn obligation() -> MemorySemanticProjectionObligation { MemorySemanticProjectionObligation { schema_version: MEMORY_SEMANTIC_PROJECTION_OBLIGATION_SCHEMA_VERSION.to_string(), @@ -1206,6 +1333,8 @@ mod tests { source_commit_id: obligation.source_commit_id, source_generation: obligation.source_generation, projection_schema_version: obligation.projection_schema_version, + claim_id: "claim-7".to_string(), + claim_epoch: 1, } } diff --git a/epiphany-core/src/memory_graph/semantic_projector.rs b/epiphany-core/src/memory_graph/semantic_projector.rs index de21af3f..2b3517c6 100644 --- a/epiphany-core/src/memory_graph/semantic_projector.rs +++ b/epiphany-core/src/memory_graph/semantic_projector.rs @@ -99,11 +99,32 @@ pub fn load_memory_semantic_projection_success( let receipt_id = health .receipt_id .ok_or_else(|| anyhow!("ready semantic projection health lost receipt identity"))?; - receipts + let receipt = receipts .into_iter() .find(|receipt| receipt.receipt_id == receipt_id) - .map(Some) - .ok_or_else(|| anyhow!("ready semantic projection receipt disappeared")) + .ok_or_else(|| anyhow!("ready semantic projection receipt disappeared"))?; + let scope_id = projection_scope_id(&obligation.swarm_id, &obligation.partition)?; + let claim = decode_one::(&envelopes, &scope_id)? + .ok_or_else(|| anyhow!("ready semantic projection lost its scope claim"))?; + validate_memory_semantic_projection_claim(&claim)?; + if claim.status != "succeeded" + || claim.obligation_id != obligation.obligation_id + || receipt.claim_id != claim.claim_id + || receipt.claim_epoch != claim.epoch + { + return Err(anyhow!( + "ready semantic projection receipt is not authenticated by the succeeded scope claim" + )); + } + let attempt = decode_one::(&envelopes, &claim.attempt_id)? + .ok_or_else(|| anyhow!("ready semantic projection lost its claim attempt"))?; + validate_memory_semantic_projection_attempt(&attempt)?; + if attempt.status != "succeeded" || attempt.obligation_id != claim.obligation_id { + return Err(anyhow!( + "ready semantic projection claim is not authenticated by its succeeded attempt" + )); + } + Ok(Some(receipt)) } pub fn load_memory_semantic_projection_readiness( @@ -165,6 +186,11 @@ pub fn execute_memory_semantic_projection( &input.snapshot, &input.obligation.swarm_id, partition, + &super::semantic_index::MemorySemanticProjectionNamespace { + obligation_id: claim.obligation_id.clone(), + claim_id: claim.claim_id.clone(), + claim_epoch: claim.epoch, + }, &started_at, config, ) { @@ -275,16 +301,10 @@ pub(crate) fn claim_memory_semantic_projection( if let Some(current) = ¤t { validate_memory_semantic_projection_claim(current)?; if current.status == "running" { - return if current.obligation_id == obligation.obligation_id - && current.executor_id == executor_id - { - Ok(current.clone()) - } else { - Err(anyhow!( - "semantic projection scope is already claimed by executor {:?}", - current.executor_id - )) - }; + return Err(anyhow!( + "semantic projection scope is already claimed by executor {:?}; executor identity does not confer shared mutation authority", + current.executor_id + )); } if current.status == "succeeded" && current.obligation_id == obligation.obligation_id { return Ok(current.clone()); @@ -446,6 +466,14 @@ pub(crate) fn succeed_memory_semantic_projection_claim( let obligation = decode_one::(&opening, &claim.obligation_id)? .ok_or_else(|| anyhow!("semantic projection success lost obligation"))?; + if receipt.obligation_id != claim.obligation_id + || receipt.claim_id != claim.claim_id + || receipt.claim_epoch != claim.epoch + { + return Err(anyhow!( + "semantic projection result belongs to a different physical claim namespace" + )); + } let receipt = bind_memory_semantic_index_receipt(receipt, &obligation)?; if !memory_semantic_projection_query_eligible(&obligation, &authority.head, &receipt) { return Err(anyhow!( @@ -779,6 +807,17 @@ mod tests { source_commit_id: String::new(), source_generation: obligation.source_generation, projection_schema_version: SEMANTIC_PROJECTION_SCHEMA_VERSION.to_string(), + claim_id: String::new(), + claim_epoch: 0, + } + } + + fn receipt_for_claim(claim: &MemorySemanticProjectionClaim) -> MemorySemanticIndexReceipt { + MemorySemanticIndexReceipt { + obligation_id: claim.obligation_id.clone(), + claim_id: claim.claim_id.clone(), + claim_epoch: claim.epoch, + ..receipt() } } @@ -796,6 +835,16 @@ mod tests { "executor-a", "2026-07-15T04:01:00Z", )?; + assert!( + claim_memory_semantic_projection( + &store, + &obligation, + "executor-a", + "2026-07-15T04:01:00Z" + ) + .is_err(), + "executor labels are not reusable mutation capabilities" + ); assert!( claim_memory_semantic_projection( &store, @@ -840,7 +889,7 @@ mod tests { head: source_head(), envelopes: vec![authority_envelope], }, - receipt(), + receipt_for_claim(&recovered), "2026-07-15T04:03:00Z", )?; assert_eq!(bound.obligation_id, obligation.obligation_id); @@ -889,7 +938,7 @@ mod tests { "executor-a", "2026-07-15T04:01:00Z", )?; - let mut collision = receipt(); + let mut collision = receipt_for_claim(&claim); collision.status = "failed".to_string(); cache.put(&collision.receipt_id, &collision)?; let authority_envelope = SingleFileMessagePackBackingStore::new(&store) @@ -908,7 +957,7 @@ mod tests { head: source_head(), envelopes: vec![authority_envelope], }, - receipt(), + receipt_for_claim(&claim), "2026-07-15T04:03:00Z", ) .is_err() @@ -944,7 +993,7 @@ mod tests { head: source_head(), envelopes: vec![authority_envelope], }, - receipt(), + receipt_for_claim(&claim), "2026-07-15T04:03:00Z", )?; let mut cache = semantic_projector_cache(&store)?; @@ -965,4 +1014,102 @@ mod tests { assert_ne!(reopened.claim_id, terminal.claim_id); Ok(()) } + + #[test] + fn readiness_refuses_receipt_namespace_substitution_and_invented_epoch() -> Result<()> { + let temp = tempdir()?; + let store = temp.path().join("receipt-namespace-substitution.msgpack"); + let obligation = obligation(); + let mut cache = semantic_projector_cache(&store)?; + cache.put(&obligation.obligation_id, &obligation)?; + let claim = claim_memory_semantic_projection( + &store, + &obligation, + "executor-a", + "2026-07-15T04:01:00Z", + )?; + let authority_envelope = SingleFileMessagePackBackingStore::new(&store) + .pull_all()? + .into_iter() + .find(|envelope| { + envelope.r#type == "gamecult.epiphany.memory_semantic_projection_obligation" + && envelope.key == obligation.obligation_id + }) + .expect("obligation envelope"); + let bound = succeed_memory_semantic_projection_claim( + &store, + &claim.claim_id, + &MemorySemanticProjectionAuthoritySnapshot { + head: source_head(), + envelopes: vec![authority_envelope], + }, + receipt_for_claim(&claim), + "2026-07-15T04:03:00Z", + )?; + + let mut cache = semantic_projector_cache(&store)?; + cache.pull_all_backing_stores()?; + cache.delete::(&bound.receipt_id)?; + let mut substituted = bound.clone(); + substituted.claim_id = "invented-claim".to_string(); + cache.put(&substituted.receipt_id, &substituted)?; + assert!( + load_memory_semantic_projection_success(&store, &obligation, &source_head()).is_err() + ); + + cache.delete::(&substituted.receipt_id)?; + let mut invented_epoch = bound; + invented_epoch.claim_epoch = claim.epoch + 99; + cache.put(&invented_epoch.receipt_id, &invented_epoch)?; + assert!( + load_memory_semantic_projection_success(&store, &obligation, &source_head()).is_err() + ); + Ok(()) + } + + #[test] + fn readiness_refuses_succeeded_claim_with_substituted_attempt_obligation() -> Result<()> { + let temp = tempdir()?; + let store = temp.path().join("attempt-substitution.msgpack"); + let obligation = obligation(); + let mut cache = semantic_projector_cache(&store)?; + cache.put(&obligation.obligation_id, &obligation)?; + let claim = claim_memory_semantic_projection( + &store, + &obligation, + "executor-a", + "2026-07-15T04:01:00Z", + )?; + let authority_envelope = SingleFileMessagePackBackingStore::new(&store) + .pull_all()? + .into_iter() + .find(|envelope| { + envelope.r#type == "gamecult.epiphany.memory_semantic_projection_obligation" + && envelope.key == obligation.obligation_id + }) + .expect("obligation envelope"); + succeed_memory_semantic_projection_claim( + &store, + &claim.claim_id, + &MemorySemanticProjectionAuthoritySnapshot { + head: source_head(), + envelopes: vec![authority_envelope], + }, + receipt_for_claim(&claim), + "2026-07-15T04:03:00Z", + )?; + + let mut cache = semantic_projector_cache(&store)?; + cache.pull_all_backing_stores()?; + let mut attempt = cache + .get::(&claim.attempt_id)? + .expect("terminal attempt"); + cache.delete::(&attempt.attempt_id)?; + attempt.obligation_id = "invented-obligation".to_string(); + cache.put(&attempt.attempt_id, &attempt)?; + assert!( + load_memory_semantic_projection_success(&store, &obligation, &source_head()).is_err() + ); + Ok(()) + } } diff --git a/notes/fresh-workspace-handoff.md b/notes/fresh-workspace-handoff.md index 99500991..b05b6494 100644 --- a/notes/fresh-workspace-handoff.md +++ b/notes/fresh-workspace-handoff.md @@ -1139,3 +1139,33 @@ Next: derive and publish provider-owned health through CultMesh, make Idunn own projector process survival/restart and explicit fenced recovery routing, then run the deployability audit against daemon packaging, configuration, and live Yggdrasil assumptions. Neither CultMesh nor Idunn may mint readiness. + +## Physical semantic fence correction (2026-07-15) + +Hostile recovery review found that the logical claim epoch fenced CultCache +terminal receipts but did not fence the Qdrant actuator. A resumed old process +could still mutate the shared `(swarm, partition)` points after replacement. +The physical projection is now isolated by exact obligation, claim id, and +claim epoch in its UUIDs, typed payload, mutation/observation filters, success +receipt v1, and opaque-readiness-selected query filter. Old receipt v0 decodes +only for migration inspection and cannot become ready. A second process cannot +share a running claim merely by reusing its executor label. + +The readiness loader now authenticates the full persisted success chain: +obligation, succeeded scope claim, exact claim id/epoch on the receipt, and the +claim's succeeded attempt. Hostile substituted claim, epoch, and attempt +evidence fails closed. The live Mind and Modeling projections were rebuilt into +claim-owned namespaces (43 and 3 documents at 1024 dimensions), and exact live +queries used Qdrant ranking while resolving canonical documents. Core proof is +353 passed, 0 failed, 1 intentional ignore. + +Deployment Eyes found the live boundary: local Qdrant and Ollama work, but no +Epiphany/Idunn projector service is installed. New Yggdrasil has healthy GPU +Ollama, Odin, and Idunn, but no Qdrant, Epiphany artifact/config, or projector +target. Local Qdrant is unexpectedly published on all host interfaces. Do not +deploy dual projectors or silently move canonical Mind/Modeling stores. + +Next: cut Idunn supervisor writes to provider-owned daemon heartbeat/status; +then publish derived semantic health through CultMesh and add typed Idunn +executor/recovery authority plus open-obligation discovery. Recovery remains +withheld until that authority exists. diff --git a/notes/memory-graph-semantic-projection-authority-map.md b/notes/memory-graph-semantic-projection-authority-map.md index 54871cf3..f570130a 100644 --- a/notes/memory-graph-semantic-projection-authority-map.md +++ b/notes/memory-graph-semantic-projection-authority-map.md @@ -391,3 +391,121 @@ CultMesh, attach Idunn-owned daemon survival and authorize the fenced recovery transition with typed stale/recovery evidence, and add the daemon pulse that discovers open obligations without letting time own their creation or completion. + +## CultMesh Health And Idunn Recovery Authority Map (2026-07-15) + +### Owner + +- Canonical Mind and Modeling admission own projection demand through the exact + source head and semantic-projection obligation in their source store. +- The projector executor owns only its scope claim, projection mutation, + post-write observation, attempt terminalization, and exact success receipt. +- The provider-side health projector owns recomputing and publishing derived + sight from that canonical evidence. It cannot mint the opaque readiness token + consumed by query. +- Idunn owns projector process survival, restart policy, stale-process + observation, and explicit recovery authorization. The projector still owns + the fenced recovery CAS; Idunn cannot write projection completion. +- CultMesh transports the provider-owned health projection. Eve, Gjallar, and + other downstream consumers lower or render it without acquiring authority. + +### Inputs And Outputs + +- Health input is an authenticated `MemorySemanticProjectionInput` plus the + exact claims, attempts, and receipts in the same canonical source store. +- Recovery input binds the exact swarm, partition, claim id, epoch, abandoned + executor incarnation, replacement executor incarnation, and Idunn evidence + that the abandoned process is dead or otherwise physically unable to write. + Elapsed time alone may nominate a stale claim for inspection; it cannot grant + fencing authority. +- Health output is one typed provider-authored CultMesh document per + `(swarm_id, partition)`: canonical source identity and generation, obligation + id, derived `pending|failed|stale|ready`, optional exact receipt and latest + attempt/error, provider incarnation, observation time, and private-state + seal. Any `queryEligible` field is display-only. +- Recovery output is an Idunn authorization/receipt paired with the projector's + epoch-advancing CAS. The old running attempt becomes terminally failed and the + replacement claim becomes the only logical completion writer. + +### Physical Epoch Isolation + +Projector mutation is now confined to the exact +`(obligation_id, claim_id, claim_epoch)` namespace. Qdrant payload filters bind +all three identities, physical point UUIDs include all three identities plus +the canonical locator point id, and payload `pointId` remains the canonical +locator identity. Empty synchronization, upsert, observation, deletion, and +query therefore address one claim incarnation only. + +Success receipts use schema v1 and bind `claim_id` and `claim_epoch`. Legacy +receipts decode with empty/zero defaults but cannot become query eligible. +Opaque readiness carries the exact successful receipt, and query derives its +Qdrant filter only from that receipt. Activation is consequently the CultCache +terminal-success CAS selecting an already-observed physical namespace; a +fenced writer can continue harming only its abandoned namespace. + +This cut makes the following structurally true: + +- an executor can upsert, observe, query, and delete only within its own epoch; +- the exact success receipt binds the epoch namespace that query will filter or + address; +- activation of a successful epoch is atomic from the query gate's perspective; +- a superseded executor resuming late cannot mutate the active epoch; +- retirement and garbage collection of old epochs are separate derived + maintenance and cannot decide readiness. + +Executor labels are diagnostic identity, not reusable capabilities: a second +claim call is refused even when it presents the same `executor_id`. Recovery +remains withheld until Idunn provides typed authorization; this physical cut +does not invent that grant. + +### Derived State And Forbidden Writers + +- Projection health, staleness, Qdrant counts, collection existence, process + liveness, timestamps, and latest error are observation/cache state only. +- Idunn command exit is not provider heartbeat, semantic readiness, or proof + that a restarted child initialized successfully. +- The health mirror, Self, operator commands, CultMesh, Eve, and Gjallar cannot + create obligations, attempts, receipts, canonical state, or readiness. +- Arbitrary peers cannot invoke recovery with free-form claim and reason + strings. Production recovery requires store-authenticated typed Idunn + authorization and physical epoch isolation. + +### Provider-Status Ownership Cut + +`epiphany-cluster-daemon` is the legitimate production writer of its typed +heartbeat/status. The current daemon supervisor contains two obsolete writers +that must be removed before its liveness model is trusted: + +- scheduler staleness handling rewrites the provider status to `degraded` and + adds a supervisor-authored heartbeat note; +- restart reconciliation rewrites provider status to `ready` or `down` and + advances `last_heartbeat_utc` from the child command's exit result. + +Idunn should instead record stale observation, command execution, restart, and +awaiting-provider-heartbeat in its own scheduler/poke/recovery receipts. A +restarted provider becomes ready only when that provider publishes a newer +authentic heartbeat. The generic daemon-status writer should be narrowed so +production callers cannot counterfeit provider liveness; synthetic writers +remain confined to test/quarantine bodies. + +### Shared Paths And Verification Layer + +- Initial execution, retry, crash replay, scheduled rediscovery, and operator + request share the same projector execution primitive. A stuck running claim + branches only through typed Idunn recovery, then re-enters that path. +- Mind and Modeling authenticate from distinct canonical stores but publish the + same renderer-neutral CultMesh health schema. +- The daemon pulse discovers current open obligations after restart. It never + creates an obligation or infers completion from time, process state, or + Qdrant existence. +- Negative verification must prove supervisor tick/reconcile cannot change a + provider status envelope and command exit zero cannot produce a heartbeat. +- Recovery verification must suspend an old executor at each mutation phase, + recover under a new epoch, resume the old executor after replacement success, + and prove it cannot alter the active epoch or publish terminal success. +- Publication verification must derive every health state from canonical + evidence, repair a missing/stale CultMesh mirror without changing readiness, + and prove hostile mirror state cannot open semantic query. +- Restart verification must rediscover open Mind and Modeling obligations, + preserve exact-success idempotence, and require a real provider heartbeat + before Idunn reports the projector body ready. diff --git a/state/agents.msgpack b/state/agents.msgpack index 26c21a82ed757526e2986116a59843fe2098d42b..92e0b34dd84ef62a11c60b23b0742cf680de25df 100644 GIT binary patch delta 701 zcmb`Dy-Pw-9EQ6$NHB#Gs--ESC61nRKhE_y6}2>IUef}@;hryONTVp)nqB(|{Rt5f z36?~GNMK{oD6~X-b5rys)EL=;W&P!A;=O+RKpJDZs6mk$Yl;T!+?xQBd9 z@*JwU==6^mR=l#XMG^K8ar3w*I?ikg*SXW-Uj7h|U~A>F5EZHR(dI^3 zA}Y13T@fHnJc1mT36vBxK*V+f&Z$MUr_Ga{T_Kc`N`AbB^+I^&F9wP=DCI@trI0t@ E0k>t^-T(jq delta 143 zcmX@MoAJ;F#tlh4+yYaU2c#Ai=jSDEo&1ndbn-cE_RXC<<}Q;JLklL)3~^#Ju`;x< zGT8huB!F?UWyD-=2;Va5D3jFWhY@Qh2ZSz~Y!GDtq#ZQ(rWRxtWF+QQ>gMDpC+6rD s6y;~7CYR(F>E>qUr7*56E=^8OO-)Hnnfxi#h;ho~386`w3!_7s0BJKfApigX diff --git a/state/ledgers.msgpack b/state/ledgers.msgpack index 7d3c2e4a79f37dd9bc78ce52e846db6b1cf3d412..c5d69683cfff68ab66ec36abbbd31cb3628989f5 100644 GIT binary patch delta 480 zcmX|7u}T9$6!c;=m0b`7kq5R(?qZBeQfeb8D451>v-@IJ_jb?Q-C!D1SO`{v#(aU; zhzb_s5BLlIigPyYl_#TC}!NjW( z=2D-)08`nMC|YnRQlg;K+1&{fHdKa*F!qi)X>@q*F&%>CCPC;*EiB*#;|@$$$&-Ag z0$ec}SAb~&m>Aw~Ly8<_;zls2L!!I|lQ2@EITKQ&wZOdVV-|=xv|v5whyuk_E0