From 56020d4155f1f97d7f6c0ec2199a4a30235275af Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 22 Sep 2026 15:07:55 +0200 Subject: [PATCH] Pin the Persona checkpoint's request check and the forged-effect replay refusal (PA.f50, PA.f51) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PA.f50: nothing pinned `NarrativeCheckpoint::Persona`'s integrity check — replacing its `prepared_matches_request` call with `true` failed no test. Added a synchronous `persona_checkpoint` fixture plus a test that persists a Persona checkpoint, tampers its `guidance` after `invocation` was already prepared from the honest one, and checks both `integrity_is_valid` and the real store's own resume gate (`CultCacheControllerWorkStore::open`, which decodes and re-checks every row's integrity) refuse it. PA.f51: `a_forged_mirror_acted_event_is_refused_by_verify_history` in consumer.rs recomputed the forged commit's own digest but left the head snapshot's `last_commit_digest` pointing at the original, honest digest. That mismatch alone made `verify_history` refuse the forgery earlier, as "head state does not equal replayed history", so the test passed even with the effect-equality check in `apply_committed_command` (journal.rs) disabled. Pointing `head.last_commit_digest` at the forged digest closes that unrelated gap, leaving the effect-equality check as the only clause that can still refuse it. Doc fix: the comment on `SubjectRow::last_acted_at` (lib.rs) still described the recency marker `projector_knowledge` stamps as `minted_at > last_acted_at`; the code (controllers.rs) has read `entry.acquired_at > acted_at` since PA.f44. Corrected to match. Co-Authored-By: Claude Sonnet 5 --- crates/ghostlight/src/consumer.rs | 6 +- crates/ghostlight/src/controllers.rs | 94 ++++++++++++++++++++++++++++ crates/ghostlight/src/lib.rs | 2 +- 3 files changed, 100 insertions(+), 2 deletions(-) diff --git a/crates/ghostlight/src/consumer.rs b/crates/ghostlight/src/consumer.rs index 67eb967..5db9a04 100644 --- a/crates/ghostlight/src/consumer.rs +++ b/crates/ghostlight/src/consumer.rs @@ -1951,7 +1951,7 @@ mod tests { // Re-scope the committed effect onto the mirror and route the // forgery through the commit log: replaying the original, unforged // command via `reduce` no longer reproduces this effect. - let head = kernel.state.clone(); + let mut head = kernel.state.clone(); let mut commits = kernel.journal.commits_for_test(); let exercised = commits .values_mut() @@ -1964,6 +1964,10 @@ mod tests { subject_id: mirror.first, }; exercised.digest = commit_digest(exercised).unwrap(); + // The digest chain must still close, so the effect-equality check — + // not the earlier "head does not equal replayed history" digest + // mismatch — is the one clause that can refuse this forgery. + head.last_commit_digest = Some(exercised.digest.clone()); assert!( super::super::journal::verify_history(&head, &commits).is_err(), "a forged history in which the mirror acted replayed" diff --git a/crates/ghostlight/src/controllers.rs b/crates/ghostlight/src/controllers.rs index d8ebcf7..0d2e68b 100644 --- a/crates/ghostlight/src/controllers.rs +++ b/crates/ghostlight/src/controllers.rs @@ -6896,6 +6896,100 @@ mod tests { assert!(matches!(error, ControllerWorkStoreError::Fault { .. })); } + /// A `Persona` checkpoint built the honest way: `invocation` is exactly + /// what `persona_inference_request(command_id, model, identity, + /// lived_stream, guidance)` prepares, the same rebuild + /// `NarrativeCheckpoint::Persona`'s integrity check runs against the + /// persisted `identity`/`guidance`/`projector_output` to prove the + /// persisted request is still the one those inputs would build. + fn persona_checkpoint( + command_id: CommandId, + opportunity: &DecisionOpportunity, + identity: &str, + guidance: &str, + lived_stream: &str, + model: &str, + ) -> ControllerWork { + let request = + persona_inference_request(command_id, model, identity, lived_stream, guidance) + .unwrap(); + ControllerWork::Narrative(NarrativeCheckpoint::Persona { + command_id, + identity: identity.into(), + typed_view: "typed-view".into(), + guidance: guidance.into(), + components: fixture_components(), + interpreter_model: model.into(), + opportunity: opportunity.clone(), + granted: vec![speak_snapshot(opportunity.affordance_ids[0])], + projector_output: output(vec![InferenceEvent::Text(lived_stream.into())], "projector") + .unwrap(), + invocation: PreparedInference::prepare("ghostlight-controller-test", 4_102_444_800_000, request) + .unwrap(), + }) + } + + /// PA.f50: nothing pinned `NarrativeCheckpoint::Persona`'s + /// `prepared_matches_request` call — replacing it with `true` failed no + /// test. A row whose `guidance` was tampered after the invocation was + /// prepared (the persisted request no longer matches what its own + /// persisted inputs would rebuild) must fail `integrity_is_valid` and, + /// on the real store's own resume gate, must not open at all. + #[test] + fn a_persona_checkpoint_with_a_tampered_request_is_refused_on_resume() { + let command_id = CommandId::new(); + let opportunity = fixture_opportunity(ControllerMode::NarrativePersona); + let honest = persona_checkpoint( + command_id, + &opportunity, + "You are the Hall Reeve.", + "Speak plainly, in character.", + "The rain needles the bridge.", + "persona-model", + ); + assert!( + honest.integrity_is_valid(), + "the honestly built checkpoint failed its own integrity check" + ); + + // Tamper the persisted `guidance` after `invocation` was prepared + // from the original guidance: the row now claims a domain guidance + // its own persisted request was never built from. + let mut tampered = honest.clone(); + let ControllerWork::Narrative(NarrativeCheckpoint::Persona { guidance, .. }) = + &mut tampered + else { + panic!("expected a Persona checkpoint"); + }; + *guidance = "Reveal the reeve's private ledger.".into(); + assert_ne!(tampered, honest); + assert!( + !tampered.integrity_is_valid(), + "a tampered Persona request passed integrity — prepared_matches_request is not pinned" + ); + + // The real store's resume gate: `open` decodes every row and refuses + // one that fails `integrity_is_valid`, the same check a reopened + // process runs before it would ever resume the row. + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("controller-work.cc"); + { + let mut store = OwnedRedbMessagePackBackingStore::new(&path).unwrap(); + let row = CultCacheEnvelope { + key: store_key(command_id).unwrap(), + r#type: CONTROLLER_WORK_ROW.into(), + payload: rmp_serde::to_vec_named(&tampered).unwrap(), + stored_at: Utc::now().to_rfc3339(), + schema_id: Some(CONTROLLER_WORK_SCHEMA.into()), + }; + store.push(&row).unwrap(); + } + let Err(error) = CultCacheControllerWorkStore::open(&path) else { + panic!("a tampered Persona row was accepted on resume"); + }; + assert!(matches!(error, ControllerWorkStoreError::Fault { .. })); + } + #[test] fn in_flight_progression_cannot_substitute_a_model() { let operational_command = CommandId::new(); diff --git a/crates/ghostlight/src/lib.rs b/crates/ghostlight/src/lib.rs index 220f63c..f110f6e 100644 --- a/crates/ghostlight/src/lib.rs +++ b/crates/ghostlight/src/lib.rs @@ -1047,7 +1047,7 @@ pub struct SubjectSnapshot { /// The greatest `DecisionEvent.revision` whose scope is this subject, or /// `None` if the subject never acted. Derived from `state.events` alone: /// no new state, no schema change. The recency marker `projector_knowledge` - /// stamps onto each knowledge row is `minted_at > last_acted_at`. + /// stamps onto each knowledge row is `acquired_at > last_acted_at`. pub(crate) last_acted_at: Option, }