From f503c85484846cce5e89315ac13799c1e261dd31 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 17 Oct 2026 19:44:35 +0200 Subject: [PATCH] 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 --- crates/ghostlight/src/controllers.rs | 74 ++++++++++++++++++++++++---- 1 file changed, 65 insertions(+), 9 deletions(-) diff --git a/crates/ghostlight/src/controllers.rs b/crates/ghostlight/src/controllers.rs index 2847574..7090ee0 100644 --- a/crates/ghostlight/src/controllers.rs +++ b/crates/ghostlight/src/controllers.rs @@ -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 { output( vec![