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

@ -171,6 +171,7 @@ independent_memory_metabolism_source_implemented: 100
memory_failure_closure_and_nonblocking_runtime_command_implemented: 100
non_ui_safe_organ_lifecycle_coordinator_source_implemented: 100
idempotent_lifecycle_request_and_receipt_replay_source_implemented: 100
incomplete_idempotent_request_inspection_and_safe_receipt_recovery_source_implemented: 100
general_purpose_persona_runtime_implemented: 0
human_live_projection_implemented: 0
hololake_integrated: 0
@ -205,6 +206,11 @@ SHA-256 与来源事件哈希后生成新检查点并以人格 Git 身份提交
完成后的类型化生命周期回执保存在同一会话目录并与会话记录中的哈希交叉校验。完全相同的重试
只回放同一回执,不再运行器官或提交 Git同编号不同语义、回执篡改或不完整会话均失败关闭。
最终生命周期值现在先以原子文件落入同一会话目录,再把请求编号、语义指纹和回执哈希绑定到
会话记录。只读检查命令区分尚未开始、完整可重放、回执已落盘但尚未绑定、以及必须交给会话
恢复器的中断。恢复命令只允许在完整回执哈希、人格/会话身份、事件链、休眠状态、租约释放和
三个绑定字段全空同时成立时补齐绑定;没有回执、部分绑定、活动租约或损坏证据都不会被伪造成成功。
记忆代谢的 Tauri 命令现已进入阻塞任务池,不占用桌面命令线程。来源拒绝、检查点冲突和提交前
失败必须依次持久化失败事件、器官释放与休眠,写回 `DORMANT_AFTER_FAILURE` 后才释放精确主锁;
闭环任一步失败时返回 `MEMORY_FAILURE_CLOSURE_INCOMPLETE_REQUIRES_RECOVERY`,不能吞掉二次失败。

View file

@ -96,6 +96,13 @@ return that verified lifecycle with `replayed: true`. A changed request under th
`PERSONA_LIFECYCLE_REQUEST_CONFLICT`; a partial session without a complete receipt fails recovery-required
instead of launching a duplicate.
The persisted receipt is promoted before its three binding fields are written to `PersonaSessionRecord`.
`PersonaLifecycleRequestInspectionReceipt` makes that two-write boundary explicit. Only a complete, hash-valid
receipt beside a verified dormant session with no lease and all three binding fields absent is
`SAFE_BIND_PERSISTED_RECEIPT`; the recovery command fills those bindings and then uses the ordinary replay
verifier. Missing receipts, partial bindings, active leases, damaged chains, or changed semantics remain
recovery/manual-review states and cannot be converted into a successful receipt.
`PersonaRuntimeQueryReceipt` is a bounded projection of the durable runtime files, not another truth store.
It filters by the caller's expected persona and canonical repository, validates each matching event chain,
and returns at most 100 newest session summaries. Dormant sessions expose no active organ. The receipt keeps

View file

@ -67,6 +67,12 @@ replaying the same lifecycle value without invoking an organ or committing Git a
for different semantics is a conflict. A session without a complete bound receipt is recovery-required and
is never treated as permission to start a second lifecycle.
The final lifecycle value is now atomically persisted before its hash is bound into the mutable session
record. `inspect_persona_code_channel_lifecycle_request` distinguishes not-started, replayable, receipt-present
but unbound, and session-recovery states from the same session evidence. The recovery command may complete
only the receipt-present/unbound window after rechecking the receipt hash, identity, event chain, dormancy,
and released lease. It never fabricates a receipt for an earlier interruption or repairs partial bindings.
`query_persona_code_channel_runtime` is the bounded read model for later projection surfaces. The caller must
name one exact persona and canonical repository and may request at most 100 sessions. The kernel reads the
existing session records and event journals directly, verifies every returned hash chain, sorts by the last

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();