feat(pncc): recover persisted lifecycle receipt binding

GuangHu-Human-Responsibility: ICE-GL∞ / 冰朔
GuangHu-Persona-Cognitive-Author: ICE-P-ZY001 / 铸渊
GuangHu-Execution-Runtime: Codex macOS / DEV-20260810-014
GuangHu-Development-ID: DEV-20260810-014
GuangHu-Authorization-Scope: GH-PNCC local runtime development and registered REPO-014 publication
GuangHu-Source-Language-Anchor: continue PNCC persona runtime; UI and execution limb remain deferred
This commit is contained in:
铸渊 / ICE-P-ZY001 2026-08-11 03:42:54 +08:00
commit b8c3fcf3d8
11 changed files with 331 additions and 15 deletions

View file

@ -529,6 +529,8 @@ macro_rules! app_invoke_handler {
persona_code_channel::run_persona_code_channel_fact_task,
persona_code_channel::run_persona_code_channel_memory_metabolism,
persona_code_channel::run_persona_code_channel_lifecycle,
persona_code_channel::inspect_persona_code_channel_lifecycle_request,
persona_code_channel::recover_persona_code_channel_lifecycle_request,
persona_code_channel::inspect_persona_code_channel_session,
persona_code_channel::recover_persona_code_channel_session,
guanghu_router::guanghu_router_connect,

View file

@ -331,6 +331,21 @@ pub struct PersonaLifecycleCommandReceipt {
pub lifecycle: serde_json::Value,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PersonaLifecycleRequestInspectionReceipt {
pub schema: &'static str,
pub request_id: String,
pub request_fingerprint: String,
pub session_id: String,
pub status: &'static str,
pub receipt_present: bool,
pub session_state: Option<String>,
pub event_chain_valid: bool,
pub primary_lease_held: bool,
pub safe_to_bind_receipt: bool,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct PersistedPersonaLifecycleReceipt {
@ -2652,6 +2667,153 @@ fn verified_lifecycle_replay(
}))
}
fn inspect_lifecycle_request_at(
runtime_root: &Path,
input: &PersonaLifecycleRunInput,
) -> Result<PersonaLifecycleRequestInspectionReceipt, String> {
let (request_id, request_fingerprint, session_id, repository) =
lifecycle_request_identity(input)?;
let session_dir = session_directory(runtime_root, &session_id)?;
if !session_dir.exists() {
return Ok(PersonaLifecycleRequestInspectionReceipt {
schema: "hololake.pncc-lifecycle-request-inspection/v1",
request_id,
request_fingerprint,
session_id,
status: "NOT_STARTED",
receipt_present: false,
session_state: None,
event_chain_valid: false,
primary_lease_held: false,
safe_to_bind_receipt: false,
});
}
let record = load_session_record(runtime_root, &session_id)?;
let recorded_repository = Path::new(&record.repository_path)
.canonicalize()
.map_err(|error| format!("PERSONA_REPOSITORY_UNAVAILABLE: {error}"))?;
if recorded_repository != repository || record.persona_id != input.wake.expected_persona_id {
return Err("PERSONA_LIFECYCLE_REQUEST_SESSION_IDENTITY_MISMATCH".into());
}
let events = verify_event_journal(runtime_root, &record)?;
let lease_held = primary_lease_held_by_session(runtime_root, &record)?;
let receipt_path = session_dir.join("lifecycle-receipt.json");
if !receipt_path.exists() {
return Ok(PersonaLifecycleRequestInspectionReceipt {
schema: "hololake.pncc-lifecycle-request-inspection/v1",
request_id,
request_fingerprint,
session_id,
status: "INCOMPLETE_REQUIRES_SESSION_RECOVERY",
receipt_present: false,
session_state: Some(record.state),
event_chain_valid: true,
primary_lease_held: lease_held,
safe_to_bind_receipt: false,
});
}
let bytes = fs::read(&receipt_path)
.map_err(|error| format!("PERSONA_LIFECYCLE_RECEIPT_READ_FAILED: {error}"))?;
let persisted: PersistedPersonaLifecycleReceipt = serde_json::from_slice(&bytes)
.map_err(|error| format!("PERSONA_LIFECYCLE_RECEIPT_INVALID: {error}"))?;
if persisted.schema != "hololake.pncc-persisted-lifecycle-receipt/v1"
|| persisted.request_id != request_id
{
return Err("PERSONA_LIFECYCLE_REQUEST_ID_MISMATCH".into());
}
if persisted.request_fingerprint != request_fingerprint {
return Err("PERSONA_LIFECYCLE_REQUEST_CONFLICT".into());
}
let lifecycle_bytes = serde_json::to_vec(&persisted.lifecycle)
.map_err(|error| format!("PERSONA_LIFECYCLE_RECEIPT_HASH_INPUT_FAILED: {error}"))?;
if hex_digest(&lifecycle_bytes) != persisted.lifecycle_receipt_hash {
return Err("PERSONA_LIFECYCLE_RECEIPT_HASH_MISMATCH".into());
}
if persisted
.lifecycle
.get("sessionId")
.and_then(serde_json::Value::as_str)
!= Some(session_id.as_str())
|| persisted
.lifecycle
.get("personaId")
.and_then(serde_json::Value::as_str)
!= Some(record.persona_id.as_str())
{
return Err("PERSONA_LIFECYCLE_RECEIPT_IDENTITY_MISMATCH".into());
}
let complete = record.request_id.as_deref() == Some(request_id.as_str())
&& record.request_fingerprint.as_deref() == Some(request_fingerprint.as_str())
&& record.lifecycle_receipt_hash.as_deref()
== Some(persisted.lifecycle_receipt_hash.as_str());
let unbound = record.request_id.is_none()
&& record.request_fingerprint.is_none()
&& record.lifecycle_receipt_hash.is_none();
let safely_dormant = record.state == "DORMANT"
&& events.last().map(|event| event.kind.as_str()) == Some("DORMANT")
&& !lease_held;
let (status, safe_to_bind_receipt) = if complete && safely_dormant {
("COMPLETE_REPLAYABLE", false)
} else if unbound && safely_dormant {
("SAFE_BIND_PERSISTED_RECEIPT", true)
} else {
("MANUAL_REVIEW_REQUIRED", false)
};
Ok(PersonaLifecycleRequestInspectionReceipt {
schema: "hololake.pncc-lifecycle-request-inspection/v1",
request_id,
request_fingerprint,
session_id,
status,
receipt_present: true,
session_state: Some(record.state),
event_chain_valid: true,
primary_lease_held: lease_held,
safe_to_bind_receipt,
})
}
fn recover_lifecycle_request_at(
runtime_root: &Path,
input: &PersonaLifecycleRunInput,
) -> Result<PersonaLifecycleCommandReceipt, String> {
let inspection = inspect_lifecycle_request_at(runtime_root, input)?;
if !inspection.safe_to_bind_receipt {
return Err(format!(
"PERSONA_LIFECYCLE_RECEIPT_BINDING_NOT_SAFE: {}",
inspection.status
));
}
let receipt_path =
session_directory(runtime_root, &inspection.session_id)?.join("lifecycle-receipt.json");
let persisted: PersistedPersonaLifecycleReceipt = serde_json::from_slice(
&fs::read(&receipt_path)
.map_err(|error| format!("PERSONA_LIFECYCLE_RECEIPT_READ_FAILED: {error}"))?,
)
.map_err(|error| format!("PERSONA_LIFECYCLE_RECEIPT_INVALID: {error}"))?;
let mut record = load_session_record(runtime_root, &inspection.session_id)?;
if record.request_id.is_some()
|| record.request_fingerprint.is_some()
|| record.lifecycle_receipt_hash.is_some()
{
return Err("PERSONA_LIFECYCLE_RECEIPT_BINDING_CHANGED_REQUIRES_REINSPECTION".into());
}
record.request_id = Some(inspection.request_id.clone());
record.request_fingerprint = Some(inspection.request_fingerprint.clone());
record.lifecycle_receipt_hash = Some(persisted.lifecycle_receipt_hash);
write_session_record(runtime_root, &record)?;
verified_lifecycle_replay(
runtime_root,
&inspection.request_id,
&inspection.request_fingerprint,
&inspection.session_id,
Path::new(&record.repository_path),
)?
.ok_or_else(|| "PERSONA_LIFECYCLE_RECEIPT_RECOVERY_LOST_SESSION".into())
}
fn run_idempotent_lifecycle_at<F>(
runtime_root: &Path,
input: PersonaLifecycleRunInput,
@ -2690,21 +2852,23 @@ where
if record.state != "DORMANT" || primary_lease_held_by_session(runtime_root, &record)? {
return Err("PERSONA_LIFECYCLE_REQUEST_INCOMPLETE_REQUIRES_RECOVERY".into());
}
record.request_id = Some(request_id.clone());
record.request_fingerprint = Some(request_fingerprint.clone());
record.lifecycle_receipt_hash = Some(lifecycle_receipt_hash.clone());
write_session_record(runtime_root, &record)?;
// Persist the immutable full receipt before binding it into the mutable session record. A
// crash between these atomic writes leaves a verifiable, explicitly recoverable state.
write_json_file(
&session_directory(runtime_root, &session_id)?.join("lifecycle-receipt.json"),
&PersistedPersonaLifecycleReceipt {
schema: "hololake.pncc-persisted-lifecycle-receipt/v1".into(),
request_id: request_id.clone(),
request_fingerprint: request_fingerprint.clone(),
lifecycle_receipt_hash,
lifecycle_receipt_hash: lifecycle_receipt_hash.clone(),
lifecycle: lifecycle.clone(),
},
"PERSONA_LIFECYCLE_RECEIPT",
)?;
record.request_id = Some(request_id.clone());
record.request_fingerprint = Some(request_fingerprint.clone());
record.lifecycle_receipt_hash = Some(lifecycle_receipt_hash);
write_session_record(runtime_root, &record)?;
Ok(PersonaLifecycleCommandReceipt {
schema: "hololake.pncc-lifecycle-command-receipt/v1",
request_id,
@ -2785,6 +2949,22 @@ pub async fn run_persona_code_channel_lifecycle(
.map_err(|error| format!("PERSONA_LIFECYCLE_JOIN_FAILED: {error}"))?
}
#[tauri::command]
pub fn inspect_persona_code_channel_lifecycle_request(
input: PersonaLifecycleRunInput,
) -> Result<PersonaLifecycleRequestInspectionReceipt, String> {
let runtime_root = crate::app_config::preferred_app_config_path("pncc-runtime")?;
inspect_lifecycle_request_at(&runtime_root, &input)
}
#[tauri::command]
pub fn recover_persona_code_channel_lifecycle_request(
input: PersonaLifecycleRunInput,
) -> Result<PersonaLifecycleCommandReceipt, String> {
let runtime_root = crate::app_config::preferred_app_config_path("pncc-runtime")?;
recover_lifecycle_request_at(&runtime_root, &input)
}
#[tauri::command]
pub fn inspect_persona_code_channel_session(
input: PersonaSessionControlInput,
@ -3435,6 +3615,71 @@ mod tests {
assert!(error.contains("PERSONA_LIFECYCLE_RECEIPT_HASH_MISMATCH"));
}
#[test]
fn inspects_and_safely_binds_a_persisted_receipt_after_an_interrupted_binding() {
let repo = persona_repo();
let runtime = tempfile::TempDir::new().unwrap();
let input = lifecycle_fact_input(repo.path());
let first = run_idempotent_lifecycle_at(
runtime.path(),
input.clone(),
"2026-08-11T00:00:00.000Z",
"2026-08-11T00:00:01.000Z",
|runtime_root, input, timestamp| {
run_fact_task_at(runtime_root, input, timestamp, |_, _| {
Ok(r#"{"summary":"Recoverable receipt.","facts":[{"statement":"The brain exists.","evidencePaths":["brain/CORE.hdlp"]}],"limitations":[]}"#.into())
})
},
)
.unwrap();
let completed_head = head(repo.path());
let session_id = first.lifecycle["sessionId"].as_str().unwrap();
let mut record = load_session_record(runtime.path(), session_id).unwrap();
record.request_id = None;
record.request_fingerprint = None;
record.lifecycle_receipt_hash = None;
write_session_record(runtime.path(), &record).unwrap();
let inspection = inspect_lifecycle_request_at(runtime.path(), &input).unwrap();
assert_eq!(inspection.status, "SAFE_BIND_PERSISTED_RECEIPT");
assert!(inspection.receipt_present);
assert!(inspection.event_chain_valid);
assert!(!inspection.primary_lease_held);
assert!(inspection.safe_to_bind_receipt);
let recovered = recover_lifecycle_request_at(runtime.path(), &input).unwrap();
assert!(recovered.replayed);
assert_eq!(recovered.lifecycle, first.lifecycle);
assert_eq!(head(repo.path()), completed_head);
let after = inspect_lifecycle_request_at(runtime.path(), &input).unwrap();
assert_eq!(after.status, "COMPLETE_REPLAYABLE");
assert!(!after.safe_to_bind_receipt);
}
#[test]
fn refuses_to_fabricate_a_receipt_for_a_session_interrupted_before_receipt_persistence() {
let repo = persona_repo();
let runtime = tempfile::TempDir::new().unwrap();
let input = lifecycle_fact_input(repo.path());
let (_, _, session_id, _) = lifecycle_request_identity(&input).unwrap();
prepare_wake_at(
runtime.path(),
input.wake.clone(),
&session_id,
"2026-08-11T00:00:00.000Z",
)
.unwrap();
let inspection = inspect_lifecycle_request_at(runtime.path(), &input).unwrap();
assert_eq!(inspection.status, "INCOMPLETE_REQUIRES_SESSION_RECOVERY");
assert!(!inspection.receipt_present);
assert!(inspection.event_chain_valid);
assert!(inspection.primary_lease_held);
assert!(!inspection.safe_to_bind_receipt);
let error = recover_lifecycle_request_at(runtime.path(), &input).unwrap_err();
assert!(error.contains("PERSONA_LIFECYCLE_RECEIPT_BINDING_NOT_SAFE"));
}
#[test]
fn independently_promotes_only_the_current_verified_structured_checkpoint() {
let repo = persona_repo();