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::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 tool_name: String,
117 args: serde_json::Value,
118 },
119 MachineTerminalFailure {
124 error: meerkat_core::TurnErrorMetadata,
125 },
126 Cancelled,
127 RuntimeTerminated {
128 reason: String,
129 },
130}
131
132impl InteractionTerminalCandidate {
133 pub(crate) fn core_apply_terminal(
134 &self,
135 ) -> Option<meerkat_core::lifecycle::core_executor::CoreApplyTerminal> {
136 use meerkat_core::lifecycle::core_executor::CoreApplyTerminal;
137 match self {
138 Self::RunResult { result } => Some(CoreApplyTerminal::RunResult(result.clone())),
139 Self::CompletedWithoutResult => Some(CoreApplyTerminal::NoPendingBoundary),
140 Self::CallbackPending { tool_name, args } => Some(CoreApplyTerminal::CallbackPending {
141 tool_name: tool_name.clone(),
142 args: args.clone(),
143 }),
144 Self::MachineTerminalFailure { error } => {
145 Some(CoreApplyTerminal::MachineTerminalFailure {
146 error: error.clone(),
147 })
148 }
149 Self::Cancelled | Self::RuntimeTerminated { .. } => None,
150 }
151 }
152
153 pub(crate) fn terminal_observation(
154 &self,
155 ) -> crate::meerkat_machine::dsl::RuntimeCompletionTerminalObservation {
156 use crate::meerkat_machine::dsl::RuntimeCompletionTerminalObservation;
157 match self {
158 Self::RunResult { .. } => RuntimeCompletionTerminalObservation::RunResult,
159 Self::CallbackPending { .. } => RuntimeCompletionTerminalObservation::CallbackPending,
160 Self::RuntimeTerminated { .. } => {
161 RuntimeCompletionTerminalObservation::RuntimeTerminated
162 }
163 Self::MachineTerminalFailure { .. } | Self::Cancelled => {
164 RuntimeCompletionTerminalObservation::MachineTerminal
165 }
166 Self::CompletedWithoutResult => RuntimeCompletionTerminalObservation::NoResult,
167 }
168 }
169
170 pub(crate) fn completion_error_metadata(&self) -> Option<meerkat_core::TurnErrorMetadata> {
171 match self {
172 Self::MachineTerminalFailure { error } => Some(error.clone()),
173 _ => None,
174 }
175 }
176}
177
178#[derive(Debug, Clone, Serialize, Deserialize)]
181pub(crate) struct InteractionTerminalPublication {
182 pub(crate) terminal_seq: u64,
183 pub(crate) payload_digest: String,
184}
185
186#[derive(Debug, Clone, Serialize, Deserialize)]
193#[serde(tag = "phase", rename_all = "snake_case")]
194pub(crate) enum InteractionTerminalOutboxPhase {
195 Candidate,
196 Finalized {
197 finalization_failed: bool,
198 #[serde(default, skip_serializing_if = "Option::is_none")]
199 finalized_event: Option<AgentEvent>,
200 finalized_payload_digest: String,
201 },
202 Published {
203 finalization_failed: bool,
204 publication: InteractionTerminalPublication,
205 },
206}
207
208#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
210#[serde(tag = "scope", rename_all = "snake_case")]
211pub(crate) enum InteractionTerminalBatchKey {
212 Run { run_id: RunId },
213 RuntimeTermination { candidate_owner_input_id: InputId },
214}
215
216impl InteractionTerminalBatchKey {
217 pub(crate) fn run_id(&self) -> Option<&RunId> {
218 match self {
219 Self::Run { run_id } => Some(run_id),
220 Self::RuntimeTermination { .. } => None,
221 }
222 }
223}
224
225#[derive(Debug, Clone, Serialize, Deserialize)]
232pub(crate) struct InteractionTerminalOutbox {
233 pub(crate) interaction_id: InteractionId,
234 pub(crate) input_id: InputId,
235 pub(crate) batch_ordinal: u16,
238 pub(crate) batch_key: InteractionTerminalBatchKey,
239 pub(crate) owner_session_id: SessionId,
240 #[serde(default, skip_serializing_if = "Option::is_none")]
241 pub(crate) owner_agent_runtime_id: Option<String>,
242 #[serde(default, skip_serializing_if = "Option::is_none")]
243 pub(crate) owner_fence_token: Option<u64>,
244 #[serde(default, skip_serializing_if = "Option::is_none")]
245 pub(crate) owner_runtime_generation: Option<u64>,
246 #[serde(default, skip_serializing_if = "Option::is_none")]
247 pub(crate) owner_runtime_epoch_id: Option<String>,
248 pub(crate) candidate_owner_input_id: InputId,
250 #[serde(default, skip_serializing_if = "Option::is_none")]
252 pub(crate) candidate: Option<InteractionTerminalCandidate>,
253 pub(crate) candidate_digest: String,
254 #[serde(default, skip_serializing_if = "Option::is_none")]
258 pub(crate) completion_input_ids: Option<Vec<InputId>>,
259 pub(crate) completion_input_ids_digest: String,
261 pub(crate) phase: InteractionTerminalOutboxPhase,
262}
263
264pub(crate) fn interaction_terminal_payload_digest<T: Serialize>(
265 payload: &T,
266) -> Result<String, String> {
267 serde_json::to_vec(payload)
268 .map(|encoded| format!("{:x}", Sha256::digest(encoded)))
269 .map_err(|error| format!("failed to encode interaction terminal payload: {error}"))
270}
271
272pub(crate) fn interaction_terminal_event_id(event: &AgentEvent) -> Option<InteractionId> {
273 match event {
274 AgentEvent::InteractionComplete { interaction_id, .. }
275 | AgentEvent::InteractionCallbackPending { interaction_id, .. }
276 | AgentEvent::InteractionFailed { interaction_id, .. } => Some(*interaction_id),
277 _ => None,
278 }
279}
280
281pub(crate) fn interaction_terminal_event_for_id(
282 event: &AgentEvent,
283 interaction_id: InteractionId,
284) -> Option<AgentEvent> {
285 match event {
286 AgentEvent::InteractionComplete {
287 result,
288 structured_output,
289 ..
290 } => Some(AgentEvent::InteractionComplete {
291 interaction_id,
292 result: result.clone(),
293 structured_output: structured_output.clone(),
294 }),
295 AgentEvent::InteractionCallbackPending {
296 tool_name, args, ..
297 } => Some(AgentEvent::InteractionCallbackPending {
298 interaction_id,
299 tool_name: tool_name.clone(),
300 args: args.clone(),
301 }),
302 AgentEvent::InteractionFailed { reason, .. } => Some(AgentEvent::InteractionFailed {
303 interaction_id,
304 reason: reason.clone(),
305 }),
306 _ => None,
307 }
308}
309
310impl InteractionTerminalOutbox {
311 pub(crate) fn validate(&self) -> Result<(), String> {
312 if self.input_id.0 != self.interaction_id.0 {
313 return Err("interaction terminal outbox input/interaction identity mismatch".into());
314 }
315 if self.candidate_digest.is_empty() {
316 return Err("interaction terminal outbox candidate digest is empty".into());
317 }
318 if self.completion_input_ids_digest.is_empty() {
319 return Err("interaction terminal outbox completion recipient digest is empty".into());
320 }
321 if self.owner_agent_runtime_id.is_none()
322 || self.owner_fence_token.is_none()
323 || self.owner_runtime_generation.is_none()
324 {
325 return Err(
326 "interaction terminal outbox is missing required runtime placement binding".into(),
327 );
328 }
329 match (&self.batch_key, self.candidate.as_ref()) {
330 (
331 InteractionTerminalBatchKey::RuntimeTermination {
332 candidate_owner_input_id,
333 },
334 candidate,
335 ) => {
336 if candidate_owner_input_id != &self.candidate_owner_input_id {
337 return Err("runtime-termination batch key/candidate-owner mismatch".into());
338 }
339 if candidate.is_some_and(|candidate| {
340 !matches!(
341 candidate,
342 InteractionTerminalCandidate::RuntimeTerminated { .. }
343 )
344 }) {
345 return Err("runtime-termination batch carried a run-scoped candidate".into());
346 }
347 }
348 (
349 InteractionTerminalBatchKey::Run { .. },
350 Some(InteractionTerminalCandidate::RuntimeTerminated { .. }),
351 ) => {
352 return Err("run-scoped terminal batch carried runtime termination".into());
353 }
354 (InteractionTerminalBatchKey::Run { .. }, _) => {}
355 }
356 let published = matches!(self.phase, InteractionTerminalOutboxPhase::Published { .. });
357 let owns_candidate = self.input_id == self.candidate_owner_input_id;
358 if owns_candidate != (self.batch_ordinal == 0) {
359 return Err("interaction terminal outbox candidate owner/ordinal mismatch".into());
360 }
361 match (&self.completion_input_ids, owns_candidate, published) {
362 (Some(input_ids), true, false) => {
363 if input_ids.is_empty() || input_ids.len() > 256 {
364 return Err(
365 "interaction terminal completion recipient set has invalid size".into(),
366 );
367 }
368 let unique = input_ids.iter().collect::<std::collections::HashSet<_>>();
369 if unique.len() != input_ids.len() {
370 return Err(
371 "interaction terminal completion recipient set contains duplicates".into(),
372 );
373 }
374 if !input_ids.contains(&self.input_id) {
375 return Err(
376 "interaction terminal candidate owner is not a completion recipient".into(),
377 );
378 }
379 if interaction_terminal_payload_digest(input_ids)?
380 != self.completion_input_ids_digest
381 {
382 return Err("interaction terminal completion recipient digest mismatch".into());
383 }
384 }
385 (None, false, false) | (None, _, true) => {}
386 (Some(_), false, _) => {
387 return Err("non-owner interaction outbox duplicated completion recipients".into());
388 }
389 (Some(_), true, true) => {
390 return Err("published interaction outbox retained completion recipients".into());
391 }
392 (None, true, false) => {
393 return Err("interaction outbox candidate owner lost completion recipients".into());
394 }
395 }
396 match (&self.candidate, owns_candidate, published) {
397 (Some(candidate), true, false) => {
398 if interaction_terminal_payload_digest(candidate)? != self.candidate_digest {
399 return Err("interaction terminal outbox candidate digest mismatch".into());
400 }
401 if let InteractionTerminalCandidate::RunResult { result } = candidate
402 && result.session_id != self.owner_session_id
403 {
404 return Err("interaction terminal candidate session/owner mismatch".into());
405 }
406 }
407 (None, false, false) => {}
408 (None, _, true) => {}
409 (Some(_), false, _) => {
410 return Err("non-owner interaction outbox duplicated shared candidate".into());
411 }
412 (Some(_), true, true) => {
413 return Err("published interaction outbox retained its shared candidate".into());
414 }
415 (None, true, false) => {
416 return Err("interaction outbox candidate owner has no candidate".into());
417 }
418 }
419 match &self.phase {
420 InteractionTerminalOutboxPhase::Candidate => {}
421 InteractionTerminalOutboxPhase::Finalized {
422 finalization_failed,
423 finalized_event: Some(event),
424 finalized_payload_digest: digest,
425 } if self.input_id == self.candidate_owner_input_id => {
426 if interaction_terminal_event_id(event) != Some(self.interaction_id) {
427 return Err(
428 "interaction terminal outbox finalized event identity mismatch".into(),
429 );
430 }
431 if interaction_terminal_payload_digest(event)? != *digest {
432 return Err("interaction terminal outbox finalized digest mismatch".into());
433 }
434 let Some(candidate) = self.candidate.as_ref() else {
435 return Err("finalized candidate owner lost shared candidate".into());
436 };
437 if !interaction_terminal_candidate_matches_event(
438 candidate,
439 self.interaction_id,
440 event,
441 *finalization_failed,
442 ) {
443 return Err("interaction terminal finalized event/candidate mismatch".into());
444 }
445 }
446 InteractionTerminalOutboxPhase::Finalized {
447 finalized_event: None,
448 finalized_payload_digest,
449 ..
450 } if self.input_id != self.candidate_owner_input_id
451 && !finalized_payload_digest.is_empty() => {}
452 InteractionTerminalOutboxPhase::Finalized { .. } => {
453 return Err(
454 "interaction terminal outbox finalized payload ownership is invalid".into(),
455 );
456 }
457 InteractionTerminalOutboxPhase::Published { publication, .. } => {
458 if publication.terminal_seq == 0 {
459 return Err("interaction terminal publication sequence must be non-zero".into());
460 }
461 if publication.payload_digest.is_empty() {
462 return Err("interaction terminal publication digest is empty".into());
463 }
464 }
465 }
466 Ok(())
467 }
468}
469
470pub(crate) fn validate_interaction_terminal_outbox_batch_shape(
473 outboxes: &[InteractionTerminalOutbox],
474) -> Result<(), String> {
475 if outboxes.is_empty() || outboxes.len() > 256 {
476 return Err("interaction terminal batch has invalid directed-row count".into());
477 }
478 let owner = &outboxes[0];
479 if owner.batch_ordinal != 0 || owner.input_id != owner.candidate_owner_input_id {
480 return Err("interaction terminal batch has no ordinal-zero candidate owner".into());
481 }
482 let mut row_input_ids = std::collections::HashSet::new();
483 for (ordinal, outbox) in outboxes.iter().enumerate() {
484 outbox.validate()?;
485 if usize::from(outbox.batch_ordinal) != ordinal {
486 return Err("interaction terminal batch ordinals are not contiguous".into());
487 }
488 if outbox.batch_key != owner.batch_key
489 || outbox.candidate_owner_input_id != owner.candidate_owner_input_id
490 || outbox.candidate_digest != owner.candidate_digest
491 || outbox.completion_input_ids_digest != owner.completion_input_ids_digest
492 {
493 return Err("interaction terminal batch has split immutable identity".into());
494 }
495 if !row_input_ids.insert(outbox.input_id.clone()) {
496 return Err("interaction terminal batch repeats a directed input".into());
497 }
498 }
499 Ok(())
500}
501
502pub(crate) fn validate_unpublished_interaction_terminal_outbox_batch(
505 outboxes: &[InteractionTerminalOutbox],
506) -> Result<Vec<InputId>, String> {
507 validate_interaction_terminal_outbox_batch_shape(outboxes)?;
508 let owner = &outboxes[0];
509 let completion_input_ids = owner.completion_input_ids.clone().ok_or_else(|| {
510 "unpublished interaction terminal batch owner lost completion recipients".to_string()
511 })?;
512 for outbox in outboxes {
513 if matches!(
514 outbox.phase,
515 InteractionTerminalOutboxPhase::Published { .. }
516 ) {
517 return Err("published row appeared in an unpublished terminal batch".into());
518 }
519 if !completion_input_ids.contains(&outbox.input_id) {
520 return Err("directed terminal input is not a completion recipient".into());
521 }
522 }
523 Ok(completion_input_ids)
524}
525
526pub(crate) fn interaction_terminal_candidate_matches_event(
527 candidate: &InteractionTerminalCandidate,
528 interaction_id: InteractionId,
529 event: &AgentEvent,
530 finalization_failed: bool,
531) -> bool {
532 use meerkat_core::event::InteractionFailureReason;
533 if interaction_terminal_event_id(event) != Some(interaction_id) {
534 return false;
535 }
536 if finalization_failed {
537 return matches!(
538 (candidate, event),
539 (
540 InteractionTerminalCandidate::RunResult { .. },
541 AgentEvent::InteractionFailed {
542 reason: InteractionFailureReason::FinalizationFailed { .. },
543 ..
544 },
545 ) | (
546 InteractionTerminalCandidate::CompletedWithoutResult
547 | InteractionTerminalCandidate::CallbackPending { .. }
548 | InteractionTerminalCandidate::MachineTerminalFailure { .. },
549 AgentEvent::InteractionFailed {
550 reason: InteractionFailureReason::Abandoned { .. },
551 ..
552 },
553 )
554 );
555 }
556 match (candidate, event) {
557 (
558 InteractionTerminalCandidate::RunResult { result },
559 AgentEvent::InteractionComplete {
560 result: event_result,
561 structured_output: event_structured,
562 ..
563 },
564 ) if result.extraction_error.is_none() => {
565 result.text == *event_result && result.structured_output == *event_structured
566 }
567 (
568 InteractionTerminalCandidate::RunResult { result },
569 AgentEvent::InteractionFailed {
570 reason:
571 InteractionFailureReason::ExtractionFailed {
572 last_output,
573 attempts,
574 reason,
575 },
576 ..
577 },
578 ) if result.extraction_error.is_some() => {
579 let Some(extraction) = result.extraction_error.as_ref() else {
580 return false;
581 };
582 extraction.last_output == *last_output
583 && extraction.attempts == *attempts
584 && extraction.reason == *reason
585 }
586 (
587 InteractionTerminalCandidate::CompletedWithoutResult,
588 AgentEvent::InteractionComplete {
589 result,
590 structured_output,
591 ..
592 },
593 ) => result.is_empty() && structured_output.is_none(),
594 (
595 InteractionTerminalCandidate::CallbackPending { tool_name, args },
596 AgentEvent::InteractionCallbackPending {
597 tool_name: event_tool,
598 args: event_args,
599 ..
600 },
601 ) => tool_name == event_tool && args == event_args,
602 (
603 InteractionTerminalCandidate::MachineTerminalFailure { error },
604 AgentEvent::InteractionFailed {
605 reason: InteractionFailureReason::Abandoned { detail },
606 ..
607 },
608 ) => error.detail.as_deref() == Some(detail.as_str()),
609 (
610 InteractionTerminalCandidate::Cancelled,
611 AgentEvent::InteractionFailed {
612 reason: InteractionFailureReason::Cancelled,
613 ..
614 },
615 ) => true,
616 (
617 InteractionTerminalCandidate::RuntimeTerminated { reason },
618 AgentEvent::InteractionFailed {
619 reason: InteractionFailureReason::Abandoned { detail },
620 ..
621 },
622 ) => reason == detail,
623 _ => false,
624 }
625}
626
627#[derive(Debug, Clone, Serialize, Deserialize)]
629pub struct InputStateEvent {
630 pub timestamp: DateTime<Utc>,
631 pub state: InputLifecycleState,
632 #[serde(skip_serializing_if = "Option::is_none")]
633 pub detail: Option<String>,
634}
635
636#[derive(Debug, Clone, PartialEq, Eq)]
647pub struct InputStateSeed {
648 pub phase: InputLifecycleState,
649 pub last_run_id: Option<RunId>,
650 pub last_boundary_sequence: Option<u64>,
651 pub admission_sequence: Option<u64>,
652 pub terminal_outcome: Option<InputTerminalOutcome>,
653 pub attempt_count: u32,
654 pub recovery_lane: Option<HandlingMode>,
655}
656
657impl InputStateSeed {
658 pub fn new_accepted() -> Self {
661 Self {
662 phase: InputLifecycleState::Accepted,
663 last_run_id: None,
664 last_boundary_sequence: None,
665 admission_sequence: None,
666 terminal_outcome: None,
667 attempt_count: 0,
668 recovery_lane: None,
669 }
670 }
671}
672
673#[derive(Debug, Clone)]
680pub struct StoredInputState {
681 pub state: InputState,
682 pub seed: InputStateSeed,
683}
684
685impl StoredInputState {
686 pub fn new_accepted(input_id: InputId) -> Self {
688 Self {
689 state: InputState::new_accepted(input_id),
690 seed: InputStateSeed::new_accepted(),
691 }
692 }
693}
694
695#[derive(Debug, Clone)]
698pub struct InputStatePersistenceRecord {
699 bundle: StoredInputState,
700}
701
702impl InputStatePersistenceRecord {
703 pub(crate) fn from_machine_snapshot(bundle: StoredInputState) -> Result<Self, String> {
707 crate::meerkat_machine::authorize_stored_input_state_seed(
708 &bundle.state.input_id,
709 &bundle.seed,
710 )?;
711 Ok(Self { bundle })
712 }
713
714 pub fn as_stored(&self) -> &StoredInputState {
716 &self.bundle
717 }
718
719 pub fn clone_stored(&self) -> StoredInputState {
721 self.bundle.clone()
722 }
723
724 pub fn into_stored(self) -> StoredInputState {
726 self.bundle
727 }
728}
729
730#[derive(Debug, Clone)]
741pub struct InputState {
742 pub input_id: InputId,
743 pub history: Vec<InputStateHistoryEntry>,
744 pub updated_at: DateTime<Utc>,
745 pub policy: Option<PolicySnapshot>,
746 pub runtime_semantics: Option<RuntimeInputSemantics>,
749 pub durability: Option<crate::input::InputDurability>,
750 pub idempotency_key: Option<crate::identifiers::IdempotencyKey>,
751 pub recovery_count: u32,
752 pub reconstruction_source: Option<ReconstructionSource>,
753 pub(crate) interaction_terminal_outbox: Option<InteractionTerminalOutbox>,
756 pub persisted_input: Option<Input>,
757 pub created_at: DateTime<Utc>,
758}
759
760impl InputState {
761 pub fn new_accepted(input_id: InputId) -> Self {
765 let now = Utc::now();
766 Self {
767 input_id,
768 history: Vec::new(),
769 updated_at: now,
770 policy: None,
771 runtime_semantics: None,
772 durability: None,
773 idempotency_key: None,
774 recovery_count: 0,
775 reconstruction_source: None,
776 interaction_terminal_outbox: None,
777 persisted_input: None,
778 created_at: now,
779 }
780 }
781
782 pub fn history(&self) -> &[InputStateHistoryEntry] {
783 &self.history
784 }
785
786 pub fn updated_at(&self) -> DateTime<Utc> {
787 self.updated_at
788 }
789}
790
791#[derive(Serialize, Deserialize)]
804struct InputStateSerde {
805 stored_input_state_version: u32,
806 input_id: InputId,
807 current_state: InputLifecycleState,
808 #[serde(skip_serializing_if = "Option::is_none")]
809 policy: Option<PolicySnapshot>,
810 #[serde(default, skip_serializing_if = "Option::is_none")]
811 runtime_semantics: Option<RuntimeInputSemantics>,
812 #[serde(skip_serializing_if = "Option::is_none")]
813 terminal_outcome: Option<InputTerminalOutcome>,
814 #[serde(skip_serializing_if = "Option::is_none")]
815 durability: Option<crate::input::InputDurability>,
816 #[serde(skip_serializing_if = "Option::is_none")]
817 idempotency_key: Option<crate::identifiers::IdempotencyKey>,
818 #[serde(default)]
819 attempt_count: u32,
820 #[serde(default)]
821 recovery_count: u32,
822 #[serde(default, skip_serializing_if = "Vec::is_empty")]
823 history: Vec<InputStateHistoryEntry>,
824 #[serde(skip_serializing_if = "Option::is_none")]
825 reconstruction_source: Option<ReconstructionSource>,
826 #[serde(default, skip_serializing_if = "Option::is_none")]
827 interaction_terminal_outbox: Option<InteractionTerminalOutbox>,
828 #[serde(default, skip_serializing_if = "Option::is_none")]
829 persisted_input: Option<Input>,
830 #[serde(default, skip_serializing_if = "Option::is_none")]
831 last_run_id: Option<RunId>,
832 #[serde(default, skip_serializing_if = "Option::is_none")]
833 last_boundary_sequence: Option<u64>,
834 #[serde(default, skip_serializing_if = "Option::is_none")]
835 admission_sequence: Option<u64>,
836 #[serde(default, skip_serializing_if = "Option::is_none")]
837 recovery_lane: Option<HandlingMode>,
838 created_at: DateTime<Utc>,
839 updated_at: DateTime<Utc>,
840}
841
842impl Serialize for StoredInputState {
843 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
844 let helper = InputStateSerde {
845 stored_input_state_version:
846 meerkat_core::generated::session_persistence_version_authority::stored_input_state_version(
847 ),
848 input_id: self.state.input_id.clone(),
849 current_state: self.seed.phase,
850 policy: self.state.policy.clone(),
851 runtime_semantics: self.state.runtime_semantics,
852 terminal_outcome: self.seed.terminal_outcome.clone(),
853 durability: self.state.durability,
854 idempotency_key: self.state.idempotency_key.clone(),
855 attempt_count: self.seed.attempt_count,
856 recovery_count: self.state.recovery_count,
857 history: self.state.history.clone(),
858 reconstruction_source: self.state.reconstruction_source.clone(),
859 interaction_terminal_outbox: self.state.interaction_terminal_outbox.clone(),
860 persisted_input: self.state.persisted_input.clone(),
861 last_run_id: self.seed.last_run_id.clone(),
862 last_boundary_sequence: self.seed.last_boundary_sequence,
863 admission_sequence: self.seed.admission_sequence,
864 recovery_lane: self.seed.recovery_lane,
865 created_at: self.state.created_at,
866 updated_at: self.state.updated_at,
867 };
868 helper.serialize(serializer)
869 }
870}
871
872impl<'de> Deserialize<'de> for StoredInputState {
873 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
874 let helper = InputStateSerde::deserialize(deserializer)?;
875 let observed_stored_input_state_version = helper.stored_input_state_version;
876 let _stored_input_state_version =
877 meerkat_core::generated::session_persistence_version_authority::restore_stored_input_state_version(
878 observed_stored_input_state_version,
879 )
880 .map_err(<D::Error as serde::de::Error>::custom)?;
881 if observed_stored_input_state_version == 3 && helper.interaction_terminal_outbox.is_some()
882 {
883 return Err(<D::Error as serde::de::Error>::custom(
884 "stored input state v3 cannot carry a v4 interaction terminal outbox",
885 ));
886 }
887 if let Some(outbox) = helper.interaction_terminal_outbox.as_ref() {
888 outbox
889 .validate()
890 .map_err(<D::Error as serde::de::Error>::custom)?;
891 }
892 let state = InputState {
893 input_id: helper.input_id,
894 history: helper.history,
895 updated_at: helper.updated_at,
896 policy: helper.policy,
897 runtime_semantics: helper.runtime_semantics,
898 durability: helper.durability,
899 idempotency_key: helper.idempotency_key,
900 recovery_count: helper.recovery_count,
901 reconstruction_source: helper.reconstruction_source,
902 interaction_terminal_outbox: helper.interaction_terminal_outbox,
903 persisted_input: helper.persisted_input,
904 created_at: helper.created_at,
905 };
906 let seed = InputStateSeed {
907 phase: helper.current_state,
908 last_run_id: helper.last_run_id,
909 last_boundary_sequence: helper.last_boundary_sequence,
910 admission_sequence: helper.admission_sequence,
911 terminal_outcome: helper.terminal_outcome,
912 attempt_count: helper.attempt_count,
913 recovery_lane: helper.recovery_lane,
914 };
915 Ok(StoredInputState { state, seed })
916 }
917}
918
919#[cfg(test)]
920#[allow(clippy::unwrap_used)]
921mod tests {
922 use super::*;
923 use crate::policy::{
924 ApplyMode, ConsumePoint, DrainPolicy, QueueMode, RoutingDisposition, WakeMode,
925 };
926 use meerkat_core::ops::{OpEvent, OperationId};
927
928 fn terminal_outbox_batch_fixture() -> Vec<InteractionTerminalOutbox> {
929 let mut completion_input_ids = vec![InputId::new(), InputId::new(), InputId::new()];
930 completion_input_ids.sort_by_key(|input_id| input_id.0);
931 let directed_input_ids = vec![
932 completion_input_ids[0].clone(),
933 completion_input_ids[2].clone(),
934 ];
935 let candidate = InteractionTerminalCandidate::CompletedWithoutResult;
936 let candidate_digest = interaction_terminal_payload_digest(&candidate).unwrap();
937 let completion_input_ids_digest =
938 interaction_terminal_payload_digest(&completion_input_ids).unwrap();
939 let candidate_owner_input_id = directed_input_ids[0].clone();
940 let batch_key = InteractionTerminalBatchKey::Run {
941 run_id: RunId::new(),
942 };
943 directed_input_ids
944 .into_iter()
945 .enumerate()
946 .map(|(ordinal, input_id)| {
947 let owns_candidate = input_id == candidate_owner_input_id;
948 InteractionTerminalOutbox {
949 interaction_id: InteractionId(input_id.0),
950 input_id,
951 batch_ordinal: ordinal as u16,
952 batch_key: batch_key.clone(),
953 owner_session_id: SessionId::new(),
954 owner_agent_runtime_id: Some("fixture-runtime".to_string()),
955 owner_fence_token: Some(7),
956 owner_runtime_generation: Some(3),
957 owner_runtime_epoch_id: Some("fixture-epoch".to_string()),
958 candidate_owner_input_id: candidate_owner_input_id.clone(),
959 candidate: owns_candidate.then(|| candidate.clone()),
960 candidate_digest: candidate_digest.clone(),
961 completion_input_ids: owns_candidate.then(|| completion_input_ids.clone()),
962 completion_input_ids_digest: completion_input_ids_digest.clone(),
963 phase: InteractionTerminalOutboxPhase::Candidate,
964 }
965 })
966 .collect()
967 }
968
969 #[test]
970 fn terminal_outbox_batch_preserves_full_mixed_completion_recipients() {
971 let outboxes = terminal_outbox_batch_fixture();
972 let recipients = validate_unpublished_interaction_terminal_outbox_batch(&outboxes).unwrap();
973
974 assert_eq!(recipients.len(), 3);
975 assert_eq!(outboxes.len(), 2);
976 assert!(recipients.contains(&outboxes[0].input_id));
977 assert!(recipients.contains(&outboxes[1].input_id));
978 }
979
980 #[test]
981 fn terminal_outbox_batch_rejects_noncontiguous_or_reordered_ordinals() {
982 let mut outboxes = terminal_outbox_batch_fixture();
983 outboxes[1].batch_ordinal = 2;
984 assert!(
985 validate_unpublished_interaction_terminal_outbox_batch(&outboxes)
986 .unwrap_err()
987 .contains("ordinals are not contiguous")
988 );
989 }
990
991 #[test]
992 fn terminal_outbox_owner_rejects_duplicate_completion_recipients() {
993 let mut outboxes = terminal_outbox_batch_fixture();
994 let owner = &mut outboxes[0];
995 let recipients = owner.completion_input_ids.as_mut().unwrap();
996 recipients.push(recipients[0].clone());
997 owner.completion_input_ids_digest =
998 interaction_terminal_payload_digest(recipients).unwrap();
999
1000 assert!(
1001 owner
1002 .validate()
1003 .unwrap_err()
1004 .contains("contains duplicates")
1005 );
1006 }
1007
1008 #[test]
1009 fn terminal_outbox_resource_bounds_reject_257_rows_or_recipients() {
1010 let fixture = terminal_outbox_batch_fixture();
1011 let oversized_rows = vec![fixture[0].clone(); 257];
1012 assert!(
1013 validate_interaction_terminal_outbox_batch_shape(&oversized_rows)
1014 .unwrap_err()
1015 .contains("invalid directed-row count")
1016 );
1017
1018 let mut owner = fixture[0].clone();
1019 let recipients = (0..257).map(|_| InputId::new()).collect::<Vec<_>>();
1020 owner.completion_input_ids_digest =
1021 interaction_terminal_payload_digest(&recipients).unwrap();
1022 owner.completion_input_ids = Some(recipients);
1023 assert!(
1024 owner
1025 .validate()
1026 .unwrap_err()
1027 .contains("recipient set has invalid size")
1028 );
1029 }
1030
1031 #[test]
1032 fn published_terminal_outbox_compaction_retains_only_immutable_proofs() {
1033 let mut outbox = terminal_outbox_batch_fixture().remove(0);
1034 let recipient_digest = outbox.completion_input_ids_digest.clone();
1035 let candidate_digest = outbox.candidate_digest.clone();
1036 outbox.candidate = None;
1037 outbox.completion_input_ids = None;
1038 outbox.phase = InteractionTerminalOutboxPhase::Published {
1039 finalization_failed: false,
1040 publication: InteractionTerminalPublication {
1041 terminal_seq: 9,
1042 payload_digest: "published-event-digest".to_string(),
1043 },
1044 };
1045
1046 outbox.validate().unwrap();
1047 assert_eq!(outbox.candidate_digest, candidate_digest);
1048 assert_eq!(outbox.completion_input_ids_digest, recipient_digest);
1049 }
1050
1051 #[test]
1052 fn published_terminal_batch_rejects_split_immutable_recipient_proof() {
1053 let mut outboxes = terminal_outbox_batch_fixture();
1054 for outbox in &mut outboxes {
1055 outbox.candidate = None;
1056 outbox.completion_input_ids = None;
1057 outbox.phase = InteractionTerminalOutboxPhase::Published {
1058 finalization_failed: false,
1059 publication: InteractionTerminalPublication {
1060 terminal_seq: u64::from(outbox.batch_ordinal) + 1,
1061 payload_digest: format!("event-{}", outbox.batch_ordinal),
1062 },
1063 };
1064 }
1065 outboxes[1].completion_input_ids_digest = "split-proof".to_string();
1066
1067 assert!(
1068 validate_interaction_terminal_outbox_batch_shape(&outboxes)
1069 .unwrap_err()
1070 .contains("split immutable identity")
1071 );
1072 }
1073
1074 #[test]
1075 fn new_accepted_starts_with_no_shell_history() {
1076 let id = InputId::new();
1077 let state = InputState::new_accepted(id.clone());
1078 assert_eq!(state.input_id, id);
1079 assert!(state.history.is_empty());
1080 }
1081
1082 #[test]
1083 fn seed_new_accepted_defaults_match_queue_lifecycle() {
1084 let seed = InputStateSeed::new_accepted();
1085 assert_eq!(seed.phase, InputLifecycleState::Accepted);
1086 assert!(seed.last_run_id.is_none());
1087 assert!(seed.last_boundary_sequence.is_none());
1088 assert!(seed.admission_sequence.is_none());
1089 assert!(seed.terminal_outcome.is_none());
1090 assert_eq!(seed.attempt_count, 0);
1091 }
1092
1093 #[test]
1094 fn lifecycle_state_serde() {
1095 for state in [
1096 InputLifecycleState::Accepted,
1097 InputLifecycleState::Queued,
1098 InputLifecycleState::Staged,
1099 InputLifecycleState::Applied,
1100 InputLifecycleState::AppliedPendingConsumption,
1101 InputLifecycleState::Consumed,
1102 InputLifecycleState::Superseded,
1103 InputLifecycleState::Coalesced,
1104 InputLifecycleState::Abandoned,
1105 ] {
1106 let json = serde_json::to_value(state).unwrap();
1107 let parsed: InputLifecycleState = serde_json::from_value(json).unwrap();
1108 assert_eq!(state, parsed);
1109 }
1110 }
1111
1112 #[test]
1113 fn stored_input_state_serde_roundtrip_preserves_fields() {
1114 let mut state = InputState::new_accepted(InputId::new());
1115 let policy = PolicyDecision {
1116 apply_mode: ApplyMode::StageRunStart,
1117 wake_mode: WakeMode::WakeIfIdle,
1118 queue_mode: QueueMode::Fifo,
1119 consume_point: ConsumePoint::OnRunComplete,
1120 drain_policy: DrainPolicy::QueueNextTurn,
1121 routing_disposition: RoutingDisposition::Queue,
1122 record_transcript: true,
1123 emit_operator_content: true,
1124 policy_version: PolicyVersion(1),
1125 };
1126 state.policy = Some(PolicySnapshot {
1127 version: PolicyVersion(1),
1128 decision: policy.clone(),
1129 });
1130 state.runtime_semantics = Some(
1131 crate::policy_table::generated_admission_projection_for_kind(
1132 crate::identifiers::KindId::new(crate::identifiers::InputKind::Prompt),
1133 true,
1134 )
1135 .expect("generated admission projection")
1136 .runtime_semantics,
1137 );
1138 state.history.push(InputStateHistoryEntry {
1139 timestamp: state.updated_at,
1140 from: InputLifecycleState::Accepted,
1141 to: InputLifecycleState::Queued,
1142 reason: Some("QueueAccepted".into()),
1143 });
1144 let bundle = StoredInputState {
1145 state,
1146 seed: InputStateSeed {
1147 phase: InputLifecycleState::Queued,
1148 last_run_id: None,
1149 last_boundary_sequence: None,
1150 admission_sequence: Some(42),
1151 terminal_outcome: None,
1152 attempt_count: 0,
1153 recovery_lane: Some(HandlingMode::Queue),
1154 },
1155 };
1156
1157 let json = serde_json::to_value(&bundle).unwrap();
1158 let parsed: StoredInputState = serde_json::from_value(json).unwrap();
1159 assert_eq!(parsed.state.input_id, bundle.state.input_id);
1160 assert_eq!(parsed.seed.phase, bundle.seed.phase);
1161 assert_eq!(
1162 parsed.seed.admission_sequence,
1163 bundle.seed.admission_sequence
1164 );
1165 assert_eq!(parsed.seed.recovery_lane, bundle.seed.recovery_lane);
1166 assert_eq!(
1167 parsed.state.runtime_semantics,
1168 bundle.state.runtime_semantics
1169 );
1170 assert_eq!(parsed.state.history.len(), 1);
1171 }
1172
1173 #[test]
1174 fn stored_input_state_v3_fixture_migrates_to_v4() {
1175 let fixture = include_str!("../tests/fixtures/stored_input_state_v3.json");
1176 let restored: StoredInputState =
1177 serde_json::from_str(fixture).expect("serialized v3 fixture must migrate");
1178 assert_eq!(restored.seed.phase, InputLifecycleState::Queued);
1179 assert_eq!(restored.seed.attempt_count, 2);
1180 assert_eq!(restored.state.recovery_count, 1);
1181 assert_eq!(restored.seed.admission_sequence, Some(17));
1182 assert_eq!(restored.seed.recovery_lane, Some(HandlingMode::Queue));
1183 assert!(restored.state.interaction_terminal_outbox.is_none());
1184
1185 let migrated = serde_json::to_value(&restored).expect("serialize migrated v4 row");
1186 assert_eq!(
1187 migrated["stored_input_state_version"],
1188 meerkat_core::generated::session_persistence_version_authority::STORED_INPUT_STATE_VERSION,
1189 );
1190 }
1191
1192 #[test]
1193 fn stored_input_state_unlisted_legacy_version_still_fails_closed() {
1194 let mut fixture: serde_json::Value =
1195 serde_json::from_str(include_str!("../tests/fixtures/stored_input_state_v3.json"))
1196 .expect("fixture json");
1197 for rejected in [2, 5] {
1198 fixture["stored_input_state_version"] = serde_json::json!(rejected);
1199 let error = serde_json::from_value::<StoredInputState>(fixture.clone())
1200 .expect_err("unlisted historical and future versions must fail closed");
1201 assert!(error.to_string().contains("expected current 4"));
1202 }
1203 }
1204
1205 #[test]
1206 fn stored_input_state_rejects_legacy_persisted_input_tags() {
1207 let continuation_bundle = StoredInputState {
1211 state: InputState {
1212 persisted_input: Some(Input::Continuation(
1213 crate::input::ContinuationInput::detached_background_op_completed(),
1214 )),
1215 ..InputState::new_accepted(InputId::new())
1216 },
1217 seed: InputStateSeed::new_accepted(),
1218 };
1219 let mut continuation_json = serde_json::to_value(&continuation_bundle).unwrap();
1220 continuation_json["persisted_input"]["input_type"] =
1221 serde_json::Value::String("system_generated".into());
1222 serde_json::from_value::<StoredInputState>(continuation_json)
1223 .expect_err("legacy system_generated persisted input tag must be rejected");
1224
1225 let operation_bundle = StoredInputState {
1226 state: InputState {
1227 persisted_input: Some(Input::Operation(crate::input::OperationInput {
1228 header: crate::input::InputHeader {
1229 id: InputId::new(),
1230 timestamp: Utc::now(),
1231 source: crate::input::InputOrigin::System,
1232 durability: crate::input::InputDurability::Derived,
1233 visibility: crate::input::InputVisibility::default(),
1234 idempotency_key: None,
1235 supersession_key: None,
1236 correlation_id: None,
1237 },
1238 operation_id: OperationId::new(),
1239 event: OpEvent::Cancelled {
1240 id: OperationId::new(),
1241 },
1242 })),
1243 ..InputState::new_accepted(InputId::new())
1244 },
1245 seed: InputStateSeed::new_accepted(),
1246 };
1247 let mut operation_json = serde_json::to_value(&operation_bundle).unwrap();
1248 operation_json["persisted_input"]["input_type"] =
1249 serde_json::Value::String("projected".into());
1250 serde_json::from_value::<StoredInputState>(operation_json)
1251 .expect_err("legacy projected persisted input tag must be rejected");
1252 }
1253
1254 #[test]
1255 fn stored_input_state_rejects_legacy_dual_carrier_persisted_input_shape() {
1256 let bundle = StoredInputState {
1260 state: InputState {
1261 persisted_input: Some(Input::Prompt(crate::input::PromptInput::new("hello", None))),
1262 ..InputState::new_accepted(InputId::new())
1263 },
1264 seed: InputStateSeed::new_accepted(),
1265 };
1266 let mut json = serde_json::to_value(&bundle).unwrap();
1267 let persisted = json["persisted_input"]
1268 .as_object_mut()
1269 .expect("persisted_input object");
1270 persisted.remove("content");
1271 persisted.insert("text".into(), serde_json::Value::String("hello".into()));
1272 persisted.insert("blocks".into(), serde_json::Value::Null);
1273 serde_json::from_value::<StoredInputState>(json)
1274 .expect_err("legacy text+blocks persisted prompt shape must be rejected");
1275 }
1276
1277 #[test]
1278 fn abandon_reason_serde() {
1279 for reason in [
1280 InputAbandonReason::Retired,
1281 InputAbandonReason::Reset,
1282 InputAbandonReason::Destroyed,
1283 InputAbandonReason::Cancelled,
1284 ] {
1285 let json = serde_json::to_value(&reason).unwrap();
1286 let parsed: InputAbandonReason = serde_json::from_value(json).unwrap();
1287 assert_eq!(reason, parsed);
1288 }
1289 }
1290
1291 #[test]
1292 fn terminal_outcome_consumed_serde() {
1293 let outcome = InputTerminalOutcome::Consumed;
1294 let json = serde_json::to_value(&outcome).unwrap();
1295 assert_eq!(json["outcome_type"], "consumed");
1296 let parsed: InputTerminalOutcome = serde_json::from_value(json).unwrap();
1297 assert_eq!(outcome, parsed);
1298 }
1299
1300 #[test]
1301 fn terminal_outcome_superseded_serde() {
1302 let outcome = InputTerminalOutcome::Superseded {
1303 superseded_by: InputId::new(),
1304 };
1305 let json = serde_json::to_value(&outcome).unwrap();
1306 assert_eq!(json["outcome_type"], "superseded");
1307 let parsed: InputTerminalOutcome = serde_json::from_value(json).unwrap();
1308 assert!(matches!(parsed, InputTerminalOutcome::Superseded { .. }));
1309 }
1310
1311 #[test]
1312 fn terminal_outcome_abandoned_serde() {
1313 let outcome = InputTerminalOutcome::Abandoned {
1314 reason: InputAbandonReason::Retired,
1315 };
1316 let json = serde_json::to_value(&outcome).unwrap();
1317 let parsed: InputTerminalOutcome = serde_json::from_value(json).unwrap();
1318 assert!(matches!(
1319 parsed,
1320 InputTerminalOutcome::Abandoned {
1321 reason: InputAbandonReason::Retired,
1322 }
1323 ));
1324 }
1325
1326 #[test]
1327 fn reconstruction_source_serde() {
1328 let sources = vec![
1329 ReconstructionSource::Projection {
1330 rule_id: "rule-1".into(),
1331 source_event_id: "evt-1".into(),
1332 },
1333 ReconstructionSource::Coalescing {
1334 source_input_ids: vec![InputId::new(), InputId::new()],
1335 },
1336 ];
1337 for source in sources {
1338 let json = serde_json::to_value(&source).unwrap();
1339 assert!(json["source_type"].is_string());
1340 let parsed: ReconstructionSource = serde_json::from_value(json).unwrap();
1341 let _ = parsed;
1342 }
1343 }
1344
1345 #[test]
1346 fn input_state_event_serde() {
1347 let event = InputStateEvent {
1348 timestamp: Utc::now(),
1349 state: InputLifecycleState::Queued,
1350 detail: Some("queued for processing".into()),
1351 };
1352 let json = serde_json::to_value(&event).unwrap();
1353 let parsed: InputStateEvent = serde_json::from_value(json).unwrap();
1354 assert_eq!(parsed.state, InputLifecycleState::Queued);
1355 }
1356}