Pin the Persona checkpoint's request check and the forged-effect replay refusal (PA.f50, PA.f51)

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 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-22 15:07:55 +02:00
commit 56020d4155
3 changed files with 100 additions and 2 deletions

View file

@ -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"

View file

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

View file

@ -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<u64>,
}