Seal pre-readiness process death
This commit is contained in:
parent
3d5d729ae4
commit
9f9f9fe304
6 changed files with 313 additions and 42 deletions
|
|
@ -6,10 +6,11 @@ use crate::default_mind_cultnet_contracts;
|
|||
use crate::default_soul_cultnet_contracts;
|
||||
use crate::default_substrate_gate_cultnet_contracts;
|
||||
use crate::workspace_coverage_process_documents::{
|
||||
WORKSPACE_COVERAGE_PROCESS_EVIDENCE_HEAD_SCHEMA_VERSION,
|
||||
WORKSPACE_COVERAGE_PROCESS_LAUNCH_SCHEMA_VERSION,
|
||||
WORKSPACE_COVERAGE_PROCESS_TERMINATION_SCHEMA_VERSION,
|
||||
WORKSPACE_COVERAGE_PROVIDER_HEARTBEAT_SCHEMA_VERSION,
|
||||
WorkspaceCoverageManagedProcessLaunchEntry,
|
||||
WorkspaceCoverageManagedProcessLaunchEntry, WorkspaceCoverageProcessEvidenceHead,
|
||||
WorkspaceCoverageProcessTerminationObservationEntry, WorkspaceCoverageProviderHeartbeatEntry,
|
||||
};
|
||||
use anyhow::Context;
|
||||
|
|
@ -2646,6 +2647,7 @@ cultmesh_documents!(EpiphanyCultMeshDocuments {
|
|||
EpiphanyCultMeshDaemonServiceLifecycleReceiptEntry => EPIPHANY_CULTMESH_DAEMON_SERVICE_LIFECYCLE_RECEIPT_SCHEMA_VERSION,
|
||||
EpiphanyCultMeshManagedServicePolicyEntry => EPIPHANY_CULTMESH_MANAGED_SERVICE_POLICY_SCHEMA_VERSION,
|
||||
WorkspaceCoverageManagedProcessLaunchEntry => WORKSPACE_COVERAGE_PROCESS_LAUNCH_SCHEMA_VERSION,
|
||||
WorkspaceCoverageProcessEvidenceHead => WORKSPACE_COVERAGE_PROCESS_EVIDENCE_HEAD_SCHEMA_VERSION,
|
||||
WorkspaceCoverageProviderHeartbeatEntry => WORKSPACE_COVERAGE_PROVIDER_HEARTBEAT_SCHEMA_VERSION,
|
||||
WorkspaceCoverageProcessTerminationObservationEntry => WORKSPACE_COVERAGE_PROCESS_TERMINATION_SCHEMA_VERSION,
|
||||
EpiphanyCultMeshIdunnDeploymentReceiptEntry => EPIPHANY_CULTMESH_IDUNN_DEPLOYMENT_RECEIPT_SCHEMA_VERSION,
|
||||
|
|
|
|||
|
|
@ -30,7 +30,9 @@ pub const WORKSPACE_COVERAGE_PROVIDER_HEARTBEAT_LATEST_KEY: &str =
|
|||
pub const WORKSPACE_COVERAGE_PROCESS_TERMINATION_TYPE: &str =
|
||||
"epiphany.workspace_coverage.process_termination_observation";
|
||||
pub const WORKSPACE_COVERAGE_PROCESS_TERMINATION_SCHEMA_VERSION: &str =
|
||||
"epiphany.workspace_coverage.process_termination_observation.v0";
|
||||
"epiphany.workspace_coverage.process_termination_observation.v1";
|
||||
pub(crate) const WORKSPACE_COVERAGE_PROCESS_EVIDENCE_HEAD_SCHEMA_VERSION: &str =
|
||||
"epiphany.workspace_coverage.process_evidence_head.v0";
|
||||
|
||||
const HOST_LAUNCH_PURPOSE: &str = "epiphany.workspace-coverage.managed-process-launch.v0";
|
||||
const PROVIDER_HEARTBEAT_DOMAIN: &[u8] =
|
||||
|
|
@ -170,9 +172,9 @@ pub struct WorkspaceCoverageProcessTerminationObservationEntry {
|
|||
#[cultcache(key = 3)]
|
||||
pub launch_envelope_digest: String,
|
||||
#[cultcache(key = 4)]
|
||||
pub heartbeat_id: String,
|
||||
#[cultcache(key = 5)]
|
||||
pub heartbeat_envelope_digest: String,
|
||||
pub heartbeat_id: Option<String>,
|
||||
#[cultcache(key = 5, default)]
|
||||
pub heartbeat_envelope_digest: Option<String>,
|
||||
#[cultcache(key = 6)]
|
||||
pub policy_id: String,
|
||||
#[cultcache(key = 7)]
|
||||
|
|
@ -215,6 +217,26 @@ pub struct WorkspaceCoverageProcessTerminationObservationEntry {
|
|||
pub signature_algorithm: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
|
||||
#[cultcache(
|
||||
type = "epiphany.workspace_coverage.process_evidence_head",
|
||||
schema = "WorkspaceCoverageProcessEvidenceHead"
|
||||
)]
|
||||
pub(crate) struct WorkspaceCoverageProcessEvidenceHead {
|
||||
#[cultcache(key = 0)]
|
||||
schema_version: String,
|
||||
#[cultcache(key = 1)]
|
||||
launch_id: String,
|
||||
#[cultcache(key = 2)]
|
||||
generation: u64,
|
||||
#[cultcache(key = 3)]
|
||||
state: String,
|
||||
#[cultcache(key = 4, default)]
|
||||
heartbeat_id: Option<String>,
|
||||
#[cultcache(key = 5, default)]
|
||||
termination_id: Option<String>,
|
||||
}
|
||||
|
||||
trait WorkspaceCoverageProcessObservationSource {
|
||||
fn boot_identity(&self) -> Option<String>;
|
||||
fn observe(&self, expected: &ProcessInstanceIdentity) -> ProcessInstanceObservation;
|
||||
|
|
@ -292,8 +314,8 @@ struct TerminationStatement<'a> {
|
|||
termination_id: &'a str,
|
||||
launch_id: &'a str,
|
||||
launch_envelope_digest: &'a str,
|
||||
heartbeat_id: &'a str,
|
||||
heartbeat_envelope_digest: &'a str,
|
||||
heartbeat_id: &'a Option<String>,
|
||||
heartbeat_envelope_digest: &'a Option<String>,
|
||||
policy_id: &'a str,
|
||||
policy_envelope_digest: &'a str,
|
||||
runtime_id: &'a str,
|
||||
|
|
@ -511,10 +533,29 @@ pub fn write_workspace_coverage_managed_process_launch(
|
|||
}
|
||||
bail!("workspace coverage launch identity collision");
|
||||
}
|
||||
let evidence_head_key = process_evidence_head_key(&entry.launch_id);
|
||||
if node
|
||||
.cache()
|
||||
.get_envelope::<WorkspaceCoverageProcessEvidenceHead>(&evidence_head_key)?
|
||||
.is_some()
|
||||
{
|
||||
bail!("workspace coverage launch evidence head identity collision");
|
||||
}
|
||||
let evidence_head = WorkspaceCoverageProcessEvidenceHead {
|
||||
schema_version: WORKSPACE_COVERAGE_PROCESS_EVIDENCE_HEAD_SCHEMA_VERSION.into(),
|
||||
launch_id: entry.launch_id.clone(),
|
||||
generation: 1,
|
||||
state: "launched".into(),
|
||||
heartbeat_id: None,
|
||||
termination_id: None,
|
||||
};
|
||||
let mut expected = vec![policy_envelope.clone()];
|
||||
let mut replacements = vec![
|
||||
policy_envelope,
|
||||
node.cache().prepare_entry(&identity_key, &entry)?.0,
|
||||
node.cache()
|
||||
.prepare_entry(&evidence_head_key, &evidence_head)?
|
||||
.0,
|
||||
];
|
||||
if let (Some(old_launch_id), Some(termination_id), Some(termination_digest)) = (
|
||||
entry.replaces_launch_id.as_deref(),
|
||||
|
|
@ -601,9 +642,34 @@ pub fn write_workspace_coverage_provider_heartbeat(
|
|||
}
|
||||
bail!("workspace coverage heartbeat identity collision");
|
||||
}
|
||||
let mut expected = vec![launch_envelope.clone()];
|
||||
let evidence_head_key = process_evidence_head_key(&entry.launch_id);
|
||||
let evidence_head_envelope = node
|
||||
.cache()
|
||||
.get_envelope::<WorkspaceCoverageProcessEvidenceHead>(&evidence_head_key)?
|
||||
.ok_or_else(|| anyhow!("workspace coverage heartbeat process evidence head is absent"))?;
|
||||
let evidence_head: WorkspaceCoverageProcessEvidenceHead =
|
||||
rmp_serde::from_slice(&evidence_head_envelope.payload)?;
|
||||
validate_process_evidence_head(&evidence_head, &entry.launch_id)?;
|
||||
if evidence_head.state == "terminated" {
|
||||
bail!("workspace coverage heartbeat cannot advance a terminated process");
|
||||
}
|
||||
let next_evidence_head = WorkspaceCoverageProcessEvidenceHead {
|
||||
schema_version: WORKSPACE_COVERAGE_PROCESS_EVIDENCE_HEAD_SCHEMA_VERSION.into(),
|
||||
launch_id: entry.launch_id.clone(),
|
||||
generation: evidence_head
|
||||
.generation
|
||||
.checked_add(1)
|
||||
.ok_or_else(|| anyhow!("workspace coverage process evidence generation exhausted"))?,
|
||||
state: "heartbeat".into(),
|
||||
heartbeat_id: Some(entry.heartbeat_id.clone()),
|
||||
termination_id: None,
|
||||
};
|
||||
let mut expected = vec![launch_envelope.clone(), evidence_head_envelope.clone()];
|
||||
let mut replacements = vec![
|
||||
launch_envelope,
|
||||
node.cache()
|
||||
.prepare_entry(&evidence_head_key, &next_evidence_head)?
|
||||
.0,
|
||||
node.cache().prepare_entry(&identity_key, &entry)?.0,
|
||||
];
|
||||
let latest_key = heartbeat_latest_key(&entry.launch_id);
|
||||
|
|
@ -858,13 +924,38 @@ fn write_workspace_coverage_process_termination_observation_with_source(
|
|||
bail!("workspace coverage termination launch disagrees with current managed policy");
|
||||
}
|
||||
|
||||
let heartbeat_latest_envelope = node
|
||||
let evidence_head_key = process_evidence_head_key(launch_id);
|
||||
let evidence_head_envelope = node
|
||||
.cache()
|
||||
.get_envelope::<WorkspaceCoverageProviderHeartbeatEntry>(&heartbeat_latest_key(launch_id))?
|
||||
.ok_or_else(|| anyhow!("workspace coverage launch has no provider heartbeat"))?;
|
||||
let heartbeat: WorkspaceCoverageProviderHeartbeatEntry =
|
||||
rmp_serde::from_slice(&heartbeat_latest_envelope.payload)?;
|
||||
authenticate_heartbeat_against_launch(&heartbeat, &launch, &envelope_digest(&launch_envelope))?;
|
||||
.get_envelope::<WorkspaceCoverageProcessEvidenceHead>(&evidence_head_key)?
|
||||
.ok_or_else(|| anyhow!("workspace coverage termination process evidence head is absent"))?;
|
||||
let evidence_head: WorkspaceCoverageProcessEvidenceHead =
|
||||
rmp_serde::from_slice(&evidence_head_envelope.payload)?;
|
||||
validate_process_evidence_head(&evidence_head, launch_id)?;
|
||||
if evidence_head.state == "terminated" {
|
||||
bail!("workspace coverage process already has terminal evidence");
|
||||
}
|
||||
let heartbeat_evidence = if let Some(heartbeat_id) = evidence_head.heartbeat_id.as_deref() {
|
||||
let envelope = node
|
||||
.cache()
|
||||
.get_envelope::<WorkspaceCoverageProviderHeartbeatEntry>(&heartbeat_latest_key(
|
||||
launch_id,
|
||||
))?
|
||||
.ok_or_else(|| anyhow!("workspace coverage evidence head heartbeat is absent"))?;
|
||||
let heartbeat: WorkspaceCoverageProviderHeartbeatEntry =
|
||||
rmp_serde::from_slice(&envelope.payload)?;
|
||||
authenticate_heartbeat_against_launch(
|
||||
&heartbeat,
|
||||
&launch,
|
||||
&envelope_digest(&launch_envelope),
|
||||
)?;
|
||||
if heartbeat.heartbeat_id != heartbeat_id {
|
||||
bail!("workspace coverage evidence head disagrees with latest heartbeat");
|
||||
}
|
||||
Some((envelope, heartbeat))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let observed_boot_identity = source
|
||||
.boot_identity()
|
||||
|
|
@ -897,8 +988,12 @@ fn write_workspace_coverage_process_termination_observation_with_source(
|
|||
termination_id: launch.launch_id.clone(),
|
||||
launch_id: launch.launch_id.clone(),
|
||||
launch_envelope_digest: envelope_digest(&launch_envelope),
|
||||
heartbeat_id: heartbeat.heartbeat_id.clone(),
|
||||
heartbeat_envelope_digest: envelope_digest(&heartbeat_latest_envelope),
|
||||
heartbeat_id: heartbeat_evidence
|
||||
.as_ref()
|
||||
.map(|(_, heartbeat)| heartbeat.heartbeat_id.clone()),
|
||||
heartbeat_envelope_digest: heartbeat_evidence
|
||||
.as_ref()
|
||||
.map(|(envelope, _)| envelope_digest(envelope)),
|
||||
policy_id: policy.policy_id.clone(),
|
||||
policy_envelope_digest: envelope_digest(&policy_envelope),
|
||||
runtime_id,
|
||||
|
|
@ -928,20 +1023,38 @@ fn write_workspace_coverage_process_termination_observation_with_source(
|
|||
validate_termination(&entry, host.entry())?;
|
||||
|
||||
let key = termination_key(launch_id);
|
||||
let terminal_head = WorkspaceCoverageProcessEvidenceHead {
|
||||
schema_version: WORKSPACE_COVERAGE_PROCESS_EVIDENCE_HEAD_SCHEMA_VERSION.into(),
|
||||
launch_id: launch_id.into(),
|
||||
generation: evidence_head
|
||||
.generation
|
||||
.checked_add(1)
|
||||
.ok_or_else(|| anyhow!("workspace coverage process evidence generation exhausted"))?,
|
||||
state: "terminated".into(),
|
||||
heartbeat_id: evidence_head.heartbeat_id.clone(),
|
||||
termination_id: Some(entry.termination_id.clone()),
|
||||
};
|
||||
let replacement = node.cache().prepare_entry(&key, &entry)?.0;
|
||||
if !SingleFileMessagePackBackingStore::new(store_path).compare_and_swap_batch(
|
||||
&[
|
||||
policy_envelope.clone(),
|
||||
launch_envelope.clone(),
|
||||
heartbeat_latest_envelope.clone(),
|
||||
],
|
||||
vec![
|
||||
policy_envelope,
|
||||
launch_envelope,
|
||||
heartbeat_latest_envelope,
|
||||
replacement,
|
||||
],
|
||||
)? {
|
||||
let mut expected = vec![
|
||||
policy_envelope.clone(),
|
||||
launch_envelope.clone(),
|
||||
evidence_head_envelope,
|
||||
];
|
||||
let mut replacements = vec![
|
||||
policy_envelope,
|
||||
launch_envelope,
|
||||
node.cache()
|
||||
.prepare_entry(&evidence_head_key, &terminal_head)?
|
||||
.0,
|
||||
replacement,
|
||||
];
|
||||
if let Some((heartbeat_envelope, _)) = heartbeat_evidence {
|
||||
expected.push(heartbeat_envelope.clone());
|
||||
replacements.push(heartbeat_envelope);
|
||||
}
|
||||
if !SingleFileMessagePackBackingStore::new(store_path)
|
||||
.compare_and_swap_batch(&expected, replacements)?
|
||||
{
|
||||
bail!("workspace coverage termination lost exact policy/launch/heartbeat CAS or collided");
|
||||
}
|
||||
Ok(entry)
|
||||
|
|
@ -989,20 +1102,51 @@ pub fn authenticate_workspace_coverage_process_termination_observation(
|
|||
let launch: WorkspaceCoverageManagedProcessLaunchEntry =
|
||||
rmp_serde::from_slice(&launch_envelope.payload)?;
|
||||
validate_launch(&launch, host)?;
|
||||
let heartbeat_envelope = node
|
||||
.cache()
|
||||
.get_envelope::<WorkspaceCoverageProviderHeartbeatEntry>(&heartbeat_latest_key(launch_id))?
|
||||
.ok_or_else(|| anyhow!("workspace coverage termination heartbeat evidence is absent"))?;
|
||||
let heartbeat: WorkspaceCoverageProviderHeartbeatEntry =
|
||||
rmp_serde::from_slice(&heartbeat_envelope.payload)?;
|
||||
authenticate_heartbeat_against_launch(&heartbeat, &launch, &envelope_digest(&launch_envelope))?;
|
||||
let evidence_head: WorkspaceCoverageProcessEvidenceHead = node
|
||||
.get(&process_evidence_head_key(launch_id))?
|
||||
.ok_or_else(|| anyhow!("workspace coverage termination process evidence head is absent"))?;
|
||||
validate_process_evidence_head(&evidence_head, launch_id)?;
|
||||
if evidence_head.state != "terminated"
|
||||
|| evidence_head.termination_id.as_deref() != Some(entry.termination_id.as_str())
|
||||
|| evidence_head.heartbeat_id != entry.heartbeat_id
|
||||
{
|
||||
bail!("workspace coverage termination disagrees with process evidence head");
|
||||
}
|
||||
let heartbeat_evidence = match (
|
||||
entry.heartbeat_id.as_deref(),
|
||||
entry.heartbeat_envelope_digest.as_deref(),
|
||||
) {
|
||||
(Some(heartbeat_id), Some(expected_digest)) => {
|
||||
let envelope = node
|
||||
.cache()
|
||||
.get_envelope::<WorkspaceCoverageProviderHeartbeatEntry>(&heartbeat_latest_key(
|
||||
launch_id,
|
||||
))?
|
||||
.ok_or_else(|| {
|
||||
anyhow!("workspace coverage termination heartbeat evidence is absent")
|
||||
})?;
|
||||
let heartbeat: WorkspaceCoverageProviderHeartbeatEntry =
|
||||
rmp_serde::from_slice(&envelope.payload)?;
|
||||
authenticate_heartbeat_against_launch(
|
||||
&heartbeat,
|
||||
&launch,
|
||||
&envelope_digest(&launch_envelope),
|
||||
)?;
|
||||
if heartbeat.heartbeat_id != heartbeat_id
|
||||
|| envelope_digest(&envelope) != expected_digest
|
||||
{
|
||||
bail!("workspace coverage termination heartbeat evidence disagrees");
|
||||
}
|
||||
Some(heartbeat)
|
||||
}
|
||||
(None, None) => None,
|
||||
_ => bail!("workspace coverage termination has partial heartbeat evidence"),
|
||||
};
|
||||
if entry.policy_id != policy.policy_id
|
||||
|| entry.policy_envelope_digest != envelope_digest(&policy_envelope)
|
||||
|| launch.policy_id != policy.policy_id
|
||||
|| launch.policy_envelope_digest != entry.policy_envelope_digest
|
||||
|| entry.launch_envelope_digest != envelope_digest(&launch_envelope)
|
||||
|| entry.heartbeat_id != heartbeat.heartbeat_id
|
||||
|| entry.heartbeat_envelope_digest != envelope_digest(&heartbeat_envelope)
|
||||
|| entry.host_identity_id != launch.host_identity_id
|
||||
|| entry.host_identity_record_digest != launch.host_identity_record_digest
|
||||
|| entry.expected_boot_identity != launch.boot_identity
|
||||
|
|
@ -1012,6 +1156,13 @@ pub fn authenticate_workspace_coverage_process_termination_observation(
|
|||
{
|
||||
bail!("workspace coverage termination evidence chain disagrees with its exact sources");
|
||||
}
|
||||
if heartbeat_evidence
|
||||
.as_ref()
|
||||
.map(|heartbeat| heartbeat.heartbeat_id.as_str())
|
||||
!= entry.heartbeat_id.as_deref()
|
||||
{
|
||||
bail!("workspace coverage termination heartbeat identity disagrees");
|
||||
}
|
||||
Ok(entry)
|
||||
}
|
||||
|
||||
|
|
@ -1246,12 +1397,21 @@ fn validate_termination(
|
|||
bail!("workspace coverage termination violates its reserved authority");
|
||||
}
|
||||
uuid::Uuid::parse_str(&entry.launch_id).context("termination launch id must be UUID")?;
|
||||
uuid::Uuid::parse_str(&entry.heartbeat_id).context("termination heartbeat id must be UUID")?;
|
||||
match (
|
||||
entry.heartbeat_id.as_deref(),
|
||||
entry.heartbeat_envelope_digest.as_deref(),
|
||||
) {
|
||||
(Some(id), Some(digest)) => {
|
||||
uuid::Uuid::parse_str(id).context("termination heartbeat id must be UUID")?;
|
||||
validate_digest("heartbeat", digest)?;
|
||||
}
|
||||
(None, None) => {}
|
||||
_ => bail!("termination heartbeat evidence is partial"),
|
||||
}
|
||||
DateTime::parse_from_rfc3339(&entry.observed_at_utc)
|
||||
.context("termination observation time must be RFC3339")?;
|
||||
for (label, digest) in [
|
||||
("launch", &entry.launch_envelope_digest),
|
||||
("heartbeat", &entry.heartbeat_envelope_digest),
|
||||
("policy", &entry.policy_envelope_digest),
|
||||
("host identity record", &entry.host_identity_record_digest),
|
||||
] {
|
||||
|
|
@ -1384,6 +1544,31 @@ fn heartbeat_latest_key(launch_id: &str) -> String {
|
|||
fn termination_key(launch_id: &str) -> String {
|
||||
format!("epiphany-local/workspace-coverage/process-termination/{launch_id}")
|
||||
}
|
||||
fn process_evidence_head_key(launch_id: &str) -> String {
|
||||
format!("epiphany-local/workspace-coverage/process-evidence-head/{launch_id}")
|
||||
}
|
||||
|
||||
fn validate_process_evidence_head(
|
||||
head: &WorkspaceCoverageProcessEvidenceHead,
|
||||
launch_id: &str,
|
||||
) -> Result<()> {
|
||||
if head.schema_version != WORKSPACE_COVERAGE_PROCESS_EVIDENCE_HEAD_SCHEMA_VERSION
|
||||
|| head.launch_id != launch_id
|
||||
|| head.generation == 0
|
||||
{
|
||||
bail!("workspace coverage process evidence head is invalid");
|
||||
}
|
||||
match head.state.as_str() {
|
||||
"launched"
|
||||
if head.generation == 1
|
||||
&& head.heartbeat_id.is_none()
|
||||
&& head.termination_id.is_none() => {}
|
||||
"heartbeat" if head.heartbeat_id.is_some() && head.termination_id.is_none() => {}
|
||||
"terminated" if head.termination_id.is_some() => {}
|
||||
_ => bail!("workspace coverage process evidence head state is incoherent"),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn managed_policy_key() -> String {
|
||||
format!(
|
||||
"epiphany-local/managed-service-policy/{}",
|
||||
|
|
@ -1729,7 +1914,7 @@ mod tests {
|
|||
)
|
||||
.expect_err("termination key is immutable")
|
||||
.to_string()
|
||||
.contains("collided")
|
||||
.contains("already has terminal evidence")
|
||||
);
|
||||
let mut advanced_policy = policy()?;
|
||||
advanced_policy.updated_at_utc = "2026-07-16T23:59:59Z".to_string();
|
||||
|
|
@ -1816,6 +2001,60 @@ mod tests {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn termination_before_first_heartbeat_seals_the_process_evidence_head() -> Result<()> {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let store = temp.path().join("verse.ccmp");
|
||||
let host = enroll_host_identity_at(&temp.path().join("host.ccmp"))?;
|
||||
let policy = policy()?;
|
||||
let mut node = open_epiphany_cultmesh_node(&store, "local")?;
|
||||
node.put(managed_policy_key(), &policy)?;
|
||||
let policy_envelope = node
|
||||
.cache()
|
||||
.get_envelope::<EpiphanyCultMeshManagedServicePolicyEntry>(&managed_policy_key())?
|
||||
.context("test policy envelope absent")?;
|
||||
let provider = provider_key();
|
||||
let launch = launch(&policy, envelope_digest(&policy_envelope), &host, &provider)?;
|
||||
write_workspace_coverage_managed_process_launch(
|
||||
&store,
|
||||
"local",
|
||||
launch.clone(),
|
||||
host.entry(),
|
||||
)?;
|
||||
let source = FakeObservation {
|
||||
boot: Some(launch.boot_identity.clone()),
|
||||
process: ProcessInstanceObservation::Missing,
|
||||
};
|
||||
let termination = write_workspace_coverage_process_termination_observation_with_source(
|
||||
&store,
|
||||
"local",
|
||||
&launch.launch_id,
|
||||
&host,
|
||||
&source,
|
||||
)?;
|
||||
assert!(termination.heartbeat_id.is_none());
|
||||
assert!(termination.heartbeat_envelope_digest.is_none());
|
||||
authenticate_workspace_coverage_process_termination_observation(
|
||||
&store,
|
||||
"local",
|
||||
&launch.launch_id,
|
||||
host.entry(),
|
||||
)?;
|
||||
|
||||
let launch_envelope = open_epiphany_cultmesh_node(&store, "local")?
|
||||
.cache()
|
||||
.get_envelope::<WorkspaceCoverageManagedProcessLaunchEntry>(&launch_key(
|
||||
&launch.launch_id,
|
||||
))?
|
||||
.context("test launch envelope absent")?;
|
||||
let pulse = heartbeat(&launch, envelope_digest(&launch_envelope), &provider, 1)?;
|
||||
assert!(
|
||||
write_workspace_coverage_provider_heartbeat(&store, "local", pulse).is_err(),
|
||||
"a late heartbeat must not resurrect a terminated launch"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn body_recovery_is_one_exact_evidence_fenced_transaction() -> Result<()> {
|
||||
let temp = tempfile::tempdir()?;
|
||||
|
|
|
|||
|
|
@ -1905,3 +1905,23 @@ Rebuild termination so signed launch plus enrolled host/boot/process observation
|
|||
is sufficient, with heartbeat optional additional evidence. Then run a
|
||||
GUID-scoped live initial launch -> death -> replacement -> Body recovery smoke.
|
||||
Reboot/logon proof remains permission-bound.
|
||||
|
||||
## Pre-readiness workspace coverage death is recoverable (2026-07-16)
|
||||
|
||||
The heartbeat prerequisite was rebuilt, not relaxed with nullable fields alone.
|
||||
Each specialized launch owns one typed process-evidence head. Launch initializes
|
||||
it; every signed heartbeat advances it by exact CAS; termination advances and
|
||||
seals it by exact CAS. Termination v1 uses signed launch plus enrolled
|
||||
host/boot/process observation as sufficient death proof and includes heartbeat
|
||||
id/digest only when the head names a current signed heartbeat. Heartbeat after
|
||||
termination refuses, so publication and death cannot win on separate keys.
|
||||
|
||||
Focused proof now includes death before heartbeat sequence one, successful
|
||||
heartbeat-free termination authentication, and refusal of a late heartbeat.
|
||||
All six process-document/recovery tests, fourteen supervisor tests, fifteen
|
||||
projector tests plus one ignored live test, and all-target check pass.
|
||||
|
||||
Next: create/run a GUID-scoped live smoke through real packaged supervisor and
|
||||
projector binaries, local Qdrant/Ollama, exact child kill, causal replacement,
|
||||
epoch+1 recovery, and restart-time receipt authentication. Restore the prior
|
||||
Qdrant container state afterward. Reboot/logon remains permission-bound.
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -730,4 +730,5 @@ current_status:
|
|||
- The live workspace-coverage process path now uses only specialized authority. Supervisor generates a per-launch provider key, spawns with reserved stdin piped and all other child stdin null, captures exact process identity, sends one fixed-size launch-id/seed frame, host-signs and CAS-persists the specialized launch, and kills/waits on any bootstrap or persistence failure. The child requires exact frame plus EOF, authenticates the enrolled host, current policy, boot, its PID creation token/path, launch digest, and provider public key before constructing the service or publishing its signed per-launch heartbeat. Generic lifecycle and generic daemon-heartbeat writers reject the reserved coverage identities; their old authenticator is deleted. Body claim/attempt v1 binds `managed_process_launch_id`; v0 lifecycle semantics cannot deserialize as the new owner. This Windows installation is explicitly enrolled at the default DPAPI CurrentUser host store and status re-open proved identity `17f041421045aa66d4c8ab0488f462dbb5ea1a7d8507dbf75216f5ec368dbb7a` without exposing private state.
|
||||
- Idunn now has one immutable, host-signed process-termination observation per reserved coverage launch, with no latest/global mirror. Its writer reloads and exact-CAS-preserves the current specialized policy, exact launch, and exact per-launch latest signed heartbeat; it immediately observes the enrolled host's proven boot and exact PID incarnation. A distinct proven boot yields `boot_superseded`; same-boot proof permits only `exact_exited`, `process_missing`, or a complete different `process_replaced` identity. ExactAlive, Inaccessible, Indeterminate, unknown boot, host mismatch, identity collision, and moved source envelopes refuse publication. The injectable observation seam is private to module tests. Recovery-facing authentication reconstructs the entire persisted policy/launch/heartbeat chain rather than trusting the signed termination blob alone. Four hostile document/termination tests and all-target check pass.
|
||||
- Abandoned workspace-coverage authority now has a complete typed transfer. Recovery derives the Body store from the runtime route, reopens the authenticated Body basis, joins obligation/plan/claim to that basis, authenticates current policy plus exact old launch and immutable termination, requires a signed causal termination-to-replacement edge and the replacement's latest ready heartbeat, then performs one Body CAS over Body authority, plan, old claim/attempt, immutable recovery receipt, failed claim history, and epoch+1 successor. The receipt stores exact termination, launch, and heartbeat envelope digests and can reconstruct both CultMesh evidence and current Body authority after restart. Claim/attempt schema and evidence pairs cannot split; impossible completion-before-start timelines refuse. Idunn's reserved reconciler no longer consults generic lifecycle PID receipts for this service. It persists/reuses termination before spawn, uses the specialized bootstrap launch, waits for signed readiness, and then invokes the recovery CAS. One termination admits one replacement slot; concurrent losers are killed when launch persistence loses CAS.
|
||||
next_action: Close the remaining pre-readiness death seam: a reserved child that dies after launch persistence but before its first heartbeat currently cannot produce termination evidence because termination v0 requires a heartbeat. Rebuild termination evidence so exact launch plus native host/boot/process proof is sufficient and latest heartbeat is optional additional evidence, while preserving immutable causal replacement and recovery receipts. Then run an isolated live initial-launch/death/replacement/Body-recovery smoke. Reboot/logon proof remains permission-bound.
|
||||
- Workspace-coverage launch, heartbeat, and termination now serialize through one typed per-launch process-evidence head. Launch initializes generation 1 as `launched`; each signed heartbeat exact-CAS advances it to `heartbeat`; host-signed termination exact-CAS seals it as `terminated`. Termination v1 accepts exact launch plus enrolled host/boot/process proof before heartbeat sequence one and records heartbeat id/digest only when the head names current signed heartbeat evidence. A heartbeat cannot advance a terminated head. This removes the pre-readiness death strand without allowing heartbeat/termination split authority.
|
||||
next_action: Build and run a GUID-scoped live workspace-coverage recovery smoke through the real packaged supervisor/projector binaries: initial specialized launch, acquired Body claim, exact child death, immutable termination, unique causal replacement, signed readiness, epoch+1 Body recovery, and restart-time recovery-receipt authentication. Use local Qdrant/Ollama only inside isolated fixture state and restore Qdrant container state afterward. Reboot/logon proof remains permission-bound.
|
||||
|
|
|
|||
|
|
@ -157,3 +157,12 @@ signed launch persistence but before heartbeat sequence one can strand the
|
|||
chain. Make heartbeat optional additional termination evidence, then run an
|
||||
isolated live death/replacement/recovery smoke. Reboot still needs live operator
|
||||
approval.
|
||||
|
||||
## Pre-readiness death seam closed (2026-07-16)
|
||||
|
||||
Launch, heartbeat, and termination now contend on one typed per-launch process
|
||||
evidence head. Launch creates generation 1; heartbeat advances it; termination
|
||||
seals it. Termination v1 can carry no heartbeat when the exact signed launch and
|
||||
native host/boot/process proof already establish death. A late heartbeat cannot
|
||||
resurrect the launch. Six focused process tests, supervisor/projector suites,
|
||||
and all-target check pass. Next: real GUID-scoped recovery smoke.
|
||||
|
|
|
|||
Reference in a new issue