diff --git a/epiphany-core/src/cultmesh_integration.rs b/epiphany-core/src/cultmesh_integration.rs index 5092dbb7..76afb80a 100644 --- a/epiphany-core/src/cultmesh_integration.rs +++ b/epiphany-core/src/cultmesh_integration.rs @@ -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, diff --git a/epiphany-core/src/workspace_coverage_process_documents.rs b/epiphany-core/src/workspace_coverage_process_documents.rs index a3b63b65..d70ec359 100644 --- a/epiphany-core/src/workspace_coverage_process_documents.rs +++ b/epiphany-core/src/workspace_coverage_process_documents.rs @@ -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, + #[cultcache(key = 5, default)] + pub heartbeat_envelope_digest: Option, #[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, + #[cultcache(key = 5, default)] + termination_id: Option, +} + trait WorkspaceCoverageProcessObservationSource { fn boot_identity(&self) -> Option; 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, + heartbeat_envelope_digest: &'a Option, 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::(&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::(&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::(&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::(&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::(&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::(&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::(&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::(&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::(&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()?; diff --git a/notes/fresh-workspace-handoff.md b/notes/fresh-workspace-handoff.md index bf97d61a..70781fcb 100644 --- a/notes/fresh-workspace-handoff.md +++ b/notes/fresh-workspace-handoff.md @@ -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. diff --git a/state/ledgers.msgpack b/state/ledgers.msgpack index 798e814f..feefbaab 100644 Binary files a/state/ledgers.msgpack and b/state/ledgers.msgpack differ diff --git a/state/map.yaml b/state/map.yaml index 41477ebf..3644c023 100644 --- a/state/map.yaml +++ b/state/map.yaml @@ -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. diff --git a/state/scratch.md b/state/scratch.md index 9311b3e0..e0edca1f 100644 --- a/state/scratch.md +++ b/state/scratch.md @@ -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.