Narrative resume: pin the Confirmed arm's identity check; interrupted no longer re-decides the scope move

The kernel's ScopeChanged is the only decider of a moved scope and same_decision the only decider of identity. Deletes interrupted's fresh-vs-row digest compare; adds a mismatch test and an unmoved-scope test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-17 19:44:35 +02:00
commit f503c85484

View file

@ -4325,16 +4325,9 @@ impl ControllerRunner {
.snapshot()
.await
.map_err(ControllerError::Snapshot)?;
// The kernel's `ScopeChanged` already decided that the scope moved;
// nothing here compares digests to decide it again.
let fresh = select_fresh(&snapshot, &opportunity)?;
if fresh.opportunity.scope_digest == opportunity.scope_digest {
// The refusal was not this scope's move, and the runner does not
// invent a second explanation for it.
return Err(ControllerError::World(KernelError::ScopeChanged {
scope: opportunity.scope,
expected: opportunity.scope_digest.clone(),
actual: fresh.opportunity.scope_digest,
}));
}
// No branch here checks the fresh grant set for `SPEAK_KIND`: grants
// are recorded in an insert-only ledger (`affordance_grants` never
// removes an entry), and this checkpoint could only reach
@ -20290,6 +20283,69 @@ mod tests {
task.await.unwrap();
}
#[tokio::test]
async fn a_narrative_row_resumed_for_another_decision_is_refused_without_spend() {
let (_directory, mailbox, task) = active_cell_mailbox(vec![
NewController::NarrativePersona,
NewController::NarrativePersona,
])
.await;
let mut outputs = vec![Err(InferenceFault::retryable("the provider is busy"))];
outputs.extend(narrative_turn_outputs("Tobin", "Then we close the span."));
let (runner, store) = grouped_runner(&mailbox, outputs);
let snapshot = mailbox.snapshot().await.unwrap();
let first = snapshot.opportunities[0].clone();
let second = snapshot.opportunities[1].clone();
let command = CommandId::new();
assert!(matches!(
runner.run_narrative(command, &first).await.unwrap(),
NarrativeRun::Pending(_)
));
let (resume, port) =
runner_over(&mailbox, narrative_turn_outputs("Mara", "Rain."), store.clone());
assert!(matches!(
resume.run_narrative(command, &second).await,
Err(ControllerError::OpportunityMismatch)
));
assert_eq!(port.outputs.lock().unwrap().len(), 3, "the refused resume spent a call");
// The row's own decision still resumes: the refusal was identity alone.
assert!(matches!(
resume.run_narrative(command, &first).await.unwrap(),
NarrativeRun::Completed(_)
));
drop((runner, resume));
drop(mailbox);
task.await.unwrap();
}
#[tokio::test]
async fn a_narrative_row_whose_scope_did_not_move_commits_without_re_lowering() {
let (_directory, mailbox, task) =
active_cell_mailbox(vec![NewController::NarrativePersona]).await;
let mut outputs = vec![Err(InferenceFault::retryable("the provider is busy"))];
outputs.extend(narrative_turn_outputs("Mara", "The rain has teeth tonight."));
let (runner, _store) = grouped_runner(&mailbox, outputs);
let opportunity = mailbox.snapshot().await.unwrap().opportunities[0].clone();
let command = CommandId::new();
assert!(matches!(
runner.run_narrative(command, &opportunity).await.unwrap(),
NarrativeRun::Pending(_)
));
// The kernel accepts the unmoved scope, so nothing is refused and
// `interrupted` is never reached: the move is the kernel's to decide.
let NarrativeRun::Completed(decision) =
runner.run_narrative(command, &opportunity).await.unwrap()
else {
panic!("an unmoved scope did not commit")
};
assert!(decision.persona_turn().binding().interrupted_from.is_none());
drop(runner);
drop(mailbox);
task.await.unwrap();
}
fn operational_declines(receipt: &str) -> Result<InferenceOutput, InferenceFault> {
output(
vec![