Skip to main content

starweaver_session/
evidence.rs

1//! Atomic durable evidence committed for one agent run.
2
3use std::collections::BTreeSet;
4
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7use sha2::{Digest, Sha256};
8use starweaver_context::{AgentCheckpoint, ResumableState};
9use starweaver_core::RunId;
10use starweaver_stream::{
11    AgentStreamRecord, DisplayMessage, ReplayCursorFamily, ReplayEvent, ReplayScope, ReplaySnapshot,
12};
13
14use crate::{
15    ApprovalRecord, DeferredToolRecord, PendingHostEventPublication, RunRecord, RunStatus,
16    RunTerminalError, RunTerminalProjection, SessionStoreError, SessionStoreResult,
17    StreamCursorRef, StreamPublicationTargets,
18};
19
20/// Atomic transition applied to an existing run together with a new run evidence commit.
21///
22/// This supports durable continuations: resolved HITL records and the source run's terminal state
23/// become visible in the same transaction as the continuation run. `expected_status` provides an
24/// optimistic concurrency guard and prevents two continuations from consuming one waiting run.
25#[derive(Clone, Debug, Deserialize, Serialize)]
26pub struct RelatedRunUpdate {
27    /// Existing run to update. It must belong to the evidence commit's session.
28    pub run_id: RunId,
29    /// Status that must be present before the transition.
30    pub expected_status: RunStatus,
31    /// Terminal status written by the transition.
32    pub status: RunStatus,
33    /// Exclusive resume claim that authorizes this transition.
34    pub resume_claim_id: Option<String>,
35    /// Optional source-run output preview.
36    pub output_preview: Option<String>,
37    /// Safe source-run terminal diagnostic.
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    pub terminal_error: Option<RunTerminalError>,
40    /// Resolved approval records owned by the source run.
41    pub approvals: Vec<ApprovalRecord>,
42    /// Resolved deferred-tool records owned by the source run.
43    pub deferred_tools: Vec<DeferredToolRecord>,
44}
45
46impl RelatedRunUpdate {
47    /// Build a guarded source-run transition with no HITL records.
48    #[must_use]
49    pub const fn new(run_id: RunId, expected_status: RunStatus, status: RunStatus) -> Self {
50        Self {
51            run_id,
52            expected_status,
53            status,
54            resume_claim_id: None,
55            output_preview: None,
56            terminal_error: None,
57            approvals: Vec::new(),
58            deferred_tools: Vec::new(),
59        }
60    }
61}
62
63/// Complete product-neutral durable evidence for one run.
64///
65/// A [`crate::SessionStore`] implementation must either persist all fields in this value or leave
66/// its previous state unchanged. Repeating an identical commit is idempotent; reusing an evidence
67/// identity with a different payload is an error.
68#[derive(Clone, Debug, Deserialize, Serialize)]
69pub struct RunEvidenceCommit {
70    /// Final or checkpointed run record.
71    pub run: RunRecord,
72    /// Per-run resumable context state.
73    pub context_state: ResumableState,
74    /// Optional product-neutral serialized environment state.
75    pub environment_state: Option<Value>,
76    /// Raw runtime stream records.
77    pub stream_records: Vec<AgentStreamRecord>,
78    /// Full runtime checkpoints. Legacy checkpoint references are not accepted here.
79    pub checkpoints: Vec<AgentCheckpoint>,
80    /// Approval evidence created by the run.
81    pub approvals: Vec<ApprovalRecord>,
82    /// Deferred tool evidence created by the run.
83    pub deferred_tools: Vec<DeferredToolRecord>,
84    /// Durable stream cursors selected by the caller.
85    pub stream_cursors: Vec<StreamCursorRef>,
86    /// Display messages owned by this run's replay scope.
87    pub display_messages: Vec<DisplayMessage>,
88    /// Replay events, including terminal markers, owned by this run's replay scope.
89    pub replay_events: Vec<ReplayEvent>,
90    /// Optional compact display snapshot for this run's replay scope.
91    pub display_snapshot: Option<ReplaySnapshot>,
92    /// External sink families transactionally enqueued with this evidence.
93    pub publication_targets: StreamPublicationTargets,
94    /// View-independent host events enqueued atomically with this evidence.
95    #[serde(default, skip_serializing_if = "Vec::is_empty")]
96    pub host_event_publications: Vec<PendingHostEventPublication>,
97    /// Existing runs transitioned atomically with this commit.
98    pub related_run_updates: Vec<RelatedRunUpdate>,
99}
100
101impl RunEvidenceCommit {
102    /// Reserved legacy metadata key rejected to prevent caller-forged evidence digests.
103    ///
104    /// Stores persist trusted digests independently from caller-controlled run metadata.
105    pub const DIGEST_METADATA_KEY: &'static str = "starweaver.run_evidence_sha256";
106
107    /// Build a run-evidence commit with no optional evidence.
108    #[must_use]
109    pub const fn new(run: RunRecord, context_state: ResumableState) -> Self {
110        Self {
111            run,
112            context_state,
113            environment_state: None,
114            stream_records: Vec::new(),
115            checkpoints: Vec::new(),
116            approvals: Vec::new(),
117            deferred_tools: Vec::new(),
118            stream_cursors: Vec::new(),
119            display_messages: Vec::new(),
120            replay_events: Vec::new(),
121            display_snapshot: None,
122            publication_targets: StreamPublicationTargets::new(false, false),
123            host_event_publications: Vec::new(),
124            related_run_updates: Vec::new(),
125        }
126    }
127
128    /// Validate identities, terminal projections, cursor scopes, sequence uniqueness, snapshots,
129    /// and environment shape for a new evidence write.
130    ///
131    /// # Errors
132    ///
133    /// Returns a store error when evidence members cannot belong to one atomic run commit.
134    pub fn validate(&self) -> SessionStoreResult<()> {
135        self.validate_structure()?;
136        self.validate_terminal_projections()
137    }
138
139    /// Validate the legacy-compatible structure needed before exact-retry lookup.
140    ///
141    /// This method does not authorize a new write: stores must call
142    /// [`Self::validate_terminal_projections`] after an exact-retry miss.
143    ///
144    /// # Errors
145    ///
146    /// Returns a store error when evidence members cannot safely identify one atomic commit.
147    pub fn validate_structure(&self) -> SessionStoreResult<()> {
148        validate_primary_identity(self)?;
149        validate_stream_evidence(self)?;
150        validate_host_event_publications(self)?;
151        validate_related_run_evidence(self)
152    }
153
154    /// Validate terminal projections before inserting new evidence.
155    ///
156    /// Kept separate from structural validation so an exact retry of legacy evidence committed
157    /// before terminal diagnostics existed remains idempotent across upgrades.
158    ///
159    /// # Errors
160    ///
161    /// Returns a store error when primary or related terminal evidence is incomplete.
162    pub fn validate_terminal_projections(&self) -> SessionStoreResult<()> {
163        validate_primary_terminal_projection(self)?;
164        validate_related_terminal_projections(self)
165    }
166
167    /// Return the canonical SHA-256 digest for exact-retry conflict detection.
168    ///
169    /// # Errors
170    ///
171    /// Returns a store error when the bundle cannot be serialized.
172    pub fn digest(&self) -> SessionStoreResult<String> {
173        let payload = serde_json::to_vec(self)
174            .map_err(|error| SessionStoreError::Failed(error.to_string()))?;
175        Ok(format!("{:x}", Sha256::digest(payload)))
176    }
177}
178
179fn validate_primary_terminal_projection(commit: &RunEvidenceCommit) -> SessionStoreResult<()> {
180    commit.run.validate_new_write().map_err(|error| {
181        SessionStoreError::Failed(format!(
182            "invalid terminal evidence for run {}: {error}",
183            commit.run.run_id.as_str()
184        ))
185    })
186}
187
188fn validate_primary_identity(commit: &RunEvidenceCommit) -> SessionStoreResult<()> {
189    let session_id = &commit.run.session_id;
190    let run_id = &commit.run.run_id;
191    if commit
192        .run
193        .metadata
194        .contains_key(RunEvidenceCommit::DIGEST_METADATA_KEY)
195    {
196        return Err(SessionStoreError::Failed(format!(
197            "reserved run metadata key {} cannot be supplied by callers",
198            RunEvidenceCommit::DIGEST_METADATA_KEY
199        )));
200    }
201    let scope = ReplayScope::run(run_id.as_str());
202    // AgentContext.session_id is provider-routing affinity, and its run_id is
203    // runtime execution state. Durable store identity belongs to RunRecord and
204    // the canonical metadata keys, so those context fields must not be equated
205    // with product persistence IDs.
206    let context_binding_matches = commit
207        .context_state
208        .conversation_id
209        .as_ref()
210        .is_none_or(|conversation_id| conversation_id == &commit.run.conversation_id)
211        && optional_metadata_id_matches(
212            &commit.context_state.metadata,
213            "starweaver.durable_session_id",
214            session_id.as_str(),
215        )
216        && optional_metadata_id_matches(
217            &commit.context_state.metadata,
218            "starweaver.durable_run_id",
219            run_id.as_str(),
220        );
221    let identity_mismatch = !context_binding_matches
222        || commit.checkpoints.iter().any(|checkpoint| {
223            checkpoint.run_id != *run_id
224                || checkpoint.conversation_id != commit.run.conversation_id
225                || checkpoint.state.run_id != *run_id
226                || checkpoint.state.conversation_id != commit.run.conversation_id
227                || checkpoint.node != checkpoint.resume.node
228                || checkpoint.run_step != checkpoint.resume.run_step
229                || checkpoint.run_step != checkpoint.state.run_step
230        })
231        || commit
232            .approvals
233            .iter()
234            .any(|record| record.session_id != *session_id || record.run_id != *run_id)
235        || commit
236            .deferred_tools
237            .iter()
238            .any(|record| record.session_id != *session_id || record.run_id != *run_id)
239        || commit
240            .display_messages
241            .iter()
242            // Display run_id identifies the originating source run. A parent
243            // commit may archive source-attributed child messages under its
244            // own replay scope, so only the durable session must match here.
245            .any(|message| message.session_id != *session_id)
246        || commit
247            .replay_events
248            .iter()
249            .any(|event| event.scope != scope);
250    if identity_mismatch {
251        return Err(SessionStoreError::Failed(format!(
252            "run evidence identity mismatch for session {} and run {}",
253            session_id.as_str(),
254            run_id.as_str()
255        )));
256    }
257    Ok(())
258}
259
260fn optional_metadata_id_matches(
261    metadata: &serde_json::Map<String, Value>,
262    key: &str,
263    expected: &str,
264) -> bool {
265    metadata
266        .get(key)
267        .is_none_or(|value| value.as_str() == Some(expected))
268}
269
270fn validate_stream_evidence(commit: &RunEvidenceCommit) -> SessionStoreResult<()> {
271    let session_id = &commit.run.session_id;
272    let run_id = &commit.run.run_id;
273    let scope = ReplayScope::run(run_id.as_str());
274    ensure_unique_sequences(
275        commit.stream_records.iter().map(|record| record.sequence),
276        "raw stream",
277    )?;
278    ensure_unique_sequences(
279        commit
280            .display_messages
281            .iter()
282            .map(|message| message.sequence),
283        "display stream",
284    )?;
285    ensure_unique_sequences(
286        commit.replay_events.iter().map(|event| event.sequence),
287        "replay event",
288    )?;
289    let mut cursor_streams = BTreeSet::new();
290    for cursor in &commit.stream_cursors {
291        cursor
292            .validate_for_run(run_id)
293            .map_err(|error| SessionStoreError::Failed(error.to_string()))?;
294        let identity = (
295            cursor.family().as_str().to_string(),
296            cursor.scope().as_str().to_string(),
297        );
298        if !cursor_streams.insert(identity) {
299            return Err(SessionStoreError::Failed(format!(
300                "duplicate stream cursor family/scope for run {}",
301                run_id.as_str()
302            )));
303        }
304    }
305    if let Some(snapshot) = commit.display_snapshot.as_ref() {
306        snapshot
307            .validate(ReplayCursorFamily::Display, &scope)
308            .map_err(|error| SessionStoreError::Failed(error.to_string()))?;
309        if snapshot
310            .display_messages
311            .iter()
312            .any(|message| message.session_id != *session_id || message.run_id != *run_id)
313        {
314            return Err(SessionStoreError::Failed(format!(
315                "display snapshot ownership mismatch for session {} and run {}",
316                session_id.as_str(),
317                run_id.as_str()
318            )));
319        }
320    }
321    if let Some(environment) = commit.environment_state.as_ref() {
322        validate_environment_envelope(environment)?;
323    }
324    Ok(())
325}
326
327fn validate_host_event_publications(commit: &RunEvidenceCommit) -> SessionStoreResult<()> {
328    let mut publication_keys = BTreeSet::new();
329    let mut event_ids = BTreeSet::new();
330    for publication in &commit.host_event_publications {
331        publication.validate()?;
332        if !publication_keys.insert(publication.publication_key.as_str())
333            || !event_ids.insert(publication.event_id.as_str())
334        {
335            return Err(SessionStoreError::Failed(format!(
336                "duplicate host event publication for run {}",
337                commit.run.run_id.as_str()
338            )));
339        }
340        match &publication.scope {
341            crate::DurableHostEventScope::Global => {
342                return Err(SessionStoreError::Failed(format!(
343                    "run evidence {} cannot publish a global host event",
344                    commit.run.run_id.as_str()
345                )));
346            }
347            crate::DurableHostEventScope::Session { session_id }
348                if session_id != &commit.run.session_id =>
349            {
350                return Err(SessionStoreError::Failed(format!(
351                    "host event session scope mismatch for run {}",
352                    commit.run.run_id.as_str()
353                )));
354            }
355            crate::DurableHostEventScope::Run { session_id, run_id }
356                if session_id != &commit.run.session_id
357                    || (run_id != &commit.run.run_id
358                        && !commit
359                            .related_run_updates
360                            .iter()
361                            .any(|update| &update.run_id == run_id)) =>
362            {
363                return Err(SessionStoreError::Failed(format!(
364                    "host event run scope mismatch for run {}",
365                    commit.run.run_id.as_str()
366                )));
367            }
368            crate::DurableHostEventScope::Session { .. }
369            | crate::DurableHostEventScope::Run { .. } => {}
370        }
371    }
372    Ok(())
373}
374
375fn validate_related_run_evidence(commit: &RunEvidenceCommit) -> SessionStoreResult<()> {
376    let session_id = &commit.run.session_id;
377    let run_id = &commit.run.run_id;
378    if !commit.related_run_updates.is_empty()
379        && (commit.related_run_updates.len() != 1
380            || commit.run.restore_from_run_id.as_ref()
381                != commit
382                    .related_run_updates
383                    .first()
384                    .map(|update| &update.run_id))
385    {
386        return Err(SessionStoreError::Failed(format!(
387            "related run update must match restore_from_run_id for run {}",
388            run_id.as_str()
389        )));
390    }
391    let mut related_runs = BTreeSet::new();
392    for update in &commit.related_run_updates {
393        if update.run_id == *run_id || !related_runs.insert(update.run_id.clone()) {
394            return Err(SessionStoreError::Failed(format!(
395                "invalid or duplicate related run update {} for run {}",
396                update.run_id.as_str(),
397                run_id.as_str()
398            )));
399        }
400        if update.resume_claim_id.as_deref().is_none_or(str::is_empty) {
401            return Err(SessionStoreError::Failed(format!(
402                "related run update {} requires an exclusive resume claim",
403                update.run_id.as_str()
404            )));
405        }
406        let identity_mismatch = update
407            .approvals
408            .iter()
409            .any(|record| record.session_id != *session_id || record.run_id != update.run_id)
410            || update
411                .deferred_tools
412                .iter()
413                .any(|record| record.session_id != *session_id || record.run_id != update.run_id);
414        if identity_mismatch {
415            return Err(SessionStoreError::Failed(format!(
416                "related run evidence identity mismatch for session {} and run {}",
417                session_id.as_str(),
418                update.run_id.as_str()
419            )));
420        }
421        ensure_unique_strings(
422            update
423                .approvals
424                .iter()
425                .map(|record| record.approval_id.as_str()),
426            "approval",
427        )?;
428        ensure_unique_strings(
429            update
430                .deferred_tools
431                .iter()
432                .map(|record| record.deferred_id.as_str()),
433            "deferred tool",
434        )?;
435    }
436    Ok(())
437}
438
439fn validate_related_terminal_projections(commit: &RunEvidenceCommit) -> SessionStoreResult<()> {
440    for update in &commit.related_run_updates {
441        RunTerminalProjection {
442            status: update.status,
443            output_preview: update.output_preview.clone(),
444            error: update.terminal_error.clone(),
445        }
446        .validate()
447        .map_err(|error| {
448            SessionStoreError::Failed(format!(
449                "invalid related run update {}: {error}",
450                update.run_id.as_str()
451            ))
452        })?;
453    }
454    Ok(())
455}
456
457fn ensure_unique_sequences(
458    sequences: impl IntoIterator<Item = usize>,
459    family: &str,
460) -> SessionStoreResult<()> {
461    let mut seen = BTreeSet::new();
462    for sequence in sequences {
463        if !seen.insert(sequence) {
464            return Err(SessionStoreError::Failed(format!(
465                "duplicate {family} sequence {sequence}"
466            )));
467        }
468    }
469    Ok(())
470}
471
472fn ensure_unique_strings<'a>(
473    values: impl IntoIterator<Item = &'a str>,
474    kind: &str,
475) -> SessionStoreResult<()> {
476    let mut seen = BTreeSet::new();
477    for value in values {
478        if !seen.insert(value) {
479            return Err(SessionStoreError::Failed(format!(
480                "duplicate {kind} id {value} in related run update"
481            )));
482        }
483    }
484    Ok(())
485}
486
487fn validate_environment_envelope(value: &Value) -> SessionStoreResult<()> {
488    let Some(object) = value.as_object() else {
489        return Err(SessionStoreError::Failed(
490            "environment state must use a versioned envelope".to_string(),
491        ));
492    };
493    let schema = object.get("schema").and_then(Value::as_str);
494    let version = object.get("version").and_then(Value::as_u64);
495    if schema != Some("starweaver.environment.state")
496        || version != Some(1)
497        || !object.contains_key("payload")
498    {
499        return Err(SessionStoreError::Failed(
500            "environment state must use starweaver.environment.state version 1".to_string(),
501        ));
502    }
503    Ok(())
504}
505
506#[cfg(test)]
507mod tests {
508    #![allow(clippy::expect_used)]
509
510    use starweaver_context::{AgentCheckpoint, AgentRunState, ResumableState};
511    use starweaver_core::{AgentExecutionNode, ConversationId, RunId, SessionId};
512    use starweaver_stream::{DisplayMessage, DisplayMessageKind};
513
514    use super::RunEvidenceCommit;
515    use crate::RunRecord;
516
517    fn evidence() -> RunEvidenceCommit {
518        let session_id = SessionId::from_string("session-evidence-validation");
519        let run_id = RunId::from_string("run-evidence-validation");
520        let conversation_id = ConversationId::from_string("conversation-evidence-validation");
521        let run = RunRecord::new(session_id.clone(), run_id.clone(), conversation_id.clone());
522        let state = ResumableState {
523            session_id: Some(session_id),
524            run_id: Some(run_id),
525            conversation_id: Some(conversation_id),
526            ..ResumableState::default()
527        };
528        RunEvidenceCommit::new(run, state)
529    }
530
531    #[test]
532    fn validate_rejects_context_checkpoint_and_display_identity_mismatches() {
533        let mut provider_affinity = evidence();
534        provider_affinity.context_state.session_id =
535            Some(SessionId::from_string("provider-affinity"));
536        provider_affinity.context_state.run_id = Some(RunId::from_string("runtime-run"));
537        assert!(provider_affinity.validate().is_ok());
538
539        let mut context_mismatch = evidence();
540        context_mismatch.context_state.metadata.insert(
541            "starweaver.durable_session_id".to_string(),
542            serde_json::json!("other-session"),
543        );
544        assert!(context_mismatch.validate().is_err());
545
546        let mut checkpoint_mismatch = evidence();
547        let state = AgentRunState::new(
548            checkpoint_mismatch.run.run_id.clone(),
549            checkpoint_mismatch.run.conversation_id.clone(),
550        );
551        let mut checkpoint = AgentCheckpoint::new(AgentExecutionNode::PrepareModelRequest, &state);
552        checkpoint.run_id = RunId::from_string("other-run");
553        checkpoint_mismatch.checkpoints.push(checkpoint);
554        assert!(checkpoint_mismatch.validate().is_err());
555
556        let mut display_mismatch = evidence();
557        display_mismatch.display_messages.push(DisplayMessage::new(
558            0,
559            SessionId::from_string("other-session"),
560            display_mismatch.run.run_id.clone(),
561            DisplayMessageKind::RunStarted,
562        ));
563        assert!(display_mismatch.validate().is_err());
564    }
565
566    #[test]
567    fn validate_rejects_caller_control_of_reserved_evidence_digest() {
568        let mut commit = evidence();
569        commit.run.metadata.insert(
570            RunEvidenceCommit::DIGEST_METADATA_KEY.to_string(),
571            serde_json::json!("caller-controlled"),
572        );
573        let error = commit.validate().expect_err("reserved key must fail");
574        assert!(error.to_string().contains("reserved run metadata key"));
575    }
576}