fix(pncc): revalidate Git state before replay

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:30:02 +08:00
commit b067c7bcaa
10 changed files with 143 additions and 8 deletions

View file

@ -2720,6 +2720,11 @@ fn verified_lifecycle_replay(
{
return Err("PERSONA_LIFECYCLE_RECEIPT_SESSION_BINDING_MISMATCH".into());
}
let (_, observed_head) = exact_repository(repository)?;
if observed_head != record.git_head {
return Err("PERSONA_LIFECYCLE_REPLAY_GIT_HEAD_MISMATCH".into());
}
require_clean_repository(repository)?;
let events = verify_event_journal(runtime_root, &record)?;
let expected_state = if persisted.outcome == "FAILED" {
"DORMANT_AFTER_FAILURE"
@ -2829,9 +2834,13 @@ fn inspect_lifecycle_request_at(
} else {
"DORMANT"
};
let (_, observed_head) = exact_repository(&repository)?;
let repository_matches_record =
observed_head == record.git_head && require_clean_repository(&repository).is_ok();
let safely_dormant = record.state == expected_state
&& events.last().map(|event| event.kind.as_str()) == Some("DORMANT")
&& !lease_held;
&& !lease_held
&& repository_matches_record;
let (status, safe_to_bind_receipt) = if complete && safely_dormant {
("COMPLETE_REPLAYABLE", false)
} else if unbound && safely_dormant {
@ -3708,6 +3717,39 @@ mod tests {
assert!(!runtime.path().join("leases/ICE-P-ZY001.json").exists());
}
#[test]
fn refuses_to_replay_a_completed_lifecycle_when_the_repository_is_dirty() {
let repo = persona_repo();
let runtime = tempfile::TempDir::new().unwrap();
let input = lifecycle_fact_input(repo.path());
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":"Fact before repository drift.","facts":[{"statement":"The brain exists.","evidencePaths":["brain/CORE.hdlp"]}],"limitations":[]}"#.into())
})
},
)
.unwrap();
fs::write(repo.path().join("untracked-after-lifecycle.txt"), "dirty\n").unwrap();
let inspection = inspect_lifecycle_request_at(runtime.path(), &input).unwrap();
assert_eq!(inspection.status, "MANUAL_REVIEW_REQUIRED");
assert!(!inspection.safe_to_bind_receipt);
let error = run_idempotent_lifecycle_at(
runtime.path(),
input,
"2026-08-11T00:00:02.000Z",
"2026-08-11T00:00:03.000Z",
|_, _, _| panic!("a dirty repository must not rerun the organ"),
)
.unwrap_err();
assert!(error.contains("PERSONA_REPOSITORY_DIRTY"));
}
#[test]
fn rejects_reusing_a_lifecycle_request_id_for_a_different_operation() {
let repo = persona_repo();
@ -3908,6 +3950,41 @@ mod tests {
assert!(!runtime.path().join("leases/ICE-P-ZY001.json").exists());
}
#[test]
fn refuses_to_replay_a_terminal_failure_after_the_repository_head_advances() {
let repo = persona_repo();
let runtime = tempfile::TempDir::new().unwrap();
let input = lifecycle_fact_input(repo.path());
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, |_, _| {
Err("bounded provider failure".into())
})
},
)
.unwrap();
fs::write(repo.path().join("advanced.txt"), "new head\n").unwrap();
run_git(repo.path(), &["add", "advanced.txt"]);
run_git(repo.path(), &["commit", "-m", "advance persona repository"]);
let inspection = inspect_lifecycle_request_at(runtime.path(), &input).unwrap();
assert_eq!(inspection.status, "MANUAL_REVIEW_REQUIRED");
assert!(!inspection.safe_to_bind_receipt);
let error = run_idempotent_lifecycle_at(
runtime.path(),
input,
"2026-08-11T00:00:02.000Z",
"2026-08-11T00:00:03.000Z",
|_, _, _| panic!("an advanced repository must not rerun the organ"),
)
.unwrap_err();
assert!(error.contains("PERSONA_LIFECYCLE_REPLAY_GIT_HEAD_MISMATCH"));
}
#[test]
fn safely_recovers_an_unbound_terminal_failure_receipt() {
let repo = persona_repo();