Skip to main content

meerkat_runtime/
input_state.rs

1//! ยง13 InputState โ€” per-input data shell.
2//!
3//! Canonical lifecycle truth for every input lives in the MeerkatMachine DSL
4//! (`input_phases`, `input_run_associations`, `input_boundary_sequences` plus
5//! the `QueueAccepted` / `StageForRun` / `RecordBoundarySeq` / etc.
6//! transitions). This module owns ONLY the per-input shell metadata needed for
7//! persistence/projection: a history log, timestamps, compatibility policy
8//! snapshot, durability observation, idempotency key, and the cached payload
9//! needed to rebuild queued work after recovery. Durability admission validity
10//! and recovered keep/drop behavior are emitted by generated MeerkatMachine
11//! inputs/effects.
12//!
13//! Terminal outcome and attempt count are DSL-owned facts. Live reads go
14//! through `EphemeralRuntimeDriver::input_terminal_outcome` /
15//! `input_attempt_count`; persistence carries them on [`InputStateSeed`].
16//! `InputState` holds no copy of either.
17
18use 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/// The lifecycle state of an input โ€” mirrors the DSL's `input_phases` values.
32#[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/// Why an input was abandoned.
48#[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/// Terminal outcome for an input.
61///
62/// The authoritative live copy is split across the DSL's typed terminal maps;
63/// persistence carries it on [`InputStateSeed`].
64#[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/// A single entry in the input's state history (shell bookkeeping).
75#[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/// Snapshot of the policy that was applied to this input.
85#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct PolicySnapshot {
87    pub version: PolicyVersion,
88    pub decision: PolicyDecision,
89}
90
91/// How a derived input can be reconstructed after crash recovery.
92#[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/// Payload observed at the runtime boundary for one exact directed input.
106/// Generated completion authority later classifies this candidate after the
107/// runtime commit and session checkpoint have finalized.
108#[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    /// The runtime executor observed that the agent's generated turn machine
120    /// had already reached a typed hard-failure terminal.  The metadata is
121    /// durable because recovery must publish the same failure class and detail
122    /// as the live completion path; display-text reclassification is forbidden.
123    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/// Durable receipt proving that the exact interaction terminal row was
179/// appended (or byte-identically replayed) in the session event store.
180#[derive(Debug, Clone, Serialize, Deserialize)]
181pub(crate) struct InteractionTerminalPublication {
182    pub(crate) terminal_seq: u64,
183    pub(crate) payload_digest: String,
184}
185
186/// Structurally valid durable phases for one directed-terminal outbox row.
187///
188/// The batch candidate remains on exactly one owner row until publication.
189/// Once an exact event-store receipt is durable, both the candidate and the
190/// finalized event are compacted away; their immutable digests and the exact
191/// terminal sequence remain as the retry/provenance witness.
192#[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/// Typed durable identity for one exact terminal batch.
209#[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/// Retry carrier for an exact per-input terminal publication.
226///
227/// This is shell payload, not a competing lifecycle machine: candidate
228/// creation is bound to the machine-owned run commit/failure path, final event
229/// creation consumes generated runtime-completion authority, and publication
230/// is accepted only with an exact event-store receipt.
231#[derive(Debug, Clone, Serialize, Deserialize)]
232pub(crate) struct InteractionTerminalOutbox {
233    pub(crate) interaction_id: InteractionId,
234    pub(crate) input_id: InputId,
235    /// Stable position in the directed event batch. Ordinals are contiguous
236    /// from zero and the shared candidate owner is always ordinal zero.
237    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    /// Input row that owns the batch's single shared candidate payload.
249    pub(crate) candidate_owner_input_id: InputId,
250    /// Shared candidate payload, present only on the owner row.
251    #[serde(default, skip_serializing_if = "Option::is_none")]
252    pub(crate) candidate: Option<InteractionTerminalCandidate>,
253    pub(crate) candidate_digest: String,
254    /// Exact completion-waiter recipients for the whole runtime batch. The
255    /// vector is stored only on the candidate owner and compacted after the
256    /// terminal publication receipt is durable.
257    #[serde(default, skip_serializing_if = "Option::is_none")]
258    pub(crate) completion_input_ids: Option<Vec<InputId>>,
259    /// Shared immutable proof for the owner-only recipient vector.
260    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
470/// Validate immutable cross-row identity and ordering for one exact terminal
471/// batch. Callers must order rows by `batch_ordinal` first.
472pub(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
502/// Validate the cross-row invariants of one unpublished exact terminal batch.
503/// Callers must order rows by `batch_ordinal` before invoking this helper.
504pub(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/// An event on an input's state (for event sourcing).
628#[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/// DSL-owned lifecycle projection for an input.
637///
638/// Carries the fields that are authoritative in the MeerkatMachine DSL
639/// (`input_phases`, `input_run_associations`, `input_boundary_sequences`,
640/// `input_terminal_kind` + `input_superseded_by` / `input_aggregate_id` /
641/// `input_abandon_reason` / `input_abandon_attempt_count`, and
642/// `input_attempt_counts` / `input_admission_seq` / `input_recovery_lanes`) so
643/// they can travel alongside a persisted [`InputState`] at the store boundary,
644/// where no live DSL is available to query. Inside a running driver, these
645/// values are always read from the DSL directly, never from the seed.
646#[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    /// Freshly-accepted input: no run association, no boundary sequence,
659    /// no terminal outcome, zero attempts.
660    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/// Persisted bundle: shell [`InputState`] plus its [`InputStateSeed`].
674///
675/// Used at the store boundary so the DSL-owned fields survive persistence
676/// without being re-shadowed onto `InputState` itself. Recovery treats the
677/// seed as a durable witness and re-enters the recovered facts through typed
678/// machine inputs; it does not hydrate DSL state directly from this bundle.
679#[derive(Debug, Clone)]
680pub struct StoredInputState {
681    pub state: InputState,
682    pub seed: InputStateSeed,
683}
684
685impl StoredInputState {
686    /// Convenience: freshly-accepted bundle.
687    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/// Store-write wrapper for an input-state bundle whose DSL-owned seed facts
696/// came from a generated MeerkatMachine-owned snapshot.
697#[derive(Debug, Clone)]
698pub struct InputStatePersistenceRecord {
699    bundle: StoredInputState,
700}
701
702impl InputStatePersistenceRecord {
703    /// Package a store-bound input-state bundle that was read from generated
704    /// MeerkatMachine authority. This is intentionally crate-private so
705    /// callers cannot mint persistence records from handwritten seed facts.
706    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    /// Raw bundle approved for durable persistence.
715    pub fn as_stored(&self) -> &StoredInputState {
716        &self.bundle
717    }
718
719    /// Clone the approved raw bundle.
720    pub fn clone_stored(&self) -> StoredInputState {
721        self.bundle.clone()
722    }
723
724    /// Consume the approved record into its raw bundle.
725    pub fn into_stored(self) -> StoredInputState {
726        self.bundle
727    }
728}
729
730/// Per-input shell data. Plain fields, no hidden state machine.
731///
732/// All DSL-owned lifecycle fields (`phase`, `last_run_id`,
733/// `last_boundary_sequence`, `terminal_outcome`, `attempt_count`,
734/// `recovery_lane`) are
735/// authoritative in the DSL. Live code reads them via
736/// `EphemeralRuntimeDriver::input_phase` / `input_last_run_id` /
737/// `input_last_boundary_sequence` / `input_terminal_outcome` /
738/// `input_attempt_count` / `input_recovery_lane`. Persistence callsites
739/// serialize them via [`InputStateSeed`] bundled on [`StoredInputState`].
740#[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    /// Runtime-stamped run semantics captured at admission and persisted so
747    /// recovery does not reclassify execution kind from payload shape.
748    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    /// Exact directed-terminal retry carrier, when this input came from the
754    /// tracked cross-host flow lane.
755    pub(crate) interaction_terminal_outbox: Option<InteractionTerminalOutbox>,
756    pub persisted_input: Option<Input>,
757    pub created_at: DateTime<Utc>,
758}
759
760impl InputState {
761    /// Create a fresh InputState. Paired DSL state starts in the `Accepted`
762    /// phase via [`InputStateSeed::new_accepted`]; callers that need the
763    /// bundle use [`StoredInputState::new_accepted`].
764    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// ---------------------------------------------------------------------------
792// Custom Serialize / Deserialize โ€” preserves the on-disk wire format
793// ---------------------------------------------------------------------------
794//
795// `InputStateSerde` is the on-disk contract exercised by
796// `recovery_contract`, `recovery_replay`, and `driver_persistent` tests.
797// Field names, types, defaults, and `skip_serializing_if` markers are kept
798// verbatim from the pre-5G/1 release. Since `InputState` no longer owns the
799// three DSL-authoritative fields, serialization flows through
800// [`StoredInputState`] where shell + seed can be bundled into the wire
801// struct.
802
803#[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        // Pre-rename `system_generated` / `projected` persisted input tags are
1208        // retired shapes: a stored row carrying them must fail closed instead
1209        // of being folded into the canonical `continuation` / `operation` tags.
1210        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        // The retired persisted prompt shape carried `text` + optional
1257        // `blocks`; the single typed `content` owner replaced both. A stored
1258        // row holding the old shape must fail closed.
1259        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}