fix(pncc): revalidate terminal success evidence

Human-Responsibility: ICE-GL∞ / 冰朔
Persona-Author: ICE-P-ZY001 / 铸渊
Execution-Runtime: Codex macOS
Development-ID: DEV-20260810-014
Authorization-Scope: GH-PNCC local runtime and REPO-014 publication
Source-Anchor: UI and execution limb deferred
This commit is contained in:
铸渊 / ICE-P-ZY001 2026-08-11 04:51:20 +08:00
commit e9ee0a8bb1
9 changed files with 219 additions and 38 deletions

View file

@ -2978,6 +2978,70 @@ fn persist_terminal_lifecycle_failure(
}))
}
fn persist_successful_lifecycle_receipt(
runtime_root: &Path,
request_id: &str,
request_fingerprint: &str,
session_id: &str,
repository: &Path,
lifecycle: serde_json::Value,
) -> Result<PersonaLifecycleCommandReceipt, String> {
let lifecycle_bytes = serde_json::to_vec(&lifecycle)
.map_err(|error| format!("PERSONA_LIFECYCLE_RECEIPT_HASH_INPUT_FAILED: {error}"))?;
let lifecycle_receipt_hash = hex_digest(&lifecycle_bytes);
let mut 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 {
return Err("PERSONA_LIFECYCLE_REQUEST_SESSION_IDENTITY_MISMATCH".into());
}
let events = verify_event_journal(runtime_root, &record)?;
if record.state != "DORMANT"
|| events.last().map(|event| event.kind.as_str()) != Some("DORMANT")
|| primary_lease_held_by_session(runtime_root, &record)?
{
return Err("PERSONA_LIFECYCLE_REQUEST_INCOMPLETE_REQUIRES_RECOVERY".into());
}
let (_, observed_head) = exact_repository(repository)?;
if observed_head != record.git_head {
return Err("PERSONA_LIFECYCLE_COMPLETION_GIT_HEAD_MISMATCH".into());
}
require_clean_repository(repository)?;
let persisted = PersistedPersonaLifecycleReceipt {
schema: "hololake.pncc-persisted-lifecycle-receipt/v1".into(),
request_id: request_id.into(),
request_fingerprint: request_fingerprint.into(),
lifecycle_receipt_hash: lifecycle_receipt_hash.clone(),
outcome: "COMPLETED".into(),
lifecycle: lifecycle.clone(),
failure: None,
};
if !persisted_lifecycle_identity_matches(&persisted, session_id, &record.persona_id) {
return Err("PERSONA_LIFECYCLE_RECEIPT_IDENTITY_MISMATCH".into());
}
// 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"),
&persisted,
"PERSONA_LIFECYCLE_RECEIPT",
)?;
record.request_id = Some(request_id.into());
record.request_fingerprint = Some(request_fingerprint.into());
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: request_id.into(),
request_fingerprint: request_fingerprint.into(),
replayed: false,
outcome: "COMPLETED",
lifecycle,
failure: None,
})
}
fn run_idempotent_lifecycle_at<F>(
runtime_root: &Path,
input: PersonaLifecycleRunInput,
@ -3024,41 +3088,14 @@ where
};
let lifecycle = serde_json::to_value(lifecycle)
.map_err(|error| format!("PERSONA_LIFECYCLE_RECEIPT_SERIALIZATION_FAILED: {error}"))?;
let lifecycle_bytes = serde_json::to_vec(&lifecycle)
.map_err(|error| format!("PERSONA_LIFECYCLE_RECEIPT_HASH_INPUT_FAILED: {error}"))?;
let lifecycle_receipt_hash = hex_digest(&lifecycle_bytes);
let mut record = load_session_record(runtime_root, &session_id)?;
if record.state != "DORMANT" || primary_lease_held_by_session(runtime_root, &record)? {
return Err("PERSONA_LIFECYCLE_REQUEST_INCOMPLETE_REQUIRES_RECOVERY".into());
}
// 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.clone(),
outcome: "COMPLETED".into(),
lifecycle: lifecycle.clone(),
failure: None,
},
"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,
request_fingerprint,
replayed: false,
outcome: "COMPLETED",
persist_successful_lifecycle_receipt(
runtime_root,
&request_id,
&request_fingerprint,
&session_id,
&repository,
lifecycle,
failure: None,
})
)
}
#[tauri::command]
@ -3750,6 +3787,97 @@ mod tests {
assert!(error.contains("PERSONA_REPOSITORY_DIRTY"));
}
#[test]
fn refuses_to_persist_success_when_the_repository_becomes_dirty_after_completion() {
let repo = persona_repo();
let runtime = tempfile::TempDir::new().unwrap();
let input = lifecycle_fact_input(repo.path());
let (request_id, request_fingerprint, session_id, repository) =
lifecycle_request_identity(&input).unwrap();
let lifecycle = run_lifecycle_at(
runtime.path(),
input,
&session_id,
"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":"Completed before repository drift.","facts":[{"statement":"The brain exists.","evidencePaths":["brain/CORE.hdlp"]}],"limitations":[]}"#.into())
})
},
)
.unwrap();
fs::write(repo.path().join("dirty-before-receipt.txt"), "dirty\n").unwrap();
let error = persist_successful_lifecycle_receipt(
runtime.path(),
&request_id,
&request_fingerprint,
&session_id,
&repository,
serde_json::to_value(lifecycle).unwrap(),
)
.unwrap_err();
assert!(error.contains("PERSONA_REPOSITORY_DIRTY"));
assert!(!session_directory(runtime.path(), &session_id)
.unwrap()
.join("lifecycle-receipt.json")
.exists());
let record = load_session_record(runtime.path(), &session_id).unwrap();
assert!(record.request_id.is_none());
assert!(record.lifecycle_receipt_hash.is_none());
}
#[test]
fn refuses_to_persist_success_when_the_repository_head_advances_after_completion() {
let repo = persona_repo();
let runtime = tempfile::TempDir::new().unwrap();
let input = lifecycle_fact_input(repo.path());
let (request_id, request_fingerprint, session_id, repository) =
lifecycle_request_identity(&input).unwrap();
let lifecycle = run_lifecycle_at(
runtime.path(),
input,
&session_id,
"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":"Completed before a new commit.","facts":[{"statement":"The brain exists.","evidencePaths":["brain/CORE.hdlp"]}],"limitations":[]}"#.into())
})
},
)
.unwrap();
fs::write(
repo.path().join("advanced-before-receipt.txt"),
"new head\n",
)
.unwrap();
run_git(repo.path(), &["add", "advanced-before-receipt.txt"]);
run_git(
repo.path(),
&["commit", "-m", "advance before lifecycle receipt"],
);
let error = persist_successful_lifecycle_receipt(
runtime.path(),
&request_id,
&request_fingerprint,
&session_id,
&repository,
serde_json::to_value(lifecycle).unwrap(),
)
.unwrap_err();
assert!(error.contains("PERSONA_LIFECYCLE_COMPLETION_GIT_HEAD_MISMATCH"));
assert!(!session_directory(runtime.path(), &session_id)
.unwrap()
.join("lifecycle-receipt.json")
.exists());
let record = load_session_record(runtime.path(), &session_id).unwrap();
assert!(record.request_id.is_none());
assert!(record.lifecycle_receipt_hash.is_none());
}
#[test]
fn rejects_reusing_a_lifecycle_request_id_for_a_different_operation() {
let repo = persona_repo();