Physically fence semantic projection epochs
This commit is contained in:
parent
327a92d78f
commit
5bd1b2bd15
7 changed files with 451 additions and 27 deletions
|
|
@ -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<MemorySemanticIndexReceipt> {
|
||||
|
|
@ -513,9 +531,19 @@ pub(super) fn index_memory_semantic_partition(
|
|||
.collect::<Vec<_>>();
|
||||
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::<Vec<_>>();
|
||||
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::<HashSet<_>>();
|
||||
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::<HashSet<_>>();
|
||||
let observed_ids = observed_ids.into_iter().collect::<HashSet<_>>();
|
||||
if observed_ids != desired_ids {
|
||||
|
|
@ -622,7 +651,12 @@ pub(super) fn index_memory_semantic_partition(
|
|||
.collect::<Result<BTreeMap<_, _>>>()?;
|
||||
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::<BTreeMap<_, _>>();
|
||||
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<EpiphanyMemoryContextPacket> {
|
||||
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::<MemorySemanticPointPayload>(
|
||||
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> {
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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::<MemorySemanticProjectionClaim>(&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::<MemorySemanticProjectionAttempt>(&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::<MemorySemanticProjectionObligation>(&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::<MemorySemanticIndexReceipt>(&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::<MemorySemanticIndexReceipt>(&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::<MemorySemanticProjectionAttempt>(&claim.attempt_id)?
|
||||
.expect("terminal attempt");
|
||||
cache.delete::<MemorySemanticProjectionAttempt>(&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(())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
|
|
@ -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: Complete semantic projection physiology on the landed atomic admission, fenced projector, and exact query gate. Mind and Modeling now emit canonical obligations; the live `gamecult.epiphany.main` obligations are discharged through scope-serialized executor claims, post-write observation, terminal CAS, and immutable receipts; CLI and Persona heartbeat touch Qdrant only for the newest exact success and otherwise use canonical BM25. Next publish provider-owned derived pending/failed/stale/ready health through CultMesh without writable health state, package the projector as an Idunn-owned daemon with explicit fenced recovery routing and open-obligation discovery, and run the deployability audit for local/Yggdrasil Qdrant+Ollama configuration, daemon identity, restart behavior, and operator-safe Eve lowering. Timers, collection metadata, point counts, runtime ids, paths, CLI labels, Idunn liveness, CultMesh mirrors, Eve/Gjallar, and payload prose own no readiness.
|
||||
next_action: Complete semantic projection physiology on the landed atomic admission, physically epoch-isolated projector, and exact query gate. Mind and Modeling now emit canonical obligations; live `gamecult.epiphany.main` projections are isolated in Qdrant by obligation, claim, and epoch, and readiness authenticates the complete succeeded claim/attempt/receipt chain. First cut Idunn supervisor writes to provider-owned daemon heartbeat/status. Then publish provider-owned derived pending/failed/stale/ready health through CultMesh, add typed Idunn executor grants and single-use recovery authorization, and add open-obligation discovery without letting a timer create demand. Package exactly one projector owner after resolving local-versus-Yggdrasil canonical-store placement; Yggdrasil currently has GPU Ollama/Odin/Idunn but no Qdrant or Epiphany target, while local Qdrant is healthy but overexposed on all interfaces. Timers, collection metadata, point counts, runtime ids, paths, CLI labels, Idunn liveness, CultMesh mirrors, Eve/Gjallar, and payload prose own no readiness.
|
||||
|
|
|
|||
Reference in a new issue