1use chrono::{DateTime, Utc};
19use meerkat_core::event::AgentEvent;
20use meerkat_core::interaction::InteractionId;
21use meerkat_core::lifecycle::{InputId, RunId};
22use meerkat_core::types::{HandlingMode, SessionId};
23use serde::{Deserialize, Serialize};
24use sha2::{Digest, Sha256};
25
26use crate::identifiers::{InputKind, PolicyVersion};
27use crate::ingress_types::RuntimeInputSemantics;
28use crate::input::Input;
29use crate::policy::PolicyDecision;
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
33#[serde(rename_all = "snake_case")]
34#[non_exhaustive]
35pub enum InputLifecycleState {
36 Accepted,
37 Queued,
38 Staged,
39 Applied,
40 AppliedPendingConsumption,
41 Consumed,
42 Superseded,
43 Coalesced,
44 Abandoned,
45}
46
47#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
49#[serde(rename_all = "snake_case")]
50#[non_exhaustive]
51pub enum InputAbandonReason {
52 Retired,
53 Reset,
54 Stopped,
55 Destroyed,
56 Cancelled,
57 MaxAttemptsExhausted { attempts: u32 },
58}
59
60#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
65#[serde(tag = "outcome_type", rename_all = "snake_case")]
66#[non_exhaustive]
67pub enum InputTerminalOutcome {
68 Consumed,
69 Superseded { superseded_by: InputId },
70 Coalesced { aggregate_id: InputId },
71 Abandoned { reason: InputAbandonReason },
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct InputStateHistoryEntry {
77 pub timestamp: DateTime<Utc>,
78 pub from: InputLifecycleState,
79 pub to: InputLifecycleState,
80 #[serde(skip_serializing_if = "Option::is_none")]
81 pub reason: Option<String>,
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct PolicySnapshot {
87 pub version: PolicyVersion,
88 pub decision: PolicyDecision,
89}
90
91#[derive(Debug, Clone, Serialize, Deserialize)]
93#[serde(tag = "source_type", rename_all = "snake_case")]
94#[non_exhaustive]
95pub enum ReconstructionSource {
96 Projection {
97 rule_id: String,
98 source_event_id: String,
99 },
100 Coalescing {
101 source_input_ids: Vec<InputId>,
102 },
103}
104
105#[derive(Debug, Clone, Serialize, Deserialize)]
109#[serde(tag = "candidate_type", rename_all = "snake_case")]
110pub(crate) enum InteractionTerminalCandidate {
111 RunResult {
112 result: Box<meerkat_core::types::RunResult>,
113 },
114 CompletedWithoutResult,
115 CallbackPending {
116 #[serde(default, skip_serializing_if = "Option::is_none")]
122 tool_use_id: Option<String>,
123 tool_name: String,
124 args: serde_json::Value,
125 },
126 CallbackBatchPending {
127 pending_tool_calls: Vec<meerkat_core::error::PendingCallbackToolCall>,
128 },
129 MachineTerminalFailure {
134 error: meerkat_core::TurnErrorMetadata,
135 },
136 Cancelled,
137 RuntimeTerminated {
138 reason: String,
139 },
140}
141
142impl InteractionTerminalCandidate {
143 pub(crate) fn from_core_apply_terminal(
144 terminal: Option<&meerkat_core::lifecycle::core_executor::CoreApplyTerminal>,
145 ) -> Self {
146 use meerkat_core::lifecycle::core_executor::CoreApplyTerminal;
147 match terminal {
148 Some(CoreApplyTerminal::RunResult(result)) => Self::RunResult {
149 result: result.clone(),
150 },
151 Some(CoreApplyTerminal::CallbackPending {
152 tool_use_id,
153 tool_name,
154 args,
155 }) => Self::CallbackPending {
156 tool_use_id: Some(tool_use_id.clone()),
157 tool_name: tool_name.clone(),
158 args: args.clone(),
159 },
160 Some(CoreApplyTerminal::CallbackBatchPending { pending_tool_calls }) => {
161 Self::CallbackBatchPending {
162 pending_tool_calls: pending_tool_calls.clone(),
163 }
164 }
165 Some(CoreApplyTerminal::MachineTerminalFailure { error }) => {
166 Self::MachineTerminalFailure {
167 error: error.clone(),
168 }
169 }
170 Some(CoreApplyTerminal::NoPendingBoundary) | None => Self::CompletedWithoutResult,
171 }
172 }
173
174 pub(crate) fn core_apply_terminal(
175 &self,
176 ) -> Option<meerkat_core::lifecycle::core_executor::CoreApplyTerminal> {
177 use meerkat_core::lifecycle::core_executor::CoreApplyTerminal;
178 match self {
179 Self::RunResult { result } => Some(CoreApplyTerminal::RunResult(result.clone())),
180 Self::CompletedWithoutResult => Some(CoreApplyTerminal::NoPendingBoundary),
181 Self::CallbackPending {
182 tool_use_id,
183 tool_name,
184 args,
185 } => Some(CoreApplyTerminal::CallbackPending {
186 tool_use_id: tool_use_id.clone().unwrap_or_default(),
191 tool_name: tool_name.clone(),
192 args: args.clone(),
193 }),
194 Self::CallbackBatchPending { pending_tool_calls } => {
195 Some(CoreApplyTerminal::CallbackBatchPending {
196 pending_tool_calls: pending_tool_calls.clone(),
197 })
198 }
199 Self::MachineTerminalFailure { error } => {
200 Some(CoreApplyTerminal::MachineTerminalFailure {
201 error: error.clone(),
202 })
203 }
204 Self::Cancelled | Self::RuntimeTerminated { .. } => None,
205 }
206 }
207
208 pub(crate) fn terminal_observation(
209 &self,
210 ) -> crate::meerkat_machine::dsl::RuntimeCompletionTerminalObservation {
211 use crate::meerkat_machine::dsl::RuntimeCompletionTerminalObservation;
212 match self {
213 Self::RunResult { .. } => RuntimeCompletionTerminalObservation::RunResult,
214 Self::CallbackPending { .. } | Self::CallbackBatchPending { .. } => {
215 RuntimeCompletionTerminalObservation::CallbackPending
216 }
217 Self::RuntimeTerminated { .. } => {
218 RuntimeCompletionTerminalObservation::RuntimeTerminated
219 }
220 Self::MachineTerminalFailure { .. } | Self::Cancelled => {
221 RuntimeCompletionTerminalObservation::MachineTerminal
222 }
223 Self::CompletedWithoutResult => RuntimeCompletionTerminalObservation::NoResult,
224 }
225 }
226
227 pub(crate) fn completion_error_metadata(&self) -> Option<meerkat_core::TurnErrorMetadata> {
228 match self {
229 Self::MachineTerminalFailure { error } => Some(error.clone()),
230 _ => None,
231 }
232 }
233}
234
235#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
237#[serde(tag = "scope", rename_all = "snake_case")]
238pub(crate) enum InputTerminalCompletionBatchKey {
239 Run { run_id: RunId },
240 RuntimeTermination { owner_input_id: InputId },
241}
242
243impl InputTerminalCompletionBatchKey {
244 pub(crate) fn run_id(&self) -> Option<&RunId> {
245 match self {
246 Self::Run { run_id } => Some(run_id),
247 Self::RuntimeTermination { .. } => None,
248 }
249 }
250}
251
252#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
254#[serde(rename_all = "snake_case")]
255pub(crate) enum InputTerminalCompletionFinalizationVerdict {
256 Succeeded,
257 Failed,
258}
259
260impl InputTerminalCompletionFinalizationVerdict {
261 pub(crate) fn from_runtime_observation(
262 observation: crate::meerkat_machine::dsl::RuntimeCompletionFinalizationObservation,
263 ) -> Self {
264 match observation {
265 crate::meerkat_machine::dsl::RuntimeCompletionFinalizationObservation::Succeeded => {
266 Self::Succeeded
267 }
268 crate::meerkat_machine::dsl::RuntimeCompletionFinalizationObservation::Failed => {
269 Self::Failed
270 }
271 }
272 }
273
274 pub(crate) fn runtime_observation(
275 self,
276 ) -> crate::meerkat_machine::dsl::RuntimeCompletionFinalizationObservation {
277 match self {
278 Self::Succeeded => {
279 crate::meerkat_machine::dsl::RuntimeCompletionFinalizationObservation::Succeeded
280 }
281 Self::Failed => {
282 crate::meerkat_machine::dsl::RuntimeCompletionFinalizationObservation::Failed
283 }
284 }
285 }
286}
287
288#[derive(Debug, Clone, Serialize, Deserialize)]
290#[serde(tag = "phase", rename_all = "snake_case")]
291pub(crate) enum InputTerminalCompletionPhase {
292 Pending,
296 Finalized {
299 receipt_digest: String,
300 finalization: InputTerminalCompletionFinalizationVerdict,
301 },
302}
303
304#[derive(Debug, Clone, Serialize, Deserialize)]
310pub(crate) struct InputTerminalCompletion {
311 pub(crate) input_id: InputId,
312 pub(crate) batch_ordinal: u16,
313 pub(crate) batch_key: InputTerminalCompletionBatchKey,
314 pub(crate) owner_input_id: InputId,
315 pub(crate) candidate_digest: String,
316 pub(crate) completion_input_ids_digest: String,
317 pub(crate) requires_session_checkpoint: bool,
318 #[serde(default, skip_serializing_if = "Option::is_none")]
319 pub(crate) candidate: Option<InteractionTerminalCandidate>,
320 #[serde(default, skip_serializing_if = "Option::is_none")]
321 pub(crate) completion_input_ids: Option<Vec<InputId>>,
322 #[serde(default, skip_serializing_if = "Option::is_none")]
323 pub(crate) outcome: Option<crate::completion::CompletionOutcome>,
324 pub(crate) phase: InputTerminalCompletionPhase,
325}
326
327impl InputTerminalCompletion {
328 pub(crate) fn validate_row(&self) -> Result<(), String> {
329 if self.candidate_digest.is_empty() || self.completion_input_ids_digest.is_empty() {
330 return Err("terminal completion row carried an empty immutable digest".into());
331 }
332 let owns_payload = self.input_id == self.owner_input_id;
333 if owns_payload != (self.batch_ordinal == 0) {
334 return Err("terminal completion owner/ordinal mismatch".into());
335 }
336 if let InputTerminalCompletionBatchKey::RuntimeTermination { owner_input_id } =
337 &self.batch_key
338 && owner_input_id != &self.owner_input_id
339 {
340 return Err("runless terminal completion key/owner mismatch".into());
341 }
342 if matches!(
343 &self.batch_key,
344 InputTerminalCompletionBatchKey::RuntimeTermination { .. }
345 ) && self.requires_session_checkpoint
346 {
347 return Err("runless terminal completion unexpectedly requires a checkpoint".into());
348 }
349 match (&self.completion_input_ids, owns_payload) {
350 (Some(input_ids), true) => {
351 if input_ids.is_empty() || input_ids.len() > 256 {
352 return Err("terminal completion recipient set has invalid size".into());
353 }
354 if input_ids
355 .iter()
356 .collect::<std::collections::HashSet<_>>()
357 .len()
358 != input_ids.len()
359 {
360 return Err("terminal completion recipient set contains duplicates".into());
361 }
362 if input_ids
363 .windows(2)
364 .any(|window| window[0].0 >= window[1].0)
365 {
366 return Err(
367 "terminal completion recipient set is not in canonical order".into(),
368 );
369 }
370 if input_ids.first() != Some(&self.owner_input_id) {
371 return Err("terminal completion recipient set lost its canonical owner".into());
372 }
373 if interaction_terminal_payload_digest(input_ids)?
374 != self.completion_input_ids_digest
375 {
376 return Err("terminal completion recipient digest mismatch".into());
377 }
378 }
379 (None, false) => {}
380 _ => return Err("terminal completion payload ownership is invalid".into()),
381 }
382 match (&self.phase, &self.candidate, &self.outcome, owns_payload) {
383 (InputTerminalCompletionPhase::Pending, Some(candidate), None, true) => {
384 if interaction_terminal_payload_digest(candidate)? != self.candidate_digest {
385 return Err("terminal completion candidate digest mismatch".into());
386 }
387 match (&self.batch_key, candidate) {
388 (
389 InputTerminalCompletionBatchKey::RuntimeTermination { .. },
390 InteractionTerminalCandidate::RuntimeTerminated { .. },
391 ) => {}
392 (
393 InputTerminalCompletionBatchKey::Run { .. },
394 InteractionTerminalCandidate::RuntimeTerminated { .. },
395 )
396 | (InputTerminalCompletionBatchKey::RuntimeTermination { .. }, _) => {
397 return Err("terminal completion scope does not match its candidate".into());
398 }
399 (InputTerminalCompletionBatchKey::Run { .. }, _) => {}
400 }
401 }
402 (InputTerminalCompletionPhase::Pending, None, None, false) => {}
403 (
404 InputTerminalCompletionPhase::Finalized {
405 receipt_digest,
406 finalization,
407 },
408 None,
409 Some(outcome),
410 true,
411 ) => {
412 if interaction_terminal_payload_digest(&(outcome, finalization))? != *receipt_digest
413 {
414 return Err("terminal completion receipt digest mismatch".into());
415 }
416 match (finalization, outcome) {
417 (
418 InputTerminalCompletionFinalizationVerdict::Failed,
419 crate::completion::CompletionOutcome::CompletedWithFinalizationFailure {
420 ..
421 }
422 | crate::completion::CompletionOutcome::AbandonedWithError { .. },
423 ) => {}
424 (
425 InputTerminalCompletionFinalizationVerdict::Succeeded,
426 crate::completion::CompletionOutcome::CompletedWithFinalizationFailure {
427 ..
428 },
429 )
430 | (InputTerminalCompletionFinalizationVerdict::Failed, _) => {
431 return Err(
432 "terminal completion outcome contradicts its finalization verdict"
433 .into(),
434 );
435 }
436 (InputTerminalCompletionFinalizationVerdict::Succeeded, _) => {}
437 }
438 }
439 (InputTerminalCompletionPhase::Finalized { .. }, None, None, false) => {}
440 _ => return Err("terminal completion phase/payload shape is invalid".into()),
441 }
442 Ok(())
443 }
444}
445
446pub(crate) fn validate_input_terminal_completion_batch(
449 rows: &[InputTerminalCompletion],
450) -> Result<&InputTerminalCompletion, String> {
451 if rows.is_empty() || rows.len() > 256 {
452 return Err("terminal completion batch has invalid size".into());
453 }
454 let owner = &rows[0];
455 let owner_input_ids = owner
456 .completion_input_ids
457 .as_ref()
458 .ok_or_else(|| "terminal completion batch owner lost recipient set".to_string())?;
459 if owner_input_ids.len() != rows.len() {
460 return Err("terminal completion batch row/recipient cardinality mismatch".into());
461 }
462 for (ordinal, row) in rows.iter().enumerate() {
463 row.validate_row()?;
464 if usize::from(row.batch_ordinal) != ordinal
465 || row.input_id != owner_input_ids[ordinal]
466 || row.batch_key != owner.batch_key
467 || row.owner_input_id != owner.owner_input_id
468 || row.candidate_digest != owner.candidate_digest
469 || row.completion_input_ids_digest != owner.completion_input_ids_digest
470 || row.requires_session_checkpoint != owner.requires_session_checkpoint
471 {
472 return Err("terminal completion batch has split immutable identity".into());
473 }
474 match (&owner.phase, &row.phase) {
475 (InputTerminalCompletionPhase::Pending, InputTerminalCompletionPhase::Pending) => {}
476 (
477 InputTerminalCompletionPhase::Finalized {
478 receipt_digest: owner_digest,
479 finalization: owner_finalization,
480 },
481 InputTerminalCompletionPhase::Finalized {
482 receipt_digest,
483 finalization,
484 },
485 ) if receipt_digest == owner_digest && finalization == owner_finalization => {}
486 _ => return Err("terminal completion batch has split phase".into()),
487 }
488 }
489 Ok(owner)
490}
491
492#[derive(Debug, Clone, Serialize, Deserialize)]
495pub(crate) struct InteractionTerminalPublication {
496 pub(crate) terminal_seq: u64,
497 pub(crate) payload_digest: String,
498}
499
500#[derive(Debug, Clone, Serialize, Deserialize)]
507#[serde(tag = "phase", rename_all = "snake_case")]
508pub(crate) enum InteractionTerminalOutboxPhase {
509 Candidate,
510 Finalized {
511 finalization_failed: bool,
512 #[serde(default, skip_serializing_if = "Option::is_none")]
513 finalized_event: Option<AgentEvent>,
514 finalized_payload_digest: String,
515 },
516 Published {
517 finalization_failed: bool,
518 publication: InteractionTerminalPublication,
519 },
520}
521
522#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
524#[serde(tag = "scope", rename_all = "snake_case")]
525pub(crate) enum InteractionTerminalBatchKey {
526 Run { run_id: RunId },
527 RuntimeTermination { candidate_owner_input_id: InputId },
528}
529
530impl InteractionTerminalBatchKey {
531 pub(crate) fn run_id(&self) -> Option<&RunId> {
532 match self {
533 Self::Run { run_id } => Some(run_id),
534 Self::RuntimeTermination { .. } => None,
535 }
536 }
537}
538
539#[derive(Debug, Clone, Serialize, Deserialize)]
546pub(crate) struct InteractionTerminalOutbox {
547 pub(crate) interaction_id: InteractionId,
548 pub(crate) input_id: InputId,
549 pub(crate) batch_ordinal: u16,
552 pub(crate) batch_key: InteractionTerminalBatchKey,
553 pub(crate) owner_session_id: SessionId,
554 #[serde(default, skip_serializing_if = "Option::is_none")]
555 pub(crate) owner_agent_runtime_id: Option<String>,
556 #[serde(default, skip_serializing_if = "Option::is_none")]
557 pub(crate) owner_fence_token: Option<u64>,
558 #[serde(default, skip_serializing_if = "Option::is_none")]
559 pub(crate) owner_runtime_generation: Option<u64>,
560 #[serde(default, skip_serializing_if = "Option::is_none")]
561 pub(crate) owner_runtime_epoch_id: Option<String>,
562 pub(crate) candidate_owner_input_id: InputId,
564 #[serde(default, skip_serializing_if = "Option::is_none")]
566 pub(crate) candidate: Option<InteractionTerminalCandidate>,
567 pub(crate) candidate_digest: String,
568 #[serde(default, skip_serializing_if = "Option::is_none")]
572 pub(crate) completion_input_ids: Option<Vec<InputId>>,
573 pub(crate) completion_input_ids_digest: String,
575 pub(crate) phase: InteractionTerminalOutboxPhase,
576}
577
578pub(crate) fn interaction_terminal_payload_digest<T: Serialize>(
579 payload: &T,
580) -> Result<String, String> {
581 serde_json::to_vec(payload)
582 .map(|encoded| format!("{:x}", Sha256::digest(encoded)))
583 .map_err(|error| format!("failed to encode interaction terminal payload: {error}"))
584}
585
586pub(crate) fn interaction_terminal_event_id(event: &AgentEvent) -> Option<InteractionId> {
587 match event {
588 AgentEvent::InteractionComplete { interaction_id, .. }
589 | AgentEvent::InteractionCallbackPending { interaction_id, .. }
590 | AgentEvent::InteractionFailed { interaction_id, .. } => Some(*interaction_id),
591 _ => None,
592 }
593}
594
595pub(crate) fn interaction_terminal_event_for_id(
596 event: &AgentEvent,
597 interaction_id: InteractionId,
598) -> Option<AgentEvent> {
599 match event {
600 AgentEvent::InteractionComplete {
601 result,
602 structured_output,
603 ..
604 } => Some(AgentEvent::InteractionComplete {
605 interaction_id,
606 result: result.clone(),
607 structured_output: structured_output.clone(),
608 }),
609 AgentEvent::InteractionCallbackPending {
610 tool_name,
611 args,
612 pending_tool_calls,
613 ..
614 } => Some(AgentEvent::InteractionCallbackPending {
615 interaction_id,
616 tool_name: tool_name.clone(),
617 args: args.clone(),
618 pending_tool_calls: pending_tool_calls.clone(),
619 }),
620 AgentEvent::InteractionFailed { reason, .. } => Some(AgentEvent::InteractionFailed {
621 interaction_id,
622 reason: reason.clone(),
623 }),
624 _ => None,
625 }
626}
627
628impl InteractionTerminalOutbox {
629 pub(crate) fn validate(&self) -> Result<(), String> {
630 if self.input_id.0 != self.interaction_id.0 {
631 return Err("interaction terminal outbox input/interaction identity mismatch".into());
632 }
633 if self.candidate_digest.is_empty() {
634 return Err("interaction terminal outbox candidate digest is empty".into());
635 }
636 if self.completion_input_ids_digest.is_empty() {
637 return Err("interaction terminal outbox completion recipient digest is empty".into());
638 }
639 if self.owner_agent_runtime_id.is_none()
640 || self.owner_fence_token.is_none()
641 || self.owner_runtime_generation.is_none()
642 {
643 return Err(
644 "interaction terminal outbox is missing required runtime placement binding".into(),
645 );
646 }
647 match (&self.batch_key, self.candidate.as_ref()) {
648 (
649 InteractionTerminalBatchKey::RuntimeTermination {
650 candidate_owner_input_id,
651 },
652 candidate,
653 ) => {
654 if candidate_owner_input_id != &self.candidate_owner_input_id {
655 return Err("runtime-termination batch key/candidate-owner mismatch".into());
656 }
657 if candidate.is_some_and(|candidate| {
658 !matches!(
659 candidate,
660 InteractionTerminalCandidate::RuntimeTerminated { .. }
661 )
662 }) {
663 return Err("runtime-termination batch carried a run-scoped candidate".into());
664 }
665 }
666 (
667 InteractionTerminalBatchKey::Run { .. },
668 Some(InteractionTerminalCandidate::RuntimeTerminated { .. }),
669 ) => {
670 return Err("run-scoped terminal batch carried runtime termination".into());
671 }
672 (InteractionTerminalBatchKey::Run { .. }, _) => {}
673 }
674 let published = matches!(self.phase, InteractionTerminalOutboxPhase::Published { .. });
675 let owns_candidate = self.input_id == self.candidate_owner_input_id;
676 if owns_candidate != (self.batch_ordinal == 0) {
677 return Err("interaction terminal outbox candidate owner/ordinal mismatch".into());
678 }
679 match (&self.completion_input_ids, owns_candidate, published) {
680 (Some(input_ids), true, false) => {
681 if input_ids.is_empty() || input_ids.len() > 256 {
682 return Err(
683 "interaction terminal completion recipient set has invalid size".into(),
684 );
685 }
686 let unique = input_ids.iter().collect::<std::collections::HashSet<_>>();
687 if unique.len() != input_ids.len() {
688 return Err(
689 "interaction terminal completion recipient set contains duplicates".into(),
690 );
691 }
692 if !input_ids.contains(&self.input_id) {
693 return Err(
694 "interaction terminal candidate owner is not a completion recipient".into(),
695 );
696 }
697 if interaction_terminal_payload_digest(input_ids)?
698 != self.completion_input_ids_digest
699 {
700 return Err("interaction terminal completion recipient digest mismatch".into());
701 }
702 }
703 (None, false, false) | (None, _, true) => {}
704 (Some(_), false, _) => {
705 return Err("non-owner interaction outbox duplicated completion recipients".into());
706 }
707 (Some(_), true, true) => {
708 return Err("published interaction outbox retained completion recipients".into());
709 }
710 (None, true, false) => {
711 return Err("interaction outbox candidate owner lost completion recipients".into());
712 }
713 }
714 match (&self.candidate, owns_candidate, published) {
715 (Some(candidate), true, false) => {
716 if interaction_terminal_payload_digest(candidate)? != self.candidate_digest {
717 return Err("interaction terminal outbox candidate digest mismatch".into());
718 }
719 if let InteractionTerminalCandidate::RunResult { result } = candidate
720 && result.session_id != self.owner_session_id
721 {
722 return Err("interaction terminal candidate session/owner mismatch".into());
723 }
724 }
725 (None, false, false) => {}
726 (None, _, true) => {}
727 (Some(_), false, _) => {
728 return Err("non-owner interaction outbox duplicated shared candidate".into());
729 }
730 (Some(_), true, true) => {
731 return Err("published interaction outbox retained its shared candidate".into());
732 }
733 (None, true, false) => {
734 return Err("interaction outbox candidate owner has no candidate".into());
735 }
736 }
737 match &self.phase {
738 InteractionTerminalOutboxPhase::Candidate => {}
739 InteractionTerminalOutboxPhase::Finalized {
740 finalization_failed,
741 finalized_event: Some(event),
742 finalized_payload_digest: digest,
743 } if self.input_id == self.candidate_owner_input_id => {
744 if interaction_terminal_event_id(event) != Some(self.interaction_id) {
745 return Err(
746 "interaction terminal outbox finalized event identity mismatch".into(),
747 );
748 }
749 if interaction_terminal_payload_digest(event)? != *digest {
750 return Err("interaction terminal outbox finalized digest mismatch".into());
751 }
752 let Some(candidate) = self.candidate.as_ref() else {
753 return Err("finalized candidate owner lost shared candidate".into());
754 };
755 if !interaction_terminal_candidate_matches_event(
756 candidate,
757 self.interaction_id,
758 event,
759 *finalization_failed,
760 ) {
761 return Err("interaction terminal finalized event/candidate mismatch".into());
762 }
763 }
764 InteractionTerminalOutboxPhase::Finalized {
765 finalized_event: None,
766 finalized_payload_digest,
767 ..
768 } if self.input_id != self.candidate_owner_input_id
769 && !finalized_payload_digest.is_empty() => {}
770 InteractionTerminalOutboxPhase::Finalized { .. } => {
771 return Err(
772 "interaction terminal outbox finalized payload ownership is invalid".into(),
773 );
774 }
775 InteractionTerminalOutboxPhase::Published { publication, .. } => {
776 if publication.terminal_seq == 0 {
777 return Err("interaction terminal publication sequence must be non-zero".into());
778 }
779 if publication.payload_digest.is_empty() {
780 return Err("interaction terminal publication digest is empty".into());
781 }
782 }
783 }
784 Ok(())
785 }
786}
787
788pub(crate) fn validate_interaction_terminal_outbox_batch_shape(
791 outboxes: &[InteractionTerminalOutbox],
792) -> Result<(), String> {
793 if outboxes.is_empty() || outboxes.len() > 256 {
794 return Err("interaction terminal batch has invalid directed-row count".into());
795 }
796 let owner = &outboxes[0];
797 if owner.batch_ordinal != 0 || owner.input_id != owner.candidate_owner_input_id {
798 return Err("interaction terminal batch has no ordinal-zero candidate owner".into());
799 }
800 let mut row_input_ids = std::collections::HashSet::new();
801 for (ordinal, outbox) in outboxes.iter().enumerate() {
802 outbox.validate()?;
803 if usize::from(outbox.batch_ordinal) != ordinal {
804 return Err("interaction terminal batch ordinals are not contiguous".into());
805 }
806 if outbox.batch_key != owner.batch_key
807 || outbox.candidate_owner_input_id != owner.candidate_owner_input_id
808 || outbox.candidate_digest != owner.candidate_digest
809 || outbox.completion_input_ids_digest != owner.completion_input_ids_digest
810 {
811 return Err("interaction terminal batch has split immutable identity".into());
812 }
813 if !row_input_ids.insert(outbox.input_id.clone()) {
814 return Err("interaction terminal batch repeats a directed input".into());
815 }
816 }
817 Ok(())
818}
819
820pub(crate) fn validate_unpublished_interaction_terminal_outbox_batch(
823 outboxes: &[InteractionTerminalOutbox],
824) -> Result<Vec<InputId>, String> {
825 validate_interaction_terminal_outbox_batch_shape(outboxes)?;
826 let owner = &outboxes[0];
827 let completion_input_ids = owner.completion_input_ids.clone().ok_or_else(|| {
828 "unpublished interaction terminal batch owner lost completion recipients".to_string()
829 })?;
830 for outbox in outboxes {
831 if matches!(
832 outbox.phase,
833 InteractionTerminalOutboxPhase::Published { .. }
834 ) {
835 return Err("published row appeared in an unpublished terminal batch".into());
836 }
837 if !completion_input_ids.contains(&outbox.input_id) {
838 return Err("directed terminal input is not a completion recipient".into());
839 }
840 }
841 Ok(completion_input_ids)
842}
843
844pub(crate) fn interaction_terminal_candidate_matches_event(
845 candidate: &InteractionTerminalCandidate,
846 interaction_id: InteractionId,
847 event: &AgentEvent,
848 finalization_failed: bool,
849) -> bool {
850 use meerkat_core::event::InteractionFailureReason;
851 if interaction_terminal_event_id(event) != Some(interaction_id) {
852 return false;
853 }
854 if finalization_failed {
855 return matches!(
856 (candidate, event),
857 (
858 InteractionTerminalCandidate::RunResult { .. },
859 AgentEvent::InteractionFailed {
860 reason: InteractionFailureReason::FinalizationFailed { .. },
861 ..
862 },
863 ) | (
864 InteractionTerminalCandidate::CompletedWithoutResult
865 | InteractionTerminalCandidate::CallbackPending { .. }
866 | InteractionTerminalCandidate::CallbackBatchPending { .. }
867 | InteractionTerminalCandidate::MachineTerminalFailure { .. },
868 AgentEvent::InteractionFailed {
869 reason: InteractionFailureReason::Abandoned { .. },
870 ..
871 },
872 )
873 );
874 }
875 match (candidate, event) {
876 (
877 InteractionTerminalCandidate::RunResult { result },
878 AgentEvent::InteractionComplete {
879 result: event_result,
880 structured_output: event_structured,
881 ..
882 },
883 ) if result.extraction_error.is_none() => {
884 result.text == *event_result && result.structured_output == *event_structured
885 }
886 (
887 InteractionTerminalCandidate::RunResult { result },
888 AgentEvent::InteractionFailed {
889 reason:
890 InteractionFailureReason::ExtractionFailed {
891 last_output,
892 attempts,
893 reason,
894 },
895 ..
896 },
897 ) if result.extraction_error.is_some() => {
898 let Some(extraction) = result.extraction_error.as_ref() else {
899 return false;
900 };
901 extraction.last_output == *last_output
902 && extraction.attempts == *attempts
903 && extraction.reason == *reason
904 }
905 (
906 InteractionTerminalCandidate::CompletedWithoutResult,
907 AgentEvent::InteractionComplete {
908 result,
909 structured_output,
910 ..
911 },
912 ) => result.is_empty() && structured_output.is_none(),
913 (
914 InteractionTerminalCandidate::CallbackPending {
915 tool_use_id,
916 tool_name,
917 args,
918 },
919 AgentEvent::InteractionCallbackPending {
920 tool_name: event_tool,
921 args: event_args,
922 pending_tool_calls,
923 ..
924 },
925 ) => {
926 tool_name == event_tool
927 && args == event_args
928 && match tool_use_id {
929 Some(tool_use_id) => {
930 pending_tool_calls.as_slice()
931 == [meerkat_core::error::PendingCallbackToolCall {
932 tool_use_id: tool_use_id.clone(),
933 tool_name: tool_name.clone(),
934 args: args.clone(),
935 }]
936 }
937 None => {
941 pending_tool_calls.is_empty()
942 || pending_tool_calls.as_slice()
943 == [meerkat_core::error::PendingCallbackToolCall {
944 tool_use_id: String::new(),
945 tool_name: tool_name.clone(),
946 args: args.clone(),
947 }]
948 }
949 }
950 }
951 (
952 InteractionTerminalCandidate::CallbackBatchPending { pending_tool_calls },
953 AgentEvent::InteractionCallbackPending {
954 pending_tool_calls: event_pending,
955 ..
956 },
957 ) => pending_tool_calls == event_pending,
958 (
959 InteractionTerminalCandidate::MachineTerminalFailure { error },
960 AgentEvent::InteractionFailed {
961 reason: InteractionFailureReason::Abandoned { detail },
962 ..
963 },
964 ) => error.detail.as_deref() == Some(detail.as_str()),
965 (
966 InteractionTerminalCandidate::Cancelled,
967 AgentEvent::InteractionFailed {
968 reason: InteractionFailureReason::Cancelled,
969 ..
970 },
971 ) => true,
972 (
973 InteractionTerminalCandidate::RuntimeTerminated { reason },
974 AgentEvent::InteractionFailed {
975 reason: InteractionFailureReason::Abandoned { detail },
976 ..
977 },
978 ) => reason == detail,
979 _ => false,
980 }
981}
982
983#[derive(Debug, Clone, Serialize, Deserialize)]
985pub struct InputStateEvent {
986 pub timestamp: DateTime<Utc>,
987 pub state: InputLifecycleState,
988 #[serde(skip_serializing_if = "Option::is_none")]
989 pub detail: Option<String>,
990}
991
992#[derive(Debug, Clone, PartialEq, Eq)]
1003pub struct InputStateSeed {
1004 pub phase: InputLifecycleState,
1005 pub last_run_id: Option<RunId>,
1006 pub last_boundary_sequence: Option<u64>,
1007 pub admission_sequence: Option<u64>,
1008 pub terminal_outcome: Option<InputTerminalOutcome>,
1009 pub attempt_count: u32,
1010 pub recovery_lane: Option<HandlingMode>,
1011}
1012
1013impl InputStateSeed {
1014 pub fn new_accepted() -> Self {
1017 Self {
1018 phase: InputLifecycleState::Accepted,
1019 last_run_id: None,
1020 last_boundary_sequence: None,
1021 admission_sequence: None,
1022 terminal_outcome: None,
1023 attempt_count: 0,
1024 recovery_lane: None,
1025 }
1026 }
1027}
1028
1029#[derive(Debug, Clone)]
1036pub struct StoredInputState {
1037 pub state: InputState,
1038 pub seed: InputStateSeed,
1039}
1040
1041#[derive(Debug, Clone, PartialEq, Eq)]
1045pub struct PublishedDirectedTerminalBinding {
1046 input_id: InputId,
1047 interaction_id: InteractionId,
1048}
1049
1050impl PublishedDirectedTerminalBinding {
1051 pub fn binds(&self, input_id: &InputId, interaction_id: InteractionId) -> bool {
1052 &self.input_id == input_id && self.interaction_id == interaction_id
1053 }
1054}
1055
1056impl StoredInputState {
1057 pub fn new_accepted(input_id: InputId) -> Self {
1059 Self {
1060 state: InputState::new_accepted(input_id),
1061 seed: InputStateSeed::new_accepted(),
1062 }
1063 }
1064
1065 pub fn published_directed_terminal_binding(
1068 &self,
1069 ) -> Result<Option<PublishedDirectedTerminalBinding>, String> {
1070 let Some(outbox) = self.state.interaction_terminal_outbox.as_ref() else {
1071 return Ok(None);
1072 };
1073 outbox.validate()?;
1074 if !matches!(
1075 outbox.phase,
1076 InteractionTerminalOutboxPhase::Published { .. }
1077 ) {
1078 return Ok(None);
1079 }
1080 Ok(Some(PublishedDirectedTerminalBinding {
1081 input_id: outbox.input_id.clone(),
1082 interaction_id: outbox.interaction_id,
1083 }))
1084 }
1085}
1086
1087pub(crate) fn input_terminal_completion_outcome(
1092 states: &[StoredInputState],
1093 input_id: &InputId,
1094) -> Result<Option<crate::completion::CompletionOutcome>, InputTerminalCompletionReadError> {
1095 let Some(stored) = states
1096 .iter()
1097 .find(|stored| &stored.state.input_id == input_id)
1098 else {
1099 return Ok(None);
1100 };
1101 let Some(target) = stored.state.terminal_completion.as_ref() else {
1102 if stored.seed.terminal_outcome.is_some() {
1103 return if stored.state.terminal_completion_unavailable {
1104 Err(InputTerminalCompletionReadError::MigratedReceiptUnavailable)
1105 } else {
1106 Err(InputTerminalCompletionReadError::Corrupt(
1107 "v5 terminal input lost its exact completion receipt".to_string(),
1108 ))
1109 };
1110 }
1111 return Ok(None);
1112 };
1113 if states.iter().any(|stored| {
1114 stored
1115 .state
1116 .terminal_completion
1117 .as_ref()
1118 .is_some_and(|completion| {
1119 completion.input_id != stored.state.input_id
1120 || stored.seed.terminal_outcome.is_none()
1121 })
1122 }) {
1123 return Err(InputTerminalCompletionReadError::Corrupt(
1124 "terminal completion row is not bound to the same terminal input state".to_string(),
1125 ));
1126 }
1127 let mut rows = states
1128 .iter()
1129 .filter_map(|stored| stored.state.terminal_completion.clone())
1130 .filter(|row| row.batch_key == target.batch_key)
1131 .collect::<Vec<_>>();
1132 rows.sort_by_key(|row| row.batch_ordinal);
1133 let owner = validate_input_terminal_completion_batch(&rows)
1134 .map_err(InputTerminalCompletionReadError::Corrupt)?;
1135 match &owner.phase {
1136 InputTerminalCompletionPhase::Pending => Ok(None),
1137 InputTerminalCompletionPhase::Finalized { .. } => {
1138 owner.outcome.clone().map(Some).ok_or_else(|| {
1139 InputTerminalCompletionReadError::Corrupt(
1140 "finalized terminal completion owner lost outcome".to_string(),
1141 )
1142 })
1143 }
1144 }
1145}
1146
1147#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1148pub(crate) enum InputTerminalCompletionReadError {
1149 #[error(
1150 "0.8.10 terminal input predates exact completion receipts; its public outcome cannot be reconstructed"
1151 )]
1152 MigratedReceiptUnavailable,
1153 #[error("{0}")]
1154 Corrupt(String),
1155}
1156
1157#[derive(Debug, Clone)]
1160pub struct InputStatePersistenceRecord {
1161 bundle: StoredInputState,
1162 expected_row_digest: Option<String>,
1163}
1164
1165impl InputStatePersistenceRecord {
1166 pub(crate) fn from_machine_snapshot(bundle: StoredInputState) -> Result<Self, String> {
1170 crate::meerkat_machine::authorize_stored_input_state_seed(
1171 &bundle.state.input_id,
1172 &bundle.seed,
1173 )?;
1174 Ok(Self {
1175 bundle,
1176 expected_row_digest: None,
1177 })
1178 }
1179
1180 pub(crate) fn with_expected_row_digest(mut self, digest: String) -> Self {
1190 self.expected_row_digest = Some(digest);
1191 self
1192 }
1193
1194 pub fn expected_row_digest(&self) -> Option<&str> {
1196 self.expected_row_digest.as_deref()
1197 }
1198
1199 pub fn as_stored(&self) -> &StoredInputState {
1201 &self.bundle
1202 }
1203
1204 pub fn clone_stored(&self) -> StoredInputState {
1206 self.bundle.clone()
1207 }
1208
1209 pub fn into_stored(self) -> StoredInputState {
1211 self.bundle
1212 }
1213
1214 pub fn into_stored_and_expected(self) -> (StoredInputState, Option<String>) {
1217 (self.bundle, self.expected_row_digest)
1218 }
1219}
1220
1221#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1232#[serde(rename_all = "snake_case")]
1233pub enum DirectedInputKind {
1234 FlowStep,
1235 PeerMessage,
1236}
1237
1238impl DirectedInputKind {
1239 pub fn input_kind(self) -> InputKind {
1240 match self {
1241 Self::FlowStep => InputKind::FlowStep,
1242 Self::PeerMessage => InputKind::PeerMessage,
1243 }
1244 }
1245}
1246
1247#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1250pub struct DirectedRunStartedAttribution {
1251 kind: DirectedInputKind,
1252 content_digest: String,
1253}
1254
1255impl DirectedRunStartedAttribution {
1256 pub fn from_input(input: &Input) -> Result<Option<Self>, String> {
1259 if crate::input::validated_directed_interaction_id(input)?.is_none() {
1260 return Ok(None);
1261 }
1262 let kind = match input.kind() {
1263 InputKind::FlowStep => DirectedInputKind::FlowStep,
1264 InputKind::PeerMessage => DirectedInputKind::PeerMessage,
1265 other => {
1266 return Err(format!(
1267 "directed interaction custody belongs to unsupported {other:?} input kind"
1268 ));
1269 }
1270 };
1271 let content_digest = crate::input::directed_input_run_started_content_digest(input)?;
1272 Ok(Some(Self {
1273 kind,
1274 content_digest,
1275 }))
1276 }
1277
1278 pub fn kind(&self) -> DirectedInputKind {
1279 self.kind
1280 }
1281
1282 pub fn content_digest(&self) -> &str {
1283 &self.content_digest
1284 }
1285
1286 fn validate(&self) -> Result<(), String> {
1287 if self.content_digest.is_empty() {
1288 return Err("directed RunStarted attribution digest is empty".to_string());
1289 }
1290 Ok(())
1291 }
1292}
1293
1294#[derive(Debug, Clone)]
1295pub struct InputState {
1296 pub input_id: InputId,
1297 pub history: Vec<InputStateHistoryEntry>,
1298 pub updated_at: DateTime<Utc>,
1299 pub policy: Option<PolicySnapshot>,
1300 pub runtime_semantics: Option<RuntimeInputSemantics>,
1303 pub directed_run_started_attribution: Option<DirectedRunStartedAttribution>,
1308 pub durability: Option<crate::input::InputDurability>,
1309 pub idempotency_key: Option<crate::identifiers::IdempotencyKey>,
1310 pub recovery_count: u32,
1311 pub reconstruction_source: Option<ReconstructionSource>,
1312 pub(crate) terminal_completion: Option<InputTerminalCompletion>,
1315 pub(crate) terminal_completion_unavailable: bool,
1320 pub(crate) interaction_terminal_outbox: Option<InteractionTerminalOutbox>,
1323 pub persisted_input: Option<Input>,
1329 pub created_at: DateTime<Utc>,
1330}
1331
1332impl InputState {
1333 pub fn new_accepted(input_id: InputId) -> Self {
1337 let now = Utc::now();
1338 Self {
1339 input_id,
1340 history: Vec::new(),
1341 updated_at: now,
1342 policy: None,
1343 runtime_semantics: None,
1344 directed_run_started_attribution: None,
1345 durability: None,
1346 idempotency_key: None,
1347 recovery_count: 0,
1348 reconstruction_source: None,
1349 terminal_completion: None,
1350 terminal_completion_unavailable: false,
1351 interaction_terminal_outbox: None,
1352 persisted_input: None,
1353 created_at: now,
1354 }
1355 }
1356
1357 pub fn history(&self) -> &[InputStateHistoryEntry] {
1358 &self.history
1359 }
1360
1361 pub fn updated_at(&self) -> DateTime<Utc> {
1362 self.updated_at
1363 }
1364}
1365
1366fn is_false(value: &bool) -> bool {
1379 !*value
1380}
1381
1382#[derive(Serialize, Deserialize)]
1383struct InputStateSerde {
1384 stored_input_state_version: u32,
1385 input_id: InputId,
1386 current_state: InputLifecycleState,
1387 #[serde(skip_serializing_if = "Option::is_none")]
1388 policy: Option<PolicySnapshot>,
1389 #[serde(default, skip_serializing_if = "Option::is_none")]
1390 runtime_semantics: Option<RuntimeInputSemantics>,
1391 #[serde(default, skip_serializing_if = "Option::is_none")]
1392 directed_run_started_attribution: Option<DirectedRunStartedAttribution>,
1393 #[serde(skip_serializing_if = "Option::is_none")]
1394 terminal_outcome: Option<InputTerminalOutcome>,
1395 #[serde(skip_serializing_if = "Option::is_none")]
1396 durability: Option<crate::input::InputDurability>,
1397 #[serde(skip_serializing_if = "Option::is_none")]
1398 idempotency_key: Option<crate::identifiers::IdempotencyKey>,
1399 #[serde(default)]
1400 attempt_count: u32,
1401 #[serde(default)]
1402 recovery_count: u32,
1403 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1404 history: Vec<InputStateHistoryEntry>,
1405 #[serde(skip_serializing_if = "Option::is_none")]
1406 reconstruction_source: Option<ReconstructionSource>,
1407 #[serde(default, skip_serializing_if = "Option::is_none")]
1408 terminal_completion: Option<InputTerminalCompletion>,
1409 #[serde(default, skip_serializing_if = "is_false")]
1410 terminal_completion_unavailable: bool,
1411 #[serde(default, skip_serializing_if = "Option::is_none")]
1412 interaction_terminal_outbox: Option<InteractionTerminalOutbox>,
1413 #[serde(default, skip_serializing_if = "Option::is_none")]
1414 persisted_input: Option<Input>,
1415 #[serde(default, skip_serializing_if = "Option::is_none")]
1416 last_run_id: Option<RunId>,
1417 #[serde(default, skip_serializing_if = "Option::is_none")]
1418 last_boundary_sequence: Option<u64>,
1419 #[serde(default, skip_serializing_if = "Option::is_none")]
1420 admission_sequence: Option<u64>,
1421 #[serde(default, skip_serializing_if = "Option::is_none")]
1422 recovery_lane: Option<HandlingMode>,
1423 created_at: DateTime<Utc>,
1424 updated_at: DateTime<Utc>,
1425}
1426
1427impl Serialize for StoredInputState {
1428 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
1429 if self.state.terminal_completion_unavailable
1430 && (self.seed.terminal_outcome.is_none() || self.state.terminal_completion.is_some())
1431 {
1432 return Err(serde::ser::Error::custom(
1433 "terminal completion unavailable marker has an invalid v5 shape",
1434 ));
1435 }
1436 let helper = InputStateSerde {
1437 stored_input_state_version:
1438 meerkat_core::generated::session_persistence_version_authority::stored_input_state_version(
1439 ),
1440 input_id: self.state.input_id.clone(),
1441 current_state: self.seed.phase,
1442 policy: self.state.policy.clone(),
1443 runtime_semantics: self.state.runtime_semantics,
1444 directed_run_started_attribution: self
1445 .state
1446 .directed_run_started_attribution
1447 .clone(),
1448 terminal_outcome: self.seed.terminal_outcome.clone(),
1449 durability: self.state.durability,
1450 idempotency_key: self.state.idempotency_key.clone(),
1451 attempt_count: self.seed.attempt_count,
1452 recovery_count: self.state.recovery_count,
1453 history: self.state.history.clone(),
1454 reconstruction_source: self.state.reconstruction_source.clone(),
1455 terminal_completion: self.state.terminal_completion.clone(),
1456 terminal_completion_unavailable: self.state.terminal_completion_unavailable,
1457 interaction_terminal_outbox: self.state.interaction_terminal_outbox.clone(),
1458 persisted_input: self.state.persisted_input.clone(),
1459 last_run_id: self.seed.last_run_id.clone(),
1460 last_boundary_sequence: self.seed.last_boundary_sequence,
1461 admission_sequence: self.seed.admission_sequence,
1462 recovery_lane: self.seed.recovery_lane,
1463 created_at: self.state.created_at,
1464 updated_at: self.state.updated_at,
1465 };
1466 helper.serialize(serializer)
1467 }
1468}
1469
1470impl<'de> Deserialize<'de> for StoredInputState {
1471 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
1472 let helper = InputStateSerde::deserialize(deserializer)?;
1473 let observed_stored_input_state_version = helper.stored_input_state_version;
1474 let _stored_input_state_version =
1475 meerkat_core::generated::session_persistence_version_authority::restore_stored_input_state_version(
1476 observed_stored_input_state_version,
1477 )
1478 .map_err(<D::Error as serde::de::Error>::custom)?;
1479 if observed_stored_input_state_version < 5 && helper.terminal_completion.is_some() {
1480 return Err(<D::Error as serde::de::Error>::custom(
1481 "stored input state before v5 cannot carry a terminal completion receipt",
1482 ));
1483 }
1484 if observed_stored_input_state_version < 5 && helper.terminal_completion_unavailable {
1485 return Err(<D::Error as serde::de::Error>::custom(
1486 "stored input state before v5 cannot carry a completion-unavailable marker",
1487 ));
1488 }
1489 if observed_stored_input_state_version < 5
1490 && helper.directed_run_started_attribution.is_some()
1491 {
1492 return Err(<D::Error as serde::de::Error>::custom(
1493 "stored input state before v5 cannot carry compact directed attribution",
1494 ));
1495 }
1496 if observed_stored_input_state_version == 3 && helper.interaction_terminal_outbox.is_some()
1497 {
1498 return Err(<D::Error as serde::de::Error>::custom(
1499 "stored input state v3 cannot carry an interaction terminal outbox",
1500 ));
1501 }
1502 if let Some(outbox) = helper.interaction_terminal_outbox.as_ref() {
1503 outbox
1504 .validate()
1505 .map_err(<D::Error as serde::de::Error>::custom)?;
1506 }
1507 if let Some(completion) = helper.terminal_completion.as_ref() {
1508 if completion.input_id != helper.input_id || helper.terminal_outcome.is_none() {
1509 return Err(<D::Error as serde::de::Error>::custom(
1510 "terminal completion row is not bound to the same terminal input state",
1511 ));
1512 }
1513 completion
1514 .validate_row()
1515 .map_err(<D::Error as serde::de::Error>::custom)?;
1516 }
1517 let terminal_completion_unavailable = if observed_stored_input_state_version < 5 {
1518 helper.terminal_outcome.is_some() && helper.terminal_completion.is_none()
1519 } else {
1520 helper.terminal_completion_unavailable
1521 };
1522 if terminal_completion_unavailable
1523 && (helper.terminal_outcome.is_none() || helper.terminal_completion.is_some())
1524 {
1525 return Err(<D::Error as serde::de::Error>::custom(
1526 "terminal completion unavailable marker has an invalid v5 shape",
1527 ));
1528 }
1529 if let Some(stored) = helper.directed_run_started_attribution.as_ref() {
1530 stored
1531 .validate()
1532 .map_err(<D::Error as serde::de::Error>::custom)?;
1533 }
1534 let payload_directed_attribution = helper
1535 .persisted_input
1536 .as_ref()
1537 .map(DirectedRunStartedAttribution::from_input)
1538 .transpose()
1539 .map_err(<D::Error as serde::de::Error>::custom)?
1540 .flatten();
1541 if helper.directed_run_started_attribution.is_some()
1542 && helper.persisted_input.is_some()
1543 && payload_directed_attribution.is_none()
1544 {
1545 return Err(<D::Error as serde::de::Error>::custom(
1546 "stored directed RunStarted attribution belongs to a non-directed replay payload",
1547 ));
1548 }
1549 if helper.directed_run_started_attribution.is_some()
1550 && payload_directed_attribution.is_some()
1551 && helper.directed_run_started_attribution != payload_directed_attribution
1552 {
1553 return Err(<D::Error as serde::de::Error>::custom(
1554 "stored directed RunStarted attribution disagrees with the retained replay payload",
1555 ));
1556 }
1557 let directed_run_started_attribution = helper
1558 .directed_run_started_attribution
1559 .or(payload_directed_attribution);
1560 let state = InputState {
1561 input_id: helper.input_id,
1562 history: helper.history,
1563 updated_at: helper.updated_at,
1564 policy: helper.policy,
1565 runtime_semantics: helper.runtime_semantics,
1566 directed_run_started_attribution,
1567 durability: helper.durability,
1568 idempotency_key: helper.idempotency_key,
1569 recovery_count: helper.recovery_count,
1570 reconstruction_source: helper.reconstruction_source,
1571 terminal_completion: helper.terminal_completion,
1572 terminal_completion_unavailable,
1573 interaction_terminal_outbox: helper.interaction_terminal_outbox,
1574 persisted_input: helper.persisted_input,
1575 created_at: helper.created_at,
1576 };
1577 let seed = InputStateSeed {
1578 phase: helper.current_state,
1579 last_run_id: helper.last_run_id,
1580 last_boundary_sequence: helper.last_boundary_sequence,
1581 admission_sequence: helper.admission_sequence,
1582 terminal_outcome: helper.terminal_outcome,
1583 attempt_count: helper.attempt_count,
1584 recovery_lane: helper.recovery_lane,
1585 };
1586 Ok(StoredInputState { state, seed })
1587 }
1588}
1589
1590#[cfg(test)]
1591#[allow(clippy::unwrap_used)]
1592mod tests {
1593 use super::*;
1594 use crate::policy::{
1595 ApplyMode, ConsumePoint, DrainPolicy, QueueMode, RoutingDisposition, WakeMode,
1596 };
1597 use meerkat_core::ops::{OpEvent, OperationId};
1598
1599 fn terminal_outbox_batch_fixture() -> Vec<InteractionTerminalOutbox> {
1600 let mut completion_input_ids = vec![InputId::new(), InputId::new(), InputId::new()];
1601 completion_input_ids.sort_by_key(|input_id| input_id.0);
1602 let directed_input_ids = vec![
1603 completion_input_ids[0].clone(),
1604 completion_input_ids[2].clone(),
1605 ];
1606 let candidate = InteractionTerminalCandidate::CompletedWithoutResult;
1607 let candidate_digest = interaction_terminal_payload_digest(&candidate).unwrap();
1608 let completion_input_ids_digest =
1609 interaction_terminal_payload_digest(&completion_input_ids).unwrap();
1610 let candidate_owner_input_id = directed_input_ids[0].clone();
1611 let batch_key = InteractionTerminalBatchKey::Run {
1612 run_id: RunId::new(),
1613 };
1614 directed_input_ids
1615 .into_iter()
1616 .enumerate()
1617 .map(|(ordinal, input_id)| {
1618 let owns_candidate = input_id == candidate_owner_input_id;
1619 InteractionTerminalOutbox {
1620 interaction_id: InteractionId(input_id.0),
1621 input_id,
1622 batch_ordinal: ordinal as u16,
1623 batch_key: batch_key.clone(),
1624 owner_session_id: SessionId::new(),
1625 owner_agent_runtime_id: Some("fixture-runtime".to_string()),
1626 owner_fence_token: Some(7),
1627 owner_runtime_generation: Some(3),
1628 owner_runtime_epoch_id: Some("fixture-epoch".to_string()),
1629 candidate_owner_input_id: candidate_owner_input_id.clone(),
1630 candidate: owns_candidate.then(|| candidate.clone()),
1631 candidate_digest: candidate_digest.clone(),
1632 completion_input_ids: owns_candidate.then(|| completion_input_ids.clone()),
1633 completion_input_ids_digest: completion_input_ids_digest.clone(),
1634 phase: InteractionTerminalOutboxPhase::Candidate,
1635 }
1636 })
1637 .collect()
1638 }
1639
1640 fn pending_terminal_completion_batch_fixture() -> Vec<StoredInputState> {
1641 let mut input_ids = vec![InputId::new(), InputId::new()];
1642 input_ids.sort_by_key(|input_id| input_id.0);
1643 let owner_input_id = input_ids[0].clone();
1644 let candidate = InteractionTerminalCandidate::MachineTerminalFailure {
1645 error: meerkat_core::TurnErrorMetadata::runtime_apply_failure(
1646 "executor failed after applying the input boundary",
1647 ),
1648 };
1649 let candidate_digest = interaction_terminal_payload_digest(&candidate).unwrap();
1650 let completion_input_ids_digest = interaction_terminal_payload_digest(&input_ids).unwrap();
1651 let batch_key = InputTerminalCompletionBatchKey::Run {
1652 run_id: RunId::new(),
1653 };
1654 input_ids
1655 .iter()
1656 .enumerate()
1657 .map(|(ordinal, input_id)| {
1658 let owns_payload = input_id == &owner_input_id;
1659 let mut stored = StoredInputState::new_accepted(input_id.clone());
1660 stored.seed.phase = InputLifecycleState::Consumed;
1661 stored.seed.terminal_outcome = Some(InputTerminalOutcome::Consumed);
1662 stored.state.terminal_completion = Some(InputTerminalCompletion {
1663 input_id: input_id.clone(),
1664 batch_ordinal: ordinal as u16,
1665 batch_key: batch_key.clone(),
1666 owner_input_id: owner_input_id.clone(),
1667 candidate_digest: candidate_digest.clone(),
1668 completion_input_ids_digest: completion_input_ids_digest.clone(),
1669 requires_session_checkpoint: true,
1670 candidate: owns_payload.then(|| candidate.clone()),
1671 completion_input_ids: owns_payload.then(|| input_ids.clone()),
1672 outcome: None,
1673 phase: InputTerminalCompletionPhase::Pending,
1674 });
1675 stored
1676 })
1677 .collect()
1678 }
1679
1680 fn restart_terminal_completion_rows(rows: Vec<StoredInputState>) -> Vec<StoredInputState> {
1681 rows.into_iter()
1682 .map(|row| {
1683 let bytes = serde_json::to_vec(&row).unwrap();
1684 serde_json::from_slice(&bytes).unwrap()
1685 })
1686 .collect()
1687 }
1688
1689 #[test]
1690 fn restart_after_terminal_transaction_observes_pending_exact_receipt() {
1691 let rows = restart_terminal_completion_rows(pending_terminal_completion_batch_fixture());
1692 let input_id = rows[1].state.input_id.clone();
1693 let carriers = rows
1694 .iter()
1695 .map(|row| row.state.terminal_completion.clone().unwrap())
1696 .collect::<Vec<_>>();
1697
1698 validate_input_terminal_completion_batch(&carriers).unwrap();
1699 assert!(
1700 input_terminal_completion_outcome(&rows, &input_id)
1701 .unwrap()
1702 .is_none(),
1703 "a kill after the terminal transaction must recover the candidate, not invent a final outcome"
1704 );
1705 }
1706
1707 #[test]
1708 fn restart_after_receipt_cas_recovers_exact_public_outcome() {
1709 let mut rows = pending_terminal_completion_batch_fixture();
1710 let input_id = rows[1].state.input_id.clone();
1711 let outcome = crate::completion::CompletionOutcome::CompletedWithFinalizationFailure {
1712 error: meerkat_core::TurnErrorMetadata::runtime_apply_failure(
1713 "checkpoint rejected the committed snapshot",
1714 ),
1715 };
1716 let finalization = InputTerminalCompletionFinalizationVerdict::Failed;
1717 let receipt_digest =
1718 interaction_terminal_payload_digest(&(&outcome, finalization)).unwrap();
1719 for row in &mut rows {
1720 let completion = row.state.terminal_completion.as_mut().unwrap();
1721 completion.candidate = None;
1722 completion.outcome =
1723 (completion.input_id == completion.owner_input_id).then(|| outcome.clone());
1724 completion.phase = InputTerminalCompletionPhase::Finalized {
1725 receipt_digest: receipt_digest.clone(),
1726 finalization,
1727 };
1728 }
1729
1730 let mut forged_verdict = serde_json::to_value(&rows[0]).unwrap();
1731 forged_verdict["terminal_completion"]["phase"]["finalization"] =
1732 serde_json::json!("succeeded");
1733 let error = serde_json::from_value::<StoredInputState>(forged_verdict)
1734 .expect_err("the receipt digest must bind the typed finalization verdict");
1735 assert!(error.to_string().contains("receipt digest mismatch"));
1736
1737 let rows = restart_terminal_completion_rows(rows);
1738 let recovered = input_terminal_completion_outcome(&rows, &input_id)
1739 .unwrap()
1740 .expect("a kill after receipt CAS must recover the exact outcome");
1741 assert_eq!(
1742 serde_json::to_value(recovered).unwrap(),
1743 serde_json::to_value(outcome).unwrap()
1744 );
1745 }
1746
1747 #[test]
1748 fn terminal_outbox_batch_preserves_full_mixed_completion_recipients() {
1749 let outboxes = terminal_outbox_batch_fixture();
1750 let recipients = validate_unpublished_interaction_terminal_outbox_batch(&outboxes).unwrap();
1751
1752 assert_eq!(recipients.len(), 3);
1753 assert_eq!(outboxes.len(), 2);
1754 assert!(recipients.contains(&outboxes[0].input_id));
1755 assert!(recipients.contains(&outboxes[1].input_id));
1756 }
1757
1758 #[test]
1759 fn terminal_outbox_batch_rejects_noncontiguous_or_reordered_ordinals() {
1760 let mut outboxes = terminal_outbox_batch_fixture();
1761 outboxes[1].batch_ordinal = 2;
1762 assert!(
1763 validate_unpublished_interaction_terminal_outbox_batch(&outboxes)
1764 .unwrap_err()
1765 .contains("ordinals are not contiguous")
1766 );
1767 }
1768
1769 #[test]
1770 fn terminal_outbox_owner_rejects_duplicate_completion_recipients() {
1771 let mut outboxes = terminal_outbox_batch_fixture();
1772 let owner = &mut outboxes[0];
1773 let recipients = owner.completion_input_ids.as_mut().unwrap();
1774 recipients.push(recipients[0].clone());
1775 owner.completion_input_ids_digest =
1776 interaction_terminal_payload_digest(recipients).unwrap();
1777
1778 assert!(
1779 owner
1780 .validate()
1781 .unwrap_err()
1782 .contains("contains duplicates")
1783 );
1784 }
1785
1786 #[test]
1787 fn terminal_outbox_resource_bounds_reject_257_rows_or_recipients() {
1788 let fixture = terminal_outbox_batch_fixture();
1789 let oversized_rows = vec![fixture[0].clone(); 257];
1790 assert!(
1791 validate_interaction_terminal_outbox_batch_shape(&oversized_rows)
1792 .unwrap_err()
1793 .contains("invalid directed-row count")
1794 );
1795
1796 let mut owner = fixture[0].clone();
1797 let recipients = (0..257).map(|_| InputId::new()).collect::<Vec<_>>();
1798 owner.completion_input_ids_digest =
1799 interaction_terminal_payload_digest(&recipients).unwrap();
1800 owner.completion_input_ids = Some(recipients);
1801 assert!(
1802 owner
1803 .validate()
1804 .unwrap_err()
1805 .contains("recipient set has invalid size")
1806 );
1807 }
1808
1809 #[test]
1810 fn published_terminal_outbox_compaction_retains_only_immutable_proofs() {
1811 let mut outbox = terminal_outbox_batch_fixture().remove(0);
1812 let recipient_digest = outbox.completion_input_ids_digest.clone();
1813 let candidate_digest = outbox.candidate_digest.clone();
1814 outbox.candidate = None;
1815 outbox.completion_input_ids = None;
1816 outbox.phase = InteractionTerminalOutboxPhase::Published {
1817 finalization_failed: false,
1818 publication: InteractionTerminalPublication {
1819 terminal_seq: 9,
1820 payload_digest: "published-event-digest".to_string(),
1821 },
1822 };
1823
1824 outbox.validate().unwrap();
1825 assert_eq!(outbox.candidate_digest, candidate_digest);
1826 assert_eq!(outbox.completion_input_ids_digest, recipient_digest);
1827 }
1828
1829 #[test]
1830 fn published_terminal_batch_rejects_split_immutable_recipient_proof() {
1831 let mut outboxes = terminal_outbox_batch_fixture();
1832 for outbox in &mut outboxes {
1833 outbox.candidate = None;
1834 outbox.completion_input_ids = None;
1835 outbox.phase = InteractionTerminalOutboxPhase::Published {
1836 finalization_failed: false,
1837 publication: InteractionTerminalPublication {
1838 terminal_seq: u64::from(outbox.batch_ordinal) + 1,
1839 payload_digest: format!("event-{}", outbox.batch_ordinal),
1840 },
1841 };
1842 }
1843 outboxes[1].completion_input_ids_digest = "split-proof".to_string();
1844
1845 assert!(
1846 validate_interaction_terminal_outbox_batch_shape(&outboxes)
1847 .unwrap_err()
1848 .contains("split immutable identity")
1849 );
1850 }
1851
1852 #[test]
1853 fn new_accepted_starts_with_no_shell_history() {
1854 let id = InputId::new();
1855 let state = InputState::new_accepted(id.clone());
1856 assert_eq!(state.input_id, id);
1857 assert!(state.history.is_empty());
1858 }
1859
1860 #[test]
1861 fn seed_new_accepted_defaults_match_queue_lifecycle() {
1862 let seed = InputStateSeed::new_accepted();
1863 assert_eq!(seed.phase, InputLifecycleState::Accepted);
1864 assert!(seed.last_run_id.is_none());
1865 assert!(seed.last_boundary_sequence.is_none());
1866 assert!(seed.admission_sequence.is_none());
1867 assert!(seed.terminal_outcome.is_none());
1868 assert_eq!(seed.attempt_count, 0);
1869 }
1870
1871 #[test]
1872 fn lifecycle_state_serde() {
1873 for state in [
1874 InputLifecycleState::Accepted,
1875 InputLifecycleState::Queued,
1876 InputLifecycleState::Staged,
1877 InputLifecycleState::Applied,
1878 InputLifecycleState::AppliedPendingConsumption,
1879 InputLifecycleState::Consumed,
1880 InputLifecycleState::Superseded,
1881 InputLifecycleState::Coalesced,
1882 InputLifecycleState::Abandoned,
1883 ] {
1884 let json = serde_json::to_value(state).unwrap();
1885 let parsed: InputLifecycleState = serde_json::from_value(json).unwrap();
1886 assert_eq!(state, parsed);
1887 }
1888 }
1889
1890 #[test]
1891 fn stored_input_state_serde_roundtrip_preserves_fields() {
1892 let mut state = InputState::new_accepted(InputId::new());
1893 let policy = PolicyDecision {
1894 apply_mode: ApplyMode::StageRunStart,
1895 wake_mode: WakeMode::WakeIfIdle,
1896 queue_mode: QueueMode::Fifo,
1897 consume_point: ConsumePoint::OnRunComplete,
1898 drain_policy: DrainPolicy::QueueNextTurn,
1899 routing_disposition: RoutingDisposition::Queue,
1900 record_transcript: true,
1901 emit_operator_content: true,
1902 policy_version: PolicyVersion(1),
1903 };
1904 state.policy = Some(PolicySnapshot {
1905 version: PolicyVersion(1),
1906 decision: policy.clone(),
1907 });
1908 state.runtime_semantics = Some(
1909 crate::policy_table::generated_admission_projection_for_kind(
1910 crate::identifiers::KindId::new(crate::identifiers::InputKind::Prompt),
1911 true,
1912 )
1913 .expect("generated admission projection")
1914 .runtime_semantics,
1915 );
1916 state.history.push(InputStateHistoryEntry {
1917 timestamp: state.updated_at,
1918 from: InputLifecycleState::Accepted,
1919 to: InputLifecycleState::Queued,
1920 reason: Some("QueueAccepted".into()),
1921 });
1922 let bundle = StoredInputState {
1923 state,
1924 seed: InputStateSeed {
1925 phase: InputLifecycleState::Queued,
1926 last_run_id: None,
1927 last_boundary_sequence: None,
1928 admission_sequence: Some(42),
1929 terminal_outcome: None,
1930 attempt_count: 0,
1931 recovery_lane: Some(HandlingMode::Queue),
1932 },
1933 };
1934
1935 let json = serde_json::to_value(&bundle).unwrap();
1936 let parsed: StoredInputState = serde_json::from_value(json).unwrap();
1937 assert_eq!(parsed.state.input_id, bundle.state.input_id);
1938 assert_eq!(parsed.seed.phase, bundle.seed.phase);
1939 assert_eq!(
1940 parsed.seed.admission_sequence,
1941 bundle.seed.admission_sequence
1942 );
1943 assert_eq!(parsed.seed.recovery_lane, bundle.seed.recovery_lane);
1944 assert_eq!(
1945 parsed.state.runtime_semantics,
1946 bundle.state.runtime_semantics
1947 );
1948 assert_eq!(parsed.state.history.len(), 1);
1949 }
1950
1951 #[test]
1952 fn v4_directed_attribution_migrates_from_payload_but_ordinary_flow_stays_untracked() {
1953 let stable = uuid::Uuid::from_u128(0x00000000000040008000000000000123);
1954 let directed = crate::mob_adapter::create_tracked_flow_step_input(
1955 "step-1",
1956 meerkat_core::types::ContentInput::Text("directed".to_string()),
1957 "flow-1",
1958 None,
1959 &stable.to_string(),
1960 )
1961 .expect("directed fixture");
1962 let mut directed_row = StoredInputState::new_accepted(directed.id().clone());
1963 directed_row.state.persisted_input = Some(directed);
1964 let mut directed_json = serde_json::to_value(directed_row).expect("serialize fixture");
1965 directed_json["stored_input_state_version"] = serde_json::json!(4);
1966 directed_json
1967 .as_object_mut()
1968 .expect("fixture is an object")
1969 .remove("directed_run_started_attribution");
1970 let migrated: StoredInputState =
1971 serde_json::from_value(directed_json).expect("v4 directed row migrates from payload");
1972 assert!(migrated.state.directed_run_started_attribution.is_some());
1973
1974 let ordinary = crate::mob_adapter::create_flow_step_input(
1975 "step-2",
1976 meerkat_core::types::ContentInput::Text("ordinary".to_string()),
1977 "flow-1",
1978 2,
1979 None,
1980 );
1981 let mut ordinary_row = StoredInputState::new_accepted(ordinary.id().clone());
1982 ordinary_row.state.persisted_input = Some(ordinary);
1983 let mut ordinary_json = serde_json::to_value(ordinary_row).expect("serialize fixture");
1984 ordinary_json["stored_input_state_version"] = serde_json::json!(4);
1985 let restored: StoredInputState =
1986 serde_json::from_value(ordinary_json).expect("ordinary v4 flow row remains valid");
1987 assert!(restored.state.directed_run_started_attribution.is_none());
1988 }
1989
1990 #[test]
1991 fn compact_directed_attribution_must_match_retained_payload() {
1992 let stable = uuid::Uuid::from_u128(0x00000000000040008000000000000456);
1993 let input = crate::mob_adapter::create_tracked_flow_step_input(
1994 "step-1",
1995 meerkat_core::types::ContentInput::Text("directed".to_string()),
1996 "flow-1",
1997 None,
1998 &stable.to_string(),
1999 )
2000 .expect("directed fixture");
2001 let mut row = StoredInputState::new_accepted(input.id().clone());
2002 row.state.directed_run_started_attribution =
2003 DirectedRunStartedAttribution::from_input(&input)
2004 .expect("valid attribution derivation");
2005 row.state.persisted_input = Some(input);
2006 let mut json = serde_json::to_value(row).expect("serialize fixture");
2007 json["directed_run_started_attribution"]["content_digest"] =
2008 serde_json::json!("wrong-digest");
2009
2010 let error = serde_json::from_value::<StoredInputState>(json)
2011 .expect_err("mismatched compact attribution must fail closed");
2012 assert!(
2013 error
2014 .to_string()
2015 .contains("disagrees with the retained replay payload")
2016 );
2017 }
2018
2019 #[test]
2026 fn stored_input_state_v087_callback_pending_row_still_decodes() {
2027 let candidate_json =
2028 r#"{"candidate_type":"callback_pending","tool_name":"external","args":{"value":1}}"#;
2029 let completion_ids_json = r#"["00000000-0000-0000-0000-0000000000aa"]"#;
2030 let candidate_digest = format!("{:x}", Sha256::digest(candidate_json.as_bytes()));
2031 let completion_ids_digest = format!("{:x}", Sha256::digest(completion_ids_json.as_bytes()));
2032 let row = format!(
2033 r#"{{
2034 "stored_input_state_version": 4,
2035 "input_id": "00000000-0000-0000-0000-0000000000aa",
2036 "current_state": "applied",
2037 "created_at": "2026-01-01T00:00:00Z",
2038 "updated_at": "2026-01-01T00:00:00Z",
2039 "interaction_terminal_outbox": {{
2040 "interaction_id": "00000000-0000-0000-0000-0000000000aa",
2041 "input_id": "00000000-0000-0000-0000-0000000000aa",
2042 "batch_ordinal": 0,
2043 "batch_key": {{"scope":"run","run_id":"00000000-0000-0000-0000-0000000000bb"}},
2044 "owner_session_id": "00000000-0000-0000-0000-0000000000cc",
2045 "owner_agent_runtime_id": "runtime-a",
2046 "owner_fence_token": 7,
2047 "owner_runtime_generation": 3,
2048 "owner_runtime_epoch_id": "epoch-3",
2049 "candidate_owner_input_id": "00000000-0000-0000-0000-0000000000aa",
2050 "candidate": {candidate_json},
2051 "candidate_digest": "{candidate_digest}",
2052 "completion_input_ids": {completion_ids_json},
2053 "completion_input_ids_digest": "{completion_ids_digest}",
2054 "phase": {{"phase":"candidate"}}
2055 }}
2056 }}"#
2057 );
2058
2059 let restored: StoredInputState =
2060 serde_json::from_str(&row).expect("v0.8.7 callback-pending row must decode");
2061 let outbox = restored
2062 .state
2063 .interaction_terminal_outbox
2064 .as_ref()
2065 .expect("outbox survives decode");
2066 let candidate = outbox.candidate.as_ref().expect("owner keeps candidate");
2067 assert!(matches!(
2068 candidate,
2069 InteractionTerminalCandidate::CallbackPending {
2070 tool_use_id: None,
2071 ..
2072 }
2073 ));
2074 assert_eq!(
2075 interaction_terminal_payload_digest(candidate).unwrap(),
2076 outbox.candidate_digest,
2077 "legacy candidate must re-serialize byte-identically under its stored digest"
2078 );
2079 assert!(matches!(
2081 candidate.core_apply_terminal(),
2082 Some(meerkat_core::lifecycle::core_executor::CoreApplyTerminal::CallbackPending {
2083 tool_use_id,
2084 ..
2085 }) if tool_use_id.is_empty()
2086 ));
2087 }
2088
2089 #[test]
2094 fn legacy_callback_candidate_matches_legacy_and_reprojected_events() {
2095 let interaction_id = InteractionId(uuid::Uuid::new_v4());
2096 let args = serde_json::json!({"value": 1});
2097 let legacy_candidate = InteractionTerminalCandidate::CallbackPending {
2098 tool_use_id: None,
2099 tool_name: "external".to_string(),
2100 args: args.clone(),
2101 };
2102 let event = |pending_tool_calls| AgentEvent::InteractionCallbackPending {
2103 interaction_id,
2104 tool_name: "external".to_string(),
2105 args: args.clone(),
2106 pending_tool_calls,
2107 };
2108
2109 let legacy_event = event(Vec::new());
2110 let reprojected_event = event(vec![meerkat_core::error::PendingCallbackToolCall {
2111 tool_use_id: String::new(),
2112 tool_name: "external".to_string(),
2113 args: args.clone(),
2114 }]);
2115 assert!(interaction_terminal_candidate_matches_event(
2116 &legacy_candidate,
2117 interaction_id,
2118 &legacy_event,
2119 false,
2120 ));
2121 assert!(interaction_terminal_candidate_matches_event(
2122 &legacy_candidate,
2123 interaction_id,
2124 &reprojected_event,
2125 false,
2126 ));
2127
2128 let modern_candidate = InteractionTerminalCandidate::CallbackPending {
2129 tool_use_id: Some("call-9".to_string()),
2130 tool_name: "external".to_string(),
2131 args: args.clone(),
2132 };
2133 assert!(!interaction_terminal_candidate_matches_event(
2134 &modern_candidate,
2135 interaction_id,
2136 &legacy_event,
2137 false,
2138 ));
2139 let exact_event = event(vec![meerkat_core::error::PendingCallbackToolCall {
2140 tool_use_id: "call-9".to_string(),
2141 tool_name: "external".to_string(),
2142 args: args.clone(),
2143 }]);
2144 assert!(interaction_terminal_candidate_matches_event(
2145 &modern_candidate,
2146 interaction_id,
2147 &exact_event,
2148 false,
2149 ));
2150 }
2151
2152 #[test]
2153 fn stored_input_state_unknown_versions_still_fail_closed() {
2154 let mut fixture =
2155 serde_json::to_value(StoredInputState::new_accepted(InputId::new())).unwrap();
2156 for rejected in [2, 6] {
2157 fixture["stored_input_state_version"] = serde_json::json!(rejected);
2158 let error = serde_json::from_value::<StoredInputState>(fixture.clone())
2159 .expect_err("unknown historical and future versions must fail closed");
2160 assert!(error.to_string().contains("expected current 5"));
2161 }
2162
2163 fixture["stored_input_state_version"] = serde_json::json!(3);
2166 serde_json::from_value::<StoredInputState>(fixture)
2167 .expect("released v3 row retained by 0.8.10 remains supported");
2168 }
2169
2170 #[test]
2171 fn stored_input_state_v4_migrates_to_v5_without_inventing_a_completion_receipt() {
2172 let mut fixture =
2173 serde_json::to_value(StoredInputState::new_accepted(InputId::new())).unwrap();
2174 fixture["stored_input_state_version"] = serde_json::json!(4);
2175 fixture
2176 .as_object_mut()
2177 .expect("stored input state fixture is an object")
2178 .remove("terminal_completion");
2179
2180 let restored: StoredInputState =
2181 serde_json::from_value(fixture).expect("0.8.10 v4 input state must migrate");
2182 assert!(restored.state.terminal_completion.is_none());
2183
2184 let migrated = serde_json::to_value(restored).unwrap();
2185 assert_eq!(
2186 migrated["stored_input_state_version"],
2187 meerkat_core::generated::session_persistence_version_authority::STORED_INPUT_STATE_VERSION,
2188 );
2189
2190 let mut terminal_fixture =
2191 serde_json::to_value(StoredInputState::new_accepted(InputId::new())).unwrap();
2192 terminal_fixture["stored_input_state_version"] = serde_json::json!(4);
2193 terminal_fixture["current_state"] = serde_json::json!("consumed");
2194 terminal_fixture["terminal_outcome"] = serde_json::json!({ "outcome_type": "consumed" });
2195 let restored_terminal: StoredInputState = serde_json::from_value(terminal_fixture)
2196 .expect("0.8.10 terminal row must retain an explicit evidence-gap marker");
2197 assert!(restored_terminal.state.terminal_completion_unavailable);
2198 assert!(matches!(
2199 input_terminal_completion_outcome(
2200 std::slice::from_ref(&restored_terminal),
2201 &restored_terminal.state.input_id,
2202 ),
2203 Err(InputTerminalCompletionReadError::MigratedReceiptUnavailable)
2204 ));
2205 let migrated_terminal = serde_json::to_value(restored_terminal).unwrap();
2206 assert_eq!(
2207 migrated_terminal["terminal_completion_unavailable"],
2208 serde_json::json!(true)
2209 );
2210 }
2211
2212 #[test]
2213 fn stored_input_state_rejects_legacy_persisted_input_tags() {
2214 let continuation_bundle = StoredInputState {
2218 state: InputState {
2219 persisted_input: Some(Input::Continuation(
2220 crate::input::ContinuationInput::detached_background_op_completed(),
2221 )),
2222 ..InputState::new_accepted(InputId::new())
2223 },
2224 seed: InputStateSeed::new_accepted(),
2225 };
2226 let mut continuation_json = serde_json::to_value(&continuation_bundle).unwrap();
2227 continuation_json["persisted_input"]["input_type"] =
2228 serde_json::Value::String("system_generated".into());
2229 serde_json::from_value::<StoredInputState>(continuation_json)
2230 .expect_err("legacy system_generated persisted input tag must be rejected");
2231
2232 let operation_bundle = StoredInputState {
2233 state: InputState {
2234 persisted_input: Some(Input::Operation(crate::input::OperationInput {
2235 header: crate::input::InputHeader {
2236 id: InputId::new(),
2237 timestamp: Utc::now(),
2238 source: crate::input::InputOrigin::System,
2239 durability: crate::input::InputDurability::Derived,
2240 visibility: crate::input::InputVisibility::default(),
2241 idempotency_key: None,
2242 supersession_key: None,
2243 correlation_id: None,
2244 },
2245 operation_id: OperationId::new(),
2246 event: OpEvent::Cancelled {
2247 id: OperationId::new(),
2248 },
2249 })),
2250 ..InputState::new_accepted(InputId::new())
2251 },
2252 seed: InputStateSeed::new_accepted(),
2253 };
2254 let mut operation_json = serde_json::to_value(&operation_bundle).unwrap();
2255 operation_json["persisted_input"]["input_type"] =
2256 serde_json::Value::String("projected".into());
2257 serde_json::from_value::<StoredInputState>(operation_json)
2258 .expect_err("legacy projected persisted input tag must be rejected");
2259 }
2260
2261 #[test]
2262 fn stored_input_state_rejects_legacy_dual_carrier_persisted_input_shape() {
2263 let bundle = StoredInputState {
2267 state: InputState {
2268 persisted_input: Some(Input::Prompt(crate::input::PromptInput::new("hello", None))),
2269 ..InputState::new_accepted(InputId::new())
2270 },
2271 seed: InputStateSeed::new_accepted(),
2272 };
2273 let mut json = serde_json::to_value(&bundle).unwrap();
2274 let persisted = json["persisted_input"]
2275 .as_object_mut()
2276 .expect("persisted_input object");
2277 persisted.remove("content");
2278 persisted.insert("text".into(), serde_json::Value::String("hello".into()));
2279 persisted.insert("blocks".into(), serde_json::Value::Null);
2280 serde_json::from_value::<StoredInputState>(json)
2281 .expect_err("legacy text+blocks persisted prompt shape must be rejected");
2282 }
2283
2284 #[test]
2285 fn abandon_reason_serde() {
2286 for reason in [
2287 InputAbandonReason::Retired,
2288 InputAbandonReason::Reset,
2289 InputAbandonReason::Destroyed,
2290 InputAbandonReason::Cancelled,
2291 ] {
2292 let json = serde_json::to_value(&reason).unwrap();
2293 let parsed: InputAbandonReason = serde_json::from_value(json).unwrap();
2294 assert_eq!(reason, parsed);
2295 }
2296 }
2297
2298 #[test]
2299 fn terminal_outcome_consumed_serde() {
2300 let outcome = InputTerminalOutcome::Consumed;
2301 let json = serde_json::to_value(&outcome).unwrap();
2302 assert_eq!(json["outcome_type"], "consumed");
2303 let parsed: InputTerminalOutcome = serde_json::from_value(json).unwrap();
2304 assert_eq!(outcome, parsed);
2305 }
2306
2307 #[test]
2308 fn terminal_outcome_superseded_serde() {
2309 let outcome = InputTerminalOutcome::Superseded {
2310 superseded_by: InputId::new(),
2311 };
2312 let json = serde_json::to_value(&outcome).unwrap();
2313 assert_eq!(json["outcome_type"], "superseded");
2314 let parsed: InputTerminalOutcome = serde_json::from_value(json).unwrap();
2315 assert!(matches!(parsed, InputTerminalOutcome::Superseded { .. }));
2316 }
2317
2318 #[test]
2319 fn terminal_outcome_abandoned_serde() {
2320 let outcome = InputTerminalOutcome::Abandoned {
2321 reason: InputAbandonReason::Retired,
2322 };
2323 let json = serde_json::to_value(&outcome).unwrap();
2324 let parsed: InputTerminalOutcome = serde_json::from_value(json).unwrap();
2325 assert!(matches!(
2326 parsed,
2327 InputTerminalOutcome::Abandoned {
2328 reason: InputAbandonReason::Retired,
2329 }
2330 ));
2331 }
2332
2333 #[test]
2334 fn callback_batch_candidate_accepts_abandoned_projection_on_finalization_failure() {
2335 let interaction_id = InteractionId(uuid::Uuid::new_v4());
2336 let candidate = InteractionTerminalCandidate::CallbackBatchPending {
2337 pending_tool_calls: vec![meerkat_core::error::PendingCallbackToolCall {
2338 tool_use_id: "call-1".to_string(),
2339 tool_name: "external".to_string(),
2340 args: serde_json::json!({"value": 1}),
2341 }],
2342 };
2343 let event = AgentEvent::InteractionFailed {
2344 interaction_id,
2345 reason: meerkat_core::event::InteractionFailureReason::abandoned(
2346 "terminal publication failed",
2347 ),
2348 };
2349
2350 assert!(interaction_terminal_candidate_matches_event(
2351 &candidate,
2352 interaction_id,
2353 &event,
2354 true,
2355 ));
2356 }
2357
2358 #[test]
2359 fn reconstruction_source_serde() {
2360 let sources = vec![
2361 ReconstructionSource::Projection {
2362 rule_id: "rule-1".into(),
2363 source_event_id: "evt-1".into(),
2364 },
2365 ReconstructionSource::Coalescing {
2366 source_input_ids: vec![InputId::new(), InputId::new()],
2367 },
2368 ];
2369 for source in sources {
2370 let json = serde_json::to_value(&source).unwrap();
2371 assert!(json["source_type"].is_string());
2372 let parsed: ReconstructionSource = serde_json::from_value(json).unwrap();
2373 let _ = parsed;
2374 }
2375 }
2376
2377 #[test]
2378 fn input_state_event_serde() {
2379 let event = InputStateEvent {
2380 timestamp: Utc::now(),
2381 state: InputLifecycleState::Queued,
2382 detail: Some("queued for processing".into()),
2383 };
2384 let json = serde_json::to_value(&event).unwrap();
2385 let parsed: InputStateEvent = serde_json::from_value(json).unwrap();
2386 assert_eq!(parsed.state, InputLifecycleState::Queued);
2387 }
2388}