Skip to main content

starweaver_session/
continuation.rs

1//! Host-neutral continuation preparation over durable session evidence.
2
3use std::collections::BTreeMap;
4
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7use starweaver_context::AgentRunState;
8use starweaver_model::TOOL_RETURN_APPROVAL_ARGUMENTS_METADATA_KEY;
9use thiserror::Error;
10
11use crate::{ApprovalStatus, ExecutionStatus, SessionResumeSnapshot};
12
13/// Continuation preparation mode selected by the host.
14#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
15#[serde(rename_all = "snake_case")]
16pub enum ContinuationPreparationMode {
17    /// Restore durable context without consuming HITL decisions.
18    Ordinary,
19    /// Validate a waiting run and prepare its terminal approval/deferred decisions.
20    WaitingHitl,
21}
22
23/// Side-effect-free continuation package shared by every product host.
24///
25/// This package deliberately contains durable evidence only. Approved tools are executed later by
26/// the agent layer, after the host has atomically admitted a waiting-run replacement and moved its
27/// claim to `Started`.
28#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
29pub struct PreparedContinuation {
30    /// Preparation mode used to validate the evidence.
31    pub mode: ContinuationPreparationMode,
32    /// Canonical durable snapshot. Waiting checkpoint state is normalized to `Waiting` in-memory.
33    pub snapshot: SessionResumeSnapshot,
34}
35
36impl PreparedContinuation {
37    /// Prepare an ordinary context continuation.
38    ///
39    /// # Errors
40    ///
41    /// Returns an error when the snapshot contains inconsistent session/run identities.
42    pub fn ordinary(snapshot: SessionResumeSnapshot) -> Result<Self, ContinuationPreparationError> {
43        validate_snapshot_identity(&snapshot)?;
44        Ok(Self {
45            mode: ContinuationPreparationMode::Ordinary,
46            snapshot,
47        })
48    }
49
50    /// Prepare a waiting HITL continuation without executing hooks or tools.
51    ///
52    /// # Errors
53    ///
54    /// Returns an error when the source is not waiting, has no checkpoint, or its pending HITL
55    /// items do not have one matching terminal durable decision/result.
56    pub fn waiting_hitl(
57        mut snapshot: SessionResumeSnapshot,
58    ) -> Result<Self, ContinuationPreparationError> {
59        validate_snapshot_identity(&snapshot)?;
60        if snapshot.run.status != crate::RunStatus::Waiting {
61            return Err(ContinuationPreparationError::SourceNotWaiting(
62                snapshot.run.run_id.as_str().to_string(),
63            ));
64        }
65        let checkpoint_state = {
66            let checkpoint = snapshot.latest_checkpoint.as_mut().ok_or_else(|| {
67                ContinuationPreparationError::MissingCheckpoint(
68                    snapshot.run.run_id.as_str().to_string(),
69                )
70            })?;
71            // Checkpoints may precede the terminal Waiting projection. The durable run record is
72            // authoritative; normalization is process-local and never rewrites immutable evidence.
73            checkpoint.state.status = starweaver_core::RunLifecycle::Waiting;
74            checkpoint.state.clone()
75        };
76        validate_waiting_evidence(&snapshot, &checkpoint_state)?;
77        Ok(Self {
78            mode: ContinuationPreparationMode::WaitingHitl,
79            snapshot,
80        })
81    }
82
83    /// Return the normalized checkpoint state for a waiting HITL continuation.
84    #[must_use]
85    pub fn waiting_state(&self) -> Option<&AgentRunState> {
86        matches!(self.mode, ContinuationPreparationMode::WaitingHitl)
87            .then(|| {
88                self.snapshot
89                    .latest_checkpoint
90                    .as_ref()
91                    .map(|checkpoint| &checkpoint.state)
92            })
93            .flatten()
94    }
95
96    /// Consume the package and return its canonical snapshot.
97    #[must_use]
98    pub fn into_snapshot(self) -> SessionResumeSnapshot {
99        self.snapshot
100    }
101}
102
103/// Stable continuation preparation failures shared by CLI, RPC, and future hosts.
104#[derive(Clone, Debug, Eq, Error, PartialEq)]
105pub enum ContinuationPreparationError {
106    /// Snapshot session and run ownership disagree.
107    #[error("continuation snapshot identity mismatch: {0}")]
108    IdentityMismatch(String),
109    /// Waiting preparation was requested for another source status.
110    #[error("run {0} is not waiting")]
111    SourceNotWaiting(String),
112    /// A waiting source has no resumable checkpoint.
113    #[error("waiting run {0} has no resumable checkpoint")]
114    MissingCheckpoint(String),
115    /// The checkpoint has no pending HITL work.
116    #[error("waiting run {0} has no pending HITL work")]
117    NoPendingHitl(String),
118    /// Durable decision evidence contains duplicate identities.
119    #[error("duplicate durable {kind} decision for {id}")]
120    DuplicateDecision {
121        /// Durable decision family (`approval` or `deferred`).
122        kind: &'static str,
123        /// Duplicate durable identity.
124        id: String,
125    },
126    /// One pending item has no terminal durable decision.
127    #[error("missing terminal durable {kind} decision for {id}")]
128    MissingDecision {
129        /// Durable decision family (`approval` or `deferred`).
130        kind: &'static str,
131        /// Pending identity without a terminal decision.
132        id: String,
133    },
134    /// Durable evidence references an item that is not pending in the checkpoint.
135    #[error("unknown durable {kind} decision for {id}")]
136    UnknownDecision {
137        /// Durable decision family (`approval` or `deferred`).
138        kind: &'static str,
139        /// Durable identity absent from the waiting checkpoint.
140        id: String,
141    },
142    /// Durable evidence uses the expected durable id but references another tool call.
143    #[error("durable {kind} decision {id} references {actual}; expected {expected}")]
144    MismatchedDecisionIdentity {
145        /// Durable decision family (`approval` or `deferred`).
146        kind: &'static str,
147        /// Durable decision identity.
148        id: String,
149        /// Referenced tool call identity.
150        actual: String,
151        /// Expected tool call identity.
152        expected: String,
153    },
154    /// Durable decision evidence does not match the pending checkpoint evidence.
155    #[error("durable {kind} decision {id} has mismatched {field}")]
156    MismatchedDecisionEvidence {
157        /// Durable decision family (`approval` or `deferred`).
158        kind: &'static str,
159        /// Durable decision identity.
160        id: String,
161        /// Evidence field that did not match.
162        field: &'static str,
163    },
164    /// A durable approval status and decision record disagree.
165    #[error("inconsistent durable approval decision for {0}")]
166    InconsistentApproval(String),
167}
168
169fn validate_snapshot_identity(
170    snapshot: &SessionResumeSnapshot,
171) -> Result<(), ContinuationPreparationError> {
172    if snapshot.session.session_id != snapshot.run.session_id {
173        return Err(ContinuationPreparationError::IdentityMismatch(format!(
174            "session {} does not own run {}",
175            snapshot.session.session_id.as_str(),
176            snapshot.run.run_id.as_str()
177        )));
178    }
179    if snapshot
180        .state
181        .run_id
182        .as_ref()
183        .is_some_and(|run_id| run_id != &snapshot.run.run_id)
184    {
185        return Err(ContinuationPreparationError::IdentityMismatch(format!(
186            "context run does not match {}",
187            snapshot.run.run_id.as_str()
188        )));
189    }
190    if snapshot
191        .state
192        .conversation_id
193        .as_ref()
194        .is_some_and(|conversation_id| conversation_id != &snapshot.run.conversation_id)
195    {
196        return Err(ContinuationPreparationError::IdentityMismatch(format!(
197            "context conversation does not match {}",
198            snapshot.run.conversation_id.as_str()
199        )));
200    }
201    validate_optional_durable_id(
202        &snapshot.state.metadata,
203        "starweaver.durable_session_id",
204        snapshot.session.session_id.as_str(),
205    )?;
206    validate_optional_durable_id(
207        &snapshot.state.metadata,
208        "starweaver.durable_run_id",
209        snapshot.run.run_id.as_str(),
210    )?;
211    if let Some(checkpoint) = &snapshot.latest_checkpoint {
212        if checkpoint.run_id != snapshot.run.run_id {
213            return Err(ContinuationPreparationError::IdentityMismatch(format!(
214                "checkpoint run does not match {}",
215                snapshot.run.run_id.as_str()
216            )));
217        }
218        if checkpoint.state.run_id != snapshot.run.run_id {
219            return Err(ContinuationPreparationError::IdentityMismatch(format!(
220                "checkpoint state run does not match {}",
221                snapshot.run.run_id.as_str()
222            )));
223        }
224        if checkpoint.conversation_id != snapshot.run.conversation_id {
225            return Err(ContinuationPreparationError::IdentityMismatch(format!(
226                "checkpoint conversation does not match {}",
227                snapshot.run.conversation_id.as_str()
228            )));
229        }
230        if checkpoint.state.conversation_id != snapshot.run.conversation_id {
231            return Err(ContinuationPreparationError::IdentityMismatch(format!(
232                "checkpoint state conversation does not match {}",
233                snapshot.run.conversation_id.as_str()
234            )));
235        }
236        if checkpoint.node != checkpoint.resume.node
237            || checkpoint.run_step != checkpoint.resume.run_step
238            || checkpoint.run_step != checkpoint.state.run_step
239        {
240            return Err(ContinuationPreparationError::IdentityMismatch(
241                "checkpoint boundary metadata does not match checkpoint state".to_string(),
242            ));
243        }
244    }
245    Ok(())
246}
247
248fn validate_optional_durable_id(
249    metadata: &serde_json::Map<String, Value>,
250    key: &'static str,
251    expected: &str,
252) -> Result<(), ContinuationPreparationError> {
253    if metadata
254        .get(key)
255        .is_some_and(|value| value.as_str() != Some(expected))
256    {
257        return Err(ContinuationPreparationError::IdentityMismatch(format!(
258            "context metadata {key} does not match {expected}"
259        )));
260    }
261    Ok(())
262}
263
264fn validate_waiting_evidence(
265    snapshot: &SessionResumeSnapshot,
266    state: &AgentRunState,
267) -> Result<(), ContinuationPreparationError> {
268    if !state.has_pending_hitl() {
269        return Err(ContinuationPreparationError::NoPendingHitl(
270            snapshot.run.run_id.as_str().to_string(),
271        ));
272    }
273    validate_approval_evidence(snapshot, state)?;
274
275    let pending_deferred = state
276        .deferred_tool_returns
277        .iter()
278        .map(|item| {
279            (
280                format!("deferred_{}_{}", state.run_id.as_str(), item.tool_call_id),
281                item.tool_call_id.clone(),
282            )
283        })
284        .collect::<BTreeMap<_, _>>();
285    let mut deferred = BTreeMap::new();
286    for record in &snapshot.deferred_tools {
287        if record.session_id != snapshot.run.session_id || record.run_id != snapshot.run.run_id {
288            return Err(ContinuationPreparationError::IdentityMismatch(format!(
289                "deferred record {} does not belong to the source run",
290                record.deferred_id
291            )));
292        }
293        if deferred
294            .insert(record.deferred_id.clone(), record)
295            .is_some()
296        {
297            return Err(ContinuationPreparationError::DuplicateDecision {
298                kind: "deferred",
299                id: record.deferred_id.clone(),
300            });
301        }
302    }
303    validate_deferred_set(&pending_deferred, &deferred)
304}
305
306fn validate_approval_evidence(
307    snapshot: &SessionResumeSnapshot,
308    state: &AgentRunState,
309) -> Result<(), ContinuationPreparationError> {
310    let pending_tool_calls = state
311        .latest_response
312        .iter()
313        .flat_map(starweaver_model::ModelResponse::tool_calls)
314        .chain(state.pending_tool_calls.iter().cloned())
315        .map(|call| (call.id.clone(), call))
316        .collect::<BTreeMap<_, _>>();
317    let mut pending_approvals = BTreeMap::new();
318    for item in &state.pending_approval_tool_returns {
319        let action_id = item.tool_call_id.clone();
320        let approval_id = format!("approval_{}_{}", state.run_id.as_str(), action_id);
321        let tool_call = pending_tool_calls.get(&action_id).ok_or_else(|| {
322            ContinuationPreparationError::IdentityMismatch(format!(
323                "pending approval {approval_id} has no matching checkpoint tool call"
324            ))
325        })?;
326        if tool_call.name != item.name {
327            return Err(ContinuationPreparationError::MismatchedDecisionEvidence {
328                kind: "approval",
329                id: approval_id,
330                field: "checkpoint tool name",
331            });
332        }
333        let request = item
334            .metadata
335            .get("approval")
336            .cloned()
337            .unwrap_or(Value::Null);
338        let reviewed_arguments = item
339            .metadata
340            .get(TOOL_RETURN_APPROVAL_ARGUMENTS_METADATA_KEY)
341            .ok_or_else(
342                || ContinuationPreparationError::MismatchedDecisionEvidence {
343                    kind: "approval",
344                    id: approval_id.clone(),
345                    field: "reviewed tool arguments",
346                },
347            )?;
348        if reviewed_arguments != &tool_call.arguments.execution_value() {
349            return Err(ContinuationPreparationError::MismatchedDecisionEvidence {
350                kind: "approval",
351                id: approval_id,
352                field: "checkpoint tool arguments",
353            });
354        }
355        if pending_approvals
356            .insert(
357                action_id.clone(),
358                PendingApprovalEvidence {
359                    approval_id,
360                    action_name: item.name.clone(),
361                    request,
362                    reviewed_arguments: reviewed_arguments.clone(),
363                },
364            )
365            .is_some()
366        {
367            return Err(ContinuationPreparationError::DuplicateDecision {
368                kind: "pending approval",
369                id: action_id,
370            });
371        }
372    }
373    let mut approvals = BTreeMap::new();
374    for record in &snapshot.approvals {
375        if record.session_id != snapshot.run.session_id || record.run_id != snapshot.run.run_id {
376            return Err(ContinuationPreparationError::IdentityMismatch(format!(
377                "approval {} does not belong to the source run",
378                record.approval_id
379            )));
380        }
381        let Some(expected) = pending_approvals.get(&record.action_id) else {
382            return Err(ContinuationPreparationError::UnknownDecision {
383                kind: "approval",
384                id: record.action_id.clone(),
385            });
386        };
387        if record.approval_id != expected.approval_id {
388            return Err(ContinuationPreparationError::MismatchedDecisionEvidence {
389                kind: "approval",
390                id: record.action_id.clone(),
391                field: "approval id",
392            });
393        }
394        if approvals.insert(record.action_id.clone(), record).is_some() {
395            return Err(ContinuationPreparationError::DuplicateDecision {
396                kind: "approval",
397                id: record.action_id.clone(),
398            });
399        }
400    }
401    validate_approval_set(&pending_approvals, &approvals)
402}
403
404#[derive(Clone, Debug, Eq, PartialEq)]
405struct PendingApprovalEvidence {
406    approval_id: String,
407    action_name: String,
408    request: Value,
409    reviewed_arguments: Value,
410}
411
412fn validate_approval_set(
413    pending: &BTreeMap<String, PendingApprovalEvidence>,
414    records: &BTreeMap<String, &crate::ApprovalRecord>,
415) -> Result<(), ContinuationPreparationError> {
416    for (id, expected) in pending {
417        let record =
418            records
419                .get(id)
420                .ok_or_else(|| ContinuationPreparationError::MissingDecision {
421                    kind: "approval",
422                    id: id.clone(),
423                })?;
424        if record.action_name != expected.action_name {
425            return Err(ContinuationPreparationError::MismatchedDecisionEvidence {
426                kind: "approval",
427                id: record.approval_id.clone(),
428                field: "action name",
429            });
430        }
431        if record.request != expected.request {
432            return Err(ContinuationPreparationError::MismatchedDecisionEvidence {
433                kind: "approval",
434                id: record.approval_id.clone(),
435                field: "request",
436            });
437        }
438        if record.reviewed_arguments.as_ref() != Some(&expected.reviewed_arguments) {
439            return Err(ContinuationPreparationError::MismatchedDecisionEvidence {
440                kind: "approval",
441                id: record.approval_id.clone(),
442                field: "durable reviewed tool arguments",
443            });
444        }
445        if record.status == ApprovalStatus::Pending {
446            return Err(ContinuationPreparationError::MissingDecision {
447                kind: "approval",
448                id: id.clone(),
449            });
450        }
451        let Some(decision) = record.decision.as_ref() else {
452            return Err(ContinuationPreparationError::MissingDecision {
453                kind: "approval",
454                id: id.clone(),
455            });
456        };
457        let consistent = decision.status == record.status
458            || (matches!(
459                record.status,
460                ApprovalStatus::Expired | ApprovalStatus::Cancelled
461            ) && matches!(decision.status, ApprovalStatus::Denied));
462        if !consistent {
463            return Err(ContinuationPreparationError::InconsistentApproval(
464                id.clone(),
465            ));
466        }
467    }
468    if let Some(id) = records.keys().find(|id| !pending.contains_key(*id)) {
469        return Err(ContinuationPreparationError::UnknownDecision {
470            kind: "approval",
471            id: id.clone(),
472        });
473    }
474    Ok(())
475}
476
477fn validate_deferred_set(
478    pending: &BTreeMap<String, String>,
479    records: &BTreeMap<String, &crate::DeferredToolRecord>,
480) -> Result<(), ContinuationPreparationError> {
481    for (id, expected_tool_call_id) in pending {
482        let record =
483            records
484                .get(id)
485                .ok_or_else(|| ContinuationPreparationError::MissingDecision {
486                    kind: "deferred",
487                    id: id.clone(),
488                })?;
489        if record.tool_call_id != *expected_tool_call_id {
490            return Err(ContinuationPreparationError::MismatchedDecisionIdentity {
491                kind: "deferred",
492                id: id.clone(),
493                actual: record.tool_call_id.clone(),
494                expected: expected_tool_call_id.clone(),
495            });
496        }
497        if matches!(
498            record.status,
499            ExecutionStatus::Pending | ExecutionStatus::Running | ExecutionStatus::Waiting
500        ) {
501            return Err(ContinuationPreparationError::MissingDecision {
502                kind: "deferred",
503                id: id.clone(),
504            });
505        }
506    }
507    if let Some(id) = records.keys().find(|id| !pending.contains_key(*id)) {
508        return Err(ContinuationPreparationError::UnknownDecision {
509            kind: "deferred",
510            id: id.clone(),
511        });
512    }
513    Ok(())
514}
515
516#[cfg(test)]
517mod tests {
518    use std::collections::BTreeMap;
519
520    use serde_json::json;
521    use starweaver_context::{AgentCheckpoint, AgentRunState, ResumableState};
522    use starweaver_core::{AgentExecutionNode, ConversationId, RunId, SessionId};
523    use starweaver_model::{
524        TOOL_RETURN_APPROVAL_ARGUMENTS_METADATA_KEY, ToolArguments, ToolCallPart, ToolReturnPart,
525    };
526
527    use super::{ContinuationPreparationError, PreparedContinuation, validate_deferred_set};
528    use crate::{
529        ApprovalRecord, ApprovalStatus, DeferredToolRecord, ExecutionStatus, RunRecord, RunStatus,
530        SessionRecord, SessionResumeSnapshot, ToolApprovalDecision, ToolReturnRecordInput,
531    };
532
533    fn waiting_approval_snapshot() -> SessionResumeSnapshot {
534        let session_id = SessionId::from_string("session_waiting");
535        let run_id = RunId::from_string("run_waiting");
536        let conversation_id = ConversationId::from_string("conversation_waiting");
537        let arguments = json!({"command": "rm -rf target/tmp"});
538        let approval_request = json!({
539            "reviewer": "shell_command_reviewer",
540            "reason": "destructive command needs review",
541            "risk_level": "high",
542            "command": "rm -rf target/tmp",
543        });
544        let mut metadata = serde_json::Map::new();
545        metadata.insert("control_flow".to_string(), json!("approval_required"));
546        metadata.insert("approval".to_string(), approval_request);
547        metadata.insert(
548            TOOL_RETURN_APPROVAL_ARGUMENTS_METADATA_KEY.to_string(),
549            arguments.clone(),
550        );
551        let pending_return =
552            ToolReturnPart::new("call_approval", "shell", json!("approval required"))
553                .with_error(true)
554                .with_metadata(metadata);
555        let mut run_state = AgentRunState::new(run_id.clone(), conversation_id.clone());
556        run_state.pending_tool_calls.push(ToolCallPart {
557            id: "call_approval".to_string(),
558            name: "shell".to_string(),
559            arguments: ToolArguments::parsed(arguments),
560        });
561        run_state
562            .pending_approval_tool_returns
563            .push(pending_return.clone());
564        let checkpoint = AgentCheckpoint::new(AgentExecutionNode::ToolReturn, &run_state);
565
566        let input = ToolReturnRecordInput::new(
567            &session_id,
568            &run_id,
569            &pending_return.tool_call_id,
570            &pending_return.name,
571            &pending_return.metadata,
572        );
573        let Some(mut approval) = ApprovalRecord::from_tool_return(&input) else {
574            panic!("approval-required tool return must produce durable evidence");
575        };
576        approval.status = ApprovalStatus::Approved;
577        approval.decision = Some(ToolApprovalDecision::approved().into_approval_decision());
578
579        let mut state = ResumableState {
580            run_id: Some(run_id.clone()),
581            // Provider-routing affinity deliberately differs from durable session identity.
582            session_id: Some(SessionId::from_string("provider_routing_affinity")),
583            conversation_id: Some(conversation_id.clone()),
584            ..ResumableState::default()
585        };
586        state.metadata.insert(
587            "starweaver.durable_session_id".to_string(),
588            json!(session_id.as_str()),
589        );
590        state.metadata.insert(
591            "starweaver.durable_run_id".to_string(),
592            json!(run_id.as_str()),
593        );
594        let mut run = RunRecord::new(session_id.clone(), run_id, conversation_id);
595        run.status = RunStatus::Waiting;
596        SessionResumeSnapshot {
597            session: SessionRecord::new(session_id),
598            run,
599            state,
600            environment_state: None,
601            latest_checkpoint: Some(checkpoint),
602            stream_records: Vec::new(),
603            approvals: vec![approval],
604            deferred_tools: Vec::new(),
605            stream_cursors: Vec::new(),
606        }
607    }
608
609    #[test]
610    fn waiting_approval_binds_durable_identity_name_request_and_tool_arguments() {
611        let snapshot = waiting_approval_snapshot();
612        assert!(PreparedContinuation::waiting_hitl(snapshot.clone()).is_ok());
613
614        let mut missing_arguments_binding = snapshot.clone();
615        let Some(checkpoint) = missing_arguments_binding.latest_checkpoint.as_mut() else {
616            panic!("test snapshot must include a checkpoint");
617        };
618        checkpoint.state.pending_approval_tool_returns[0]
619            .metadata
620            .remove(TOOL_RETURN_APPROVAL_ARGUMENTS_METADATA_KEY);
621        assert!(matches!(
622            PreparedContinuation::waiting_hitl(missing_arguments_binding),
623            Err(ContinuationPreparationError::MismatchedDecisionEvidence {
624                field: "reviewed tool arguments",
625                ..
626            })
627        ));
628
629        let mut jointly_tampered_arguments = snapshot.clone();
630        let Some(checkpoint) = jointly_tampered_arguments.latest_checkpoint.as_mut() else {
631            panic!("test snapshot must include a checkpoint");
632        };
633        let tampered = json!({"command": "other", "environment": {"TOKEN": "changed"}});
634        checkpoint.state.pending_tool_calls[0].arguments = ToolArguments::parsed(tampered.clone());
635        checkpoint.state.pending_approval_tool_returns[0]
636            .metadata
637            .insert(
638                TOOL_RETURN_APPROVAL_ARGUMENTS_METADATA_KEY.to_string(),
639                tampered,
640            );
641        assert!(matches!(
642            PreparedContinuation::waiting_hitl(jointly_tampered_arguments),
643            Err(ContinuationPreparationError::MismatchedDecisionEvidence {
644                field: "durable reviewed tool arguments",
645                ..
646            })
647        ));
648
649        let mut wrong_id = snapshot.clone();
650        wrong_id.approvals[0].approval_id = "approval_other".to_string();
651        assert!(matches!(
652            PreparedContinuation::waiting_hitl(wrong_id),
653            Err(ContinuationPreparationError::MismatchedDecisionEvidence {
654                field: "approval id",
655                ..
656            })
657        ));
658
659        let mut wrong_name = snapshot.clone();
660        wrong_name.approvals[0].action_name = "other_tool".to_string();
661        assert!(matches!(
662            PreparedContinuation::waiting_hitl(wrong_name),
663            Err(ContinuationPreparationError::MismatchedDecisionEvidence {
664                field: "action name",
665                ..
666            })
667        ));
668
669        let mut wrong_request = snapshot.clone();
670        wrong_request.approvals[0].request = json!({"arguments": {"command": "other"}});
671        assert!(matches!(
672            PreparedContinuation::waiting_hitl(wrong_request),
673            Err(ContinuationPreparationError::MismatchedDecisionEvidence {
674                field: "request",
675                ..
676            })
677        ));
678
679        let mut wrong_arguments = snapshot;
680        let Some(checkpoint) = wrong_arguments.latest_checkpoint.as_mut() else {
681            panic!("test snapshot must include a checkpoint");
682        };
683        checkpoint.state.pending_tool_calls[0].arguments =
684            ToolArguments::parsed(json!({"command": "other"}));
685        assert!(matches!(
686            PreparedContinuation::waiting_hitl(wrong_arguments),
687            Err(ContinuationPreparationError::MismatchedDecisionEvidence {
688                field: "checkpoint tool arguments",
689                ..
690            })
691        ));
692    }
693
694    fn assert_identity_rejected(snapshot: SessionResumeSnapshot) {
695        assert!(matches!(
696            PreparedContinuation::ordinary(snapshot.clone()),
697            Err(ContinuationPreparationError::IdentityMismatch(_))
698        ));
699        assert!(matches!(
700            PreparedContinuation::waiting_hitl(snapshot),
701            Err(ContinuationPreparationError::IdentityMismatch(_))
702        ));
703    }
704
705    #[test]
706    fn continuation_rejects_context_and_checkpoint_identity_tampering() {
707        let snapshot = waiting_approval_snapshot();
708
709        let mut context_conversation = snapshot.clone();
710        context_conversation.state.conversation_id =
711            Some(ConversationId::from_string("conversation_other"));
712        assert_identity_rejected(context_conversation);
713
714        let mut durable_session = snapshot.clone();
715        durable_session.state.metadata.insert(
716            "starweaver.durable_session_id".to_string(),
717            json!("session_other"),
718        );
719        assert_identity_rejected(durable_session);
720
721        let mut checkpoint_run = snapshot.clone();
722        let Some(checkpoint) = checkpoint_run.latest_checkpoint.as_mut() else {
723            panic!("test snapshot must include a checkpoint");
724        };
725        checkpoint.run_id = RunId::from_string("run_other");
726        assert_identity_rejected(checkpoint_run);
727
728        let mut checkpoint_conversation = snapshot.clone();
729        let Some(checkpoint) = checkpoint_conversation.latest_checkpoint.as_mut() else {
730            panic!("test snapshot must include a checkpoint");
731        };
732        checkpoint.conversation_id = ConversationId::from_string("conversation_other");
733        assert_identity_rejected(checkpoint_conversation);
734
735        let mut checkpoint_state_conversation = snapshot;
736        let Some(checkpoint) = checkpoint_state_conversation.latest_checkpoint.as_mut() else {
737            panic!("test snapshot must include a checkpoint");
738        };
739        checkpoint.state.conversation_id = ConversationId::from_string("conversation_other");
740        assert_identity_rejected(checkpoint_state_conversation);
741    }
742
743    #[test]
744    fn deferred_decision_rejects_mismatched_tool_call_identity() {
745        let deferred_id = "deferred_run_waiting_call_expected".to_string();
746        let pending = BTreeMap::from([(deferred_id.clone(), "call_expected".to_string())]);
747        let mut record = DeferredToolRecord::new(
748            deferred_id.clone(),
749            SessionId::from_string("session_waiting"),
750            RunId::from_string("run_waiting"),
751            "call_other",
752            "shell",
753        );
754        record.status = ExecutionStatus::Completed;
755        let records = BTreeMap::from([(deferred_id.clone(), &record)]);
756
757        assert_eq!(
758            validate_deferred_set(&pending, &records),
759            Err(ContinuationPreparationError::MismatchedDecisionIdentity {
760                kind: "deferred",
761                id: deferred_id,
762                actual: "call_other".to_string(),
763                expected: "call_expected".to_string(),
764            })
765        );
766    }
767
768    #[test]
769    fn deferred_decision_accepts_matching_terminal_identity() {
770        let deferred_id = "deferred_run_waiting_call_expected".to_string();
771        let pending = BTreeMap::from([(deferred_id.clone(), "call_expected".to_string())]);
772        let mut record = DeferredToolRecord::new(
773            deferred_id.clone(),
774            SessionId::from_string("session_waiting"),
775            RunId::from_string("run_waiting"),
776            "call_expected",
777            "shell",
778        );
779        record.status = ExecutionStatus::Completed;
780        let records = BTreeMap::from([(deferred_id, &record)]);
781
782        assert_eq!(validate_deferred_set(&pending, &records), Ok(()));
783    }
784}