1use 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#[derive(Clone, Debug, Deserialize, Serialize)]
26pub struct RelatedRunUpdate {
27 pub run_id: RunId,
29 pub expected_status: RunStatus,
31 pub status: RunStatus,
33 pub resume_claim_id: Option<String>,
35 pub output_preview: Option<String>,
37 #[serde(default, skip_serializing_if = "Option::is_none")]
39 pub terminal_error: Option<RunTerminalError>,
40 pub approvals: Vec<ApprovalRecord>,
42 pub deferred_tools: Vec<DeferredToolRecord>,
44}
45
46impl RelatedRunUpdate {
47 #[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#[derive(Clone, Debug, Deserialize, Serialize)]
69pub struct RunEvidenceCommit {
70 pub run: RunRecord,
72 pub context_state: ResumableState,
74 pub environment_state: Option<Value>,
76 pub stream_records: Vec<AgentStreamRecord>,
78 pub checkpoints: Vec<AgentCheckpoint>,
80 pub approvals: Vec<ApprovalRecord>,
82 pub deferred_tools: Vec<DeferredToolRecord>,
84 pub stream_cursors: Vec<StreamCursorRef>,
86 pub display_messages: Vec<DisplayMessage>,
88 pub replay_events: Vec<ReplayEvent>,
90 pub display_snapshot: Option<ReplaySnapshot>,
92 pub publication_targets: StreamPublicationTargets,
94 #[serde(default, skip_serializing_if = "Vec::is_empty")]
96 pub host_event_publications: Vec<PendingHostEventPublication>,
97 pub related_run_updates: Vec<RelatedRunUpdate>,
99}
100
101impl RunEvidenceCommit {
102 pub const DIGEST_METADATA_KEY: &'static str = "starweaver.run_evidence_sha256";
106
107 #[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 pub fn validate(&self) -> SessionStoreResult<()> {
135 self.validate_structure()?;
136 self.validate_terminal_projections()
137 }
138
139 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 pub fn validate_terminal_projections(&self) -> SessionStoreResult<()> {
163 validate_primary_terminal_projection(self)?;
164 validate_related_terminal_projections(self)
165 }
166
167 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 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 .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}